0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): support managing user mfa on user details page (#4498)

This commit is contained in:
Xiao Yijun 2023-09-19 11:45:01 +08:00 committed by GitHub
parent e69f941e38
commit 9c8b9e4853
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 325 additions and 50 deletions

View file

@ -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, AdminConsoleKey> = {
[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 (
<>
<DynamicT forKey={factorNameLabel[type]} />
{agent && ` - ${agent}`}
</>
);
}
export default MfaFactorName;

View file

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

View file

@ -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, SvgComponent> = {
[MfaFactor.TOTP]: FactorTotp,
[MfaFactor.WebAuthn]: FactorWebAuthn,
[MfaFactor.BackupCode]: FactorBackupCode,
};
function MfaFactorTitle({ type, agent }: MfaFactorNameProps) {
const Icon = factorIcon[type];
return (
<div className={styles.factorTitle}>
<Icon className={styles.factorIcon} />
<MfaFactorName type={type} agent={agent} />
</div>
);
}
export default MfaFactorTitle;

View file

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

View file

@ -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, AdminConsoleKey> = {
[MfaFactor.TOTP]: 'mfa.otp_description',
[MfaFactor.WebAuthn]: 'mfa.webauthn_description',
[MfaFactor.BackupCode]: 'mfa.backup_code_description',
};
function FactorLabel({ type }: Props) {
return (
<div className={styles.factorLabel}>
<div className={styles.factorTitle}>
{cloneElement(icon, { className: styles.factorIcon })}
<DynamicT forKey={title} />
</div>
<div>
<DynamicT forKey={description} />
<MfaFactorTitle type={type} />
<div className={styles.factorDescription}>
<DynamicT forKey={factorDescriptionLabel[type]} />
</div>
</div>
);

View file

@ -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) {
<DynamicT forKey="mfa.multi_factors_description" />
</div>
<div className={styles.factorField}>
<Switch label={<FactorLabel type={MfaFactor.TOTP} />} {...register('totpEnabled')} />
<Switch
label={
<FactorLabel
title="mfa.totp"
description="mfa.otp_description"
icon={<FactorOtp />}
/>
}
{...register('totpEnabled')}
/>
<Switch
label={
<FactorLabel
title="mfa.webauthn"
description="mfa.webauthn_description"
icon={<FactorWebAuthn />}
/>
}
label={<FactorLabel type={MfaFactor.WebAuthn} />}
{...register('webAuthnEnabled')}
/>
<div className={styles.backupCodeField}>
@ -108,13 +90,7 @@ function MfaForm({ data, onMfaUpdated }: Props) {
<DynamicT forKey="mfa.backup_code_setup_hint" />
</div>
<Switch
label={
<FactorLabel
title="mfa.backup_code"
description="mfa.backup_code_description"
icon={<FactorBackupCode />}
/>
}
label={<FactorLabel type={MfaFactor.BackupCode} />}
hasError={!isBackupCodeAllowed}
{...register('backupCodeEnabled')}
/>

View file

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

View file

@ -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<UserMfaVerificationResponse, RequestError>(`api/users/${userId}/mfa-verifications`);
const api = useApi();
const { show: showConfirm } = useConfirmModal();
const handleDelete = useCallback(
async (mfaVerification: UserMfaVerification) => {
const [result] = await showConfirm({
ModalContent: () => (
<Trans
t={t}
i18nKey="deletion_confirmation"
components={{
name: <MfaFactorName {...mfaVerification} />,
}}
/>
),
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 && (
<div className={styles.fieldDescription}>
{t(mfaVerifications?.length ? 'field_description' : 'field_description_empty')}
</div>
)}
{(Boolean(mfaVerifications?.length) || error) && (
<Table
hasBorder
rowGroups={[{ key: 'mfaVerifications', data: mfaVerifications }]}
rowIndexKey="id"
isLoading={isLoading}
errorMessage={error?.body?.message ?? error?.message}
columns={[
{
title: t('name_column'),
dataIndex: 'name',
colSpan: 13,
render: (mfaVerification) => <MfaFactorTitle {...mfaVerification} />,
},
{
title: null,
dataIndex: 'action',
colSpan: 3,
render: (mfaVerification) => (
<Button
title="general.remove"
type="text"
size="small"
onClick={() => {
void handleDelete(mfaVerification);
}}
/>
),
},
]}
onRetry={() => {
void mutate();
}}
/>
)}
</>
);
}
export default UserMfaVerifications;

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';
@ -24,6 +25,7 @@ import { uriValidator } from '@/utils/validator';
import type { UserDetailsForm, UserDetailsOutletContext } from '../types';
import { userDetailsParser } from '../utils';
import UserMfaVerifications from './UserMfaVerifications';
import UserSocialIdentities from './components/UserSocialIdentities';
function UserSettings() {
@ -160,6 +162,11 @@ function UserSettings() {
}}
/>
</FormField>
{isDevFeaturesEnabled && (
<FormField title="user_details.mfa.field_name">
<UserMfaVerifications userId={user.id} />
</FormField>
)}
<FormField
isRequired
title="user_details.field_custom_data"

View file

@ -0,0 +1,3 @@
import { type UserMfaVerificationResponse } from '@logto/schemas';
export type UserMfaVerification = UserMfaVerificationResponse[number];

View file

@ -44,6 +44,15 @@ const user_details = {
deletion_confirmation:
'Du entfernst die bestehende <name/> Identität. Bist du sicher, dass du das tun möchtest?',
},
mfa: {
field_name: 'Zwei-Faktor-Authentifizierung',
field_description: 'Dieser Benutzer hat 2-Stufen-Authentifizierungsfaktoren aktiviert.',
name_column: 'Zwei-Faktor',
field_description_empty:
'Dieser Benutzer hat keine zweistufigen Authentifizierungsfaktoren aktiviert.',
deletion_confirmation:
'Sie entfernen den bestehenden <name/> für den zweistufigen Authentifikator. Sind Sie sicher, dass Sie das tun möchten?',
},
suspended: 'Gesperrt',
suspend_user: 'Benutzer sperren',
suspend_user_reminder:

View file

@ -42,6 +42,14 @@ const user_details = {
deletion_confirmation:
'You are removing the existing <name/> identity. Are you sure you want to do that?',
},
mfa: {
field_name: 'Multi-factor authentication',
field_description: 'This user has enabled 2-step authentication factors.',
name_column: 'Multi-Factor',
field_description_empty: 'This user has not enabled 2-step authentication factors.',
deletion_confirmation:
'You are removing the existing <name/> for the 2-step authenticator. Are you sure you want to do that?',
},
suspended: 'Suspended',
suspend_user: 'Suspend user',
suspend_user_reminder:

View file

@ -44,6 +44,14 @@ const user_details = {
deletion_confirmation:
'Está eliminando la identidad de <name/> existente. ¿Está seguro de que desea hacer esto?',
},
mfa: {
field_name: 'Autenticación de dos factores',
field_description: 'Este usuario ha habilitado factores de autenticación de 2 pasos.',
name_column: 'Autenticación de dos factores',
field_description_empty: 'Este usuario no ha habilitado factores de autenticación de 2 pasos.',
deletion_confirmation:
'Está eliminando el <name/> existente del autenticador de 2 pasos. ¿Está seguro de que desea hacerlo?',
},
suspended: 'Suspendido',
suspend_user: 'Suspender usuario',
suspend_user_reminder:

View file

@ -44,6 +44,15 @@ const user_details = {
deletion_confirmation:
"Vous supprimez l'identité existante <nom/>. Etes-vous sûr de vouloir faire ça ?",
},
mfa: {
field_name: 'Authentification à deux facteurs',
field_description: "Cet utilisateur a activé des facteurs d'authentification à 2 étapes.",
name_column: 'Authentification à deux facteurs',
field_description_empty:
"Cet utilisateur n'a pas activé les facteurs d'authentification à deux étapes.",
deletion_confirmation:
"Vous supprimez l'<name/> existant pour l'authentification à deux étapes. Êtes-vous sûr de vouloir faire cela ?",
},
suspended: 'Suspendu',
suspend_user: "Suspendre l'utilisateur",
suspend_user_reminder:

View file

@ -44,6 +44,15 @@ const user_details = {
deletion_confirmation:
"Stai rimuovendo l'identità esistente <name/>. Sei sicuro di voler procedere?",
},
mfa: {
field_name: 'Autenticazione a due fattori',
field_description: 'Questo utente ha abilitato fattori di autenticazione a 2 passaggi.',
name_column: 'Autenticazione a due fattori',
field_description_empty:
'Questo utente non ha abilitato fattori di autenticazione a due fattori.',
deletion_confirmation:
"Stai rimuovendo il <name/> esistente per l'autenticatore a due fattori. Sei sicuro di volerlo fare?",
},
suspended: 'Sospeso',
suspend_user: 'Sospendi utente',
suspend_user_reminder:

View file

@ -42,6 +42,13 @@ const user_details = {
deletion_confirmation:
'既存の<name/>アイデンティティを削除しています。本当にそれをやり遂げますか?',
},
mfa: {
field_name: '多要素認証',
field_description: 'このユーザーは2段階認証要素を有効にしました。',
name_column: '多要素認証',
field_description_empty: 'このユーザーは2段階認証の要因を有効にしていません。',
deletion_confirmation: '2段階認証の既存の<name/>を削除しています。本当にそれを行いたいですか?',
},
suspended: '停止中',
suspend_user: 'ユーザーを一時停止',
suspend_user_reminder:

View file

@ -41,6 +41,14 @@ const user_details = {
not_connected: '이 사용자는 아직 소셜에 연동되지 않았아요.',
deletion_confirmation: '<name/> 신원을 삭제하려고 해요. 정말로 진행할까요?',
},
mfa: {
field_name: '다단계 인증',
field_description: '이 사용자는 2단계 인증 요소를 활성화했습니다.',
name_column: '다단계 인증',
field_description_empty: '이 사용자는 2단계 인증 요소를 활성화하지 않았습니다.',
deletion_confirmation:
'2단계 인증기에 대한 기존 <name/>을 제거하려고 합니다. 정말로 그렇게 하시겠습니까?',
},
suspended: '정지됨',
suspend_user: '사용자 정지',
suspend_user_reminder:

View file

@ -41,6 +41,14 @@ const user_details = {
not_connected: 'Użytkownik nie jest połączony z żadnym połączeniem społecznościowym',
deletion_confirmation: 'Usuwasz istniejącą tożsamość <name/>. Czy na pewno chcesz to zrobić?',
},
mfa: {
field_name: 'Wieloetapowa autoryzacja',
field_description: 'Ten użytkownik włączył czynniki autoryzacji dwuetapowej.',
name_column: 'Wieloetapowa autoryzacja',
field_description_empty: 'Ten użytkownik nie włączył czynników uwierzytelniania dwuetapowego.',
deletion_confirmation:
'Usuwasz istniejący <name/> dla autentykatora dwuetapowego. Czy na pewno chcesz to zrobić?',
},
suspended: 'Zawieszony',
suspend_user: 'Zawieś użytkownika',
suspend_user_reminder:

View file

@ -42,6 +42,14 @@ const user_details = {
deletion_confirmation:
'Você está removendo a identidade <name/> existente. Você tem certeza que deseja fazer isso?',
},
mfa: {
field_name: 'Autenticação de dois fatores',
field_description: 'Este usuário habilitou fatores de autenticação de 2 etapas.',
name_column: 'Autenticação de dois fatores',
field_description_empty: 'Este usuário não habilitou fatores de autenticação em duas etapas.',
deletion_confirmation:
'Você está removendo o <name/> existente para o autenticador em duas etapas. Tem certeza de que deseja fazer isso?',
},
suspended: 'Suspenso',
suspend_user: 'Suspender usuário',
suspend_user_reminder:

View file

@ -44,6 +44,14 @@ const user_details = {
deletion_confirmation:
'Está removendo a identidade <name/> existente. Tem a certeza que deseja fazer isso?',
},
mfa: {
field_name: 'Autenticação de dois fatores',
field_description: 'Este utilizador ativou fatores de autenticação de 2 etapas.',
name_column: 'Autenticação de dois fatores',
field_description_empty: 'Este utilizador não ativou fatores de autenticação de 2 passos.',
deletion_confirmation:
'Está a remover o <name/> existente para o autenticador de 2 passos. Tem a certeza de que deseja fazê-lo?',
},
suspended: 'suspenso',
suspend_user: 'Suspender utilizador',
suspend_user_reminder:

View file

@ -42,6 +42,14 @@ const user_details = {
deletion_confirmation:
'Вы удаляете существующий идентификатор <name/>. Вы уверены, что хотите это сделать?',
},
mfa: {
field_name: 'Двухфакторная аутентификация',
field_description: 'Этот пользователь включил двухэтапные факторы аутентификации.',
name_column: 'Двухфакторная аутентификация',
field_description_empty: 'Этот пользователь не включил двухфакторную аутентификацию.',
deletion_confirmation:
'Вы удаляете существующий <name/> для двухфакторного аутентификатора. Вы уверены, что хотите это сделать?',
},
suspended: 'Приостановлен',
suspend_user: 'Приостановить пользователя',
suspend_user_reminder:

View file

@ -42,6 +42,15 @@ const user_details = {
deletion_confirmation:
'Mevcut <name/> kimliğini kaldırıyorsunuz. Bunu yapmak istediğinizden emin misiniz?',
},
mfa: {
field_name: 'Çok faktörlü kimlik doğrulama',
field_description: 'Bu kullanıcı 2 adımlı kimlik doğrulama faktörlerini etkinleştirdi.',
name_column: 'Çok Faktörlü Kimlik Doğrulama',
field_description_empty:
'Bu kullanıcı 2 aşamalı kimlik doğrulama faktörlerini etkinleştirmedi.',
deletion_confirmation:
'2 aşamalı kimlik doğrulama için mevcut olan <name/> kaldırıyorsunuz. Bunun yapmak istediğinizden emin misiniz?',
},
suspended: 'Askıya alınmış',
suspend_user: 'Kullanıcıyı Askıya Al',
suspend_user_reminder:

View file

@ -40,6 +40,13 @@ const user_details = {
not_connected: '该用户还没有绑定社交帐号',
deletion_confirmation: '你在正要删除现有的 <name /> 身份,是否确认?',
},
mfa: {
field_name: '多因素认证',
field_description: '该用户已启用2步认证因素。',
name_column: '多因素认证',
field_description_empty: '此用户尚未启用两步身份验证因素。',
deletion_confirmation: '您正在移除现有的2步身份验证器的<name/>。您确定要这样做吗?',
},
suspended: '已禁用',
suspend_user: '禁用用户',
suspend_user_reminder:

View file

@ -40,6 +40,13 @@ const user_details = {
not_connected: '該用戶還沒有綁定社交帳號',
deletion_confirmation: '你在正要刪除現有的 <name /> 身份,是否確認?',
},
mfa: {
field_name: '多重因素驗證',
field_description: '這個使用者已啟用2步驗證因素。',
name_column: '多重因素驗證',
field_description_empty: '此用戶尚未啟用兩步驟身份驗證因素。',
deletion_confirmation: '您正在移除現有的2步驗證器的<name/>。您確定要這樣做嗎?',
},
suspended: '已禁用',
suspend_user: '禁用用户',
suspend_user_reminder:

View file

@ -40,6 +40,13 @@ const user_details = {
not_connected: '該用戶還沒有綁定社交帳號',
deletion_confirmation: '你在正要刪除現有的 <name /> 身份,是否確認?',
},
mfa: {
field_name: '多因素驗證',
field_description: '這個用戶已啟用2步驗證因素。',
name_column: '多因素驗證',
field_description_empty: '此使用者尚未啟用兩步驗證因素。',
deletion_confirmation: '您正在移除現有的2步驗證器的<name/>。您確定要這樣做嗎?',
},
suspended: '已禁用',
suspend_user: '禁用用戶',
suspend_user_reminder: