Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/users/components/MyProfileScreen.tsx
T

481 lines
26 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Form, useActionData, useNavigation, useSubmit } from "react-router";
import { useTranslation } from "react-i18next";
import { QRCodeSVG } from "qrcode.react";
import {
FeatherCheck,
FeatherCheckCircle,
FeatherKey,
FeatherShieldCheck,
FeatherUpload,
FeatherX,
} from "@subframe/core";
import { Avatar } from "~/ui/components/Avatar";
import { BadgeFeedback } from "~/ui/components/BadgeFeedback";
import { Button } from "~/ui/components/Button";
import { CopyToClipboardButton } from "~/ui/components/CopyToClipboardButton";
import { IconButton } from "~/ui/components/IconButton";
import { InputOtp } from "~/ui/components/InputOtp";
import { Progress } from "~/ui/components/Progress";
import { TextInput } from "~/ui/components/TextInput";
import { useRootData } from "~/utils/data/useRootData";
// D-P2 — Settings > Profil nach Davids Subframe-"My Profile"-Design: Profil-Card (Avatar + Name inline-edit +
// E-Mail read-only) + Sicherheits-Card (Passwort ändern -> Modal, 2FA -> Setup/Disable-Modal). Praesentational,
// an die bestehende profile-Action verdrahtet (action=profile / password / startTotpEnrollment /
// confirmTotpEnrollment / disableTotpEnrollment). QR via qrcode.react. Position/Abteilung/Telefon sind in Davids
// Mock enthalten, haben aber kein Backing-Feld -> bewusst weggelassen (CR), statt Mock-Daten zu erfinden.
type ProfileUser = { email: string; firstName?: string | null; lastName?: string | null; avatar?: string | null };
type MfaStatus = { enabled?: boolean; pending?: boolean } | null;
function passwordStrength(pw: string): number {
if (!pw) return 0;
const checks = [pw.length >= 8, /[A-Z]/.test(pw), /[a-z]/.test(pw), /\d/.test(pw), /[^A-Za-z0-9]/.test(pw)];
return Math.round((checks.filter(Boolean).length / checks.length) * 100);
}
function initials(user: ProfileUser): string {
const a = (user.firstName ?? "").trim();
const b = (user.lastName ?? "").trim();
return `${a.charAt(0)}${b.charAt(0)}`.toUpperCase() || user.email.charAt(0).toUpperCase();
}
export default function MyProfileScreen({
user,
mfaStatus,
otpauthUri,
recoveryCodes,
mfaError,
}: {
user: ProfileUser;
mfaStatus: MfaStatus;
otpauthUri?: string;
recoveryCodes?: string[];
mfaError?: string;
}) {
const { t } = useTranslation();
const { csrf = "" } = useRootData();
const navigation = useNavigation();
const actionData = useActionData<{ success?: string; passwordSuccess?: string; error?: string }>();
const submit = useSubmit();
const submitting = navigation.state !== "idle";
const saved = actionData?.success ?? actionData?.passwordSuccess;
const [editingName, setEditingName] = useState(false);
const [pwOpen, setPwOpen] = useState(false);
// 2FA-Modal: null | "password" (Setup-Gate, Action verlangt aktuelles PW) | "qr" | "verify" | "success" | "disable"
const [twoFa, setTwoFa] = useState<null | "password" | "qr" | "verify" | "success" | "disable">(null);
const fileInput = useRef<HTMLInputElement | null>(null);
const avatarForm = useRef<HTMLFormElement | null>(null);
const [avatarData, setAvatarData] = useState<string>("");
// Schritt-Übergänge des 2FA-Flows aus der Action-Antwort ableiten.
useEffect(() => {
if (otpauthUri && twoFa === "password") setTwoFa("qr");
}, [otpauthUri]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (recoveryCodes && recoveryCodes.length > 0) setTwoFa("success");
}, [recoveryCodes]);
// Modal/Inline-Edit erst bei bestätigtem Erfolg schließen (nicht optimistisch beim Submit) -> Fehler bleiben sichtbar.
useEffect(() => {
if (saved) {
setPwOpen(false);
setEditingName(false);
}
}, [saved]);
// Disable-Modal schließen, sobald 2FA tatsächlich deaktiviert ist.
useEffect(() => {
if (!mfaStatus?.enabled && twoFa === "disable") setTwoFa(null);
}, [mfaStatus?.enabled, twoFa]);
function onPickAvatar(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
setAvatarData(String(reader.result ?? ""));
requestAnimationFrame(() => avatarForm.current && submit(avatarForm.current));
};
reader.readAsDataURL(file);
}
const mfaEnabled = Boolean(mfaStatus?.enabled);
return (
<div className="flex w-full flex-col items-center" data-ui-id="ui.settings.profile.page">
<div className="flex w-full max-w-[768px] flex-col items-start gap-8">
{/* Header */}
<div className="flex w-full flex-col items-start gap-2">
<span className="text-title-page font-title-page text-text-neutral-primary mobile:text-subtitle mobile:font-subtitle">
{t("settings.profile.pageTitle")}
</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-tertiary">
{t("settings.profile.pageSubtitle")}
</span>
</div>
{/* Profil-Card */}
<section className="flex w-full flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-6 py-6 shadow-sm">
<div className="flex w-full flex-col items-start gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.section.profile.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{t("settings.profile.section.profile.subtitle")}
</span>
</div>
{/* Avatar-Reihe */}
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start">
<Avatar size="large" image={user.avatar ?? undefined}>{initials(user)}</Avatar>
<div className="flex flex-col items-start gap-1">
<Form ref={avatarForm} method="post" replace>
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="profile" readOnly />
<input type="hidden" name="firstName" value={user.firstName ?? ""} readOnly />
<input type="hidden" name="lastName" value={user.lastName ?? ""} readOnly />
<input type="hidden" name="avatar" value={avatarData} readOnly />
<input ref={fileInput} type="file" accept="image/png,image/jpeg" className="hidden" onChange={onPickAvatar} data-ui-id="ui.settings.profile.avatarInput" />
<Button
type="button"
variant="secondary"
size="small"
icon={<FeatherUpload />}
loading={submitting && Boolean(avatarData)}
onClick={() => fileInput.current?.click()}
data-ui-id="ui.settings.profile.avatarUpload"
>
{t("settings.profile.avatar.upload")}
</Button>
</Form>
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">
{t("settings.profile.avatar.hint")}
</span>
</div>
</div>
<div className="flex h-px w-full flex-none flex-col items-center bg-border-default" />
{/* Name (inline editierbar) */}
{editingName ? (
<Form method="post" replace className="flex w-full items-end gap-2">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="profile" readOnly />
<TextInput className="h-auto grow shrink-0 basis-0" variant="filled" showLabel label={t("models.user.firstName")}>
<TextInput.Input name="firstName" required defaultValue={user.firstName ?? ""} data-ui-id="ui.settings.profile.firstName" />
</TextInput>
<TextInput className="h-auto grow shrink-0 basis-0" variant="filled" showLabel label={t("models.user.lastName")}>
<TextInput.Input name="lastName" defaultValue={user.lastName ?? ""} data-ui-id="ui.settings.profile.lastName" />
</TextInput>
<IconButton type="submit" variant="primary" icon={<FeatherCheck />} loading={submitting} data-ui-id="ui.settings.profile.nameSave" />
<IconButton type="button" icon={<FeatherX />} onClick={() => setEditingName(false)} data-ui-id="ui.settings.profile.nameCancel" />
</Form>
) : (
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start mobile:gap-2">
<span className="w-40 flex-none text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-secondary mobile:w-full">
{t("settings.profile.field.name")}
</span>
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-primary">
{[user.firstName, user.lastName].filter(Boolean).join(" ") || "—"}
</span>
<Button variant="tertiary" size="small" onClick={() => setEditingName(true)} data-ui-id="ui.settings.profile.nameEdit">
{t("shared.edit")}
</Button>
</div>
)}
{/* E-Mail (read-only) */}
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start mobile:gap-2">
<span className="w-40 flex-none text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-secondary mobile:w-full">
{t("settings.profile.field.email")}
</span>
<span className="grow shrink-0 basis-0 text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-primary">{user.email}</span>
<BadgeFeedback farbe="subtle-neutral" size="small">{t("settings.profile.field.notEditable")}</BadgeFeedback>
</div>
</section>
{/* Sicherheits-Card */}
<section className="flex w-full flex-col items-start gap-6 rounded-lg border border-solid border-border-default bg-surface-neutral-secondary px-6 py-6 shadow-sm">
<div className="flex w-full flex-col items-start gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.section.security.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">
{t("settings.profile.section.security.subtitle")}
</span>
</div>
{/* Passwort */}
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start mobile:gap-2">
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{t("settings.profile.password")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("settings.profile.passwordHint")}</span>
</div>
<Button variant="tertiary" icon={<FeatherKey />} className="mobile:w-full" onClick={() => setPwOpen(true)} data-ui-id="ui.settings.profile.password.open">
{t("settings.profile.changePassword")}
</Button>
</div>
<div className="flex h-px w-full flex-none flex-col items-center bg-border-default" />
{/* 2FA */}
<div className="flex w-full items-center gap-4 mobile:flex-col mobile:items-start mobile:gap-2">
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
<div className="flex items-center gap-2">
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{t("settings.profile.mfa.title")}</span>
<BadgeFeedback farbe={mfaEnabled ? "subtle-positive" : "subtle-neutral"} size="small">
{mfaEnabled ? t("settings.profile.mfa.badge.active") : t("settings.profile.mfa.badge.inactive")}
</BadgeFeedback>
</div>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("settings.profile.mfa.subtitle")}</span>
</div>
<Button
variant="tertiary"
icon={<FeatherShieldCheck />}
className="mobile:w-full"
onClick={() => setTwoFa(mfaEnabled ? "disable" : "password")}
data-ui-id="ui.settings.profile.mfa.manage"
>
{mfaEnabled ? t("settings.profile.mfa.disable.cta") : t("settings.profile.mfa.setup.cta")}
</Button>
</div>
</section>
</div>
{pwOpen ? <PasswordModal csrf={csrf} submitting={submitting} error={actionData?.error} onClose={() => setPwOpen(false)} /> : null}
{twoFa ? (
<TwoFaModal
step={twoFa}
csrf={csrf}
submitting={submitting}
otpauthUri={otpauthUri}
recoveryCodes={recoveryCodes}
mfaError={mfaError}
onClose={() => setTwoFa(null)}
/>
) : null}
</div>
);
}
function ModalShell({ children, dataUiId, center = false }: { children: React.ReactNode; dataUiId: string; center?: boolean }) {
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-2 bg-surface-neutral-overlay px-4 py-4" data-ui-id={dataUiId}>
<div
className={`flex w-full max-w-[480px] flex-col gap-6 rounded-md border border-solid border-border-default bg-surface-neutral-modal px-6 py-6 shadow-lg mobile:max-w-full ${
center ? "items-center text-center" : "items-start"
}`}
>
{children}
</div>
</div>
);
}
function PasswordModal({ csrf, submitting, error, onClose }: { csrf: string; submitting: boolean; error?: string; onClose: () => void }) {
const { t } = useTranslation();
const [pw, setPw] = useState("");
const strength = passwordStrength(pw);
return (
<ModalShell dataUiId="ui.settings.profile.password.modal">
<div className="flex w-full flex-col items-start gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.passwordDialog.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{t("settings.profile.passwordDialog.subtitle")}</span>
</div>
<Form method="post" replace className="flex w-full flex-col items-start gap-4">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="password" readOnly />
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.current")} type="password">
<TextInput.Input name="passwordCurrent" type="password" required autoComplete="current-password" data-ui-id="ui.settings.profile.password.current" />
</TextInput>
<div className="flex w-full flex-col items-start gap-2">
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.new")} type="password">
<TextInput.Input
name="passwordNew"
type="password"
required
autoComplete="new-password"
value={pw}
onChange={(event) => setPw(event.target.value)}
data-ui-id="ui.settings.profile.password.new"
/>
</TextInput>
<Progress className="h-auto w-full flex-none" hideLabel value={strength} state={strength >= 80 ? "success" : undefined} />
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">{t("settings.profile.passwordDialog.hint")}</span>
</div>
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.confirm")} type="password">
<TextInput.Input name="passwordNewConfirm" type="password" required autoComplete="new-password" data-ui-id="ui.settings.profile.password.confirm" />
</TextInput>
{error ? <span className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative">{error}</span> : null}
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button type="button" variant="tertiary" onClick={onClose} data-ui-id="ui.settings.profile.password.cancel">{t("shared.cancel")}</Button>
<Button type="submit" variant="primary" icon={<FeatherKey />} loading={submitting} data-ui-id="ui.settings.profile.password.submit">
{t("settings.profile.changePassword")}
</Button>
</div>
</Form>
</ModalShell>
);
}
function TwoFaModal({
step,
csrf,
submitting,
otpauthUri,
recoveryCodes,
mfaError,
onClose,
}: {
step: "password" | "qr" | "verify" | "success" | "disable";
csrf: string;
submitting: boolean;
otpauthUri?: string;
recoveryCodes?: string[];
mfaError?: string;
onClose: () => void;
}) {
const { t } = useTranslation();
const secret = otpauthUri ? new URLSearchParams(otpauthUri.split("?")[1] ?? "").get("secret") ?? "" : "";
const [showVerify, setShowVerify] = useState(false);
const [digits, setDigits] = useState<string[]>(Array(6).fill(""));
const code = digits.join("");
if (step === "success") {
return (
<ModalShell dataUiId="ui.settings.profile.mfa.success" center>
<FeatherCheckCircle className="text-[48px] font-[400] text-icon-feedback-positive" />
<div className="flex w-full flex-col items-center gap-2">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.mfa.success.title")}</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">{t("settings.profile.mfa.success.subtitle")}</span>
</div>
{recoveryCodes && recoveryCodes.length > 0 ? (
<div className="flex w-full flex-col items-start gap-2 rounded-md border border-solid border-border-default bg-surface-neutral-primary px-4 py-4">
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-secondary">{t("settings.profile.mfa.recoveryCodes")}</span>
<div className="grid w-full grid-cols-2 gap-1">
{recoveryCodes.map((code) => (
<span key={code} className="font-mono text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-primary">{code}</span>
))}
</div>
</div>
) : null}
<Button className="h-10 w-full flex-none" variant="primary" onClick={onClose} data-ui-id="ui.settings.profile.mfa.done">{t("settings.profile.mfa.success.done")}</Button>
</ModalShell>
);
}
if (step === "qr") {
return (
<ModalShell dataUiId="ui.settings.profile.mfa.setup">
<div className="flex w-full flex-col items-start gap-2">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.mfa.setup.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-secondary">{t("settings.profile.mfa.setup.subtitle")}</span>
</div>
<div className="flex w-full flex-col items-center gap-4">
<div className="flex h-40 w-40 flex-none items-center justify-center rounded-md border border-solid border-border-default bg-surface-neutral-secondary">
{otpauthUri ? <QRCodeSVG value={otpauthUri} size={144} /> : null}
</div>
{secret ? (
<>
<div className="flex w-full items-center justify-center gap-3">
<div className="h-px grow shrink-0 basis-0 bg-border-default" />
<span className="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">{t("settings.profile.mfa.setup.manualKey")}</span>
<div className="h-px grow shrink-0 basis-0 bg-border-default" />
</div>
<div className="flex w-full items-center justify-center gap-2">
<span className="font-mono tracking-widest text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{secret}</span>
<CopyToClipboardButton clipboardText={secret} />
</div>
</>
) : null}
</div>
{!showVerify ? (
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button variant="tertiary" onClick={onClose} data-ui-id="ui.settings.profile.mfa.setup.cancel">{t("shared.cancel")}</Button>
<Button variant="primary" onClick={() => setShowVerify(true)} data-ui-id="ui.settings.profile.mfa.next">
{t("settings.profile.mfa.setup.next")}
</Button>
</div>
) : (
<div className="flex w-full flex-col items-start gap-4 border-t border-solid border-border-default pt-4">
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-secondary">{t("settings.profile.mfa.verify.subtitle")}</span>
<Form method="post" replace className="flex w-full flex-col items-center gap-4">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="confirmTotpEnrollment" readOnly />
<input type="hidden" name="code" value={code} readOnly />
<InputOtp state={mfaError ? "error" : "default"}>
{Array.from({ length: 6 }).map((_, i) => (
<InputOtp.OtpInputItem key={i} error={Boolean(mfaError)}>
<InputOtp.Input
className="h-auto w-12 flex-none self-stretch"
value={digits[i]}
onChange={(event) => {
const clean = event.target.value.replace(/\D/g, "").slice(0, 1);
setDigits((prev) => prev.map((d, idx) => (idx === i ? clean : d)));
}}
inputMode="numeric"
aria-label={`${t("account.loginMfa.fields.authenticatorCode")} ${i + 1}`}
data-ui-id={i === 0 ? "ui.settings.profile.mfa.code" : undefined}
/>
</InputOtp.OtpInputItem>
))}
</InputOtp>
{mfaError ? <span className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative">{mfaError}</span> : null}
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button type="button" variant="tertiary" onClick={() => setShowVerify(false)} data-ui-id="ui.settings.profile.mfa.back">
{t("settings.profile.mfa.setup.back")}
</Button>
<Button type="submit" variant="primary" icon={<FeatherShieldCheck />} loading={submitting} disabled={code.length !== 6} data-ui-id="ui.settings.profile.mfa.confirm">
{t("settings.profile.mfa.verify.confirm")}
</Button>
</div>
</Form>
</div>
)}
</ModalShell>
);
}
if (step === "disable") {
return (
<ModalShell dataUiId="ui.settings.profile.mfa.disable">
<div className="flex w-full flex-col items-start gap-2">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.mfa.disable.title")}</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">{t("settings.profile.mfa.disable.subtitle")}</span>
</div>
<Form method="post" replace className="flex w-full flex-col items-start gap-4">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="disableTotpEnrollment" readOnly />
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.current")} type="password">
<TextInput.Input name="passwordCurrent" type="password" required autoComplete="current-password" data-ui-id="ui.settings.profile.mfa.disablePassword" />
</TextInput>
{mfaError ? <span className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative">{mfaError}</span> : null}
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button type="button" variant="tertiary" onClick={onClose}>{t("shared.cancel")}</Button>
<Button type="submit" variant="destructive" loading={submitting} data-ui-id="ui.settings.profile.mfa.disableSubmit">{t("settings.profile.mfa.disable.cta")}</Button>
</div>
</Form>
</ModalShell>
);
}
// step === "password" — Setup-Gate (Action verlangt aktuelles Passwort, bevor der QR erzeugt wird)
return (
<ModalShell dataUiId="ui.settings.profile.mfa.gate">
<div className="flex w-full flex-col items-start gap-2">
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.mfa.setup.title")}</span>
<span className="text-body-default-regular-150 font-body-default-regular-150 text-text-neutral-secondary">{t("settings.profile.mfa.gate.subtitle")}</span>
</div>
<Form method="post" replace className="flex w-full flex-col items-start gap-4">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="startTotpEnrollment" readOnly />
<TextInput className="h-auto w-full flex-none" showLabel label={t("settings.profile.passwordDialog.current")} type="password">
<TextInput.Input name="passwordCurrent" type="password" required autoFocus autoComplete="current-password" data-ui-id="ui.settings.profile.mfa.gatePassword" />
</TextInput>
{mfaError ? <span className="text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative">{mfaError}</span> : null}
<div className="flex w-full items-center justify-end gap-2 mobile:flex-col-reverse mobile:items-stretch">
<Button type="button" variant="tertiary" onClick={onClose}>{t("shared.cancel")}</Button>
<Button type="submit" variant="primary" loading={submitting} data-ui-id="ui.settings.profile.mfa.gateSubmit">{t("settings.profile.mfa.setup.next")}</Button>
</div>
</Form>
</ModalShell>
);
}