diff --git a/.changeset/clever-buttons-behave.md b/.changeset/clever-buttons-behave.md new file mode 100644 index 00000000000..df0c2d41911 --- /dev/null +++ b/.changeset/clever-buttons-behave.md @@ -0,0 +1,5 @@ +--- +"@logto/core": minor +--- + +Add avatar and customData fields to create user API (POST /api/users) diff --git a/packages/core/src/routes/admin-user/basics.ts b/packages/core/src/routes/admin-user/basics.ts index c9e994d9716..fe561a94318 100644 --- a/packages/core/src/routes/admin-user/basics.ts +++ b/packages/core/src/routes/admin-user/basics.ts @@ -119,6 +119,8 @@ export default function adminUserBasicsRoutes(...args: R passwordDigest: string(), passwordAlgorithm: nativeEnum(UsersPasswordEncryptionMethod), name: string(), + avatar: string().url().or(literal('')).nullable(), + customData: jsonObjectGuard, }).partial(), response: userProfileResponseGuard, status: [200, 404, 422], @@ -132,6 +134,8 @@ export default function adminUserBasicsRoutes(...args: R name, passwordDigest, passwordAlgorithm, + avatar, + customData, } = ctx.guard.body; assertThat(!(password && passwordDigest), new RequestError('user.password_and_digest')); @@ -165,6 +169,8 @@ export default function adminUserBasicsRoutes(...args: R primaryPhone, username, name, + avatar, + ...conditional(customData && { customData }), ...conditional(password && (await encryptUserPassword(password))), ...conditional( passwordDigest && { diff --git a/packages/integration-tests/src/helpers/index.ts b/packages/integration-tests/src/helpers/index.ts index 9ae080bb864..13fb84e24fa 100644 --- a/packages/integration-tests/src/helpers/index.ts +++ b/packages/integration-tests/src/helpers/index.ts @@ -2,29 +2,30 @@ import fs from 'node:fs/promises'; import { createServer, type RequestListener } from 'node:http'; import { mockConnectorFilePaths, type SendMessagePayload } from '@logto/connector-kit'; -import { type UsersPasswordEncryptionMethod } from '@logto/schemas'; +import { type JsonObject, type UsersPasswordEncryptionMethod } from '@logto/schemas'; import { RequestError } from 'got'; import { createUser } from '#src/api/index.js'; import { generateUsername } from '#src/utils.js'; export const createUserByAdmin = async ( - username?: string, - password?: string, - primaryEmail?: string, - primaryPhone?: string, - name?: string, - passwordDigest?: string, - passwordAlgorithm?: UsersPasswordEncryptionMethod + payload: { + username?: string; + password?: string; + primaryEmail?: string; + primaryPhone?: string; + name?: string; + passwordDigest?: string; + passwordAlgorithm?: UsersPasswordEncryptionMethod; + customData?: JsonObject; + } = {} ) => { + const { username, name, ...rest } = payload; + return createUser({ + ...rest, username: username ?? generateUsername(), - password, name: name ?? username ?? 'John', - primaryEmail, - primaryPhone, - passwordDigest, - passwordAlgorithm, }); }; diff --git a/packages/integration-tests/src/tests/api/admin-user.search.test.ts b/packages/integration-tests/src/tests/api/admin-user.search.test.ts index ae7ec3fdc84..268f71d2904 100644 --- a/packages/integration-tests/src/tests/api/admin-user.search.test.ts +++ b/packages/integration-tests/src/tests/api/admin-user.search.test.ts @@ -52,7 +52,7 @@ describe('admin console user search params', () => { const primaryPhone = phonePrefix[index % phonePrefix.length]! + index.toString().padStart(5, '0'); - return createUserByAdmin(prefix + username, undefined, primaryEmail, primaryPhone, name); + return createUserByAdmin({ username: prefix + username, primaryEmail, primaryPhone, name }); }) ); }); diff --git a/packages/integration-tests/src/tests/api/admin-user.test.ts b/packages/integration-tests/src/tests/api/admin-user.test.ts index 582dba88bf6..72fd002f73a 100644 --- a/packages/integration-tests/src/tests/api/admin-user.test.ts +++ b/packages/integration-tests/src/tests/api/admin-user.test.ts @@ -38,36 +38,39 @@ describe('admin console user management', () => { }); it('should create user with password digest successfully', async () => { - const user = await createUserByAdmin( - undefined, - undefined, - undefined, - undefined, - undefined, - '5f4dcc3b5aa765d61d8327deb882cf99', - UsersPasswordEncryptionMethod.MD5 - ); + const user = await createUserByAdmin({ + passwordDigest: '5f4dcc3b5aa765d61d8327deb882cf99', + passwordAlgorithm: UsersPasswordEncryptionMethod.MD5, + }); await expect(verifyUserPassword(user.id, 'password')).resolves.not.toThrow(); }); + it('should create user with custom data successfully', async () => { + const user = await createUserByAdmin({ + customData: { foo: 'bar' }, + }); + const { customData } = await getUser(user.id); + expect(customData).toStrictEqual({ foo: 'bar' }); + }); + it('should fail when create user with conflict identifiers', async () => { - const [username, password, email, phone] = [ + const [username, password, primaryEmail, primaryPhone] = [ generateUsername(), generatePassword(), generateEmail(), generatePhone(), ]; - await createUserByAdmin(username, password, email, phone); - await expectRejects(createUserByAdmin(username, password), { + await createUserByAdmin({ username, password, primaryEmail, primaryPhone }); + await expectRejects(createUserByAdmin({ username, password }), { code: 'user.username_already_in_use', statusCode: 422, }); - await expectRejects(createUserByAdmin(undefined, undefined, email), { + await expectRejects(createUserByAdmin({ primaryEmail }), { code: 'user.email_already_in_use', statusCode: 422, }); - await expectRejects(createUserByAdmin(undefined, undefined, undefined, phone), { + await expectRejects(createUserByAdmin({ primaryPhone }), { code: 'user.phone_already_in_use', statusCode: 422, }); @@ -108,8 +111,12 @@ describe('admin console user management', () => { }); it('should fail when update userinfo with conflict identifiers', async () => { - const [username, email, phone] = [generateUsername(), generateEmail(), generatePhone()]; - await createUserByAdmin(username, undefined, email, phone); + const [username, primaryEmail, primaryPhone] = [ + generateUsername(), + generateEmail(), + generatePhone(), + ]; + await createUserByAdmin({ username, primaryEmail, primaryPhone }); const anotherUser = await createUserByAdmin(); await expectRejects(updateUser(anotherUser.id, { username }), { @@ -117,12 +124,12 @@ describe('admin console user management', () => { statusCode: 422, }); - await expectRejects(updateUser(anotherUser.id, { primaryEmail: email }), { + await expectRejects(updateUser(anotherUser.id, { primaryEmail }), { code: 'user.email_already_in_use', statusCode: 422, }); - await expectRejects(updateUser(anotherUser.id, { primaryPhone: phone }), { + await expectRejects(updateUser(anotherUser.id, { primaryPhone }), { code: 'user.phone_already_in_use', statusCode: 422, }); @@ -229,13 +236,13 @@ describe('admin console user management', () => { }); it('should return 204 if password is correct', async () => { - const user = await createUserByAdmin(undefined, 'new_password'); + const user = await createUserByAdmin({ password: 'new_password' }); expect(await verifyUserPassword(user.id, 'new_password')).toHaveProperty('statusCode', 204); await deleteUser(user.id); }); it('should return 422 if password is incorrect', async () => { - const user = await createUserByAdmin(undefined, 'new_password'); + const user = await createUserByAdmin({ password: 'new_password' }); await expectRejects(verifyUserPassword(user.id, 'wrong_password'), { code: 'session.invalid_credentials', statusCode: 422, diff --git a/packages/integration-tests/src/tests/api/dashboard.test.ts b/packages/integration-tests/src/tests/api/dashboard.test.ts index d95e9bc41da..1a2a6d356e1 100644 --- a/packages/integration-tests/src/tests/api/dashboard.test.ts +++ b/packages/integration-tests/src/tests/api/dashboard.test.ts @@ -36,7 +36,7 @@ describe('admin console dashboard', () => { const password = generatePassword(); const username = generateUsername(); - await createUserByAdmin(username, password); + await createUserByAdmin({ username, password }); const { totalUserCount } = await getTotalUsersCount(); @@ -63,7 +63,7 @@ describe('admin console dashboard', () => { const password = generatePassword(); const username = generateUsername(); - await createUserByAdmin(username, password); + await createUserByAdmin({ username, password }); await signInWithPassword({ username, password }); diff --git a/packages/integration-tests/src/tests/api/oidc/always-issue-refresh-token.test.ts b/packages/integration-tests/src/tests/api/oidc/always-issue-refresh-token.test.ts index 7a48be1c90e..65919e2f7f2 100644 --- a/packages/integration-tests/src/tests/api/oidc/always-issue-refresh-token.test.ts +++ b/packages/integration-tests/src/tests/api/oidc/always-issue-refresh-token.test.ts @@ -34,7 +34,7 @@ describe('always issue Refresh Token config', () => { }; beforeAll(async () => { - await createUserByAdmin(username, password); + await createUserByAdmin({ username, password }); await enableAllPasswordSignInMethods(); }); diff --git a/packages/integration-tests/src/tests/api/oidc/get-access-token.test.ts b/packages/integration-tests/src/tests/api/oidc/get-access-token.test.ts index 7bd2a9569be..01cd3745d8e 100644 --- a/packages/integration-tests/src/tests/api/oidc/get-access-token.test.ts +++ b/packages/integration-tests/src/tests/api/oidc/get-access-token.test.ts @@ -26,8 +26,8 @@ describe('get access token', () => { const testApiScopeNames = ['read', 'write', 'delete', 'update']; beforeAll(async () => { - await createUserByAdmin(guestUsername, password); - const user = await createUserByAdmin(username, password); + await createUserByAdmin({ username: guestUsername, password }); + const user = await createUserByAdmin({ username, password }); const testApiResource = await createResource( testApiResourceInfo.name, testApiResourceInfo.indicator diff --git a/packages/integration-tests/src/tests/api/oidc/id-token.test.ts b/packages/integration-tests/src/tests/api/oidc/id-token.test.ts index 2f7059e579d..89156eea1bc 100644 --- a/packages/integration-tests/src/tests/api/oidc/id-token.test.ts +++ b/packages/integration-tests/src/tests/api/oidc/id-token.test.ts @@ -39,7 +39,7 @@ describe('OpenID Connect ID token', () => { }; beforeAll(async () => { - const { id } = await createUserByAdmin(username, password); + const { id } = await createUserByAdmin({ username, password }); // eslint-disable-next-line @silverhand/fp/no-mutation userId = id; await enableAllPasswordSignInMethods();