0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

feat(console): show SSO identities on user details page and reorg the page (#4687)

This commit is contained in:
Darcy Ye 2023-11-16 11:26:26 +08:00 committed by GitHub
parent 6b282b6bac
commit 787359c1c4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 217 additions and 35 deletions

View file

@ -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);

View file

@ -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<ConnectorResponse[], RequestError>('api/connectors');
const [deletingConnector, setDeletingConnector] = useState<DisplayConnector>();
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 <div className={styles.empty}>{t('user_details.connectors.not_connected')}</div>;
}
return (
<div>
{displayConnectors && (
<div className={styles.description}>
{t(
displayConnectors && displayConnectors.length > 0
? 'user_details.connectors.connected'
: 'user_details.connectors.not_connected'
)}
</div>
{displayConnectors && displayConnectors.length > 0 && (
<Table
hasBorder
rowGroups={[{ key: 'identities', data: displayConnectors }]}
@ -109,7 +116,7 @@ function UserSocialIdentities({ userId, identities, onDelete }: Props) {
dataIndex: 'userId',
colSpan: 8,
render: ({ userId = '' }) => (
<div className={styles.connectorId}>
<div className={styles.userId}>
<span>{userId || '-'}</span>
{userId && <CopyToClipboard variant="icon" value={userId} />}
</div>

View file

@ -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<SsoConnectorWithProviderConfig[], RequestError>(
'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 (
<div>
<div className={styles.description}>
{t(
hasLinkedSsoIdentities
? 'user_details.sso_connectors.connected'
: 'user_details.sso_connectors.not_connected'
)}
</div>
{hasLinkedSsoIdentities && (
<Table
hasBorder
rowGroups={[{ key: 'ssoIdentity', data: displaySsoConnectors }]}
rowIndexKey="issuer"
isLoading={isLoading}
errorMessage={error?.body?.message ?? error?.message}
columns={[
{
title: t('user_details.sso_connectors.connectors'),
dataIndex: 'name',
colSpan: 5,
render: ({ logo, name }) => (
<div className={styles.connectorName}>
<ImageWithErrorFallback className={styles.icon} src={logo} alt="logo" />
<div className={styles.name}>
<span>{name}</span>
</div>
</div>
),
},
{
title: t('user_details.sso_connectors.enterprise_id'),
dataIndex: 'userIdentity',
colSpan: 8,
render: ({ userIdentity }) => (
<div className={styles.userId}>
<span>{userIdentity || '-'}</span>
{userIdentity && <CopyToClipboard variant="icon" value={userIdentity} />}
</div>
),
},
{
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: () => <div />,
},
]}
onRetry={async () => mutate(undefined, true)}
/>
)}
</div>
);
}
export default UserSsoIdentities;

View file

@ -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')}
>
<FormField title="user_details.field_name">
<TextInput {...register('name')} placeholder={t('users.placeholder_name')} />
</FormField>
<FormField title="user_details.field_email">
<TextInput
{...register('primaryEmail', {
@ -142,16 +141,6 @@ function UserSettings() {
placeholder={t('users.placeholder_username')}
/>
</FormField>
<FormField title="user_details.field_avatar">
<TextInput
{...register('avatar', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
error={errors.avatar?.message}
placeholder={t('user_details.field_avatar_placeholder')}
/>
</FormField>
<FormField title="user_details.field_connectors">
<UserSocialIdentities
userId={user.id}
@ -161,9 +150,29 @@ function UserSettings() {
}}
/>
</FormField>
{isDevFeaturesEnabled && (
<FormField title="user_details.field_sso_connectors">
<UserSsoIdentities ssoIdentities={user.ssoIdentities ?? []} />
</FormField>
)}
<FormField title="user_details.mfa.field_name">
<UserMfaVerifications userId={user.id} />
</FormField>
</FormCard>
<FormCard title="user_details.user_profile">
<FormField title="user_details.field_name">
<TextInput {...register('name')} placeholder={t('users.placeholder_name')} />
</FormField>
<FormField title="user_details.field_avatar">
<TextInput
{...register('avatar', {
validate: (value) =>
!value || uriValidator(value) || t('errors.invalid_uri_format'),
})}
error={errors.avatar?.message}
placeholder={t('user_details.field_avatar_placeholder')}
/>
</FormField>
<FormField
isRequired
title="user_details.field_custom_data"

View file

@ -1,5 +1,5 @@
import { withAppInsights } from '@logto/app-insights/react';
import type { User } from '@logto/schemas';
import type { UserProfileResponse, User } from '@logto/schemas';
import classNames from 'classnames';
import { useEffect, useState } from 'react';
import { toast } from 'react-hot-toast';
@ -24,6 +24,7 @@ import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useTenantPathname from '@/hooks/use-tenant-pathname';
import * as modalStyles from '@/scss/modal.module.scss';
import { buildUrl } from '@/utils/url';
import { getUserTitle, getUserSubtitle } from '@/utils/user';
import UserAccountInformation from '../../components/UserAccountInformation';
@ -46,7 +47,10 @@ function UserDetails() {
const [isUpdatingSuspendState, setIsUpdatingSuspendState] = useState(false);
const [resetResult, setResetResult] = useState<string>();
const { data, error, mutate } = useSWR<User, RequestError>(id && `api/users/${id}`);
// Get user info with user's SSO identities in a single API call.
const { data, error, mutate } = useSWR<UserProfileResponse, RequestError>(
id && buildUrl(`api/users/${id}`, { includeSsoIdentities: 'true' })
);
const { isSuspended: isSuspendedUser = false } = data ?? {};
const isLoading = !data && !error;
const api = useApi();

View file

@ -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;
};

View file

@ -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<Nullable<UserSsoIdentity>> {
return this.pool.maybeOne<UserSsoIdentity>(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<readonly UserSsoIdentity[]> {
return manyRows(
this.pool.query<UserSsoIdentity>(sql`
select *
from ${sql.identifier([UserSsoIdentities.table])}
where ${sql.identifier([UserSsoIdentities.fields.userId])} = ${userId}
`)
);
}
}

View file

@ -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<T extends AuthedRouter>(...args: R
hasUserWithEmail,
hasUserWithPhone,
},
userSsoIdentities,
} = queries;
const {
users: { checkIdentifierCollision, generateUserId, insertUser },
@ -31,17 +32,27 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...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();
}

View file

@ -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<User>();
export const getUser = async (userId: string) => authedAdminApi.get(`users/${userId}`).json<User>();
export const getUser = async (userId: string, withSsoIdentities = false) =>
authedAdminApi
.get(
`users/${userId}`,
conditional(
withSsoIdentities && { searchParams: new URLSearchParams({ includeSsoIdentities: 'true' }) }
)
)
.json<User & { ssoIdentities?: UserSsoIdentity[] }>();
export const getUsers = async () => authedAdminApi.get('users').json<User[]>();

View file

@ -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 () => {

View file

@ -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<typeof userInfoGuard>;
export const userProfileResponseGuard = userInfoGuard.extend({
hasPassword: z.boolean().optional(),
ssoIdentities: z.array(UserSsoIdentities.guard).optional(),
});
export type UserProfileResponse = z.infer<typeof userProfileResponseGuard>;