Skip to content

Commit

Permalink
Merge pull request #5529 from logto-io/yemq-log-8446-update-user-cont…
Browse files Browse the repository at this point in the history
…ext-type

refactor(core): update user context type
  • Loading branch information
darcyYe committed Mar 21, 2024
2 parents 9518658 + a2f20df commit f727ef8
Show file tree
Hide file tree
Showing 11 changed files with 109 additions and 112 deletions.
4 changes: 2 additions & 2 deletions packages/console/src/components/ItemPreview/UserPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type User } from '@logto/schemas';
import { type UserInfo } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';

import SuspendedTag from '@/pages/Users/components/SuspendedTag';
Expand All @@ -9,7 +9,7 @@ import UserAvatar from '../UserAvatar';
import ItemPreview from '.';

type Props = {
user: User;
user: UserInfo;
};

/** A component that renders a preview of a user. It's useful for displaying a user in a list. */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AdminConsoleKey } from '@logto/phrases';
import type { User } from '@logto/schemas';
import type { UserProfileResponse } from '@logto/schemas';
import { conditionalArray } from '@silverhand/essentials';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
Expand All @@ -15,7 +15,7 @@ import * as modalStyles from '@/scss/modal.module.scss';
import * as styles from './index.module.scss';

type Props = {
user: User;
user: UserProfileResponse;
password: string;
title: AdminConsoleKey;
onClose: () => void;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RoleResponse, User, Application } from '@logto/schemas';
import type { RoleResponse, UserProfileResponse, Application } from '@logto/schemas';
import { RoleType } from '@logto/schemas';
import { useState } from 'react';
import { toast } from 'react-hot-toast';
Expand All @@ -15,7 +15,7 @@ import { getUserTitle } from '@/utils/user';

type Props =
| {
entity: User;
entity: UserProfileResponse;
onClose: (success?: boolean) => void;
type: RoleType.User;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/console/src/pages/UserDetails/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { User } from '@logto/schemas';
import type { UserProfileResponse } from '@logto/schemas';
import { formatToInternationalPhoneNumber } from '@logto/shared/universal';
import { conditional } from '@silverhand/essentials';

import type { UserDetailsForm } from './types';

export const userDetailsParser = {
toLocalForm: (data: User): UserDetailsForm => {
toLocalForm: (data: UserProfileResponse): UserDetailsForm => {
const { primaryEmail, primaryPhone, username, name, avatar, customData } = data;
const parsedPhoneNumber = conditional(
primaryPhone && formatToInternationalPhoneNumber(primaryPhone)
Expand Down
6 changes: 3 additions & 3 deletions packages/console/src/utils/user.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { User } from '@logto/schemas';
import type { UserInfo } from '@logto/schemas';
import { getUserDisplayName } from '@logto/shared/universal';
import { t } from 'i18next';

export const getUserTitle = (user?: User): string =>
export const getUserTitle = (user?: UserInfo): string =>
(user ? getUserDisplayName(user) : undefined) ?? t('admin_console.users.unnamed');

export const getUserSubtitle = (user?: User) => {
export const getUserSubtitle = (user?: UserInfo) => {
if (!user?.name) {
return;
}
Expand Down
91 changes: 39 additions & 52 deletions packages/core/src/libraries/jwt-customizer.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,64 @@
import type { JwtCustomizerUserContext } from '@logto/schemas';
import {
userInfoSelectFields,
OrganizationScopes,
jwtCustomizerUserContextGuard,
} from '@logto/schemas';
import { userInfoSelectFields, jwtCustomizerUserContextGuard } from '@logto/schemas';
import { deduplicate, pick, pickState } from '@silverhand/essentials';

import { type ScopeLibrary } from '#src/libraries/scope.js';
import { type UserLibrary } from '#src/libraries/user.js';
import type Queries from '#src/tenants/Queries.js';

export const createJwtCustomizerLibrary = (queries: Queries, userLibrary: UserLibrary) => {
export const createJwtCustomizerLibrary = (
queries: Queries,
userLibrary: UserLibrary,
scopeLibrary: ScopeLibrary
) => {
const {
users: { findUserById },
rolesScopes: { findRolesScopesByRoleId },
scopes: { findScopeById },
resources: { findResourceById },
rolesScopes: { findRolesScopesByRoleIds },
scopes: { findScopesByIds },
userSsoIdentities,
organizations: { relations },
} = queries;
const { findUserRoles } = userLibrary;
const { attachResourceToScopes } = scopeLibrary;

/**
* We does not include org roles' scopes for the following reason:
* 1. The org scopes query method requires `limit` and `offset` parameters. Other management API get
* these APIs from console setup while this library method is a backend used method.
* 2. Logto developers can get the org roles' id from this user context and hence query the org roles' scopes via management API.
*/
const getUserContext = async (userId: string): Promise<JwtCustomizerUserContext> => {
const user = await findUserById(userId);
const fullSsoIdentities = await userSsoIdentities.findUserSsoIdentitiesByUserId(userId);
const roles = await findUserRoles(userId);
const rolesScopes = await findRolesScopesByRoleIds(roles.map(({ id }) => id));
const scopeIds = rolesScopes.map(({ scopeId }) => scopeId);
const scopes = await findScopesByIds(scopeIds);
const scopesWithResources = await attachResourceToScopes(scopes);
const organizationsWithRoles = await relations.users.getOrganizationsByUserId(userId);
const userContext = {
...pick(user, ...userInfoSelectFields),
ssoIdentities: fullSsoIdentities.map(pickState('issuer', 'identityId', 'detail')),
mfaVerificationFactors: deduplicate(user.mfaVerifications.map(({ type }) => type)),
roles: await Promise.all(
roles.map(async (role) => {
const fullRolesScopes = await findRolesScopesByRoleId(role.id);
const scopeIds = fullRolesScopes.map(({ scopeId }) => scopeId);
return {
...pick(role, 'id', 'name', 'description'),
scopes: await Promise.all(
scopeIds.map(async (scopeId) => {
const scope = await findScopeById(scopeId);
return {
...pick(scope, 'id', 'name', 'description'),
...(await findResourceById(scope.resourceId).then(
({ indicator, id: resourceId }) => ({ indicator, resourceId })
)),
};
})
),
};
})
),
// No need to deal with the type here, the type will be enforced by the guard when return the result.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
organizations: Object.fromEntries(
await Promise.all(
organizationsWithRoles.map(async ({ organizationRoles, ...organization }) => [
organization.id,
{
roles: await Promise.all(
organizationRoles.map(async ({ id, name }) => {
const [_, fullOrganizationScopes] = await relations.rolesScopes.getEntities(
OrganizationScopes,
{ organizationRoleId: id }
);
return {
id,
name,
scopes: fullOrganizationScopes.map(pickState('id', 'name', 'description')),
};
})
),
},
])
)
roles: roles.map((role) => {
const scopeIds = new Set(
rolesScopes.filter(({ roleId }) => roleId === role.id).map(({ scopeId }) => scopeId)
);
return {
...pick(role, 'id', 'name', 'description'),
scopes: scopesWithResources
.filter(({ id }) => scopeIds.has(id))
.map(pickState('id', 'name', 'description', 'resourceId', 'resource')),
};
}),
organizations: organizationsWithRoles.map(pickState('id', 'name', 'description')),
organizationRoles: organizationsWithRoles.flatMap(
({ id: organizationId, organizationRoles }) =>
organizationRoles.map(({ id: roleId, name: roleName }) => ({
organizationId,
roleId,
roleName,
}))
),
};

Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/libraries/scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Scope, ScopeResponse } from '@logto/schemas';

import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';

export type ScopeLibrary = ReturnType<typeof createScopeLibrary>;

export const createScopeLibrary = (queries: Queries) => {
const {
resources: { findResourcesByIds },
} = queries;

const attachResourceToScopes = async (scopes: readonly Scope[]): Promise<ScopeResponse[]> => {
const resources = await findResourcesByIds(scopes.map(({ resourceId }) => resourceId));
return scopes.map((scope) => {
const resource = resources.find(({ id }) => id === scope.resourceId);

assertThat(resource, new Error(`Cannot find resource for id ${scope.resourceId}`));

return {
...scope,
resource,
};
});
};

return {
attachResourceToScopes,
};
};
18 changes: 1 addition & 17 deletions packages/core/src/routes/role.scope.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { Scope, ScopeResponse } from '@logto/schemas';
import { scopeResponseGuard, Scopes } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { tryThat } from '@silverhand/essentials';
Expand All @@ -7,7 +6,6 @@ import { object, string } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import assertThat from '#src/utils/assert-that.js';
import { parseSearchParamsForSearch } from '#src/utils/search.js';

import type { AuthedRouter, RouterInitArgs } from './types.js';
Expand All @@ -16,30 +14,16 @@ export default function roleScopeRoutes<T extends AuthedRouter>(
...[router, { queries, libraries }]: RouterInitArgs<T>
) {
const {
resources: { findResourcesByIds },
rolesScopes: { deleteRolesScope, findRolesScopesByRoleId, insertRolesScopes },
roles: { findRoleById },
scopes: { findScopesByIds, countScopesByScopeIds, searchScopesByScopeIds },
} = queries;
const {
quota,
roleScopes: { validateRoleScopeAssignment },
scopes: { attachResourceToScopes },
} = libraries;

const attachResourceToScopes = async (scopes: readonly Scope[]): Promise<ScopeResponse[]> => {
const resources = await findResourcesByIds(scopes.map(({ resourceId }) => resourceId));
return scopes.map((scope) => {
const resource = resources.find(({ id }) => id === scope.resourceId);

assertThat(resource, new Error(`Cannot find resource for id ${scope.resourceId}`));

return {
...scope,
resource,
};
});
};

router.get(
'/roles/:id/scopes',
koaPagination({ isOptional: true }),
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/tenants/Libraries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createPhraseLibrary } from '#src/libraries/phrase.js';
import { createProtectedAppLibrary } from '#src/libraries/protected-app.js';
import { createQuotaLibrary } from '#src/libraries/quota.js';
import { createRoleScopeLibrary } from '#src/libraries/role-scope.js';
import { createScopeLibrary } from '#src/libraries/scope.js';
import { createSignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js';
import { createSocialLibrary } from '#src/libraries/social.js';
import { createSsoConnectorLibrary } from '#src/libraries/sso-connector.js';
Expand All @@ -22,8 +23,9 @@ export default class Libraries {
users = createUserLibrary(this.queries);
phrases = createPhraseLibrary(this.queries);
hooks = createHookLibrary(this.queries);
scopes = createScopeLibrary(this.queries);
socials = createSocialLibrary(this.queries, this.connectors);
jwtCustomizers = createJwtCustomizerLibrary(this.queries, this.users);
jwtCustomizers = createJwtCustomizerLibrary(this.queries, this.users, this.scopes);
passcodes = createPasscodeLibrary(this.queries, this.connectors);
applications = createApplicationLibrary(this.queries);
verificationStatuses = createVerificationStatusLibrary(this.queries);
Expand Down
50 changes: 20 additions & 30 deletions packages/schemas/src/types/jwt-customizer.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,33 @@
import { z } from 'zod';

import {
OrganizationRoles,
OrganizationScopes,
Resources,
Roles,
Scopes,
UserSsoIdentities,
} from '../db-entries/index.js';
import { Organizations, Roles, UserSsoIdentities } from '../db-entries/index.js';
import { mfaFactorsGuard, jsonObjectGuard } from '../foundations/index.js';

import { jwtCustomizerGuard } from './logto-config/index.js';
import { scopeResponseGuard } from './scope.js';
import { userInfoGuard } from './user.js';

const organizationDetailGuard = z.object({
roles: z.array(
OrganizationRoles.guard.pick({ id: true, name: true }).extend({
scopes: z.array(OrganizationScopes.guard.pick({ id: true, name: true, description: true })),
})
),
});

export type OrganizationDetail = z.infer<typeof organizationDetailGuard>;

export const jwtCustomizerUserContextGuard = userInfoGuard.extend({
ssoIdentities: z.array(
UserSsoIdentities.guard.pick({ issuer: true, identityId: true, detail: true })
),
ssoIdentities: UserSsoIdentities.guard
.pick({ issuer: true, identityId: true, detail: true })
.array(),
mfaVerificationFactors: mfaFactorsGuard,
roles: z.array(
Roles.guard.pick({ id: true, name: true, description: true }).extend({
scopes: z.array(
Scopes.guard
.pick({ id: true, name: true, description: true, resourceId: true })
.merge(Resources.guard.pick({ indicator: true }))
),
roles: Roles.guard
.pick({ id: true, name: true, description: true })
.extend({
scopes: scopeResponseGuard
.pick({ id: true, name: true, description: true, resourceId: true, resource: true })
.array(),
})
.array(),
organizations: Organizations.guard.pick({ id: true, name: true, description: true }).array(),
organizationRoles: z
.object({
organizationId: z.string(),
roleId: z.string(),
roleName: z.string(),
})
),
organizations: z.record(organizationDetailGuard),
.array(),
});

export type JwtCustomizerUserContext = z.infer<typeof jwtCustomizerUserContextGuard>;
Expand Down
6 changes: 5 additions & 1 deletion packages/schemas/src/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ export const userInfoSelectFields = Object.freeze([
] as const);

export const userInfoGuard = Users.guard.pick(
Object.fromEntries(userInfoSelectFields.map((key) => [key, true]))
// eslint-disable-next-line no-restricted-syntax
Object.fromEntries(userInfoSelectFields.map((field) => [field, true])) as Record<
(typeof userInfoSelectFields)[number],
true
>
);

export type UserInfo = z.infer<typeof userInfoGuard>;
Expand Down

0 comments on commit f727ef8

Please sign in to comment.