mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
refactor(console): user social identities table (#2992)
Co-authored-by: Xiao Yijun <xiaoyijun@silverhand.io>
This commit is contained in:
parent
70e126c415
commit
8fe02a0059
16 changed files with 276 additions and 279 deletions
|
@ -15,7 +15,7 @@ const ImageWithErrorFallback = ({ src, alt, className }: ImgHTMLAttributes<HTMLI
|
|||
setHasError(true);
|
||||
};
|
||||
|
||||
if (hasError) {
|
||||
if (!src || hasError) {
|
||||
return <Fallback className={className} />;
|
||||
}
|
||||
|
||||
|
|
|
@ -55,45 +55,76 @@
|
|||
background-color: var(--color-layer-1);
|
||||
border-radius: 0 0 12px 12px;
|
||||
|
||||
table {
|
||||
tbody {
|
||||
tr {
|
||||
td {
|
||||
font: var(--font-body-medium);
|
||||
border-top: 1px solid var(--color-divider);
|
||||
border-bottom: unset;
|
||||
padding: _.unit(3);
|
||||
}
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
tbody {
|
||||
tr {
|
||||
td {
|
||||
font: var(--font-body-medium);
|
||||
border-top: 1px solid var(--color-divider);
|
||||
border-bottom: unset;
|
||||
padding: _.unit(3);
|
||||
}
|
||||
|
||||
tr.hoverEffect:hover {
|
||||
background: var(--color-hover);
|
||||
|
||||
td {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
+ tr {
|
||||
td {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tr.hoverEffect:hover {
|
||||
background: var(--color-hover);
|
||||
|
||||
td {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
+ tr {
|
||||
td {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.hasBorder {
|
||||
.filterContainer {
|
||||
border: 1px solid var(--color-divider);
|
||||
border-bottom: unset;
|
||||
|
||||
.filter {
|
||||
border-bottom: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.headerTable {
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.bodyTable {
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-top: unset;
|
||||
|
||||
tr:first-child td {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
tr.hoverEffect:hover {
|
||||
td:first-child,
|
||||
td:last-child {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
|
|
|
@ -38,6 +38,7 @@ type Props<
|
|||
pagination?: PaginationProps;
|
||||
placeholder?: TablePlaceholder;
|
||||
errorMessage?: string;
|
||||
hasBorder?: boolean;
|
||||
onRetry?: () => void;
|
||||
};
|
||||
|
||||
|
@ -59,6 +60,7 @@ const Table = <
|
|||
pagination,
|
||||
placeholder,
|
||||
errorMessage,
|
||||
hasBorder,
|
||||
onRetry,
|
||||
}: Props<TFieldValues, TName>) => {
|
||||
const totalColspan = columns.reduce((result, { colSpan }) => {
|
||||
|
@ -69,7 +71,7 @@ const Table = <
|
|||
|
||||
return (
|
||||
<div className={classNames(styles.container, className)}>
|
||||
<div className={styles.tableContainer}>
|
||||
<div className={classNames(styles.tableContainer, hasBorder && styles.hasBorder)}>
|
||||
{filter && (
|
||||
<div className={styles.filterContainer}>
|
||||
<div className={styles.filter}>{filter}</div>
|
||||
|
|
|
@ -1,71 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.empty {
|
||||
color: var(--color-text-secondary);
|
||||
font: var(--font-body-medium);
|
||||
}
|
||||
|
||||
.table {
|
||||
thead {
|
||||
th {
|
||||
padding: _.unit(2);
|
||||
}
|
||||
|
||||
th:first-child {
|
||||
padding-left: _.unit(4);
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
td {
|
||||
padding: _.unit(3) _.unit(2);
|
||||
font: var(--font-body-medium);
|
||||
|
||||
&:first-child {
|
||||
padding-left: _.unit(4);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-left: _.unit(4);
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.connectorName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: _.unit(2);
|
||||
}
|
||||
|
||||
.name {
|
||||
margin-left: _.unit(3);
|
||||
}
|
||||
}
|
||||
|
||||
.connectorId {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font: var(--font-body-medium);
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
line-height: 32px;
|
||||
|
||||
span {
|
||||
max-width: 220px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,170 +0,0 @@
|
|||
import type { Identities, ConnectorResponse } from '@logto/schemas';
|
||||
import type { Optional } from '@silverhand/essentials';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import DeleteConfirmModal from '@/components/DeleteConfirmModal';
|
||||
import TableError from '@/components/Table/TableError';
|
||||
import UnnamedTrans from '@/components/UnnamedTrans';
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { getConnectorGroups } from '@/pages/Connectors/utils';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
userId: string;
|
||||
connectors: Identities;
|
||||
onDelete?: (connectorId: string) => void;
|
||||
};
|
||||
|
||||
type DisplayConnector = Pick<ConnectorResponse, 'target' | 'logo' | 'name'> & { userId?: string };
|
||||
|
||||
const UserConnectors = ({ userId, connectors, onDelete }: Props) => {
|
||||
const api = useApi();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { data, error, mutate } = useSWR<ConnectorResponse[], RequestError>('/api/connectors');
|
||||
const [deletingConnector, setDeletingConnector] = useState<DisplayConnector>();
|
||||
const connectorGroups = conditional(data && getConnectorGroups(data));
|
||||
const isLoading = !connectorGroups && !error;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleDelete = async (target: string) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await api.delete(`/api/users/${userId}/identities/${target}`);
|
||||
onDelete?.(target);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayConnectors: Optional<DisplayConnector[]> = useMemo(() => {
|
||||
if (!connectorGroups) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Object.keys(connectors).map((key) => {
|
||||
const connector = connectorGroups.find(({ target }) => target === key);
|
||||
|
||||
if (!connector) {
|
||||
return {
|
||||
logo: '',
|
||||
name: {
|
||||
'zh-CN': '未知连接器',
|
||||
en: 'Unknown Connector',
|
||||
'tr-TR': 'Bilinmeyen connector.',
|
||||
ko: '알수없는 연동',
|
||||
},
|
||||
target: key,
|
||||
userId: connectors[key]?.userId,
|
||||
};
|
||||
}
|
||||
|
||||
const { logo, name } = connector;
|
||||
|
||||
return {
|
||||
logo,
|
||||
name,
|
||||
target: key,
|
||||
userId: connectors[key]?.userId,
|
||||
};
|
||||
});
|
||||
}, [connectorGroups, connectors]);
|
||||
|
||||
if (Object.keys(connectors).length === 0) {
|
||||
return <div className={styles.empty}>{t('user_details.connectors.not_connected')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isLoading && <div>Loading</div>}
|
||||
{displayConnectors && (
|
||||
<table className={styles.table}>
|
||||
<colgroup>
|
||||
<col width="156px" />
|
||||
<col />
|
||||
<col width="110px" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('user_details.connectors.connectors')}</th>
|
||||
<th>{t('user_details.connectors.user_id')}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!connectorGroups && error && (
|
||||
<TableError
|
||||
columns={3}
|
||||
content={error.body?.message ?? error.message}
|
||||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
)}
|
||||
{displayConnectors.map((connector) => {
|
||||
const { target, userId = '', name, logo } = connector;
|
||||
|
||||
return (
|
||||
<tr key={target}>
|
||||
<td>
|
||||
<div className={styles.connectorName}>
|
||||
<img src={logo} alt="logo" />
|
||||
<div className={styles.name}>
|
||||
<UnnamedTrans resource={name} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={styles.connectorId}>
|
||||
<span>{userId || '-'}</span>
|
||||
<CopyToClipboard variant="icon" value={userId} />
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
title="user_details.connectors.remove"
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setDeletingConnector(connector);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<DeleteConfirmModal
|
||||
isOpen={deletingConnector !== undefined}
|
||||
onCancel={() => {
|
||||
setDeletingConnector(undefined);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (deletingConnector !== undefined) {
|
||||
await handleDelete(deletingConnector.target);
|
||||
setDeletingConnector(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deletingConnector && (
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="user_details.connectors.deletion_confirmation"
|
||||
components={{ name: <UnnamedTrans resource={deletingConnector.name} /> }}
|
||||
/>
|
||||
)}
|
||||
</DeleteConfirmModal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserConnectors;
|
|
@ -0,0 +1,35 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.empty {
|
||||
color: var(--color-text-secondary);
|
||||
font: var(--font-body-medium);
|
||||
}
|
||||
|
||||
.connectorName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: _.unit(2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin-left: _.unit(3);
|
||||
}
|
||||
}
|
||||
|
||||
.connectorId {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font: var(--font-body-medium);
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
line-height: 32px;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
@include _.text-ellipsis;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,162 @@
|
|||
import type { Identities, ConnectorResponse } from '@logto/schemas';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import CopyToClipboard from '@/components/CopyToClipboard';
|
||||
import DeleteConfirmModal from '@/components/DeleteConfirmModal';
|
||||
import ImageWithErrorFallback from '@/components/ImageWithErrorFallback';
|
||||
import Table from '@/components/Table';
|
||||
import UnnamedTrans from '@/components/UnnamedTrans';
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { getConnectorGroups } from '@/pages/Connectors/utils';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
userId: string;
|
||||
identities: Identities;
|
||||
onDelete?: (connectorId: string) => void;
|
||||
};
|
||||
|
||||
type DisplayConnector = {
|
||||
target: ConnectorResponse['target'];
|
||||
userId?: string;
|
||||
logo?: ConnectorResponse['logo'];
|
||||
name: ConnectorResponse['name'] | string;
|
||||
};
|
||||
|
||||
const ConnectorName = ({ name }: { name: DisplayConnector['name'] }) =>
|
||||
typeof name === 'string' ? <span>{name}</span> : <UnnamedTrans resource={name} />;
|
||||
|
||||
const UserSocialIdentities = ({ userId, identities, onDelete }: Props) => {
|
||||
const api = useApi();
|
||||
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);
|
||||
|
||||
const connectorGroups = useMemo(() => {
|
||||
if (!data?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return getConnectorGroups(data);
|
||||
}, [data]);
|
||||
|
||||
const isLoading = !connectorGroups && !error;
|
||||
|
||||
const handleDelete = async (target: string) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await api.delete(`/api/users/${userId}/identities/${target}`);
|
||||
onDelete?.(target);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayConnectors = useMemo(() => {
|
||||
if (!connectorGroups) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Object.keys(identities).map((key): DisplayConnector => {
|
||||
const { logo, name } = connectorGroups.find((group) => group.target === key) ?? {};
|
||||
const socialUserId = identities[key]?.userId;
|
||||
|
||||
return { logo, name: name ?? t('connectors.unknown'), target: key, userId: socialUserId };
|
||||
});
|
||||
}, [connectorGroups, identities, t]);
|
||||
|
||||
if (Object.keys(identities).length === 0) {
|
||||
return <div className={styles.empty}>{t('user_details.connectors.not_connected')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{displayConnectors && (
|
||||
<Table
|
||||
hasBorder
|
||||
rowGroups={[{ key: 'identities', data: displayConnectors }]}
|
||||
rowIndexKey="target"
|
||||
isLoading={isLoading}
|
||||
errorMessage={error?.body?.message ?? error?.message}
|
||||
columns={[
|
||||
{
|
||||
title: t('user_details.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}>
|
||||
<ConnectorName name={name} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('user_details.connectors.user_id'),
|
||||
dataIndex: 'userId',
|
||||
colSpan: 8,
|
||||
render: ({ userId = '' }) => (
|
||||
<div className={styles.connectorId}>
|
||||
<span>{userId || '-'}</span>
|
||||
{userId && <CopyToClipboard variant="icon" value={userId} />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: null,
|
||||
dataIndex: 'action',
|
||||
colSpan: 3,
|
||||
render: (connector) => (
|
||||
<Button
|
||||
title="user_details.connectors.remove"
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setDeletingConnector(connector);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
onRetry={async () => mutate(undefined, true)}
|
||||
/>
|
||||
)}
|
||||
<DeleteConfirmModal
|
||||
isOpen={deletingConnector !== undefined}
|
||||
onCancel={() => {
|
||||
setDeletingConnector(undefined);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (deletingConnector) {
|
||||
await handleDelete(deletingConnector.target);
|
||||
setDeletingConnector(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deletingConnector && (
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="user_details.connectors.deletion_confirmation"
|
||||
components={{
|
||||
name: <ConnectorName name={deletingConnector.name} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DeleteConfirmModal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSocialIdentities;
|
|
@ -17,7 +17,7 @@ import { uriValidator } from '@/utilities/validator';
|
|||
|
||||
import type { UserDetailsForm, UserDetailsOutletContext } from '../types';
|
||||
import { userDetailsParser } from '../utils';
|
||||
import UserConnectors from './components/UserConnectors';
|
||||
import UserSocialIdentities from './components/UserSocialIdentities';
|
||||
|
||||
const UserSettings = () => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
@ -119,9 +119,9 @@ const UserSettings = () => {
|
|||
/>
|
||||
</FormField>
|
||||
<FormField title="user_details.field_connectors">
|
||||
<UserConnectors
|
||||
<UserSocialIdentities
|
||||
userId={user.id}
|
||||
connectors={user.identities}
|
||||
identities={user.identities}
|
||||
onDelete={() => {
|
||||
onUserUpdated();
|
||||
}}
|
||||
|
|
|
@ -63,6 +63,7 @@ const connectors = {
|
|||
add_multi_platform: ' unterstützt mehrere Plattformen, wähle eine Plattform aus, um fortzufahren',
|
||||
drawer_title: 'Connector Anleitung',
|
||||
drawer_subtitle: 'Folge den Anweisungen, um deinen Connector zu integrieren',
|
||||
unknown: 'Unknown Connector', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default connectors;
|
||||
|
|
|
@ -63,6 +63,7 @@ const connectors = {
|
|||
add_multi_platform: ' supports multiple platform, select a platform to continue',
|
||||
drawer_title: 'Connector Guide',
|
||||
drawer_subtitle: 'Follow the instructions to integrate your connector',
|
||||
unknown: 'Unknown Connector',
|
||||
};
|
||||
|
||||
export default connectors;
|
||||
|
|
|
@ -64,6 +64,7 @@ const connectors = {
|
|||
add_multi_platform: ' supporte plusieurs plateformes, sélectionnez une plateforme pour continuer',
|
||||
drawer_title: 'Guide des connecteurs',
|
||||
drawer_subtitle: 'Suivez les instructions pour intégrer votre connecteur',
|
||||
unknown: 'Unknown Connector', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default connectors;
|
||||
|
|
|
@ -61,6 +61,7 @@ const connectors = {
|
|||
add_multi_platform: ' 다양한 플랫폼 지원, 플랫폼을 선택해주세요.',
|
||||
drawer_title: '연동 가이드',
|
||||
drawer_subtitle: '연동하기 위해 가이드를 따라주세요.',
|
||||
unknown: '알수없는 연동',
|
||||
};
|
||||
|
||||
export default connectors;
|
||||
|
|
|
@ -61,6 +61,7 @@ const connectors = {
|
|||
add_multi_platform: ' suporta várias plataformas, selecione uma plataforma para continuar',
|
||||
drawer_title: 'Guia do Conector',
|
||||
drawer_subtitle: 'Siga as instruções para integrar seu conector',
|
||||
unknown: 'Unknown Connector', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default connectors;
|
||||
|
|
|
@ -63,6 +63,7 @@ const connectors = {
|
|||
add_multi_platform: ' suporta várias plataformas, selecione uma plataforma para continuar',
|
||||
drawer_title: 'Guia do conector',
|
||||
drawer_subtitle: 'Siga as instruções para integrar o conector',
|
||||
unknown: 'Unknown Connector', // UNTRANSLATED
|
||||
};
|
||||
|
||||
export default connectors;
|
||||
|
|
|
@ -64,6 +64,7 @@ const connectors = {
|
|||
add_multi_platform: ' birden fazla platformu destekler, devam etmek için bir platform seçin',
|
||||
drawer_title: 'Connector Kılavuzu',
|
||||
drawer_subtitle: 'Connectorı entegre etmek için yönergeleri izleyin',
|
||||
unknown: 'Bilinmeyen connector',
|
||||
};
|
||||
|
||||
export default connectors;
|
||||
|
|
|
@ -58,6 +58,7 @@ const connectors = {
|
|||
add_multi_platform: '支持多平台,选择一个平台继续',
|
||||
drawer_title: '连接器配置指南',
|
||||
drawer_subtitle: '参考以下步骤完善或修改你的连接器设置',
|
||||
unknown: '未知连接器',
|
||||
};
|
||||
|
||||
export default connectors;
|
||||
|
|
Loading…
Add table
Reference in a new issue