0
Fork 0
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:
Wang Sijie 2022-06-22 10:22:15 +08:00 committed by GitHub
parent 67613aec7a
commit 9194a6ee54
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 106 additions and 16 deletions

View file

@ -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 (

View file

@ -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>

View file

@ -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}`
)}` )}`
); );

View file

@ -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';

View file

@ -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}
` `

View file

@ -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;

View file

@ -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',

View file

@ -96,6 +96,8 @@ const translation = {
title: '管理面板', title: '管理面板',
sign_out: '登出', sign_out: '登出',
profile: '账户管理', profile: '账户管理',
admin_user: '管理员',
system_app: '系统应用',
copy: { copy: {
pending: '拷贝', pending: '拷贝',
copying: '拷贝中', copying: '拷贝中',