990 lines
50 KiB
TypeScript
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>
|
|
);
|
|
}
|