Skip to content

Commit

Permalink
feat(core): get user profile
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie committed Oct 1, 2024
1 parent 37b05f9 commit 4116726
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 16 deletions.
10 changes: 10 additions & 0 deletions packages/core/src/routes/profile/index.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 18 additions & 12 deletions packages/core/src/routes/profile/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { usernameRegEx, UserScope } from '@logto/core-kit';
import { conditional } from '@silverhand/essentials';
import { userProfileResponseGuard } from '@logto/schemas';
import { z } from 'zod';

import koaGuard from '#src/middleware/koa-guard.js';
Expand All @@ -10,6 +10,8 @@ import { buildUserVerificationRecordById } from '../../libraries/verification.js
import assertThat from '../../utils/assert-that.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>
) {
Expand All @@ -25,6 +27,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();
}

Check warning on line 40 in packages/core/src/routes/profile/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/index.ts#L37-L40

Added lines #L37 - L40 were not covered by tests
);

router.patch(
'/profile',
koaGuard({
Expand All @@ -33,11 +48,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) => {
Expand All @@ -55,12 +66,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);

Check warning on line 69 in packages/core/src/routes/profile/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/index.ts#L69

Added line #L69 was not covered by tests

return next();
}
Expand Down
69 changes: 69 additions & 0 deletions packages/core/src/routes/profile/utils/get-scoped-profile.ts
Original file line number Diff line number Diff line change
@@ -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,
}
),
};
};

Check warning on line 69 in packages/core/src/routes/profile/utils/get-scoped-profile.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/utils/get-scoped-profile.ts#L14-L69

Added lines #L14 - L69 were not covered by tests
5 changes: 3 additions & 2 deletions packages/integration-tests/src/api/profile.ts
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -14,4 +14,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>>();
14 changes: 12 additions & 2 deletions packages/integration-tests/src/tests/api/profile/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ describe('profile', () => {
await webHookApi.cleanUp();
});

describe('GET /profile', () => {
it('should be able to get profile', async () => {
const { user, username, password } = await createDefaultTenantUserWithPassword();
const api = await signInAndGetUserApi(username, password);
const response = await getUserInfo(api);
expect(response).toMatchObject({ username });

await deleteDefaultTenantUser(user.id);
});
});

describe('PATCH /profile', () => {
it('should be able to update name', async () => {
const { user, username, password } = await createDefaultTenantUserWithPassword();
Expand Down Expand Up @@ -79,8 +90,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);
});
Expand Down

0 comments on commit 4116726

Please sign in to comment.