mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
Merge pull request #5529 from logto-io/yemq-log-8446-update-user-context-type
refactor(core): update user context type
This commit is contained in:
commit
f727ef8415
11 changed files with 109 additions and 112 deletions
|
@ -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';
|
||||
|
@ -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. */
|
||||
|
|
|
@ -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';
|
||||
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
@ -15,7 +15,7 @@ import { getUserTitle } from '@/utils/user';
|
|||
|
||||
type Props =
|
||||
| {
|
||||
entity: User;
|
||||
entity: UserProfileResponse;
|
||||
onClose: (success?: boolean) => void;
|
||||
type: RoleType.User;
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}))
|
||||
),
|
||||
};
|
||||
|
||||
|
|
30
packages/core/src/libraries/scope.ts
Normal file
30
packages/core/src/libraries/scope.ts
Normal file
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -16,7 +14,6 @@ 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 },
|
||||
|
@ -24,22 +21,9 @@ export default function roleScopeRoutes<T extends AuthedRouter>(
|
|||
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 }),
|
||||
|
|
|
@ -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';
|
||||
|
@ -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);
|
||||
|
|
|
@ -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(),
|
||||
})
|
||||
),
|
||||
organizations: z.record(organizationDetailGuard),
|
||||
.array(),
|
||||
organizations: Organizations.guard.pick({ id: true, name: true, description: true }).array(),
|
||||
organizationRoles: z
|
||||
.object({
|
||||
organizationId: z.string(),
|
||||
roleId: z.string(),
|
||||
roleName: z.string(),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
export type JwtCustomizerUserContext = z.infer<typeof jwtCustomizerUserContextGuard>;
|
||||
|
|
|
@ -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>;
|
||||
|
|
Loading…
Add table
Reference in a new issue