0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): add update name and avatar endpoint (#6636)

This commit is contained in:
wangsijie 2024-09-29 10:34:59 +08:00 committed by GitHub
parent fc6f94f111
commit 3131802c6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 139 additions and 17 deletions

View file

@ -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",

View file

@ -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<T extends UserRouter>(
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({

View file

@ -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: {

View file

@ -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<string, string>) =>
api.patch('api/profile', { json: body }).json<{
name?: string;
avatar?: string;
}>();
export const getUserInfo = async (api: KyInstance) => api.get('oidc/me').json<UserInfoResponse>();

View file

@ -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,
},

View file

@ -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, {

View file

@ -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<LogtoConfig>
) => {
const client = await initClientAndSignIn(username, password, config);
const accessToken = await client.getAccessToken();
return baseAdminTenantApi.extend({
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
};

View file

@ -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();