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:
parent
72a57e23cd
commit
3eede7893f
6 changed files with 167 additions and 19 deletions
|
@ -10,6 +10,16 @@
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"/api/profile": {
|
"/api/profile": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "GetProfile",
|
||||||
|
"summary": "Get profile",
|
||||||
|
"description": "Get profile for the user.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The profile was retrieved successfully."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"patch": {
|
"patch": {
|
||||||
"operationId": "UpdateProfile",
|
"operationId": "UpdateProfile",
|
||||||
"summary": "Update profile",
|
"summary": "Update profile",
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { emailRegEx, usernameRegEx, UserScope } from '@logto/core-kit';
|
import { emailRegEx, usernameRegEx, UserScope } from '@logto/core-kit';
|
||||||
import { VerificationType } from '@logto/schemas';
|
import { VerificationType, userProfileResponseGuard } from '@logto/schemas';
|
||||||
import { conditional } from '@silverhand/essentials';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
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 { PasswordValidator } from '../experience/classes/libraries/password-validator.js';
|
||||||
import type { UserRouter, RouterInitArgs } from '../types.js';
|
import type { UserRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
|
import { getScopedProfile } from './utils/get-scoped-profile.js';
|
||||||
|
|
||||||
export default function profileRoutes<T extends UserRouter>(
|
export default function profileRoutes<T extends UserRouter>(
|
||||||
...[router, { queries, libraries }]: RouterInitArgs<T>
|
...[router, { queries, libraries }]: RouterInitArgs<T>
|
||||||
) {
|
) {
|
||||||
|
@ -31,6 +32,19 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
return;
|
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(
|
router.patch(
|
||||||
'/profile',
|
'/profile',
|
||||||
koaGuard({
|
koaGuard({
|
||||||
|
@ -39,11 +53,7 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
avatar: z.string().url().nullable().optional(),
|
avatar: z.string().url().nullable().optional(),
|
||||||
username: z.string().regex(usernameRegEx).optional(),
|
username: z.string().regex(usernameRegEx).optional(),
|
||||||
}),
|
}),
|
||||||
response: z.object({
|
response: userProfileResponseGuard.partial(),
|
||||||
name: z.string().nullable().optional(),
|
|
||||||
avatar: z.string().nullable().optional(),
|
|
||||||
username: z.string().optional(),
|
|
||||||
}),
|
|
||||||
status: [200, 400, 422],
|
status: [200, 400, 422],
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
|
@ -61,12 +71,7 @@ export default function profileRoutes<T extends UserRouter>(
|
||||||
|
|
||||||
ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
|
ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
|
||||||
|
|
||||||
// Only return the fields that were actually updated
|
ctx.body = await getScopedProfile(queries, libraries, scopes, userId);
|
||||||
ctx.body = {
|
|
||||||
...conditional(name !== undefined && { name: updatedUser.name }),
|
|
||||||
...conditional(avatar !== undefined && { avatar: updatedUser.avatar }),
|
|
||||||
...conditional(username !== undefined && { username: updatedUser.username }),
|
|
||||||
};
|
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
69
packages/core/src/routes/profile/utils/get-scoped-profile.ts
Normal file
69
packages/core/src/routes/profile/utils/get-scoped-profile.ts
Normal 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,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,4 +1,4 @@
|
||||||
import { type UserInfoResponse } from '@logto/js';
|
import { type UserProfileResponse } from '@logto/schemas';
|
||||||
import { type KyInstance } from 'ky';
|
import { type KyInstance } from 'ky';
|
||||||
|
|
||||||
export const updatePassword = async (
|
export const updatePassword = async (
|
||||||
|
@ -24,4 +24,5 @@ export const updateUser = async (api: KyInstance, body: Record<string, string>)
|
||||||
username?: 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>>();
|
||||||
|
|
|
@ -104,7 +104,7 @@ describe('profile (email and phone)', () => {
|
||||||
await updatePrimaryEmail(api, newEmail, verificationRecordId, newVerificationRecordId);
|
await updatePrimaryEmail(api, newEmail, verificationRecordId, newVerificationRecordId);
|
||||||
|
|
||||||
const userInfo = await getUserInfo(api);
|
const userInfo = await getUserInfo(api);
|
||||||
expect(userInfo).toHaveProperty('email', newEmail);
|
expect(userInfo).toHaveProperty('primaryEmail', newEmail);
|
||||||
|
|
||||||
await deleteDefaultTenantUser(user.id);
|
await deleteDefaultTenantUser(user.id);
|
||||||
});
|
});
|
||||||
|
@ -130,7 +130,7 @@ describe('profile (email and phone)', () => {
|
||||||
await updatePrimaryEmail(api, newEmail, verificationRecordId, newVerificationRecordId);
|
await updatePrimaryEmail(api, newEmail, verificationRecordId, newVerificationRecordId);
|
||||||
|
|
||||||
const userInfo = await getUserInfo(api);
|
const userInfo = await getUserInfo(api);
|
||||||
expect(userInfo).toHaveProperty('email', newEmail);
|
expect(userInfo).toHaveProperty('primaryEmail', newEmail);
|
||||||
|
|
||||||
await deleteDefaultTenantUser(user.id);
|
await deleteDefaultTenantUser(user.id);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { UserScope } from '@logto/core-kit';
|
||||||
import { hookEvents } from '@logto/schemas';
|
import { hookEvents } from '@logto/schemas';
|
||||||
|
|
||||||
import { getUserInfo, updatePassword, updateUser } from '#src/api/profile.js';
|
import { getUserInfo, updatePassword, updateUser } from '#src/api/profile.js';
|
||||||
|
@ -44,6 +45,69 @@ describe('profile', () => {
|
||||||
await webHookApi.cleanUp();
|
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', () => {
|
describe('PATCH /profile', () => {
|
||||||
it('should be able to update name', async () => {
|
it('should be able to update name', async () => {
|
||||||
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
@ -79,8 +143,7 @@ describe('profile', () => {
|
||||||
expect(response).toMatchObject({ avatar: newAvatar });
|
expect(response).toMatchObject({ avatar: newAvatar });
|
||||||
|
|
||||||
const userInfo = await getUserInfo(api);
|
const userInfo = await getUserInfo(api);
|
||||||
// In OIDC, the avatar is mapped to the `picture` field
|
expect(userInfo).toHaveProperty('avatar', newAvatar);
|
||||||
expect(userInfo).toHaveProperty('picture', newAvatar);
|
|
||||||
|
|
||||||
await deleteDefaultTenantUser(user.id);
|
await deleteDefaultTenantUser(user.id);
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue