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:
parent
6b282b6bac
commit
787359c1c4
11 changed files with 217 additions and 35 deletions
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
`)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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[]>();
|
||||
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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>;
|
||||
|
|
Loading…
Reference in a new issue