From 787359c1c493a161da8a21f1933064ea9de98adc Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 16 Nov 2023 11:26:26 +0800 Subject: [PATCH] feat(console): show SSO identities on user details page and reorg the page (#4687) --- .../UserSocialIdentities/index.module.scss | 5 +- .../components/UserSocialIdentities/index.tsx | 21 ++-- .../components/UserSsoIdentities/index.tsx | 116 ++++++++++++++++++ .../pages/UserDetails/UserSettings/index.tsx | 37 +++--- .../console/src/pages/UserDetails/index.tsx | 8 +- .../console/src/pages/UserDetails/types.ts | 4 +- .../core/src/queries/user-sso-identities.ts | 17 ++- packages/core/src/routes/admin-user/basics.ts | 15 ++- .../integration-tests/src/api/admin-user.ts | 20 ++- .../src/tests/api/admin-user.test.ts | 6 + packages/schemas/src/types/user.ts | 3 +- 11 files changed, 217 insertions(+), 35 deletions(-) create mode 100644 packages/console/src/pages/UserDetails/UserSettings/components/UserSsoIdentities/index.tsx diff --git a/packages/console/src/pages/UserDetails/UserSettings/components/UserSocialIdentities/index.module.scss b/packages/console/src/pages/UserDetails/UserSettings/components/UserSocialIdentities/index.module.scss index aed5b4b60..36611ebc4 100644 --- a/packages/console/src/pages/UserDetails/UserSettings/components/UserSocialIdentities/index.module.scss +++ b/packages/console/src/pages/UserDetails/UserSettings/components/UserSocialIdentities/index.module.scss @@ -1,8 +1,9 @@ @use '@/scss/underscore' as _; -.empty { +.description { color: var(--color-text-secondary); font: var(--font-body-2); + margin-bottom: _.unit(1); } .connectorName { @@ -21,7 +22,7 @@ } } -.connectorId { +.userId { display: flex; align-items: center; font: var(--font-body-2); diff --git a/packages/console/src/pages/UserDetails/UserSettings/components/UserSocialIdentities/index.tsx b/packages/console/src/pages/UserDetails/UserSettings/components/UserSocialIdentities/index.tsx index 81b039c8c..dcb64cd1c 100644 --- a/packages/console/src/pages/UserDetails/UserSettings/components/UserSocialIdentities/index.tsx +++ b/packages/console/src/pages/UserDetails/UserSettings/components/UserSocialIdentities/index.tsx @@ -34,8 +34,12 @@ function ConnectorName({ name }: { name: DisplayConnector['name'] }) { function UserSocialIdentities({ userId, identities, onDelete }: Props) { const api = useApi(); - const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { t } = useTranslation(undefined, { + keyPrefix: 'admin_console', + }); + const { data, error, mutate } = useSWR('api/connectors'); + const [deletingConnector, setDeletingConnector] = useState(); const [isSubmitting, setIsSubmitting] = useState(false); @@ -77,13 +81,16 @@ function UserSocialIdentities({ userId, identities, onDelete }: Props) { }); }, [connectorGroups, identities, t]); - if (Object.keys(identities).length === 0) { - return
{t('user_details.connectors.not_connected')}
; - } - return (
- {displayConnectors && ( +
+ {t( + displayConnectors && displayConnectors.length > 0 + ? 'user_details.connectors.connected' + : 'user_details.connectors.not_connected' + )} +
+ {displayConnectors && displayConnectors.length > 0 && ( ( -
+
{userId || '-'} {userId && }
diff --git a/packages/console/src/pages/UserDetails/UserSettings/components/UserSsoIdentities/index.tsx b/packages/console/src/pages/UserDetails/UserSettings/components/UserSsoIdentities/index.tsx new file mode 100644 index 000000000..6ee1ddd77 --- /dev/null +++ b/packages/console/src/pages/UserDetails/UserSettings/components/UserSsoIdentities/index.tsx @@ -0,0 +1,116 @@ +import type { SsoConnectorWithProviderConfig, UserSsoIdentity } from '@logto/schemas'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import useSWR from 'swr'; + +import CopyToClipboard from '@/ds-components/CopyToClipboard'; +import ImageWithErrorFallback from '@/ds-components/ImageWithErrorFallback'; +import Table from '@/ds-components/Table'; +import type { RequestError } from '@/hooks/use-api'; + +import * as styles from '../UserSocialIdentities/index.module.scss'; + +type Props = { + ssoIdentities: UserSsoIdentity[]; +}; + +type DisplayConnector = { + logo: SsoConnectorWithProviderConfig['providerLogo']; + name: SsoConnectorWithProviderConfig['connectorName']; + userIdentity: UserSsoIdentity['identityId']; + issuer: UserSsoIdentity['issuer']; +}; + +function UserSsoIdentities({ ssoIdentities }: Props) { + const { t } = useTranslation(undefined, { + keyPrefix: 'admin_console', + }); + + const { data, error, mutate } = useSWR( + 'api/sso-connectors' + ); + + const isLoading = !data && !error; + + const displaySsoConnectors = useMemo(() => { + if (!data) { + return; + } + + return ( + ssoIdentities + .map((identity) => { + const { providerLogo: logo, connectorName: name } = + data.find((ssoConnector) => ssoConnector.id === identity.ssoConnectorId) ?? {}; + const { identityId: userIdentity, issuer } = identity; + + if (!(logo && name)) { + return; + } + + return { logo, name, userIdentity, issuer }; + }) + // eslint-disable-next-line unicorn/prefer-native-coercion-functions + .filter((identity): identity is DisplayConnector => Boolean(identity)) + ); + }, [data, ssoIdentities]); + + const hasLinkedSsoIdentities = displaySsoConnectors && displaySsoConnectors.length > 0; + + return ( +
+
+ {t( + hasLinkedSsoIdentities + ? 'user_details.sso_connectors.connected' + : 'user_details.sso_connectors.not_connected' + )} +
+ {hasLinkedSsoIdentities && ( +
( +
+ +
+ {name} +
+
+ ), + }, + { + title: t('user_details.sso_connectors.enterprise_id'), + dataIndex: 'userIdentity', + colSpan: 8, + render: ({ userIdentity }) => ( +
+ {userIdentity || '-'} + {userIdentity && } +
+ ), + }, + { + title: null, + dataIndex: 'action', + colSpan: 3, + // Align with the social identities column span, leave the deletion action blank since SSO identities is not deletable in this user details page. + render: () =>
, + }, + ]} + onRetry={async () => mutate(undefined, true)} + /> + )} +
+ ); +} + +export default UserSsoIdentities; diff --git a/packages/console/src/pages/UserDetails/UserSettings/index.tsx b/packages/console/src/pages/UserDetails/UserSettings/index.tsx index a1f88c00f..dba1518dd 100644 --- a/packages/console/src/pages/UserDetails/UserSettings/index.tsx +++ b/packages/console/src/pages/UserDetails/UserSettings/index.tsx @@ -10,6 +10,7 @@ import { useOutletContext } from 'react-router-dom'; import DetailsForm from '@/components/DetailsForm'; import FormCard from '@/components/FormCard'; import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; +import { isDevFeaturesEnabled } from '@/consts/env'; import CodeEditor from '@/ds-components/CodeEditor'; import FormField from '@/ds-components/FormField'; import TextInput from '@/ds-components/TextInput'; @@ -21,11 +22,12 @@ import { safeParseJsonObject } from '@/utils/json'; import { parsePhoneNumber } from '@/utils/phone'; import { uriValidator } from '@/utils/validator'; -import type { UserDetailsForm, UserDetailsOutletContext } from '../types'; +import { type UserDetailsForm, type UserDetailsOutletContext } from '../types'; import { userDetailsParser } from '../utils'; import UserMfaVerifications from './UserMfaVerifications'; import UserSocialIdentities from './components/UserSocialIdentities'; +import UserSsoIdentities from './components/UserSsoIdentities'; function UserSettings() { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); @@ -102,9 +104,6 @@ function UserSettings() { description="user_details.authentication_description" learnMoreLink={getDocumentationUrl('/docs/references/users')} > - - - - - - !value || uriValidator(value) || t('errors.invalid_uri_format'), - })} - error={errors.avatar?.message} - placeholder={t('user_details.field_avatar_placeholder')} - /> - + {isDevFeaturesEnabled && ( + + + + )} + + + + + + + + !value || uriValidator(value) || t('errors.invalid_uri_format'), + })} + error={errors.avatar?.message} + placeholder={t('user_details.field_avatar_placeholder')} + /> + (); - const { data, error, mutate } = useSWR(id && `api/users/${id}`); + // Get user info with user's SSO identities in a single API call. + const { data, error, mutate } = useSWR( + id && buildUrl(`api/users/${id}`, { includeSsoIdentities: 'true' }) + ); const { isSuspended: isSuspendedUser = false } = data ?? {}; const isLoading = !data && !error; const api = useApi(); diff --git a/packages/console/src/pages/UserDetails/types.ts b/packages/console/src/pages/UserDetails/types.ts index a97bd43c9..c5042160e 100644 --- a/packages/console/src/pages/UserDetails/types.ts +++ b/packages/console/src/pages/UserDetails/types.ts @@ -1,4 +1,4 @@ -import type { User } from '@logto/schemas'; +import type { User, UserProfileResponse } from '@logto/schemas'; export type UserDetailsForm = { primaryEmail: string; @@ -10,7 +10,7 @@ export type UserDetailsForm = { }; export type UserDetailsOutletContext = { - user: User; + user: UserProfileResponse; isDeleting: boolean; onUserUpdated: (user?: User) => void; }; diff --git a/packages/core/src/queries/user-sso-identities.ts b/packages/core/src/queries/user-sso-identities.ts index f97d2aef7..b01bd79c7 100644 --- a/packages/core/src/queries/user-sso-identities.ts +++ b/packages/core/src/queries/user-sso-identities.ts @@ -4,6 +4,7 @@ import { type UserSsoIdentity, UserSsoIdentities, } from '@logto/schemas'; +import { manyRows } from '@logto/shared'; import { type Nullable } from '@silverhand/essentials'; import { sql, type CommonQueryMethods } from 'slonik'; @@ -24,9 +25,19 @@ export default class UserSsoIdentityQueries extends SchemaQueries< ): Promise> { return this.pool.maybeOne(sql` select * - from ${UserSsoIdentities.table} - where ${UserSsoIdentities.fields.issuer} = ${issuer} - and ${UserSsoIdentities.fields.identityId} = ${ssoIdentityId} + from ${sql.identifier([UserSsoIdentities.table])} + where ${sql.identifier([UserSsoIdentities.fields.issuer])} = ${issuer} + and ${sql.identifier([UserSsoIdentities.fields.identityId])} = ${ssoIdentityId} `); } + + async findUserSsoIdentitiesByUserId(userId: string): Promise { + return manyRows( + this.pool.query(sql` + select * + from ${sql.identifier([UserSsoIdentities.table])} + where ${sql.identifier([UserSsoIdentities.fields.userId])} = ${userId} + `) + ); + } } diff --git a/packages/core/src/routes/admin-user/basics.ts b/packages/core/src/routes/admin-user/basics.ts index 726656bf0..fe17335c2 100644 --- a/packages/core/src/routes/admin-user/basics.ts +++ b/packages/core/src/routes/admin-user/basics.ts @@ -1,6 +1,6 @@ import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit'; import { jsonObjectGuard, userInfoSelectFields, userProfileResponseGuard } from '@logto/schemas'; -import { conditional, pick } from '@silverhand/essentials'; +import { conditional, pick, yes } from '@silverhand/essentials'; import { boolean, literal, object, string } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; @@ -22,6 +22,7 @@ export default function adminUserBasicsRoutes(...args: R hasUserWithEmail, hasUserWithPhone, }, + userSsoIdentities, } = queries; const { users: { checkIdentifierCollision, generateUserId, insertUser }, @@ -31,17 +32,27 @@ export default function adminUserBasicsRoutes(...args: R '/users/:userId', koaGuard({ params: object({ userId: string() }), + query: object({ includeSsoIdentities: string().optional() }), response: userProfileResponseGuard, status: [200, 404], }), async (ctx, next) => { const { params: { userId }, + query: { includeSsoIdentities }, } = ctx.guard; const user = await findUserById(userId); - ctx.body = pick(user, ...userInfoSelectFields); + ctx.body = { + ...pick(user, ...userInfoSelectFields), + ...conditional( + includeSsoIdentities && + yes(includeSsoIdentities) && { + ssoIdentities: await userSsoIdentities.findUserSsoIdentitiesByUserId(userId), + } + ), + }; return next(); } diff --git a/packages/integration-tests/src/api/admin-user.ts b/packages/integration-tests/src/api/admin-user.ts index 2180ee705..0c3c69289 100644 --- a/packages/integration-tests/src/api/admin-user.ts +++ b/packages/integration-tests/src/api/admin-user.ts @@ -1,4 +1,12 @@ -import type { Identities, MfaFactor, MfaVerification, Role, User } from '@logto/schemas'; +import type { + Identities, + MfaFactor, + MfaVerification, + Role, + User, + UserSsoIdentity, +} from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; import { authedAdminApi } from './api.js'; @@ -17,7 +25,15 @@ export const createUser = async (payload: CreateUserPayload = {}) => }) .json(); -export const getUser = async (userId: string) => authedAdminApi.get(`users/${userId}`).json(); +export const getUser = async (userId: string, withSsoIdentities = false) => + authedAdminApi + .get( + `users/${userId}`, + conditional( + withSsoIdentities && { searchParams: new URLSearchParams({ includeSsoIdentities: 'true' }) } + ) + ) + .json(); export const getUsers = async () => authedAdminApi.get('users').json(); diff --git a/packages/integration-tests/src/tests/api/admin-user.test.ts b/packages/integration-tests/src/tests/api/admin-user.test.ts index 527b86e02..12de031a6 100644 --- a/packages/integration-tests/src/tests/api/admin-user.test.ts +++ b/packages/integration-tests/src/tests/api/admin-user.test.ts @@ -25,8 +25,14 @@ describe('admin console user management', () => { it('should create and get user successfully', async () => { const user = await createUserByAdmin(); + // `ssoIdentities` field is undefined if not specified. const userDetails = await getUser(user.id); expect(userDetails.id).toBe(user.id); + expect(userDetails.ssoIdentities).toBeUndefined(); + + // `ssoIdentities` field should be array type is specified that return user info with `includeSsoIdentities`. + const userDetailsWithSsoIdentities = await getUser(user.id, true); + expect(userDetailsWithSsoIdentities.ssoIdentities).toStrictEqual([]); }); it('should fail when create user with conflict identifiers', async () => { diff --git a/packages/schemas/src/types/user.ts b/packages/schemas/src/types/user.ts index 24a28b65b..07ab49418 100644 --- a/packages/schemas/src/types/user.ts +++ b/packages/schemas/src/types/user.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { type User, Users } from '../db-entries/index.js'; +import { type User, Users, UserSsoIdentities } from '../db-entries/index.js'; import { MfaFactor } from '../foundations/index.js'; export const userInfoSelectFields = Object.freeze([ @@ -26,6 +26,7 @@ export type UserInfo = z.infer; export const userProfileResponseGuard = userInfoGuard.extend({ hasPassword: z.boolean().optional(), + ssoIdentities: z.array(UserSsoIdentities.guard).optional(), }); export type UserProfileResponse = z.infer;