IAM-08: benutzerprofil-login-oberflaeche (login+2fa/passwort-reset/profil-backend-handler + web/account next.js-frontend auf shl-01)
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user