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); const fileInput = useRef(null); const avatarForm = useRef(null); const [avatarData, setAvatarData] = useState(""); // 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) { 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 (
{/* Header */}
{t("settings.profile.pageTitle")} {t("settings.profile.pageSubtitle")}
{/* Profil-Card */}
{t("settings.profile.section.profile.title")} {t("settings.profile.section.profile.subtitle")}
{/* Avatar-Reihe */}
{initials(user)}
{t("settings.profile.avatar.hint")}
{/* Name (inline editierbar) */} {editingName ? (
} loading={submitting} data-ui-id="ui.settings.profile.nameSave" /> } onClick={() => setEditingName(false)} data-ui-id="ui.settings.profile.nameCancel" /> ) : (
{t("settings.profile.field.name")} {[user.firstName, user.lastName].filter(Boolean).join(" ") || "—"}
)} {/* E-Mail (read-only) */}
{t("settings.profile.field.email")} {user.email} {t("settings.profile.field.notEditable")}
{/* Sicherheits-Card */}
{t("settings.profile.section.security.title")} {t("settings.profile.section.security.subtitle")}
{/* Passwort */}
{t("settings.profile.password")} {t("settings.profile.passwordHint")}
{/* 2FA */}
{t("settings.profile.mfa.title")} {mfaEnabled ? t("settings.profile.mfa.badge.active") : t("settings.profile.mfa.badge.inactive")}
{t("settings.profile.mfa.subtitle")}
{pwOpen ? setPwOpen(false)} /> : null} {twoFa ? ( setTwoFa(null)} /> ) : null}
); } function ModalShell({ children, dataUiId, center = false }: { children: React.ReactNode; dataUiId: string; center?: boolean }) { return (
{children}
); } 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 (
{t("settings.profile.passwordDialog.title")} {t("settings.profile.passwordDialog.subtitle")}
setPw(event.target.value)} data-ui-id="ui.settings.profile.password.new" /> = 80 ? "success" : undefined} /> {t("settings.profile.passwordDialog.hint")}
{error ? {error} : null}
); } 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(Array(6).fill("")); const code = digits.join(""); if (step === "success") { return (
{t("settings.profile.mfa.success.title")} {t("settings.profile.mfa.success.subtitle")}
{recoveryCodes && recoveryCodes.length > 0 ? (
{t("settings.profile.mfa.recoveryCodes")}
{recoveryCodes.map((code) => ( {code} ))}
) : null}
); } if (step === "qr") { return (
{t("settings.profile.mfa.setup.title")} {t("settings.profile.mfa.setup.subtitle")}
{otpauthUri ? : null}
{secret ? ( <>
{t("settings.profile.mfa.setup.manualKey")}
{secret}
) : null}
{!showVerify ? (
) : (
{t("settings.profile.mfa.verify.subtitle")}
{Array.from({ length: 6 }).map((_, i) => ( { 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} /> ))} {mfaError ? {mfaError} : null}
)} ); } if (step === "disable") { return (
{t("settings.profile.mfa.disable.title")} {t("settings.profile.mfa.disable.subtitle")}
{mfaError ? {mfaError} : null}
); } // step === "password" — Setup-Gate (Action verlangt aktuelles Passwort, bevor der QR erzeugt wird) return (
{t("settings.profile.mfa.setup.title")} {t("settings.profile.mfa.gate.subtitle")}
{mfaError ? {mfaError} : null}
); }