From 3131802c6a23d9e8e1ed538545ba376bb42abe30 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Sun, 29 Sep 2024 10:34:59 +0800 Subject: [PATCH] feat(core): add update name and avatar endpoint (#6636) --- .../src/routes/profile/index.openapi.json | 31 ++++++++++++++ packages/core/src/routes/profile/index.ts | 37 +++++++++++++++++ packages/integration-tests/src/api/api.ts | 4 ++ packages/integration-tests/src/api/profile.ts | 11 ++++- .../src/api/verification-record.ts | 2 +- .../src/helpers/admin-tenant.ts | 10 ----- .../integration-tests/src/helpers/profile.ts | 20 +++++++++ .../src/tests/api/profile/index.test.ts | 41 ++++++++++++++++--- 8 files changed, 139 insertions(+), 17 deletions(-) create mode 100644 packages/integration-tests/src/helpers/profile.ts diff --git a/packages/core/src/routes/profile/index.openapi.json b/packages/core/src/routes/profile/index.openapi.json index da25c8f93..ef688f6e7 100644 --- a/packages/core/src/routes/profile/index.openapi.json +++ b/packages/core/src/routes/profile/index.openapi.json @@ -9,6 +9,37 @@ } ], "paths": { + "/api/profile": { + "patch": { + "operationId": "UpdateProfile", + "summary": "Update profile", + "description": "Update profile for the user, only the fields that are passed in will be updated.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "description": "The new name for the user." + }, + "avatar": { + "description": "The new avatar for the user, must be a URL." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The profile was updated successfully." + }, + "400": { + "description": "The request body is invalid." + } + } + } + }, "/api/profile/password": { "post": { "operationId": "UpdatePassword", diff --git a/packages/core/src/routes/profile/index.ts b/packages/core/src/routes/profile/index.ts index 39fca4134..059b75cc5 100644 --- a/packages/core/src/routes/profile/index.ts +++ b/packages/core/src/routes/profile/index.ts @@ -1,3 +1,5 @@ +import { UserScope } from '@logto/core-kit'; +import { conditional } from '@silverhand/essentials'; import { z } from 'zod'; import koaGuard from '#src/middleware/koa-guard.js'; @@ -22,6 +24,41 @@ export default function profileRoutes( return; } + router.patch( + '/profile', + koaGuard({ + body: z.object({ + name: z.string().nullable().optional(), + avatar: z.string().url().nullable().optional(), + }), + response: z.object({ + name: z.string().nullable().optional(), + avatar: z.string().nullable().optional(), + }), + status: [200, 400], + }), + async (ctx, next) => { + const { id: userId, scopes } = ctx.auth; + const { + body: { name, avatar }, + } = ctx.guard; + + assertThat(scopes.has(UserScope.Profile), 'auth.unauthorized'); + + const updatedUser = await updateUserById(userId, { name, avatar }); + + // TODO(LOG-10005): trigger user updated webhook + + // Only return the fields that were actually updated + ctx.body = { + ...conditional(name !== undefined && { name: updatedUser.name }), + ...conditional(avatar !== undefined && { avatar: updatedUser.avatar }), + }; + + return next(); + } + ); + router.post( '/profile/password', koaGuard({ diff --git a/packages/integration-tests/src/api/api.ts b/packages/integration-tests/src/api/api.ts index 5fcd0882e..06b025259 100644 --- a/packages/integration-tests/src/api/api.ts +++ b/packages/integration-tests/src/api/api.ts @@ -10,6 +10,10 @@ const api = ky.extend({ export default api; +export const baseAdminTenantApi = ky.extend({ + prefixUrl: new URL(logtoConsoleUrl), +}); + // TODO: @gao rename export const authedAdminApi = api.extend({ headers: { diff --git a/packages/integration-tests/src/api/profile.ts b/packages/integration-tests/src/api/profile.ts index 8c9a5ac3b..b35aff546 100644 --- a/packages/integration-tests/src/api/profile.ts +++ b/packages/integration-tests/src/api/profile.ts @@ -1,7 +1,16 @@ +import { type UserInfoResponse } from '@logto/js'; import { type KyInstance } from 'ky'; export const updatePassword = async ( api: KyInstance, verificationRecordId: string, password: string -) => api.post('profile/password', { json: { password, verificationRecordId } }); +) => api.post('api/profile/password', { json: { password, verificationRecordId } }); + +export const updateUser = async (api: KyInstance, body: Record) => + api.patch('api/profile', { json: body }).json<{ + name?: string; + avatar?: string; + }>(); + +export const getUserInfo = async (api: KyInstance) => api.get('oidc/me').json(); diff --git a/packages/integration-tests/src/api/verification-record.ts b/packages/integration-tests/src/api/verification-record.ts index 5e74378d0..d7a71cd6d 100644 --- a/packages/integration-tests/src/api/verification-record.ts +++ b/packages/integration-tests/src/api/verification-record.ts @@ -2,7 +2,7 @@ import { type KyInstance } from 'ky'; export const createVerificationRecordByPassword = async (api: KyInstance, password: string) => { const { verificationRecordId } = await api - .post('verifications/password', { + .post('api/verifications/password', { json: { password, }, diff --git a/packages/integration-tests/src/helpers/admin-tenant.ts b/packages/integration-tests/src/helpers/admin-tenant.ts index 65fc35191..091febeb4 100644 --- a/packages/integration-tests/src/helpers/admin-tenant.ts +++ b/packages/integration-tests/src/helpers/admin-tenant.ts @@ -114,16 +114,6 @@ export const initClientAndSignIn = async ( return client; }; -export const signInAndGetProfileApi = async (username: string, password: string) => { - const client = await initClientAndSignIn(username, password); - const accessToken = await client.getAccessToken(); - return api.extend({ - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); -}; - export const createUserWithAllRolesAndSignInToClient = async () => { const [{ id }, { username, password }] = await createUserWithAllRoles(); const client = await initClientAndSignIn(username, password, { diff --git a/packages/integration-tests/src/helpers/profile.ts b/packages/integration-tests/src/helpers/profile.ts new file mode 100644 index 000000000..c92264131 --- /dev/null +++ b/packages/integration-tests/src/helpers/profile.ts @@ -0,0 +1,20 @@ +import { type LogtoConfig } from '@logto/node'; + +import { baseAdminTenantApi } from '../api/api.js'; + +import { initClientAndSignIn } from './admin-tenant.js'; + +export const signInAndGetUserApi = async ( + username: string, + password: string, + config?: Partial +) => { + const client = await initClientAndSignIn(username, password, config); + const accessToken = await client.getAccessToken(); + + return baseAdminTenantApi.extend({ + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); +}; 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 776f2fc3c..aee0d7aef 100644 --- a/packages/integration-tests/src/tests/api/profile/index.test.ts +++ b/packages/integration-tests/src/tests/api/profile/index.test.ts @@ -1,14 +1,14 @@ -import { updatePassword } from '#src/api/profile.js'; +import { getUserInfo, updatePassword, updateUser } from '#src/api/profile.js'; import { createVerificationRecordByPassword } from '#src/api/verification-record.js'; import { createUserWithPassword, deleteUser, initClientAndSignIn, - signInAndGetProfileApi, } from '#src/helpers/admin-tenant.js'; import { expectRejects } from '#src/helpers/index.js'; +import { signInAndGetUserApi } from '#src/helpers/profile.js'; import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; -import { devFeatureTest, generatePassword } from '#src/utils.js'; +import { devFeatureTest, generatePassword, generateUsername } from '#src/utils.js'; const { describe, it } = devFeatureTest; @@ -17,10 +17,41 @@ describe('profile', () => { await enableAllPasswordSignInMethods(); }); + describe('PATCH /profile', () => { + it('should be able to update name', async () => { + const { user, username, password } = await createUserWithPassword(); + const api = await signInAndGetUserApi(username, password); + const newName = generateUsername(); + + const response = await updateUser(api, { name: newName }); + expect(response).toMatchObject({ name: newName }); + + const userInfo = await getUserInfo(api); + expect(userInfo).toHaveProperty('name', newName); + + await deleteUser(user.id); + }); + + it('should be able to update picture', async () => { + const { user, username, password } = await createUserWithPassword(); + const api = await signInAndGetUserApi(username, password); + const newAvatar = 'https://example.com/avatar.png'; + + const response = await updateUser(api, { avatar: newAvatar }); + expect(response).toMatchObject({ avatar: newAvatar }); + + const userInfo = await getUserInfo(api); + // In OIDC, the avatar is mapped to the `picture` field + expect(userInfo).toHaveProperty('picture', newAvatar); + + await deleteUser(user.id); + }); + }); + describe('POST /profile/password', () => { it('should fail if verification record is invalid', async () => { const { user, username, password } = await createUserWithPassword(); - const api = await signInAndGetProfileApi(username, password); + const api = await signInAndGetUserApi(username, password); const newPassword = generatePassword(); await expectRejects(updatePassword(api, 'invalid-varification-record-id', newPassword), { @@ -33,7 +64,7 @@ describe('profile', () => { it('should be able to update password', async () => { const { user, username, password } = await createUserWithPassword(); - const api = await signInAndGetProfileApi(username, password); + const api = await signInAndGetUserApi(username, password); const verificationRecordId = await createVerificationRecordByPassword(api, password); const newPassword = generatePassword();