Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(core): update user context type #5529

Merged
merged 5 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
darcyYe marked this conversation as resolved.
Show resolved Hide resolved
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
Loading