From 0105d19d99ab66af28e6627c289c7f64f7aec220 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 28 Jan 2022 13:33:57 +0800 Subject: [PATCH] feat(core): add user roles related api (#204) * feat(core): add user role related api add user roles related api * fix: insert array to db fix insert array to db fix * feat(core): add role related routes config add role related routes config * fix(core): update user role patch error type update user role patch error type * fix(core): cr fix cr fix * fix(core): cr fix cr fix * fix(core): cr fix cr fix --- .../core/src/database/update-where.test.ts | 7 +-- packages/core/src/database/utils.test.ts | 7 +-- packages/core/src/database/utils.ts | 4 +- packages/core/src/queries/roles.ts | 20 +++++++ packages/core/src/routes/admin-user.ts | 53 +++++++++++++++++++ packages/core/src/routes/init.ts | 4 ++ packages/core/src/routes/role.ts | 11 ++++ packages/core/src/routes/user.ts | 15 ++---- packages/schemas/src/db-entries/user.ts | 6 +-- packages/schemas/src/types/user.ts | 1 + packages/schemas/tables/users.sql | 2 +- 11 files changed, 101 insertions(+), 29 deletions(-) create mode 100644 packages/core/src/queries/roles.ts create mode 100644 packages/core/src/routes/admin-user.ts create mode 100644 packages/core/src/routes/role.ts diff --git a/packages/core/src/database/update-where.test.ts b/packages/core/src/database/update-where.test.ts index 1f9553e24..806b39172 100644 --- a/packages/core/src/database/update-where.test.ts +++ b/packages/core/src/database/update-where.test.ts @@ -41,17 +41,12 @@ describe('buildUpdateWhere()', () => { ); const updateWhere = buildUpdateWhere(pool, Users); - const errorMessage: Partial> = { - darwin: "Cannot read properties of undefined (reading 'toString')", - linux: "Cannot read property 'toString' of undefined", - }; - await expect( updateWhere({ set: { username: '123', id: undefined }, where: { id: 'foo', username: '456' }, }) - ).rejects.toMatchError(new TypeError(errorMessage[process.platform])); + ).rejects.toMatchError(new Error(`Cannot convert id to primitive`)); }); it('throws `entity.not_exists_with_id` error with `undefined` when `returning` is true', async () => { diff --git a/packages/core/src/database/utils.test.ts b/packages/core/src/database/utils.test.ts index bcd0ea44b..25472e54d 100644 --- a/packages/core/src/database/utils.test.ts +++ b/packages/core/src/database/utils.test.ts @@ -50,6 +50,7 @@ describe('convertToPrimitiveOrSql()', () => { expect(convertToPrimitiveOrSql(normalKey, 123)).toEqual(123); expect(convertToPrimitiveOrSql(normalKey, true)).toEqual(true); expect(convertToPrimitiveOrSql(normalKey, { foo: 'bar' })).toEqual('{"foo":"bar"}'); + expect(convertToPrimitiveOrSql(normalKey, ['bar'])).toEqual('["bar"]'); }); it('converts value to sql when key ends with special set and value is number', () => { @@ -67,12 +68,6 @@ describe('convertToPrimitiveOrSql()', () => { expect(convertToPrimitiveOrSql(`${normalKey}${value}`, '123')).toEqual('123'); } }); - - it('throws an error when value is not primitive', () => { - expect(() => convertToPrimitiveOrSql(normalKey, [123, 456])).toThrow( - 'Cannot convert foo with 123,456 to primitive' - ); - }); }); describe('convertToIdentifiers()', () => { diff --git a/packages/core/src/database/utils.ts b/packages/core/src/database/utils.ts index 275f61629..7a58df2eb 100644 --- a/packages/core/src/database/utils.ts +++ b/packages/core/src/database/utils.ts @@ -40,7 +40,7 @@ export const convertToPrimitiveOrSql = ( return null; } - if (typeof value === 'object' && !Array.isArray(value)) { + if (typeof value === 'object') { return JSON.stringify(value); } @@ -52,7 +52,7 @@ export const convertToPrimitiveOrSql = ( return value; } - throw new Error(`Cannot convert ${key} with ${value.toString()} to primitive`); + throw new Error(`Cannot convert ${key} to primitive`); }; export const convertToIdentifiers = ( diff --git a/packages/core/src/queries/roles.ts b/packages/core/src/queries/roles.ts new file mode 100644 index 000000000..1c1bc92c4 --- /dev/null +++ b/packages/core/src/queries/roles.ts @@ -0,0 +1,20 @@ +import { Roles, Role } from '@logto/schemas'; +import { sql } from 'slonik'; + +import pool from '@/database/pool'; +import { convertToIdentifiers } from '@/database/utils'; + +const { table, fields } = convertToIdentifiers(Roles); + +export const findAllRoles = async () => + pool.any(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + `); + +export const findRolesByRoleNames = async (roleNames: string[]) => + pool.any(sql` + select ${sql.join(Object.values(fields), sql`,`)} + from ${table} + where ${fields.name} in (${sql.join(roleNames, sql`,`)}) + `); diff --git a/packages/core/src/routes/admin-user.ts b/packages/core/src/routes/admin-user.ts new file mode 100644 index 000000000..2a84eabc1 --- /dev/null +++ b/packages/core/src/routes/admin-user.ts @@ -0,0 +1,53 @@ +import { userInfoSelectFields } from '@logto/schemas'; +import pick from 'lodash.pick'; +import { InvalidInputError } from 'slonik'; +import { object, string } from 'zod'; + +import koaGuard from '@/middleware/koa-guard'; +import { findRolesByRoleNames } from '@/queries/roles'; +import { findAllUsers, findUserById, updateUserById } from '@/queries/user'; + +import { AuthedRouter } from './types'; + +export default function adminUserRoutes(router: T) { + router.get('/users', async (ctx, next) => { + const users = await findAllUsers(); + ctx.body = users.map((user) => pick(user, ...userInfoSelectFields)); + + return next(); + }); + + router.patch( + '/users/:userId/roleNames', + koaGuard({ + params: object({ userId: string().min(1) }), + body: object({ roleNames: string().array() }), + }), + async (ctx, next) => { + const { + params: { userId }, + body: { roleNames }, + } = ctx.guard; + + await findUserById(userId); + + // Temp solution to validate the existence of input roleNames + if (roleNames.length > 0) { + const roles = await findRolesByRoleNames(roleNames); + + if (roles.length !== roleNames.length) { + const resourcesNotFound = roleNames.filter( + (roleName) => !roles.some(({ name }) => roleName === name) + ); + // TODO: Should be cached by the error handler and return request error + throw new InvalidInputError(`role names (${resourcesNotFound.join(',')}) are not valid`); + } + } + + const user = await updateUserById(userId, { roleNames }); + ctx.body = pick(user, ...userInfoSelectFields); + + return next(); + } + ); +} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index cd1aa2440..f2ec55f6c 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -14,6 +14,8 @@ import statusRoutes from '@/routes/status'; import swaggerRoutes from '@/routes/swagger'; import userRoutes from '@/routes/user'; +import adminUserRoutes from './admin-user'; +import roleRoutes from './role'; import { AnonymousRouter, AuthedRouter } from './types'; const createRouters = (provider: Provider) => { @@ -31,6 +33,8 @@ const createRouters = (provider: Provider) => { connectorRoutes(router); resourceRoutes(router); signInExperiencesRoutes(router); + adminUserRoutes(router); + roleRoutes(router); return [anonymousRouter, router]; }; diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts new file mode 100644 index 000000000..4342a70a8 --- /dev/null +++ b/packages/core/src/routes/role.ts @@ -0,0 +1,11 @@ +import { findAllRoles } from '@/queries/roles'; + +import { AuthedRouter } from './types'; + +export default function roleRoutes(router: T) { + router.get('/roles', async (ctx, next) => { + ctx.body = await findAllRoles(); + + return next(); + }); +} diff --git a/packages/core/src/routes/user.ts b/packages/core/src/routes/user.ts index 3a5d1253d..a31a6a41d 100644 --- a/packages/core/src/routes/user.ts +++ b/packages/core/src/routes/user.ts @@ -4,20 +4,13 @@ import { object, string } from 'zod'; import { encryptUserPassword } from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; -import { deleteUserById, findAllUsers, findUserById, updateUserById } from '@/queries/user'; +import { deleteUserById, findUserById, updateUserById } from '@/queries/user'; import { AnonymousRouter } from './types'; export default function userRoutes(router: T) { - router.get('/users', async (ctx, next) => { - const users = await findAllUsers(); - ctx.body = users.map((user) => pick(user, ...userInfoSelectFields)); - - return next(); - }); - router.get( - '/users/:userId', + '/user/:userId', koaGuard({ params: object({ userId: string().min(1) }), }), @@ -33,7 +26,7 @@ export default function userRoutes(router: T) { ); router.patch( - '/users/:userId/password', + '/user/:userId/password', koaGuard({ params: object({ userId: string().min(1) }), body: object({ password: string().min(6) }), @@ -59,7 +52,7 @@ export default function userRoutes(router: T) { ); router.delete( - '/users/:userId', + '/user/:userId', koaGuard({ params: object({ userId: string().min(1) }), }), diff --git a/packages/schemas/src/db-entries/user.ts b/packages/schemas/src/db-entries/user.ts index 22f77dda7..f3903cf74 100644 --- a/packages/schemas/src/db-entries/user.ts +++ b/packages/schemas/src/db-entries/user.ts @@ -13,7 +13,7 @@ export type CreateUser = { passwordEncrypted?: string | null; passwordEncryptionMethod?: PasswordEncryptionMethod | null; passwordEncryptionSalt?: string | null; - roleNames?: RoleNames | null; + roleNames?: RoleNames; }; export type User = { @@ -24,7 +24,7 @@ export type User = { passwordEncrypted: string | null; passwordEncryptionMethod: PasswordEncryptionMethod | null; passwordEncryptionSalt: string | null; - roleNames: RoleNames | null; + roleNames: RoleNames; }; const createGuard: Guard = z.object({ @@ -35,7 +35,7 @@ const createGuard: Guard = z.object({ passwordEncrypted: z.string().nullable().optional(), passwordEncryptionMethod: z.nativeEnum(PasswordEncryptionMethod).nullable().optional(), passwordEncryptionSalt: z.string().nullable().optional(), - roleNames: roleNamesGuard.nullable().optional(), + roleNames: roleNamesGuard.optional(), }); export const Users: GeneratedSchema = Object.freeze({ diff --git a/packages/schemas/src/types/user.ts b/packages/schemas/src/types/user.ts index 81c658010..6cee5b717 100644 --- a/packages/schemas/src/types/user.ts +++ b/packages/schemas/src/types/user.ts @@ -5,6 +5,7 @@ export const userInfoSelectFields = Object.freeze([ 'username', 'primaryEmail', 'primaryPhone', + 'roleNames', ] as const); export type UserInfo = Pick< diff --git a/packages/schemas/tables/users.sql b/packages/schemas/tables/users.sql index 5aedb137c..ab49a5605 100644 --- a/packages/schemas/tables/users.sql +++ b/packages/schemas/tables/users.sql @@ -8,6 +8,6 @@ create table users ( password_encrypted varchar(128), password_encryption_method password_encryption_method, password_encryption_salt varchar(128), - role_names jsonb /* @use RoleNames */, + role_names jsonb /* @use RoleNames */ not null default '[]'::jsonb, primary key (id) );