From 9194a6ee547e2eb83ec106a834409c33644481e5 Mon Sep 17 00:00:00 2001 From: Wang Sijie Date: Wed, 22 Jun 2022 10:22:15 +0800 Subject: [PATCH] feat(console,core): hide admin user (#1182) * feat(console,core): hide admin user * fix: extract hideAdminUser * test: add tests for hideAdminUser --- .../src/components/ApplicationName/index.tsx | 4 +- .../console/src/components/UserName/index.tsx | 11 +++- packages/console/src/pages/Users/index.tsx | 2 +- packages/core/src/queries/user.test.ts | 60 ++++++++++++++++++- packages/core/src/queries/user.ts | 28 +++++++-- packages/core/src/routes/admin-user.ts | 13 ++-- packages/phrases/src/locales/en.ts | 2 + packages/phrases/src/locales/zh-cn.ts | 2 + 8 files changed, 106 insertions(+), 16 deletions(-) diff --git a/packages/console/src/components/ApplicationName/index.tsx b/packages/console/src/components/ApplicationName/index.tsx index 5030aea23..2e416180a 100644 --- a/packages/console/src/components/ApplicationName/index.tsx +++ b/packages/console/src/components/ApplicationName/index.tsx @@ -1,6 +1,7 @@ import { Application } from '@logto/schemas'; import { adminConsoleApplicationId } from '@logto/schemas/lib/seeds'; import React from 'react'; +import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import useSWR from 'swr'; @@ -15,8 +16,9 @@ const ApplicationName = ({ applicationId, isLink = false }: Props) => { const isAdminConsole = applicationId === adminConsoleApplicationId; const { data } = useSWR(!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) { return ( diff --git a/packages/console/src/components/UserName/index.tsx b/packages/console/src/components/UserName/index.tsx index a8fc0a2ee..03dfdcfd9 100644 --- a/packages/console/src/components/UserName/index.tsx +++ b/packages/console/src/components/UserName/index.tsx @@ -1,4 +1,4 @@ -import { User } from '@logto/schemas'; +import { User, UserRole } from '@logto/schemas'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; @@ -20,18 +20,23 @@ const UserName = ({ userId, isLink = false }: Props) => { const isLoading = !data && !error; const name = data?.name || t('users.unnamed'); + const isAdmin = data?.roleNames.includes(UserRole.Admin); + if (isLoading) { return null; } return (
- {isLink ? ( + {isLink && !isAdmin ? ( {name} ) : ( - {name} + + {name} + {isAdmin && <> ({t('admin_user')})} + )} {userId}
diff --git a/packages/console/src/pages/Users/index.tsx b/packages/console/src/pages/Users/index.tsx index 2874fab13..ab972a8c2 100644 --- a/packages/console/src/pages/Users/index.tsx +++ b/packages/console/src/pages/Users/index.tsx @@ -36,7 +36,7 @@ const Users = () => { const pageIndex = Number(query.get('page') ?? '1'); const keyword = query.get('search') ?? ''; 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}` )}` ); diff --git a/packages/core/src/queries/user.test.ts b/packages/core/src/queries/user.test.ts index 04c8926fd..e63303b11 100644 --- a/packages/core/src/queries/user.test.ts +++ b/packages/core/src/queries/user.test.ts @@ -1,4 +1,4 @@ -import { Users } from '@logto/schemas'; +import { UserRole, Users } from '@logto/schemas'; import { createMockPool, createMockQueryResult, sql } from 'slonik'; import { mockUser } from '@/__mocks__'; @@ -280,6 +280,31 @@ describe('user query', () => { 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 () => { const search = 'foo'; const limit = 100; @@ -311,6 +336,39 @@ describe('user query', () => { 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 () => { const username = 'Joe'; const id = 'foo'; diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index 19d3a48fc..4a24b5ea7 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -1,4 +1,4 @@ -import { User, CreateUser, Users } from '@logto/schemas'; +import { User, CreateUser, Users, UserRole } from '@logto/schemas'; import { sql } from 'slonik'; import { buildInsertInto } from '@/database/insert-into'; @@ -94,19 +94,37 @@ const buildUserSearchConditionSql = (search: string) => { 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` select count(*) 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( sql` select ${sql.join(Object.values(fields), sql`,`)} from ${table} - ${conditionalSql(search, (search) => sql`where ${buildUserSearchConditionSql(search)}`)} + ${buildUserConditions(search, hideAdminUser)} limit ${limit} offset ${offset} ` diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts index 0526150ca..49371fb29 100644 --- a/packages/core/src/routes/admin-user.ts +++ b/packages/core/src/routes/admin-user.ts @@ -3,7 +3,7 @@ import { passwordRegEx, usernameRegEx } from '@logto/shared'; import { has } from '@silverhand/essentials'; import pick from 'lodash.pick'; import { InvalidInputError } from 'slonik'; -import { object, string } from 'zod'; +import { literal, object, string } from 'zod'; import RequestError from '@/errors/RequestError'; import { encryptUserPassword, generateUserId } from '@/lib/user'; @@ -28,16 +28,19 @@ export default function adminUserRoutes(router: T) { router.get( '/users', koaPagination(), - koaGuard({ query: object({ search: string().optional() }) }), + koaGuard({ + query: object({ search: string().optional(), hideAdminUser: literal('true').optional() }), + }), async (ctx, next) => { const { limit, offset } = ctx.pagination; const { - query: { search }, + query: { search, hideAdminUser: _hideAdminUser }, } = ctx.guard; + const hideAdminUser = _hideAdminUser === 'true'; const [{ count }, users] = await Promise.all([ - countUsers(search), - findUsers(limit, offset, search), + countUsers(search, hideAdminUser), + findUsers(limit, offset, search, hideAdminUser), ]); ctx.pagination.totalCount = count; diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index 64f3fbbba..14fa4e306 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -96,6 +96,8 @@ const translation = { title: 'Admin Console', sign_out: 'Sign out', profile: 'Profile', + admin_user: 'Admin', + system_app: 'System', copy: { pending: 'Copy', copying: 'Copying', diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index 6527e6944..c30ee1fb3 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -96,6 +96,8 @@ const translation = { title: '管理面板', sign_out: '登出', profile: '账户管理', + admin_user: '管理员', + system_app: '系统应用', copy: { pending: '拷贝', copying: '拷贝中',