diff --git a/packages/core/src/routes/profile/index.openapi.json b/packages/core/src/routes/profile/index.openapi.json index ef688f6e7..383be79e9 100644 --- a/packages/core/src/routes/profile/index.openapi.json +++ b/packages/core/src/routes/profile/index.openapi.json @@ -24,6 +24,9 @@ }, "avatar": { "description": "The new avatar for the user, must be a URL." + }, + "username": { + "description": "The new username for the user, must be a valid username and unique." } } } @@ -36,6 +39,9 @@ }, "400": { "description": "The request body is invalid." + }, + "422": { + "description": "The username is already in use." } } } diff --git a/packages/core/src/routes/profile/index.ts b/packages/core/src/routes/profile/index.ts index 059b75cc5..3e79334af 100644 --- a/packages/core/src/routes/profile/index.ts +++ b/packages/core/src/routes/profile/index.ts @@ -1,4 +1,4 @@ -import { UserScope } from '@logto/core-kit'; +import { usernameRegEx, UserScope } from '@logto/core-kit'; import { conditional } from '@silverhand/essentials'; import { z } from 'zod'; @@ -18,6 +18,10 @@ export default function profileRoutes( users: { updateUserById }, } = queries; + const { + users: { checkIdentifierCollision }, + } = libraries; + router.use(koaOidcAuth(provider)); if (!EnvSet.values.isDevFeaturesEnabled) { @@ -30,22 +34,27 @@ export default function profileRoutes( body: z.object({ name: z.string().nullable().optional(), avatar: z.string().url().nullable().optional(), + username: z.string().regex(usernameRegEx).optional(), }), response: z.object({ name: z.string().nullable().optional(), avatar: z.string().nullable().optional(), + username: z.string().optional(), }), - status: [200, 400], + status: [200, 400, 422], }), async (ctx, next) => { const { id: userId, scopes } = ctx.auth; - const { - body: { name, avatar }, - } = ctx.guard; + const { body } = ctx.guard; + const { name, avatar, username } = body; assertThat(scopes.has(UserScope.Profile), 'auth.unauthorized'); - const updatedUser = await updateUserById(userId, { name, avatar }); + if (username !== undefined) { + await checkIdentifierCollision({ username }, userId); + } + + const updatedUser = await updateUserById(userId, { name, avatar, username }); // TODO(LOG-10005): trigger user updated webhook @@ -53,6 +62,7 @@ export default function profileRoutes( ctx.body = { ...conditional(name !== undefined && { name: updatedUser.name }), ...conditional(avatar !== undefined && { avatar: updatedUser.avatar }), + ...conditional(username !== undefined && { username: updatedUser.username }), }; return next(); diff --git a/packages/integration-tests/src/api/profile.ts b/packages/integration-tests/src/api/profile.ts index b35aff546..2a6bc44e5 100644 --- a/packages/integration-tests/src/api/profile.ts +++ b/packages/integration-tests/src/api/profile.ts @@ -11,6 +11,7 @@ export const updateUser = async (api: KyInstance, body: Record) api.patch('api/profile', { json: body }).json<{ name?: string; avatar?: string; + username?: string; }>(); export const getUserInfo = async (api: KyInstance) => api.get('oidc/me').json(); diff --git a/packages/integration-tests/src/tests/api/profile/index.test.ts b/packages/integration-tests/src/tests/api/profile/index.test.ts index aee0d7aef..64700e565 100644 --- a/packages/integration-tests/src/tests/api/profile/index.test.ts +++ b/packages/integration-tests/src/tests/api/profile/index.test.ts @@ -46,6 +46,34 @@ describe('profile', () => { await deleteUser(user.id); }); + + it('should be able to update username', async () => { + const { user, username, password } = await createUserWithPassword(); + const api = await signInAndGetUserApi(username, password); + const newUsername = generateUsername(); + + const response = await updateUser(api, { username: newUsername }); + expect(response).toMatchObject({ username: newUsername }); + + // Sign in with new username + await initClientAndSignIn(newUsername, password); + + await deleteUser(user.id); + }); + + it('should fail if username is already in use', async () => { + const { user, username, password } = await createUserWithPassword(); + const { user: user2, username: username2 } = await createUserWithPassword(); + const api = await signInAndGetUserApi(username, password); + + await expectRejects(updateUser(api, { username: username2 }), { + code: 'user.username_already_in_use', + status: 422, + }); + + await deleteUser(user.id); + await deleteUser(user2.id); + }); }); describe('POST /profile/password', () => {