229 lines
6.9 KiB
TypeScript
229 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { TextField, useToast } from "@nexarch/shl";
|
|
import {
|
|
ApiError,
|
|
Me,
|
|
beginTotpSetup,
|
|
changePassword,
|
|
confirmTotpSetup,
|
|
fetchMe,
|
|
fetchTotpStatus,
|
|
} from "../../lib/api";
|
|
|
|
export default function ProfilePage() {
|
|
const { push } = useToast();
|
|
const [me, setMe] = useState<Me | null>(null);
|
|
const [totpEnabled, setTotpEnabled] = useState<boolean | null>(null);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
Promise.all([fetchMe(), fetchTotpStatus()])
|
|
.then(([meResult, statusResult]) => {
|
|
setMe(meResult);
|
|
setTotpEnabled(statusResult.enabled);
|
|
})
|
|
.catch(() => setLoadError("Bitte melden Sie sich an, um Ihr Profil zu sehen."));
|
|
}, []);
|
|
|
|
if (loadError) {
|
|
return (
|
|
<main style={{ maxWidth: 480, margin: "80px auto", padding: "0 16px" }}>
|
|
<p role="alert">{loadError}</p>
|
|
<a href="/login">Zur Anmeldung</a>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<main style={{ maxWidth: 480, margin: "40px auto", padding: "0 16px" }}>
|
|
<h1>Mein Profil</h1>
|
|
{me && (
|
|
<section aria-labelledby="stammdaten-heading">
|
|
<h2 id="stammdaten-heading">Stammdaten</h2>
|
|
<p>
|
|
{me.name} — {me.email}
|
|
</p>
|
|
</section>
|
|
)}
|
|
|
|
<ChangePasswordSection onSuccess={() => push("Passwort geändert.", "success")} />
|
|
|
|
<TotpSection
|
|
enabled={totpEnabled}
|
|
onEnabled={() => setTotpEnabled(true)}
|
|
onNotice={(text) => push(text, "info")}
|
|
/>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function ChangePasswordSection({ onSuccess }: { onSuccess: () => void }) {
|
|
const [currentPassword, setCurrentPassword] = useState("");
|
|
const [newPassword, setNewPassword] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
setError(null);
|
|
setSubmitting(true);
|
|
try {
|
|
await changePassword(currentPassword, newPassword);
|
|
setCurrentPassword("");
|
|
setNewPassword("");
|
|
onSuccess();
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : "Passwort konnte nicht geändert werden.");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section aria-labelledby="passwort-heading">
|
|
<h2 id="passwort-heading">Passwort ändern</h2>
|
|
<form onSubmit={onSubmit} noValidate>
|
|
<TextField
|
|
label="Aktuelles Passwort"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
required
|
|
value={currentPassword}
|
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
/>
|
|
<TextField
|
|
label="Neues Passwort"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
required
|
|
minLength={8}
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
/>
|
|
{error && (
|
|
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
|
{error}
|
|
</p>
|
|
)}
|
|
<button type="submit" disabled={submitting}>
|
|
{submitting ? "Wird gespeichert…" : "Passwort ändern"}
|
|
</button>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function TotpSection({
|
|
enabled,
|
|
onEnabled,
|
|
onNotice,
|
|
}: {
|
|
enabled: boolean | null;
|
|
onEnabled: () => void;
|
|
onNotice: (text: string) => void;
|
|
}) {
|
|
const [setupData, setSetupData] = useState<{ secret: string; provisioningUri: string } | null>(null);
|
|
const [confirmCode, setConfirmCode] = useState("");
|
|
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
async function onBeginSetup() {
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
const result = await beginTotpSetup();
|
|
setSetupData({ secret: result.secret, provisioningUri: result.provisioning_uri });
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : "Einrichtung konnte nicht gestartet werden.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function onConfirm(event: React.FormEvent<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
setError(null);
|
|
setBusy(true);
|
|
try {
|
|
const result = await confirmTotpSetup(confirmCode);
|
|
// Akzeptanzkriterium 2: Wiederherstellungscodes werden HIER, EINMALIG
|
|
// im Klartext angezeigt — die Backend-API liefert sie an keiner
|
|
// anderen Stelle je wieder aus (siehe internal/totp/store.go).
|
|
setRecoveryCodes(result.recovery_codes);
|
|
setSetupData(null);
|
|
onEnabled();
|
|
onNotice("Zwei-Faktor-Authentifizierung aktiviert. Bewahren Sie die Wiederherstellungscodes sicher auf.");
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : "Code ungültig.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section aria-labelledby="zwei-faktor-heading">
|
|
<h2 id="zwei-faktor-heading">Zwei-Faktor-Authentifizierung</h2>
|
|
|
|
{recoveryCodes ? (
|
|
<div role="alert">
|
|
<p>
|
|
<strong>Wiederherstellungscodes — jetzt notieren, sie werden nicht erneut angezeigt:</strong>
|
|
</p>
|
|
<ul>
|
|
{recoveryCodes.map((code) => (
|
|
<li key={code}>
|
|
<code>{code}</code>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : enabled ? (
|
|
<p>Zwei-Faktor-Authentifizierung ist aktiv.</p>
|
|
) : setupData ? (
|
|
<form onSubmit={onConfirm} noValidate>
|
|
<p>
|
|
Scannen Sie den folgenden Schlüssel mit Ihrer Authenticator-App oder geben Sie ihn manuell ein:
|
|
</p>
|
|
<p>
|
|
<code>{setupData.secret}</code>
|
|
</p>
|
|
<p style={{ wordBreak: "break-all" }}>
|
|
<a href={setupData.provisioningUri}>{setupData.provisioningUri}</a>
|
|
</p>
|
|
<TextField
|
|
label="Bestätigungscode aus der App"
|
|
type="text"
|
|
inputMode="numeric"
|
|
required
|
|
value={confirmCode}
|
|
onChange={(e) => setConfirmCode(e.target.value)}
|
|
/>
|
|
{error && (
|
|
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
|
{error}
|
|
</p>
|
|
)}
|
|
<button type="submit" disabled={busy}>
|
|
{busy ? "Wird bestätigt…" : "Einrichtung bestätigen"}
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<>
|
|
<p>Zwei-Faktor-Authentifizierung ist derzeit nicht aktiv.</p>
|
|
{error && (
|
|
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
|
{error}
|
|
</p>
|
|
)}
|
|
<button type="button" onClick={onBeginSetup} disabled={busy}>
|
|
{busy ? "Wird gestartet…" : "Einrichten"}
|
|
</button>
|
|
</>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|