mirror of
https://github.com/logto-io/logto.git
synced 2025-01-20 21:32:31 -05:00
feat(console,core): hide admin user (#1182)
* feat(console,core): hide admin user * fix: extract hideAdminUser * test: add tests for hideAdminUser
This commit is contained in:
parent
67613aec7a
commit
9194a6ee54
8 changed files with 106 additions and 16 deletions
|
@ -1,6 +1,7 @@
|
||||||
import { Application } from '@logto/schemas';
|
import { Application } from '@logto/schemas';
|
||||||
import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds';
|
import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
@ -15,8 +16,9 @@ const ApplicationName = ({ applicationId, isLink = false }: Props) => {
|
||||||
const isAdminConsole = applicationId === adminConsoleApplicationId;
|
const isAdminConsole = applicationId === adminConsoleApplicationId;
|
||||||
|
|
||||||
const { data } = useSWR<Application>(!isAdminConsole && `/api/applications/${applicationId}`);
|
const { data } = useSWR<Application>(!isAdminConsole && `/api/applications/${applicationId}`);
|
||||||
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
|
|
||||||
const name = (isAdminConsole ? 'Admin Console' : data?.name) || '-';
|
const name = (isAdminConsole ? <>Admin Console ({t('system_app')})</> : data?.name) || '-';
|
||||||
|
|
||||||
if (isLink && !isAdminConsole) {
|
if (isLink && !isAdminConsole) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { User } from '@logto/schemas';
|
import { User, UserRole } from '@logto/schemas';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
@ -20,18 +20,23 @@ const UserName = ({ userId, isLink = false }: Props) => {
|
||||||
const isLoading = !data && !error;
|
const isLoading = !data && !error;
|
||||||
const name = data?.name || t('users.unnamed');
|
const name = data?.name || t('users.unnamed');
|
||||||
|
|
||||||
|
const isAdmin = data?.roleNames.includes(UserRole.Admin);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.userName}>
|
<div className={styles.userName}>
|
||||||
{isLink ? (
|
{isLink && !isAdmin ? (
|
||||||
<Link to={`/users/${userId}`} target="_blank" className={styles.link}>
|
<Link to={`/users/${userId}`} target="_blank" className={styles.link}>
|
||||||
{name}
|
{name}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span>{name}</span>
|
<span>
|
||||||
|
{name}
|
||||||
|
{isAdmin && <> ({t('admin_user')})</>}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className={styles.userId}>{userId}</span>
|
<span className={styles.userId}>{userId}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -36,7 +36,7 @@ const Users = () => {
|
||||||
const pageIndex = Number(query.get('page') ?? '1');
|
const pageIndex = Number(query.get('page') ?? '1');
|
||||||
const keyword = query.get('search') ?? '';
|
const keyword = query.get('search') ?? '';
|
||||||
const { data, error, mutate } = useSWR<[User[], number], RequestError>(
|
const { data, error, mutate } = useSWR<[User[], number], RequestError>(
|
||||||
`/api/users?page=${pageIndex}&page_size=${pageSize}${conditionalString(
|
`/api/users?page=${pageIndex}&page_size=${pageSize}&hideAdminUser=true${conditionalString(
|
||||||
keyword && `&search=${keyword}`
|
keyword && `&search=${keyword}`
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Users } from '@logto/schemas';
|
import { UserRole, Users } from '@logto/schemas';
|
||||||
import { createMockPool, createMockQueryResult, sql } from 'slonik';
|
import { createMockPool, createMockQueryResult, sql } from 'slonik';
|
||||||
|
|
||||||
import { mockUser } from '@/__mocks__';
|
import { mockUser } from '@/__mocks__';
|
||||||
|
@ -280,6 +280,31 @@ describe('user query', () => {
|
||||||
await expect(countUsers(search)).resolves.toEqual(dbvalue);
|
await expect(countUsers(search)).resolves.toEqual(dbvalue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('countUsers with hideAdminUser', async () => {
|
||||||
|
const search = 'foo';
|
||||||
|
const expectSql = sql`
|
||||||
|
select count(*)
|
||||||
|
from ${table}
|
||||||
|
where not (${fields.roleNames}::jsonb?$1)
|
||||||
|
and (${fields.primaryEmail} like $2 or ${fields.primaryPhone} like $3 or ${fields.username} like $4 or ${fields.name} like $5)
|
||||||
|
`;
|
||||||
|
|
||||||
|
mockQuery.mockImplementationOnce(async (sql, values) => {
|
||||||
|
expectSqlAssert(sql, expectSql.sql);
|
||||||
|
expect(values).toEqual([
|
||||||
|
UserRole.Admin,
|
||||||
|
`%${search}%`,
|
||||||
|
`%${search}%`,
|
||||||
|
`%${search}%`,
|
||||||
|
`%${search}%`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return createMockQueryResult([dbvalue]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(countUsers(search, true)).resolves.toEqual(dbvalue);
|
||||||
|
});
|
||||||
|
|
||||||
it('findUsers', async () => {
|
it('findUsers', async () => {
|
||||||
const search = 'foo';
|
const search = 'foo';
|
||||||
const limit = 100;
|
const limit = 100;
|
||||||
|
@ -311,6 +336,39 @@ describe('user query', () => {
|
||||||
await expect(findUsers(limit, offset, search)).resolves.toEqual([dbvalue]);
|
await expect(findUsers(limit, offset, search)).resolves.toEqual([dbvalue]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('findUsers with hideAdminUser', async () => {
|
||||||
|
const search = 'foo';
|
||||||
|
const limit = 100;
|
||||||
|
const offset = 1;
|
||||||
|
const expectSql = sql`
|
||||||
|
select ${sql.join(Object.values(fields), sql`,`)}
|
||||||
|
from ${table}
|
||||||
|
where not (${fields.roleNames}::jsonb?$1)
|
||||||
|
and (${fields.primaryEmail} like $2 or ${fields.primaryPhone} like $3 or ${
|
||||||
|
fields.username
|
||||||
|
} like $4 or ${fields.name} like $5)
|
||||||
|
limit $6
|
||||||
|
offset $7
|
||||||
|
`;
|
||||||
|
|
||||||
|
mockQuery.mockImplementationOnce(async (sql, values) => {
|
||||||
|
expectSqlAssert(sql, expectSql.sql);
|
||||||
|
expect(values).toEqual([
|
||||||
|
UserRole.Admin,
|
||||||
|
`%${search}%`,
|
||||||
|
`%${search}%`,
|
||||||
|
`%${search}%`,
|
||||||
|
`%${search}%`,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return createMockQueryResult([dbvalue]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(findUsers(limit, offset, search, true)).resolves.toEqual([dbvalue]);
|
||||||
|
});
|
||||||
|
|
||||||
it('updateUserById', async () => {
|
it('updateUserById', async () => {
|
||||||
const username = 'Joe';
|
const username = 'Joe';
|
||||||
const id = 'foo';
|
const id = 'foo';
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { User, CreateUser, Users } from '@logto/schemas';
|
import { User, CreateUser, Users, UserRole } from '@logto/schemas';
|
||||||
import { sql } from 'slonik';
|
import { sql } from 'slonik';
|
||||||
|
|
||||||
import { buildInsertInto } from '@/database/insert-into';
|
import { buildInsertInto } from '@/database/insert-into';
|
||||||
|
@ -94,19 +94,37 @@ const buildUserSearchConditionSql = (search: string) => {
|
||||||
return sql`${sql.join(conditions, sql` or `)}`;
|
return sql`${sql.join(conditions, sql` or `)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const countUsers = async (search?: string) =>
|
const buildUserConditions = (search?: string, hideAdminUser?: boolean) => {
|
||||||
|
if (hideAdminUser) {
|
||||||
|
return sql`
|
||||||
|
where not (${fields.roleNames}::jsonb?${UserRole.Admin})
|
||||||
|
${conditionalSql(search, (search) => sql`and (${buildUserSearchConditionSql(search)})`)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql`
|
||||||
|
${conditionalSql(search, (search) => sql`where ${buildUserSearchConditionSql(search)}`)}
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const countUsers = async (search?: string, hideAdminUser?: boolean) =>
|
||||||
envSet.pool.one<{ count: number }>(sql`
|
envSet.pool.one<{ count: number }>(sql`
|
||||||
select count(*)
|
select count(*)
|
||||||
from ${table}
|
from ${table}
|
||||||
${conditionalSql(search, (search) => sql`where ${buildUserSearchConditionSql(search)}`)}
|
${buildUserConditions(search, hideAdminUser)}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export const findUsers = async (limit: number, offset: number, search?: string) =>
|
export const findUsers = async (
|
||||||
|
limit: number,
|
||||||
|
offset: number,
|
||||||
|
search?: string,
|
||||||
|
hideAdminUser?: boolean
|
||||||
|
) =>
|
||||||
envSet.pool.any<User>(
|
envSet.pool.any<User>(
|
||||||
sql`
|
sql`
|
||||||
select ${sql.join(Object.values(fields), sql`,`)}
|
select ${sql.join(Object.values(fields), sql`,`)}
|
||||||
from ${table}
|
from ${table}
|
||||||
${conditionalSql(search, (search) => sql`where ${buildUserSearchConditionSql(search)}`)}
|
${buildUserConditions(search, hideAdminUser)}
|
||||||
limit ${limit}
|
limit ${limit}
|
||||||
offset ${offset}
|
offset ${offset}
|
||||||
`
|
`
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { passwordRegEx, usernameRegEx } from '@logto/shared';
|
||||||
import { has } from '@silverhand/essentials';
|
import { has } from '@silverhand/essentials';
|
||||||
import pick from 'lodash.pick';
|
import pick from 'lodash.pick';
|
||||||
import { InvalidInputError } from 'slonik';
|
import { InvalidInputError } from 'slonik';
|
||||||
import { object, string } from 'zod';
|
import { literal, object, string } from 'zod';
|
||||||
|
|
||||||
import RequestError from '@/errors/RequestError';
|
import RequestError from '@/errors/RequestError';
|
||||||
import { encryptUserPassword, generateUserId } from '@/lib/user';
|
import { encryptUserPassword, generateUserId } from '@/lib/user';
|
||||||
|
@ -28,16 +28,19 @@ export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
|
||||||
router.get(
|
router.get(
|
||||||
'/users',
|
'/users',
|
||||||
koaPagination(),
|
koaPagination(),
|
||||||
koaGuard({ query: object({ search: string().optional() }) }),
|
koaGuard({
|
||||||
|
query: object({ search: string().optional(), hideAdminUser: literal('true').optional() }),
|
||||||
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { limit, offset } = ctx.pagination;
|
const { limit, offset } = ctx.pagination;
|
||||||
const {
|
const {
|
||||||
query: { search },
|
query: { search, hideAdminUser: _hideAdminUser },
|
||||||
} = ctx.guard;
|
} = ctx.guard;
|
||||||
|
|
||||||
|
const hideAdminUser = _hideAdminUser === 'true';
|
||||||
const [{ count }, users] = await Promise.all([
|
const [{ count }, users] = await Promise.all([
|
||||||
countUsers(search),
|
countUsers(search, hideAdminUser),
|
||||||
findUsers(limit, offset, search),
|
findUsers(limit, offset, search, hideAdminUser),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ctx.pagination.totalCount = count;
|
ctx.pagination.totalCount = count;
|
||||||
|
|
|
@ -96,6 +96,8 @@ const translation = {
|
||||||
title: 'Admin Console',
|
title: 'Admin Console',
|
||||||
sign_out: 'Sign out',
|
sign_out: 'Sign out',
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
|
admin_user: 'Admin',
|
||||||
|
system_app: 'System',
|
||||||
copy: {
|
copy: {
|
||||||
pending: 'Copy',
|
pending: 'Copy',
|
||||||
copying: 'Copying',
|
copying: 'Copying',
|
||||||
|
|
|
@ -96,6 +96,8 @@ const translation = {
|
||||||
title: '管理面板',
|
title: '管理面板',
|
||||||
sign_out: '登出',
|
sign_out: '登出',
|
||||||
profile: '账户管理',
|
profile: '账户管理',
|
||||||
|
admin_user: '管理员',
|
||||||
|
system_app: '系统应用',
|
||||||
copy: {
|
copy: {
|
||||||
pending: '拷贝',
|
pending: '拷贝',
|
||||||
copying: '拷贝中',
|
copying: '拷贝中',
|
||||||
|
|
Loading…
Add table
Reference in a new issue