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:
@@ -0,0 +1,152 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/archivmail/internal/audit"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── PROJ-25: Profile Handlers (Password & Email Change) ─────────────────
|
||||||
|
|
||||||
|
// handleChangePassword allows a local user to change their own password.
|
||||||
|
// PATCH /api/auth/password
|
||||||
|
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if sess.UserID == 0 {
|
||||||
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
CurrentPassword string `json:"current_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load user
|
||||||
|
user, err := s.users.GetByUsername(sess.Username)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("change_password: user not found", "err", err, "username", sess.Username)
|
||||||
|
writeError(w, http.StatusInternalServerError, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAP users cannot change password here
|
||||||
|
if user.Source == "ldap" {
|
||||||
|
writeError(w, http.StatusBadRequest, "password is managed by LDAP")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
currentHash, err := s.users.GetPasswordHash(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("change_password: get hash failed", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.CurrentPassword)); err != nil {
|
||||||
|
writeError(w, http.StatusForbidden, "current password is incorrect")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate new password length
|
||||||
|
if len(req.NewPassword) < 8 {
|
||||||
|
writeError(w, http.StatusBadRequest, "new password must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password
|
||||||
|
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), 12)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("change_password: bcrypt failed", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist
|
||||||
|
if err := s.users.UpdatePassword(r.Context(), user.ID, string(newHash)); err != nil {
|
||||||
|
s.logger.Error("change_password: update failed", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventUserMgmt,
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: remoteIP(r),
|
||||||
|
Success: true,
|
||||||
|
Detail: "change_password",
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleChangeEmail allows a local user to change their own email address.
|
||||||
|
// PATCH /api/auth/email
|
||||||
|
func (s *Server) handleChangeEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess := sessionFromCtx(r.Context())
|
||||||
|
if sess.UserID == 0 {
|
||||||
|
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load user
|
||||||
|
user, err := s.users.GetByUsername(sess.Username)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("change_email: user not found", "err", err, "username", sess.Username)
|
||||||
|
writeError(w, http.StatusInternalServerError, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAP users cannot change email here
|
||||||
|
if user.Source == "ldap" {
|
||||||
|
writeError(w, http.StatusBadRequest, "email is managed by LDAP")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic email validation
|
||||||
|
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid email address")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist
|
||||||
|
if err := s.users.UpdateEmail(r.Context(), user.ID, req.Email); err != nil {
|
||||||
|
// Check for unique constraint violation (duplicate email)
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
writeError(w, http.StatusConflict, "email already in use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Error("change_email: update failed", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.audlog.Log(audit.Entry{
|
||||||
|
EventType: audit.EventUserMgmt,
|
||||||
|
Username: sess.Username,
|
||||||
|
IPAddress: remoteIP(r),
|
||||||
|
Success: true,
|
||||||
|
Detail: "change_email",
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "email": req.Email})
|
||||||
|
}
|
||||||
@@ -195,6 +195,10 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("POST /api/pop3/{id}/import", s.auth(s.handleStartPop3Import))
|
s.mux.HandleFunc("POST /api/pop3/{id}/import", s.auth(s.handleStartPop3Import))
|
||||||
s.mux.HandleFunc("GET /api/pop3/{id}/progress", s.auth(s.handlePop3Progress))
|
s.mux.HandleFunc("GET /api/pop3/{id}/progress", s.auth(s.handlePop3Progress))
|
||||||
|
|
||||||
|
// PROJ-25: Profile routes (password & email change)
|
||||||
|
s.mux.HandleFunc("PATCH /api/auth/password", s.auth(s.handleChangePassword))
|
||||||
|
s.mux.HandleFunc("PATCH /api/auth/email", s.auth(s.handleChangeEmail))
|
||||||
|
|
||||||
// PROJ-24: TOTP 2FA routes
|
// PROJ-24: TOTP 2FA routes
|
||||||
s.mux.HandleFunc("GET /api/auth/totp/setup", s.auth(s.handleTOTPSetupGet))
|
s.mux.HandleFunc("GET /api/auth/totp/setup", s.auth(s.handleTOTPSetupGet))
|
||||||
s.mux.HandleFunc("POST /api/auth/totp/setup", s.auth(s.handleTOTPSetupPost))
|
s.mux.HandleFunc("POST /api/auth/totp/setup", s.auth(s.handleTOTPSetupPost))
|
||||||
|
|||||||
@@ -453,6 +453,36 @@ func (s *Store) ResetTOTP(ctx context.Context, userID int64, resetBy string) err
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PROJ-25: Profile Update Methods ───────────────────────────────────────
|
||||||
|
|
||||||
|
// GetPasswordHash returns the bcrypt password hash for a user by ID.
|
||||||
|
func (s *Store) GetPasswordHash(ctx context.Context, userID int64) (string, error) {
|
||||||
|
var hash string
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&hash)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("userstore: get password hash: %w", err)
|
||||||
|
}
|
||||||
|
return hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePassword sets a new password hash for the given user.
|
||||||
|
func (s *Store) UpdatePassword(ctx context.Context, userID int64, passwordHash string) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `UPDATE users SET password_hash = $1 WHERE id = $2`, passwordHash, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("userstore: update password: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateEmail sets a new email address for the given user.
|
||||||
|
func (s *Store) UpdateEmail(ctx context.Context, userID int64, email string) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `UPDATE users SET email = $1 WHERE id = $2`, email, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("userstore: update email: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetTOTPSecret returns the encrypted TOTP secret and enabled status for a user.
|
// GetTOTPSecret returns the encrypted TOTP secret and enabled status for a user.
|
||||||
func (s *Store) GetTOTPSecret(ctx context.Context, userID int64) (secret []byte, enabled bool, err error) {
|
func (s *Store) GetTOTPSecret(ctx context.Context, userID int64) (secret []byte, enabled bool, err error) {
|
||||||
err = s.pool.QueryRow(ctx,
|
err = s.pool.QueryRow(ctx,
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & Einstellungen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleLogout} className="text-red-600">
|
||||||
|
Abmelden
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { UserNav } from "@/components/UserNav";
|
||||||
import { logout } from "@/lib/api";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -12,17 +9,6 @@ interface NavbarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Navbar({ username, role }: NavbarProps) {
|
export function Navbar({ username, role }: NavbarProps) {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
try {
|
|
||||||
await logout();
|
|
||||||
} catch {
|
|
||||||
// ignore logout errors
|
|
||||||
}
|
|
||||||
router.push("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className="border-b bg-background"
|
className="border-b bg-background"
|
||||||
@@ -63,13 +49,7 @@ export function Navbar({ username, role }: NavbarProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<UserNav username={username} role={role} />
|
||||||
<span className="text-sm">{username}</span>
|
|
||||||
<Badge variant="secondary">{role}</Badge>
|
|
||||||
<Button variant="outline" size="sm" onClick={handleLogout}>
|
|
||||||
Abmelden
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -799,3 +799,44 @@ export async function testAdminTenantLDAPConfig(
|
|||||||
body: JSON.stringify(payload),
|
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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user