0
Fork 0
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:
Charles Zhao 2023-01-30 12:23:56 +08:00 committed by GitHub
parent 70e126c415
commit 8fe02a0059
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 276 additions and 279 deletions

View file

@ -15,7 +15,7 @@ const ImageWithErrorFallback = ({ src, alt, className }: ImgHTMLAttributes<HTMLI
setHasError(true);
};
if (hasError) {
if (!src || hasError) {
return <Fallback className={className} />;
}

View file

@ -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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -61,6 +61,7 @@ const connectors = {
add_multi_platform: ' 다양한 플랫폼 지원, 플랫폼을 선택해주세요.',
drawer_title: '연동 가이드',
drawer_subtitle: '연동하기 위해 가이드를 따라주세요.',
unknown: '알수없는 연동',
};
export default connectors;

View file

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

View file

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

View file

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

View file

@ -58,6 +58,7 @@ const connectors = {
add_multi_platform: '支持多平台,选择一个平台继续',
drawer_title: '连接器配置指南',
drawer_subtitle: '参考以下步骤完善或修改你的连接器设置',
unknown: '未知连接器',
};
export default connectors;