0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): get user profile (#6650)

This commit is contained in:
wangsijie 2024-10-10 10:30:20 +08:00 committed by GitHub
parent 72a57e23cd
commit 3eede7893f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 167 additions and 19 deletions

View file

@ -10,6 +10,16 @@
],
"paths": {
"/api/profile": {
"get": {
"operationId": "GetProfile",
"summary": "Get profile",
"description": "Get profile for the user.",
"responses": {
"200": {
"description": "The profile was retrieved successfully."
}
}
},
"patch": {
"operationId": "UpdateProfile",
"summary": "Update profile",

View file

@ -1,6 +1,5 @@
import { emailRegEx, usernameRegEx, UserScope } from '@logto/core-kit';
import { VerificationType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { VerificationType, userProfileResponseGuard } from '@logto/schemas';
import { z } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
@ -15,6 +14,8 @@ import assertThat from '../../utils/assert-that.js';
import { PasswordValidator } from '../experience/classes/libraries/password-validator.js';
import type { UserRouter, RouterInitArgs } from '../types.js';
import { getScopedProfile } from './utils/get-scoped-profile.js';
export default function profileRoutes<T extends UserRouter>(
...[router, { queries, libraries }]: RouterInitArgs<T>
) {
@ -31,6 +32,19 @@ export default function profileRoutes<T extends UserRouter>(
return;
}
router.get(
'/profile',
koaGuard({
response: userProfileResponseGuard.partial(),
status: [200],
}),
async (ctx, next) => {
const { id: userId, scopes } = ctx.auth;
ctx.body = await getScopedProfile(queries, libraries, scopes, userId);
return next();
}
);
router.patch(
'/profile',
koaGuard({
@ -39,11 +53,7 @@ export default function profileRoutes<T extends UserRouter>(
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(),
}),
response: userProfileResponseGuard.partial(),
status: [200, 400, 422],
}),
async (ctx, next) => {
@ -61,12 +71,7 @@ export default function profileRoutes<T extends UserRouter>(
ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
// Only return the fields that were actually updated
ctx.body = {
...conditional(name !== undefined && { name: updatedUser.name }),
...conditional(avatar !== undefined && { avatar: updatedUser.avatar }),
...conditional(username !== undefined && { username: updatedUser.username }),
};
ctx.body = await getScopedProfile(queries, libraries, scopes, userId);
return next();
}

View file

@ -0,0 +1,69 @@
import { UserScope } from '@logto/core-kit';
import { type UserProfileResponse } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type Libraries from '../../../tenants/Libraries.js';
import type Queries from '../../../tenants/Queries.js';
import { transpileUserProfileResponse } from '../../../utils/user.js';
/**
* Get the user profile, and filter the fields according to the scopes.
* The scopes and fields are defined in the core-kit, see packages/toolkit/core-kit/src/openid.ts
*/
export const getScopedProfile = async (
queries: Queries,
libraries: Libraries,
scopes: Set<string>,
userId: string
): Promise<Partial<UserProfileResponse>> => {
const user = await queries.users.findUserById(userId);
const ssoIdentities = scopes.has(UserScope.Identities) && [
...(await libraries.users.findUserSsoIdentities(userId)),
];
const {
id,
username,
primaryEmail,
primaryPhone,
name,
avatar,
customData,
identities,
lastSignInAt,
createdAt,
updatedAt,
profile: { address, ...restProfile },
applicationId,
isSuspended,
hasPassword,
} = transpileUserProfileResponse(user);
return {
id,
...conditional(ssoIdentities),
...conditional(scopes.has(UserScope.Identities) && { identities }),
...conditional(scopes.has(UserScope.CustomData) && { customData }),
...conditional(scopes.has(UserScope.Email) && { primaryEmail }),
...conditional(scopes.has(UserScope.Phone) && { primaryPhone }),
...conditional(
// Basic profile and all custom claims not defined in the scope are included
scopes.has(UserScope.Profile) && {
name,
avatar,
username,
profile: {
...restProfile,
...conditional(scopes.has(UserScope.Address) && { address }),
},
lastSignInAt,
createdAt,
updatedAt,
applicationId,
isSuspended,
hasPassword,
}
),
};
};

View file

@ -1,4 +1,4 @@
import { type UserInfoResponse } from '@logto/js';
import { type UserProfileResponse } from '@logto/schemas';
import { type KyInstance } from 'ky';
export const updatePassword = async (
@ -24,4 +24,5 @@ export const updateUser = async (api: KyInstance, body: Record<string, string>)
username?: string;
}>();
export const getUserInfo = async (api: KyInstance) => api.get('oidc/me').json<UserInfoResponse>();
export const getUserInfo = async (api: KyInstance) =>
api.get('api/profile').json<Partial<UserProfileResponse>>();

View file

@ -104,7 +104,7 @@ describe('profile (email and phone)', () => {
await updatePrimaryEmail(api, newEmail, verificationRecordId, newVerificationRecordId);
const userInfo = await getUserInfo(api);
expect(userInfo).toHaveProperty('email', newEmail);
expect(userInfo).toHaveProperty('primaryEmail', newEmail);
await deleteDefaultTenantUser(user.id);
});
@ -130,7 +130,7 @@ describe('profile (email and phone)', () => {
await updatePrimaryEmail(api, newEmail, verificationRecordId, newVerificationRecordId);
const userInfo = await getUserInfo(api);
expect(userInfo).toHaveProperty('email', newEmail);
expect(userInfo).toHaveProperty('primaryEmail', newEmail);
await deleteDefaultTenantUser(user.id);
});

View file

@ -1,3 +1,4 @@
import { UserScope } from '@logto/core-kit';
import { hookEvents } from '@logto/schemas';
import { getUserInfo, updatePassword, updateUser } from '#src/api/profile.js';
@ -44,6 +45,69 @@ describe('profile', () => {
await webHookApi.cleanUp();
});
describe('GET /profile', () => {
it('should be able to get profile with default scopes', async () => {
const { user, username, password } = await createDefaultTenantUserWithPassword();
const api = await signInAndGetUserApi(username, password);
const response = await getUserInfo(api);
expect(response).toMatchObject({ username });
expect(response).not.toHaveProperty('customData');
expect(response).not.toHaveProperty('identities');
expect(response).not.toHaveProperty('primaryEmail');
expect(response).not.toHaveProperty('primaryPhone');
await deleteDefaultTenantUser(user.id);
});
it('should return profile data based on scopes (email)', async () => {
const { user, username, password } = await createDefaultTenantUserWithPassword();
const api = await signInAndGetUserApi(username, password, {
scopes: [UserScope.Email],
});
const response = await getUserInfo(api);
expect(response).toHaveProperty('primaryEmail');
await deleteDefaultTenantUser(user.id);
});
it('should return profile data based on scopes (phone)', async () => {
const { user, username, password } = await createDefaultTenantUserWithPassword();
const api = await signInAndGetUserApi(username, password, {
scopes: [UserScope.Phone],
});
const response = await getUserInfo(api);
expect(response).toHaveProperty('primaryPhone');
await deleteDefaultTenantUser(user.id);
});
it('should return profile data based on scopes (identities)', async () => {
const { user, username, password } = await createDefaultTenantUserWithPassword();
const api = await signInAndGetUserApi(username, password, {
scopes: [UserScope.Identities],
});
const response = await getUserInfo(api);
expect(response).toHaveProperty('identities');
await deleteDefaultTenantUser(user.id);
});
it('should return profile data based on scopes (custom data)', async () => {
const { user, username, password } = await createDefaultTenantUserWithPassword();
const api = await signInAndGetUserApi(username, password, {
scopes: [UserScope.CustomData],
});
const response = await getUserInfo(api);
expect(response).toHaveProperty('customData');
await deleteDefaultTenantUser(user.id);
});
});
describe('PATCH /profile', () => {
it('should be able to update name', async () => {
const { user, username, password } = await createDefaultTenantUserWithPassword();
@ -79,8 +143,7 @@ describe('profile', () => {
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);
expect(userInfo).toHaveProperty('avatar', newAvatar);
await deleteDefaultTenantUser(user.id);
});