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

990 lines
50 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { Form, useActionData, useNavigation, useSubmit } from "react-router";
import { useTranslation } from "react-i18next";
import { formatDate } from "~/locale/format";
import { QRCodeSVG } from "qrcode.react";
import {
FeatherArrowLeft,
FeatherArrowRight,
FeatherCheckCircle,
FeatherKey,
FeatherPlus,
FeatherShieldAlert,
FeatherShieldCheck,
FeatherShieldOff,
FeatherSmartphone,
FeatherTrash2,
FeatherUpload,
FeatherX,
} from "@subframe/core";
import { Alert } from "~/ui/components/Alert";
import { Avatar } from "~/ui/components/Avatar";
import { BadgeFeedback } from "~/ui/components/BadgeFeedback";
import { Button } from "~/ui/components/Button";
import { Card } from "~/ui/components/Card";
import { CopyToClipboardButton } from "~/ui/components/CopyToClipboardButton";
import { Dialog } from "~/ui/components/Dialog";
import { IconButton } from "~/ui/components/IconButton";
import { IconWithBackground } from "~/ui/components/IconWithBackground";
import { InputOtp } from "~/ui/components/InputOtp";
import { Progress } from "~/ui/components/Progress";
import { Select } from "~/ui/components/Select";
import { TextInput } from "~/ui/components/TextInput";
import { TenantUserType } from "~/application/enums/tenants/TenantUserType";
import { useRootData } from "~/utils/data/useRootData";
import AvatarCropDialog from "./AvatarCropDialog";
// 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 -> Verwalten/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.
//
// WF-P2 ("2FA verwalten") — Davids Subframe-Screens token-genau uebernommen:
// M01 "2FA verwalten" -> step "manage" (Verwaltungs-Dialog: Methoden-Liste + Deaktivieren-Banner + Geraet hinzufuegen)
// M02 "Geraet/Token hinzufuegen" -> step "qr" (QR + manueller Schluessel + OTP-Bestaetigung in einer Card)
// M03 "2FA deaktivieren" -> step "disable" (Davids Warn-Card; Bestaetigung per Passwort, s. FLAG unten)
// Backend-Luecken (NICHT erfunden, geflaggt, statt Mock-Daten zu zeigen):
// - M01 Methoden-Liste: nur "Authenticator-App" ist backend-gedeckt. Sicherheitsschluessel (WebAuthn),
// Geraete-Metadaten/"Dieses Geraet"-Badge sowie der Wiederherstellungscodes-Eintrag mit "Neu erstellen"
// (Regenerate) haben kein Backing -> weggelassen.
// - M03: Davids Mock bestaetigt mit dem aktuellen 2FA-Code (OTP). Das bestehende disableTotpEnrollment
// verifiziert das PASSWORT -> Bestaetigung bleibt Passwort-basiert, Texte entsprechend angepasst.
type ProfileUser = { email: string; firstName?: string | null; lastName?: string | null; avatar?: string | null; phone?: string | null };
type MfaStatus = { enabled?: boolean; pending?: boolean; enabledAt?: string | Date | null } | 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();
}
function userDisplayName(user: ProfileUser): string {
return [user.firstName, user.lastName].filter(Boolean).join(" ") || user.email;
}
// Lokale, token-gleiche Nachbauten von Davids Subframe-"Settings Page"-Slots (NeutralCard/SettingRow/Divider) —
// die SettingsPage/DefaultPageLayoutWithInnerSidebar sind reine Subframe-Library-Komponenten (nicht im Repo);
// hier 1:1 als Markup nachgebaut, OHNE die Settings-Shell/Navigation umzubauen.
function NeutralCard({ children }: { children: React.ReactNode }) {
return (
<div className="bg-surface-neutral-secondary flex h-full w-full flex-col items-start gap-3 rounded-lg px-4 py-4">
<div className="flex w-full shrink-0 grow basis-0 flex-col items-start gap-3">{children}</div>
</div>
);
}
function CardDivider() {
return <div className="bg-surface-neutral-tertiary flex h-px w-full flex-col items-center gap-2" />;
}
function SettingRow({ label, helpText, children }: { label: React.ReactNode; helpText?: React.ReactNode; children: React.ReactNode }) {
return (
<div className="flex w-full items-center gap-4">
<div className="flex flex-col items-start gap-1">
<span className="text-body-default-medium-0 font-body-default-medium-0 text-text-neutral-primary">{label}</span>
{helpText ? (
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary w-full">{helpText}</span>
) : null}
</div>
<div className="flex shrink-0 grow basis-0 items-center justify-end">{children}</div>
</div>
);
}
function StatusPill({ tone = "positive", children }: { tone?: "positive" | "neutral"; children: React.ReactNode }) {
return (
<span
className={
tone === "positive"
? "text-body-extra-small-semibold-150 font-body-extra-small-semibold-150 text-text-feedback-positive rounded-round bg-surface-feedback-subtle-positive px-2 py-0.5"
: "text-body-extra-small-semibold-150 font-body-extra-small-semibold-150 text-text-neutral-secondary rounded-round bg-surface-feedback-subtle-neutral px-2 py-0.5"
}
>
{children}
</span>
);
}
export default function MyProfileScreen({
user,
mfaStatus,
otpauthUri,
recoveryCodes,
mfaError,
role,
}: {
user: ProfileUser;
mfaStatus: MfaStatus;
otpauthUri?: string;
recoveryCodes?: string[];
mfaError?: string;
role?: TenantUserType | null;
}) {
const { t } = useTranslation();
const { csrf = "" } = useRootData();
const navigation = useNavigation();
const actionData = useActionData<{
success?: string;
passwordSuccess?: string;
error?: string;
// WF-P2 Change Password / V01: Lockout-Signal. FLAG: kein Rate-Limit-/Lockout-Backend fuer den
// Passwortwechsel -> action=password setzt diese Felder (noch) nicht; UI ist vorbereitet (CR).
passwordLocked?: boolean;
passwordLockedRetryLabel?: string;
}>();
const submit = useSubmit();
const submitting = navigation.state !== "idle";
const saved = actionData?.success ?? actionData?.passwordSuccess;
const [pwOpen, setPwOpen] = useState(false);
// 2FA-Modal: null | "manage" (M01) | "password" (Setup-Gate, Action verlangt aktuelles PW) | "qr" (M02) | "success" | "disable" (M03)
const [twoFa, setTwoFa] = useState<null | "manage" | "password" | "qr" | "success" | "disable">(null);
const fileInput = useRef<HTMLInputElement | null>(null);
const avatarForm = useRef<HTMLFormElement | null>(null);
const [avatarData, setAvatarData] = useState<string>("");
// Data URI of the picked file while the crop dialog is open; null when closed.
const [cropSource, setCropSource] = useState<string | null>(null);
// 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);
}, [saved]);
// Disable-/Verwalten-Modal schließen, sobald 2FA tatsächlich deaktiviert ist.
useEffect(() => {
if (!mfaStatus?.enabled && (twoFa === "disable" || twoFa === "manage")) setTwoFa(null);
}, [mfaStatus?.enabled, twoFa]);
// A picked file goes into the crop dialog first; only the section the user
// confirmed there is submitted. Clearing the input lets the same file be
// picked again after a cancel, which otherwise fires no change event.
function onPickAvatar(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = () => setCropSource(String(reader.result ?? ""));
reader.readAsDataURL(file);
}
function onCropConfirmed(dataUri: string) {
setCropSource(null);
setAvatarData(dataUri);
requestAnimationFrame(() => avatarForm.current && submit(avatarForm.current));
}
const mfaEnabled = Boolean(mfaStatus?.enabled);
const roleName = role === null || role === undefined ? "" : TenantUserType[role];
const roleLabel = roleName ? t(`settings.profile.types.${roleName}`) : "";
return (
<div className="flex w-full flex-col items-start gap-4" data-ui-id="ui.settings.profile.page">
<div className="flex w-full flex-col items-start gap-4">
{/* Page headline (title/subtitle) lives in the route's shared PageHeader. */}
{/* Reihe 1 — Profil + Accountsicherheit */}
<div className="mobile:flex-col flex w-full items-start gap-4">
<NeutralCard>
<div className="mobile:flex-col mobile:items-start flex w-full items-center gap-6">
<div className="flex shrink-0 grow basis-0 items-center gap-4">
<Avatar size="extra-large" image={user.avatar ?? undefined}>
{initials(user)}
</Avatar>
<div className="flex shrink-0 grow basis-0 flex-col items-start gap-3">
<div className="flex flex-col items-start gap-0.5">
<span className="text-heading- font-heading- text-text-neutral-primary">{userDisplayName(user)}</span>
{roleLabel ? <span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary">{roleLabel}</span> : null}
</div>
<div className="flex items-center gap-2">
{/* Bild ändern (Avatar-Upload, autosubmit per onPick) */}
<Form ref={avatarForm} method="post" replace className="contents">
<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.change")}
</Button>
</Form>
{/* Entfernen (Avatar löschen -> action=profile mit leerem avatar) */}
<Form method="post" replace className="contents">
<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="" readOnly />
<Button
type="submit"
variant="tertiary"
size="small"
icon={<FeatherTrash2 />}
disabled={!user.avatar}
data-ui-id="ui.settings.profile.avatarRemove"
>
{t("settings.profile.avatar.remove")}
</Button>
</Form>
</div>
</div>
</div>
<div className="bg-surface-neutral-tertiary mobile:hidden flex w-px flex-none flex-col items-center self-stretch" />
<div className="mobile:w-full flex w-80 flex-none flex-col items-start gap-2">
<span className="text-body-small-semibold-150 font-body-small-semibold-150 text-text-neutral-tertiary">
{t("settings.profile.security.accountSecurity")}
</span>
<div className="bg-surface-neutral-primary flex w-full items-center gap-3 rounded-md px-3 py-2">
<FeatherShieldCheck
className={
"text-body-default-regular-0 font-body-default-regular-0 " + (mfaEnabled ? "text-icon-feedback-positive" : "text-icon-neutral-tertiary")
}
/>
<div className="flex shrink-0 grow basis-0 flex-col items-start">
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-primary">
{t("settings.profile.security.twoFactorShort")}
</span>
</div>
<StatusPill tone={mfaEnabled ? "positive" : "neutral"}>
{mfaEnabled ? t("settings.profile.mfa.badge.active") : t("settings.profile.mfa.badge.inactive")}
</StatusPill>
<Button size="small" onClick={() => setTwoFa(mfaEnabled ? "manage" : "password")} data-ui-id="ui.settings.profile.security.manage">
{t("settings.profile.security.manage")}
</Button>
</div>
</div>
</div>
</NeutralCard>
</div>
{/* Reihe 2 — Basis info + Login info */}
<div className="mobile:flex-col flex w-full items-start gap-4">
<NeutralCard>
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.basicInfo.title")}</span>
<CardDivider />
<Form method="post" replace className="flex w-full flex-col items-start gap-3" data-ui-id="ui.settings.profile.form">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="profile" readOnly />
{/* Avatar mitsenden, damit Speichern des Namens das vorhandene Bild nicht löscht. */}
<input type="hidden" name="avatar" value={user.avatar ?? ""} readOnly />
<SettingRow label={t("models.user.firstName")}>
<TextInput className="h-auto w-56 flex-none">
<TextInput.Input
name="firstName"
required
aria-label={t("models.user.firstName")}
defaultValue={user.firstName ?? ""}
placeholder={t("account.shared.firstNamePlaceholder")}
data-ui-id="ui.settings.profile.firstName"
/>
</TextInput>
</SettingRow>
<CardDivider />
<SettingRow label={t("models.user.lastName")}>
<TextInput className="h-auto w-56 flex-none">
<TextInput.Input
name="lastName"
aria-label={t("models.user.lastName")}
defaultValue={user.lastName ?? ""}
placeholder={t("account.shared.lastNamePlaceholder")}
data-ui-id="ui.settings.profile.lastName"
/>
</TextInput>
</SettingRow>
<CardDivider />
<SettingRow label={t("settings.profile.basicInfo.phone")}>
<TextInput disabled className="h-auto w-56 flex-none" type="tel">
<TextInput.Input
type="tel"
disabled
readOnly
aria-label={t("settings.profile.basicInfo.phone")}
value={user.phone ?? ""}
data-ui-id="ui.settings.profile.phone"
/>
</TextInput>
</SettingRow>
<CardDivider />
<SettingRow label={t("settings.profile.basicInfo.department")}>
<Select
disabled
className="h-auto w-56 flex-none"
placeholder={t("settings.profile.basicInfo.department")}
triggerAriaLabel={t("settings.profile.basicInfo.department")}
data-ui-id="ui.settings.profile.department"
/>
</SettingRow>
<CardDivider />
<SettingRow label={t("models.user.role")} helpText={t("settings.profile.basicInfo.roleAssignedByAdmin")}>
<TextInput disabled className="h-auto w-56 flex-none">
<TextInput.Input
disabled
readOnly
aria-label={t("models.user.role")}
value={roleLabel}
placeholder={t("models.user.role")}
data-ui-id="ui.settings.profile.role"
/>
</TextInput>
</SettingRow>
<div className="flex w-full items-center justify-end">
<Button type="submit" size="small" loading={submitting && !avatarData} data-ui-id="ui.settings.profile.basicInfo.save">
{t("shared.save")}
</Button>
</div>
</Form>
</NeutralCard>
{/* Login info */}
<NeutralCard>
<span className="text-heading- font-heading- text-text-neutral-primary">{t("settings.profile.loginInfo.title")}</span>
<CardDivider />
<SettingRow label={t("settings.profile.loginInfo.email")}>
<TextInput disabled className="h-auto w-72 flex-none" type="email">
<TextInput.Input
type="email"
disabled
readOnly
aria-label={t("settings.profile.loginInfo.email")}
value={user.email}
data-ui-id="ui.settings.profile.email"
/>
</TextInput>
</SettingRow>
<CardDivider />
<SettingRow label={t("settings.profile.password")}>
<div className="flex items-center gap-3">
<span className="text-body-default-regular-0 font-body-default-regular-0 text-text-neutral-secondary tracking-widest">••••••••••</span>
<Button size="small" onClick={() => setPwOpen(true)} data-ui-id="ui.settings.profile.password.open">
{t("settings.profile.changePassword")}
</Button>
</div>
</SettingRow>
<CardDivider />
<SettingRow label={t("settings.profile.loginInfo.twoFactor")}>
<div className="flex items-center gap-3">
<StatusPill tone={mfaEnabled ? "positive" : "neutral"}>
{mfaEnabled ? t("settings.profile.mfa.badge.active") : t("settings.profile.mfa.badge.inactive")}
</StatusPill>
<Button size="small" onClick={() => setTwoFa(mfaEnabled ? "manage" : "password")} data-ui-id="ui.settings.profile.mfa.manage">
{t("settings.profile.loginInfo.manageTwoFactor")}
</Button>
</div>
</SettingRow>
</NeutralCard>
</div>
</div>
{cropSource ? (
<AvatarCropDialog source={cropSource} submitting={submitting} onCancel={() => setCropSource(null)} onConfirm={onCropConfirmed} />
) : null}
{pwOpen ? (
actionData?.passwordLocked ? (
<ChangePasswordLockedDialog retryLabel={actionData.passwordLockedRetryLabel} onClose={() => setPwOpen(false)} />
) : (
<ChangePasswordDialog user={user} 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}
enabledAt={mfaStatus?.enabledAt ?? null}
onStep={setTwoFa}
onClose={() => setTwoFa(null)}
/>
) : null}
</div>
);
}
function ModalShell({
children,
dataUiId,
center = false,
onClose,
}: {
children: React.ReactNode;
dataUiId: string;
center?: boolean;
onClose: () => void;
}) {
return (
<DialogOverlay dataUiId={dataUiId} onClose={onClose}>
<div
className={`border-border-default bg-surface-neutral-modal mobile:max-w-full flex w-full max-w-[480px] flex-col gap-6 rounded-md border border-solid px-6 py-6 shadow-lg ${
center ? "items-center text-center" : "items-start"
}`}
>
{children}
</div>
</DialogOverlay>
);
}
// WF-P2 ("Change Password") — Davids Subframe-Screens token-genau uebernommen:
// M01 "Identitaet bestaetigen" -> ChangePasswordDialog (Shield-Header + Konto-Card + aktuelles Passwort,
// Footer Abbrechen/Weiter). An die bestehende action=password verdrahtet (Single-Step, prueft das
// aktuelle Passwort serverseitig).
// V01 "Zu viele Fehlversuche" -> ChangePasswordLockedDialog (Davids Lockout-Card).
// Backend-Luecken (NICHT erfunden, geflaggt):
// - M01 zeigt zusaetzlich eine 2FA-Code-Karte (TOTP-Step-up vor dem Wechsel). Es gibt KEIN Backend, das
// einen TOTP-Step-up waehrend des Passwortwechsels verifiziert (action=password prueft nur das aktuelle
// Passwort) -> 2FA-Karte weggelassen, statt eine wirkungslose Eingabe zu bauen (CR). Die fuer den
// bestehenden Single-Step-Flow noetigen Felder (neues Passwort + Wiederholung) haben kein eigenes
// M01-Design und sind hier konsistent re-skinned ergaenzt.
// - V01: kein Rate-Limit-/Lockout-Backend fuer den Passwortwechsel -> einsatzbereit, wird aber erst
// ausgeloest, wenn die Action passwordLocked (+ Restzeit) liefert (s. actionData-Typ oben).
function ChangePasswordDialog({
user,
csrf,
submitting,
error,
onClose,
}: {
user: ProfileUser;
csrf: string;
submitting: boolean;
error?: string;
onClose: () => void;
}) {
const { t } = useTranslation();
const [pw, setPw] = useState("");
const strength = passwordStrength(pw);
const fullName = [user.firstName, user.lastName].filter(Boolean).join(" ");
return (
<DialogOverlay dataUiId="ui.settings.profile.password.modal" onClose={onClose}>
<div className="border-border-default bg-surface-neutral-secondary mobile:px-4 mobile:py-5 relative flex w-full max-w-[480px] flex-col items-start gap-6 rounded-lg border border-solid px-6 py-6 shadow-lg">
<IconButton
className="absolute top-3 right-3"
variant="ghost"
size="small"
icon={<FeatherX />}
aria-label={t("shared.close")}
onClick={onClose}
data-ui-id="ui.settings.profile.password.close"
/>
<div className="flex w-full flex-col items-center justify-center gap-1">
<div className="rounded-round bg-surface-feedback-subtle-information flex h-11 w-11 flex-none items-center justify-center">
<FeatherShieldCheck className="text-heading- font-heading- text-icon-feedback-information" />
</div>
<span className="text-heading- font-heading- text-text-neutral-primary w-full text-center">{t("settings.profile.reauth.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary w-full text-center">
{t("settings.profile.reauth.subtitle")}
</span>
</div>
<div className="border-border-default bg-surface-neutral-secondary flex w-full items-center gap-3 rounded-md border border-solid px-4 py-3">
<Avatar size="medium" image={user.avatar ?? undefined}>
{initials(user)}
</Avatar>
<div className="flex shrink-0 grow basis-0 flex-col items-start">
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-primary w-full">{fullName || user.email}</span>
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary w-full">{user.email}</span>
</div>
<BadgeFeedback farbe="subtle-neutral" size="small">
{t("settings.profile.reauth.currentAccount")}
</BadgeFeedback>
</div>
<Form method="post" replace className="flex w-full flex-col items-start gap-6">
<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"
placeholder={t("settings.profile.reauth.currentPasswordPlaceholder")}
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}
aria-label={t("settings.profile.passwordDialog.hint")}
/>
<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="mobile:flex-col-reverse flex w-full items-center justify-end gap-2">
<Button className="h-9 shrink-0 grow basis-0" type="button" variant="secondary" onClick={onClose} data-ui-id="ui.settings.profile.password.cancel">
{t("shared.cancel")}
</Button>
<Button
className="h-9 shrink-0 grow basis-0"
type="submit"
iconRight={<FeatherArrowRight />}
loading={submitting}
data-ui-id="ui.settings.profile.password.submit"
>
{t("settings.profile.mfa.setup.next")}
</Button>
</div>
</Form>
</div>
</DialogOverlay>
);
}
function ChangePasswordLockedDialog({ retryLabel, onClose }: { retryLabel?: string; onClose: () => void }) {
const { t } = useTranslation();
return (
<DialogOverlay dataUiId="ui.settings.profile.password.locked" onClose={onClose}>
<Card className="mobile:gap-6 mobile:shadow-lg mobile:px-4 mobile:py-5 max-w-[480px]">
<div className="flex w-full flex-col items-center gap-4">
<IconWithBackground variant="warning" size="extra-large" round={true} icon={<FeatherShieldAlert />} />
<div className="flex w-full flex-col items-center gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary w-full text-center">{t("settings.profile.changePasswordLocked.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary w-full text-center">
{t("settings.profile.changePasswordLocked.subtitle")}
</span>
</div>
</div>
<Alert
variant="warning"
notDismissible={true}
title={t("settings.profile.changePasswordLocked.retryIn", { time: retryLabel ?? "" })}
description={t("settings.profile.changePasswordLocked.retryInDescription")}
/>
<Alert
variant="neutral"
notDismissible={true}
title={t("settings.profile.changePasswordLocked.support.title")}
description={t("settings.profile.changePasswordLocked.support.description")}
/>
<div className="flex w-full flex-col items-center gap-2">
<Button className="h-10 w-full flex-none" disabled={true} icon={<FeatherKey />} data-ui-id="ui.settings.profile.password.locked.retry">
{t("settings.profile.changePasswordLocked.retryCta")}
</Button>
<Button className="h-10 w-full flex-none" icon={<FeatherArrowLeft />} onClick={onClose} data-ui-id="ui.settings.profile.password.locked.back">
{t("settings.profile.changePasswordLocked.backToSettings")}
</Button>
</div>
</Card>
</DialogOverlay>
);
}
function DialogOverlay({ children, dataUiId, onClose }: { children: React.ReactNode; dataUiId: string; onClose: () => void }) {
// Davids DialogLayout (zentrierte Card auf abgedunkeltem Hintergrund) — aber
// durch die geteilte Dialog-Komponente, wie jeder andere Pop-up im Produkt.
// Vorher war das ein handgebautes `fixed inset-0` mitten im Seiteninhalt: das
// dunkelte nur den Inhaltsbereich ab, weil Sidebar und Kopfleiste auf
// derselben z-Ebene darueber lagen, und brachte weder Fokus-Falle noch
// Escape-to-close mit. Die Dialog-Komponente portalisiert nach document.body
// und loest das strukturell.
//
// Dialog.Content bleibt hier bewusst transparent und randlos: die Karte
// darin ist Davids Screen und bringt Rahmen, Flaeche und Schatten selbst mit.
return (
<Dialog
open
onOpenChange={(open: boolean) => {
if (!open) onClose();
}}
>
<Dialog.Content
className="max-h-[90vh] w-auto min-w-0 max-w-[92vw] overflow-auto border-none bg-transparent shadow-none"
data-ui-id={dataUiId}
>
{children}
</Dialog.Content>
</Dialog>
);
}
type TwoFaStep = "manage" | "password" | "qr" | "success" | "disable";
function TwoFaModal({
step,
csrf,
submitting,
otpauthUri,
recoveryCodes,
mfaError,
enabledAt,
onStep,
onClose,
}: {
step: TwoFaStep;
csrf: string;
submitting: boolean;
otpauthUri?: string;
recoveryCodes?: string[];
mfaError?: string;
enabledAt?: string | Date | null;
onStep: (step: TwoFaStep) => void;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const secret = otpauthUri ? (new URLSearchParams(otpauthUri.split("?")[1] ?? "").get("secret") ?? "") : "";
const groupedSecret = secret.replace(/(.{4})/g, "$1 ").trim();
const [digits, setDigits] = useState<string[]>(Array(6).fill(""));
const code = digits.join("");
function onOtpChange(index: number, raw: string) {
const clean = raw.replace(/\D/g, "").slice(0, 1);
setDigits((prev) => prev.map((d, idx) => (idx === index ? clean : d)));
}
// M01 — "2FA verwalten": Verwaltungs-Dialog (Einstieg bei aktivem 2FA). Nur die backend-gedeckte
// Authenticator-App-Methode wird gelistet; Sicherheitsschlüssel/Geräte-Metadaten/Recovery-Regenerate
// existieren backendseitig nicht und werden bewusst weggelassen (FLAG), statt Mock-Daten zu zeigen.
if (step === "manage") {
const activatedLabel =
enabledAt != null
? t("settings.profile.mfa.manage.activatedAt", { date: formatDate(enabledAt, i18n.language) })
: t("settings.profile.mfa.status.enabled");
return (
<DialogOverlay dataUiId="ui.settings.profile.mfa.manage.dialog" onClose={onClose}>
<div className="border-border-default bg-surface-neutral-modal mobile:w-full mobile:min-w-0 flex h-auto max-h-[90vh] w-auto max-w-[576px] min-w-[384px] flex-none flex-col items-start gap-2 overflow-auto rounded-md border border-solid shadow-lg">
<div className="mobile:pb-4 flex w-full items-start gap-4 px-4 pt-4">
<div className="flex shrink-0 grow basis-0 flex-col items-start gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary w-full">{t("settings.profile.mfa.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary w-full">
{t("settings.profile.mfa.manage.subtitle")}
</span>
</div>
<div className="flex items-start gap-4">
<IconButton variant="ghost" icon={<FeatherX />} onClick={onClose} data-ui-id="ui.settings.profile.mfa.manage.close" />
</div>
</div>
<div className="mobile:pt-4 flex w-full flex-col items-start gap-3 px-4 pb-4">
<div className="border-border-negative-subtle bg-surface-feedback-subtle-negative mobile:flex-col mobile:items-start mobile:justify-start mobile:gap-3 flex w-full items-end justify-end gap-4 rounded-md border border-solid px-4 py-3">
<div className="flex shrink-0 grow basis-0 flex-col items-start gap-1">
<span className="text-body-small-semibold-0 font-body-small-semibold-0 text-text-neutral-primary">
{t("settings.profile.mfa.disable.title")}
</span>
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary w-full">
{t("settings.profile.mfa.manage.deactivateHint")}
</span>
</div>
<Button
className="mobile:w-full"
variant="destructive"
size="small"
icon={<FeatherShieldOff />}
onClick={() => onStep("disable")}
data-ui-id="ui.settings.profile.mfa.manage.deactivate"
>
{t("settings.profile.mfa.disable.cta")}
</Button>
</div>
<div className="flex w-full flex-col items-start gap-1">
<span className="text-body-extra-small-semibold-150 font-body-extra-small-semibold-150 text-text-neutral-tertiary tracking-wider uppercase">
{t("settings.profile.mfa.manage.methodsLabel")}
</span>
<div className="border-border-default bg-surface-neutral-secondary flex w-full flex-col items-start rounded-md border border-solid">
<div className="mobile:flex-col mobile:items-start flex w-full items-center gap-3 px-4 py-3">
<IconWithBackground variant="neutral" size="large" icon={<FeatherSmartphone />} />
<div className="flex shrink-0 grow 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.authenticatorApp")}
</span>
</div>
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">{activatedLabel}</span>
</div>
<Button
className="mobile:w-full"
variant="tertiary"
size="small"
icon={<FeatherTrash2 />}
onClick={() => onStep("disable")}
data-ui-id="ui.settings.profile.mfa.manage.remove"
>
{t("shared.remove")}
</Button>
</div>
</div>
</div>
<div className="flex w-full flex-col items-center justify-end">
<Button
className="h-8 w-full flex-none"
variant="secondary"
size="small"
icon={<FeatherPlus />}
onClick={() => onStep("password")}
data-ui-id="ui.settings.profile.mfa.manage.add"
>
{t("settings.profile.mfa.manage.addDevice")}
</Button>
</div>
</div>
</div>
</DialogOverlay>
);
}
if (step === "success") {
return (
<ModalShell dataUiId="ui.settings.profile.mfa.success" center onClose={onClose}>
<FeatherCheckCircle className="text-icon-feedback-positive text-[48px] font-[400]" />
<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="border-border-default bg-surface-neutral-primary flex w-full flex-col items-start gap-2 rounded-md border border-solid 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="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-primary font-mono">
{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>
);
}
// M02 — "Gerät/Token hinzufügen": QR + manueller Schlüssel + OTP-Bestätigung in Davids Card.
// (Der Passwort-Gate-Step "password" läuft davor, da startTotpEnrollment das aktuelle Passwort verlangt.)
if (step === "qr") {
return (
<DialogOverlay dataUiId="ui.settings.profile.mfa.setup" onClose={onClose}>
<Card
className="mobile:w-full mobile:min-w-0 mobile:px-4 mobile:py-4 mobile:shadow-lg h-auto max-h-[90vh] w-auto max-w-[576px] min-w-[384px] overflow-auto"
size="large"
>
<div className="flex w-full items-start justify-between">
<div className="flex shrink-0 grow basis-0 flex-col items-center gap-1">
<span className="text-heading- font-heading- text-text-neutral-primary text-center">{t("settings.profile.mfa.manage.addDevice")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary w-full text-center">
{t("settings.profile.mfa.add.subtitle")}
</span>
</div>
<IconButton variant="ghost" size="small" icon={<FeatherX />} onClick={onClose} data-ui-id="ui.settings.profile.mfa.setup.close" />
</div>
<div className="flex w-full flex-col items-center gap-2">
<div className="border-border-default bg-surface-neutral-secondary flex h-44 w-44 flex-none items-center justify-center rounded-md border border-solid px-2 py-2">
{otpauthUri ? <QRCodeSVG className="h-40 w-40 flex-none" value={otpauthUri} size={160} /> : null}
</div>
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary text-center">
{t("settings.profile.mfa.add.appHint")}
</span>
</div>
{secret ? (
<>
<div className="flex w-full items-center gap-2.5">
<div className="bg-border-default flex h-px shrink-0 grow basis-0 flex-col items-center" />
<span className="text-body-extra-small-regular-150 font-body-extra-small-regular-150 text-text-neutral-tertiary">
{t("settings.profile.mfa.setup.manualKey")}
</span>
<div className="bg-border-default flex h-px shrink-0 grow basis-0 flex-col items-center" />
</div>
<div className="border-border-default bg-surface-neutral-secondary flex w-full items-center justify-between rounded-md border border-solid px-1 py-1">
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-primary shrink-0 grow basis-0 text-center">
{groupedSecret}
</span>
<CopyToClipboardButton
clipboardText={secret}
tooltipText={t("settings.profile.mfa.add.copyKey")}
successKey="settings.profile.mfa.add.keyCopied"
/>
</div>
</>
) : null}
<Form method="post" replace className="contents">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="confirmTotpEnrollment" readOnly />
<input type="hidden" name="code" value={code} readOnly />
<div className="flex w-full flex-col items-center gap-2">
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-secondary text-center">
{t("settings.profile.mfa.add.codeLabel")}
</span>
<div className="flex w-full flex-col items-center gap-2 px-1 py-1">
<InputOtp state={mfaError ? "error" : "default"} title="" helperText={mfaError ?? ""}>
{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) => onOtpChange(i, event.target.value)}
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>
</div>
</div>
<div className="mobile:flex-col-reverse flex w-full items-center justify-end gap-2">
<Button type="button" className="mobile:w-full" variant="secondary" onClick={onClose} data-ui-id="ui.settings.profile.mfa.setup.cancel">
{t("shared.cancel")}
</Button>
<Button
type="submit"
className="mobile:w-full"
icon={<FeatherShieldCheck />}
loading={submitting}
disabled={code.length !== 6}
data-ui-id="ui.settings.profile.mfa.confirm"
>
{t("settings.profile.mfa.manage.addDevice")}
</Button>
</div>
</Form>
</Card>
</DialogOverlay>
);
}
// M03 — "2FA deaktivieren": Davids Warn-Card. FLAG: Davids Mock bestätigt mit dem aktuellen 2FA-Code (OTP);
// das bestehende disableTotpEnrollment verifiziert das Passwort -> Bestätigung bleibt Passwort-basiert.
if (step === "disable") {
return (
<DialogOverlay dataUiId="ui.settings.profile.mfa.disable" onClose={onClose}>
<Card className="mobile:gap-6 mobile:px-4 mobile:py-5 mobile:shadow-lg max-h-[90vh] w-full max-w-[480px] overflow-auto">
<div className="flex w-full flex-col items-center justify-center gap-1">
<IconWithBackground variant="destructive" size="large" round={true} icon={<FeatherShieldOff />} />
<span className="text-heading- font-heading- text-text-neutral-primary w-full text-center">{t("settings.profile.mfa.deactivate.title")}</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary w-full text-center">
{t("settings.profile.mfa.deactivate.subtitle")}
</span>
</div>
<div className="border-border-negative-subtle bg-surface-feedback-subtle-negative flex w-full flex-col items-center justify-center gap-2 rounded-md border border-solid px-4 py-3">
<div className="flex w-full items-center justify-center gap-2">
<span className="text-body-small-semibold-150 font-body-small-semibold-150 text-text-feedback-negative shrink-0 grow basis-0 text-center">
{t("settings.profile.mfa.deactivate.consequencesTitle")}
</span>
</div>
<div className="flex w-full flex-col items-start gap-2">
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-secondary w-full">
{t("settings.profile.mfa.deactivate.consequence1")}
</span>
<span className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-secondary w-full">
{t("settings.profile.mfa.deactivate.consequence2")}
</span>
</div>
</div>
<Form method="post" replace className="contents">
<input type="hidden" name="csrf" value={csrf} readOnly />
<input type="hidden" name="action" value="disableTotpEnrollment" readOnly />
<div className="flex w-full flex-col items-center justify-center gap-2">
<span className="text-body-small-medium-150 font-body-small-medium-150 text-text-neutral-secondary">
{t("settings.profile.passwordDialog.current")}
</span>
<TextInput className="h-auto w-full flex-none" type="password">
<TextInput.Input
name="passwordCurrent"
type="password"
required
autoFocus
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>
<div className="bg-border-default flex h-px w-full flex-none flex-col items-center" />
<div className="mobile:flex-col-reverse flex w-full items-center justify-end gap-2">
<Button type="button" className="mobile:w-full" variant="secondary" onClick={onClose} data-ui-id="ui.settings.profile.mfa.disableCancel">
{t("shared.cancel")}
</Button>
<Button
type="submit"
className="mobile:w-full"
variant="destructive"
icon={<FeatherShieldOff />}
loading={submitting}
data-ui-id="ui.settings.profile.mfa.disableSubmit"
>
{t("settings.profile.mfa.deactivate.cta")}
</Button>
</div>
</Form>
</Card>
</DialogOverlay>
);
}
// step === "password" — Setup-Gate (Action verlangt aktuelles Passwort, bevor der QR erzeugt wird)
return (
<ModalShell dataUiId="ui.settings.profile.mfa.gate" onClose={onClose}>
<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 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="mobile:flex-col-reverse mobile:items-stretch flex w-full items-center justify-end gap-2">
<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>
);
}