feat(PROJ-25): User-Profil & Einstellungen — Passwort, E-Mail, 2FA

Backend:
- PATCH /api/auth/password — Passwort ändern (bcrypt, LDAP-Guard, Audit-Log)
- PATCH /api/auth/email — E-Mail ändern (Unique-Check, LDAP-Guard, Audit-Log)
- userstore: UpdatePassword, UpdateEmail, GetPasswordHash

Frontend:
- UserNav.tsx: Dropdown-Menü (Profil & Einstellungen, Abmelden)
- navbar.tsx: UserNav eingebunden
- /settings: Passwort ändern, E-Mail ändern, 2FA verwalten (QR-Code + Deaktivieren)
- api.ts: changePassword, changeEmail, getTOTPSetup, confirmTOTPSetup, disableTOTP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-18 01:05:33 +01:00
parent 89a6651b62
commit 280034679e
7 changed files with 753 additions and 22 deletions
+464
View File
@@ -0,0 +1,464 @@
"use client";
import { useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { Navbar } from "@/components/navbar";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import {
changePassword,
changeEmail,
getTOTPSetup,
confirmTOTPSetup,
disableTOTP,
} from "@/lib/api";
export default function SettingsPage() {
const { user, loading } = useAuth();
// ── Password state ────────────────────────────────────────────────────
const [currentPw, setCurrentPw] = useState("");
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const [pwError, setPwError] = useState("");
const [pwSuccess, setPwSuccess] = useState("");
const [pwLoading, setPwLoading] = useState(false);
// ── Email state ───────────────────────────────────────────────────────
const [email, setEmail] = useState("");
const [emailInitialized, setEmailInitialized] = useState(false);
const [emailError, setEmailError] = useState("");
const [emailSuccess, setEmailSuccess] = useState("");
const [emailLoading, setEmailLoading] = useState(false);
// ── TOTP state ────────────────────────────────────────────────────────
const [totpEnabled, setTotpEnabled] = useState(false);
const [showSetup, setShowSetup] = useState(false);
const [qrSvg, setQrSvg] = useState("");
const [secret, setSecret] = useState("");
const [totpCode, setTotpCode] = useState("");
const [totpError, setTotpError] = useState("");
const [totpSuccess, setTotpSuccess] = useState("");
const [totpLoading, setTotpLoading] = useState(false);
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
const [disableCode, setDisableCode] = useState("");
const [disableError, setDisableError] = useState("");
const [disableLoading, setDisableLoading] = useState(false);
// Initialize email from user data once available
if (user && !emailInitialized) {
setEmail(user.email || "");
setEmailInitialized(true);
}
// ── Handlers ──────────────────────────────────────────────────────────
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setPwError("");
setPwSuccess("");
if (newPw.length < 8) {
setPwError("Das neue Passwort muss mindestens 8 Zeichen lang sein.");
return;
}
if (newPw !== confirmPw) {
setPwError("Die Passwoerter stimmen nicht ueberein.");
return;
}
setPwLoading(true);
try {
await changePassword(currentPw, newPw);
setPwSuccess("Passwort wurde erfolgreich geaendert.");
setCurrentPw("");
setNewPw("");
setConfirmPw("");
} catch (err) {
setPwError(err instanceof Error ? err.message : "Fehler beim Aendern des Passworts");
} finally {
setPwLoading(false);
}
}
async function handleChangeEmail(e: React.FormEvent) {
e.preventDefault();
setEmailError("");
setEmailSuccess("");
if (!email || !email.includes("@")) {
setEmailError("Bitte eine gueltige E-Mail-Adresse eingeben.");
return;
}
setEmailLoading(true);
try {
const result = await changeEmail(email);
setEmail(result.email);
setEmailSuccess("E-Mail-Adresse wurde erfolgreich geaendert.");
} catch (err) {
setEmailError(err instanceof Error ? err.message : "Fehler beim Aendern der E-Mail");
} finally {
setEmailLoading(false);
}
}
async function handleSetupTOTP() {
setTotpError("");
setTotpSuccess("");
setTotpLoading(true);
try {
const data = await getTOTPSetup();
setQrSvg(data.qr_code_svg);
setSecret(data.secret);
setShowSetup(true);
setTotpCode("");
} catch (err) {
setTotpError(err instanceof Error ? err.message : "TOTP-Setup fehlgeschlagen");
} finally {
setTotpLoading(false);
}
}
async function handleConfirmTOTP(e: React.FormEvent) {
e.preventDefault();
setTotpError("");
setTotpLoading(true);
try {
await confirmTOTPSetup(totpCode);
setTotpEnabled(true);
setShowSetup(false);
setTotpSuccess("2FA wurde erfolgreich aktiviert.");
setQrSvg("");
setSecret("");
setTotpCode("");
} catch (err) {
setTotpError(err instanceof Error ? err.message : "Ungueltiger Code");
} finally {
setTotpLoading(false);
}
}
async function handleDisableTOTP() {
setDisableError("");
setDisableLoading(true);
try {
await disableTOTP(disableCode);
setTotpEnabled(false);
setDisableDialogOpen(false);
setDisableCode("");
setTotpSuccess("2FA wurde deaktiviert.");
} catch (err) {
setDisableError(err instanceof Error ? err.message : "Ungueltiger Code");
} finally {
setDisableLoading(false);
}
}
// ── Loading / auth guard ──────────────────────────────────────────────
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">Laden...</p>
</div>
);
}
if (!user) {
return null;
}
return (
<div className="min-h-screen bg-background">
<Navbar username={user.username} role={user.role} />
<main className="mx-auto max-w-2xl px-4 py-8 space-y-6">
<h1 className="text-2xl font-bold tracking-tight">
Profil &amp; Einstellungen
</h1>
{/* ── Card 1: Passwort aendern ─────────────────────────────────── */}
<Card>
<CardHeader>
<CardTitle>Passwort aendern</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Aktuelles Passwort</Label>
<Input
id="current-password"
type="password"
value={currentPw}
onChange={(e) => setCurrentPw(e.target.value)}
required
autoComplete="current-password"
aria-label="Aktuelles Passwort"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">Neues Passwort</Label>
<Input
id="new-password"
type="password"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
required
minLength={8}
autoComplete="new-password"
aria-label="Neues Passwort"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Neues Passwort bestaetigen</Label>
<Input
id="confirm-password"
type="password"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
required
minLength={8}
autoComplete="new-password"
aria-label="Neues Passwort bestaetigen"
/>
</div>
{pwError && (
<Alert variant="destructive">
<AlertDescription>{pwError}</AlertDescription>
</Alert>
)}
{pwSuccess && (
<Alert>
<AlertDescription>{pwSuccess}</AlertDescription>
</Alert>
)}
<Button type="submit" disabled={pwLoading}>
{pwLoading ? "Speichern..." : "Passwort aendern"}
</Button>
</form>
</CardContent>
</Card>
{/* ── Card 2: E-Mail aendern ───────────────────────────────────── */}
<Card>
<CardHeader>
<CardTitle>E-Mail-Adresse aendern</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleChangeEmail} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
aria-label="E-Mail-Adresse"
/>
</div>
{emailError && (
<Alert variant="destructive">
<AlertDescription>{emailError}</AlertDescription>
</Alert>
)}
{emailSuccess && (
<Alert>
<AlertDescription>{emailSuccess}</AlertDescription>
</Alert>
)}
<Button type="submit" disabled={emailLoading}>
{emailLoading ? "Speichern..." : "E-Mail aendern"}
</Button>
</form>
</CardContent>
</Card>
{/* ── Card 3: Zwei-Faktor-Authentifizierung ────────────────────── */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-3">
Zwei-Faktor-Authentifizierung (2FA)
{totpEnabled ? (
<Badge variant="default" className="bg-green-600">
Aktiv
</Badge>
) : (
<Badge variant="secondary">Inaktiv</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{totpError && (
<Alert variant="destructive">
<AlertDescription>{totpError}</AlertDescription>
</Alert>
)}
{totpSuccess && (
<Alert>
<AlertDescription>{totpSuccess}</AlertDescription>
</Alert>
)}
{!totpEnabled && !showSetup && (
<div>
<p className="text-sm text-muted-foreground mb-4">
Schuetzen Sie Ihr Konto mit einem Einmalpasswort (TOTP).
Kompatibel mit Google Authenticator, Authy und anderen
TOTP-Apps.
</p>
<Button onClick={handleSetupTOTP} disabled={totpLoading}>
{totpLoading ? "Laden..." : "2FA einrichten"}
</Button>
</div>
)}
{showSetup && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Scannen Sie den QR-Code mit Ihrer Authenticator-App und geben
Sie den angezeigten Code ein.
</p>
{qrSvg && (
<div
className="flex justify-center p-4 bg-white rounded-lg border w-fit mx-auto"
dangerouslySetInnerHTML={{ __html: qrSvg }}
aria-label="TOTP QR-Code"
/>
)}
{secret && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Manueller Schluessel
</Label>
<code className="block text-sm font-mono bg-muted px-3 py-2 rounded break-all select-all">
{secret}
</code>
</div>
)}
<form onSubmit={handleConfirmTOTP} className="space-y-3">
<div className="space-y-2">
<Label htmlFor="totp-code">Bestaetigungscode</Label>
<Input
id="totp-code"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000000"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
required
autoComplete="one-time-code"
aria-label="TOTP Bestaetigungscode"
/>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={totpLoading}>
{totpLoading ? "Pruefen..." : "Bestaetigen"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setShowSetup(false);
setQrSvg("");
setSecret("");
setTotpCode("");
setTotpError("");
}}
>
Abbrechen
</Button>
</div>
</form>
</div>
)}
{totpEnabled && (
<div>
<p className="text-sm text-muted-foreground mb-4">
2FA ist aktiv. Zum Deaktivieren benoetigen Sie einen
aktuellen Code aus Ihrer Authenticator-App.
</p>
<Button
variant="destructive"
onClick={() => {
setDisableDialogOpen(true);
setDisableCode("");
setDisableError("");
}}
>
2FA deaktivieren
</Button>
</div>
)}
{/* Disable TOTP dialog */}
<Dialog open={disableDialogOpen} onOpenChange={setDisableDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>2FA deaktivieren</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Geben Sie einen aktuellen Code aus Ihrer Authenticator-App
ein, um 2FA zu deaktivieren.
</p>
<div className="space-y-2">
<Label htmlFor="disable-totp-code">TOTP-Code</Label>
<Input
id="disable-totp-code"
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
placeholder="000000"
value={disableCode}
onChange={(e) => setDisableCode(e.target.value)}
autoComplete="one-time-code"
aria-label="TOTP-Code zum Deaktivieren"
/>
</div>
{disableError && (
<Alert variant="destructive">
<AlertDescription>{disableError}</AlertDescription>
</Alert>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDisableDialogOpen(false)}
>
Abbrechen
</Button>
<Button
variant="destructive"
onClick={handleDisableTOTP}
disabled={disableLoading || disableCode.length !== 6}
>
{disableLoading ? "Pruefen..." : "Deaktivieren"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</main>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useRouter } from "next/navigation";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ChevronDown } from "lucide-react";
import { logout } from "@/lib/api";
interface UserNavProps {
username: string;
role: string;
}
export function UserNav({ username, role }: UserNavProps) {
const router = useRouter();
async function handleLogout() {
try {
await logout();
} catch {
// ignore logout errors
}
router.push("/");
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2"
aria-label="Benutzermenu"
>
<span className="text-sm font-medium">{username}</span>
<Badge variant="secondary" className="text-xs">
{role}
</Badge>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => router.push("/settings")}>
Profil &amp; Einstellungen
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-red-600">
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+2 -22
View File
@@ -1,10 +1,7 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { logout } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { UserNav } from "@/components/UserNav";
interface NavbarProps {
username: string;
@@ -12,17 +9,6 @@ interface NavbarProps {
}
export function Navbar({ username, role }: NavbarProps) {
const router = useRouter();
async function handleLogout() {
try {
await logout();
} catch {
// ignore logout errors
}
router.push("/");
}
return (
<nav
className="border-b bg-background"
@@ -63,13 +49,7 @@ export function Navbar({ username, role }: NavbarProps) {
</Link>
)}
</div>
<div className="flex items-center gap-3">
<span className="text-sm">{username}</span>
<Badge variant="secondary">{role}</Badge>
<Button variant="outline" size="sm" onClick={handleLogout}>
Abmelden
</Button>
</div>
<UserNav username={username} role={role} />
</div>
</nav>
);
+41
View File
@@ -799,3 +799,44 @@ export async function testAdminTenantLDAPConfig(
body: JSON.stringify(payload),
});
}
// ── Profil-Einstellungen ──────────────────────────────────────────────────
export async function changePassword(
currentPassword: string,
newPassword: string
): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>("/api/auth/password", {
method: "PATCH",
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
}
export async function changeEmail(
email: string
): Promise<{ ok: boolean; email: string }> {
return request<{ ok: boolean; email: string }>("/api/auth/email", {
method: "PATCH",
body: JSON.stringify({ email }),
});
}
// ── TOTP / 2FA ────────────────────────────────────────────────────────────
export async function getTOTPSetup(): Promise<{ secret: string; otpauth_url: string; qr_code_svg: string }> {
return request<{ secret: string; otpauth_url: string; qr_code_svg: string }>("/api/auth/totp/setup");
}
export async function confirmTOTPSetup(code: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>("/api/auth/totp/setup", {
method: "POST",
body: JSON.stringify({ code }),
});
}
export async function disableTOTP(code: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>("/api/auth/totp", {
method: "DELETE",
body: JSON.stringify({ code }),
});
}