481 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|