diff --git a/packages/console/src/components/MfaFactorName/index.tsx b/packages/console/src/components/MfaFactorName/index.tsx new file mode 100644 index 000000000..ec5fd055b --- /dev/null +++ b/packages/console/src/components/MfaFactorName/index.tsx @@ -0,0 +1,26 @@ +import { type AdminConsoleKey } from '@logto/phrases'; +import { MfaFactor } from '@logto/schemas'; + +import DynamicT from '@/ds-components/DynamicT'; + +const factorNameLabel: Record = { + [MfaFactor.TOTP]: 'mfa.totp', + [MfaFactor.WebAuthn]: 'mfa.webauthn', + [MfaFactor.BackupCode]: 'mfa.backup_code', +}; + +export type Props = { + type: MfaFactor; + agent?: string; +}; + +function MfaFactorName({ type, agent }: Props) { + return ( + <> + + {agent && ` - ${agent}`} + + ); +} + +export default MfaFactorName; diff --git a/packages/console/src/components/MfaFactorTitle/index.module.scss b/packages/console/src/components/MfaFactorTitle/index.module.scss new file mode 100644 index 000000000..bb829a5d5 --- /dev/null +++ b/packages/console/src/components/MfaFactorTitle/index.module.scss @@ -0,0 +1,11 @@ +@use '@/scss/underscore' as _; + +.factorTitle { + display: inline-flex; + align-items: center; +} + +.factorIcon { + color: var(--color-text-secondary); + margin-right: _.unit(3); +} diff --git a/packages/console/src/components/MfaFactorTitle/index.tsx b/packages/console/src/components/MfaFactorTitle/index.tsx new file mode 100644 index 000000000..a951f0047 --- /dev/null +++ b/packages/console/src/components/MfaFactorTitle/index.tsx @@ -0,0 +1,28 @@ +import { MfaFactor } from '@logto/schemas'; + +import FactorBackupCode from '@/assets/icons/factor-backup-code.svg'; +import FactorTotp from '@/assets/icons/factor-totp.svg'; +import FactorWebAuthn from '@/assets/icons/factor-webauthn.svg'; + +import MfaFactorName, { type Props as MfaFactorNameProps } from '../MfaFactorName'; + +import * as styles from './index.module.scss'; + +const factorIcon: Record = { + [MfaFactor.TOTP]: FactorTotp, + [MfaFactor.WebAuthn]: FactorWebAuthn, + [MfaFactor.BackupCode]: FactorBackupCode, +}; + +function MfaFactorTitle({ type, agent }: MfaFactorNameProps) { + const Icon = factorIcon[type]; + + return ( +
+ + +
+ ); +} + +export default MfaFactorTitle; diff --git a/packages/console/src/pages/Mfa/MfaForm/FactorLabel/index.module.scss b/packages/console/src/pages/Mfa/MfaForm/FactorLabel/index.module.scss index f5106c920..f7b508f8e 100644 --- a/packages/console/src/pages/Mfa/MfaForm/FactorLabel/index.module.scss +++ b/packages/console/src/pages/Mfa/MfaForm/FactorLabel/index.module.scss @@ -4,17 +4,9 @@ display: flex; flex-direction: column; gap: _.unit(1); +} + +.factorDescription { font: var(--font-body-2); color: var(--color-text-secondary); } - -.factorTitle { - display: flex; - align-items: center; - color: var(--color-text); - - .factorIcon { - color: var(--color-text-secondary); - margin-right: _.unit(3); - } -} diff --git a/packages/console/src/pages/Mfa/MfaForm/FactorLabel/index.tsx b/packages/console/src/pages/Mfa/MfaForm/FactorLabel/index.tsx index c232c9ae5..856ffeb9c 100644 --- a/packages/console/src/pages/Mfa/MfaForm/FactorLabel/index.tsx +++ b/packages/console/src/pages/Mfa/MfaForm/FactorLabel/index.tsx @@ -1,25 +1,27 @@ import { type AdminConsoleKey } from '@logto/phrases'; -import { type ReactElement, cloneElement } from 'react'; +import { MfaFactor } from '@logto/schemas'; +import MfaFactorTitle from '@/components/MfaFactorTitle'; import DynamicT from '@/ds-components/DynamicT'; import * as styles from './index.module.scss'; type Props = { - title: AdminConsoleKey; - description: AdminConsoleKey; - icon: ReactElement; + type: MfaFactor; }; -function FactorLabel({ title, description, icon }: Props) { +const factorDescriptionLabel: Record = { + [MfaFactor.TOTP]: 'mfa.otp_description', + [MfaFactor.WebAuthn]: 'mfa.webauthn_description', + [MfaFactor.BackupCode]: 'mfa.backup_code_description', +}; + +function FactorLabel({ type }: Props) { return (
-
- {cloneElement(icon, { className: styles.factorIcon })} - -
-
- + +
+
); diff --git a/packages/console/src/pages/Mfa/MfaForm/index.tsx b/packages/console/src/pages/Mfa/MfaForm/index.tsx index e3527d146..bb5d2e853 100644 --- a/packages/console/src/pages/Mfa/MfaForm/index.tsx +++ b/packages/console/src/pages/Mfa/MfaForm/index.tsx @@ -1,13 +1,10 @@ -import { MfaPolicy, type SignInExperience } from '@logto/schemas'; +import { MfaFactor, MfaPolicy, type SignInExperience } from '@logto/schemas'; import classNames from 'classnames'; import { useMemo } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { toast } from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; -import FactorBackupCode from '@/assets/icons/factor-backup-code.svg'; -import FactorOtp from '@/assets/icons/factor-totp.svg'; -import FactorWebAuthn from '@/assets/icons/factor-webauthn.svg'; import DetailsForm from '@/components/DetailsForm'; import FormCard from '@/components/FormCard'; import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; @@ -83,24 +80,9 @@ function MfaForm({ data, onMfaUpdated }: Props) {
+ } {...register('totpEnabled')} /> } - /> - } - {...register('totpEnabled')} - /> - } - /> - } + label={} {...register('webAuthnEnabled')} />
@@ -108,13 +90,7 @@ function MfaForm({ data, onMfaUpdated }: Props) {
} - /> - } + label={} hasError={!isBackupCodeAllowed} {...register('backupCodeEnabled')} /> diff --git a/packages/console/src/pages/UserDetails/UserSettings/UserMfaVerifications/index.module.scss b/packages/console/src/pages/UserDetails/UserSettings/UserMfaVerifications/index.module.scss new file mode 100644 index 000000000..53b768b6d --- /dev/null +++ b/packages/console/src/pages/UserDetails/UserSettings/UserMfaVerifications/index.module.scss @@ -0,0 +1,7 @@ +@use '@/scss/underscore' as _; + +.fieldDescription { + font: var(--font-body-2); + color: var(--color-text-secondary); + margin: _.unit(1) 0 _.unit(2); +} diff --git a/packages/console/src/pages/UserDetails/UserSettings/UserMfaVerifications/index.tsx b/packages/console/src/pages/UserDetails/UserSettings/UserMfaVerifications/index.tsx new file mode 100644 index 000000000..b3ca58532 --- /dev/null +++ b/packages/console/src/pages/UserDetails/UserSettings/UserMfaVerifications/index.tsx @@ -0,0 +1,103 @@ +import { type UserMfaVerificationResponse } from '@logto/schemas'; +import { useCallback } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import useSWR from 'swr'; + +import MfaFactorName from '@/components/MfaFactorName'; +import MfaFactorTitle from '@/components/MfaFactorTitle'; +import Button from '@/ds-components/Button'; +import Table from '@/ds-components/Table'; +import useApi, { type RequestError } from '@/hooks/use-api'; +import { useConfirmModal } from '@/hooks/use-confirm-modal'; +import { type UserMfaVerification } from '@/types/mfa'; + +import * as styles from './index.module.scss'; + +type Props = { + userId: string; +}; + +function UserMfaVerifications({ userId }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.user_details.mfa' }); + const { + data: mfaVerifications, + error, + isLoading, + mutate, + } = useSWR(`api/users/${userId}/mfa-verifications`); + + const api = useApi(); + const { show: showConfirm } = useConfirmModal(); + + const handleDelete = useCallback( + async (mfaVerification: UserMfaVerification) => { + const [result] = await showConfirm({ + ModalContent: () => ( + , + }} + /> + ), + confirmButtonText: 'general.remove', + }); + + if (!result) { + return; + } + + await api.delete(`api/users/${userId}/mfa-verifications/${mfaVerification.id}`); + void mutate(mfaVerifications?.filter((item) => item.id !== mfaVerification.id)); + }, + [api, mfaVerifications, mutate, showConfirm, t, userId] + ); + + return ( + <> + {!isLoading && !error && ( +
+ {t(mfaVerifications?.length ? 'field_description' : 'field_description_empty')} +
+ )} + {(Boolean(mfaVerifications?.length) || error) && ( + , + }, + { + title: null, + dataIndex: 'action', + colSpan: 3, + render: (mfaVerification) => ( +