From 280034679e8aa80d3aa8621ee5d2edf3f1641d9a Mon Sep 17 00:00:00 2001 From: sysops Date: Wed, 18 Mar 2026 01:05:33 +0100 Subject: [PATCH] =?UTF-8?q?feat(PROJ-25):=20User-Profil=20&=20Einstellunge?= =?UTF-8?q?n=20=E2=80=94=20Passwort,=20E-Mail,=202FA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/api/profile_handlers.go | 152 ++++++++++ internal/api/server.go | 4 + internal/userstore/userstore.go | 30 ++ src/app/settings/page.tsx | 464 +++++++++++++++++++++++++++++++ src/components/UserNav.tsx | 60 ++++ src/components/navbar.tsx | 24 +- src/lib/api.ts | 41 +++ 7 files changed, 753 insertions(+), 22 deletions(-) create mode 100644 internal/api/profile_handlers.go create mode 100644 src/app/settings/page.tsx create mode 100644 src/components/UserNav.tsx diff --git a/internal/api/profile_handlers.go b/internal/api/profile_handlers.go new file mode 100644 index 0000000..b3f1a1b --- /dev/null +++ b/internal/api/profile_handlers.go @@ -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}) +} diff --git a/internal/api/server.go b/internal/api/server.go index 22e9c55..3482706 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -195,6 +195,10 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/pop3/{id}/import", s.auth(s.handleStartPop3Import)) 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 s.mux.HandleFunc("GET /api/auth/totp/setup", s.auth(s.handleTOTPSetupGet)) s.mux.HandleFunc("POST /api/auth/totp/setup", s.auth(s.handleTOTPSetupPost)) diff --git a/internal/userstore/userstore.go b/internal/userstore/userstore.go index 8f142f6..172b6d2 100644 --- a/internal/userstore/userstore.go +++ b/internal/userstore/userstore.go @@ -453,6 +453,36 @@ func (s *Store) ResetTOTP(ctx context.Context, userID int64, resetBy string) err 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. func (s *Store) GetTOTPSecret(ctx context.Context, userID int64) (secret []byte, enabled bool, err error) { err = s.pool.QueryRow(ctx, diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..068165a --- /dev/null +++ b/src/app/settings/page.tsx @@ -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 ( +
+

Laden...

+
+ ); + } + + if (!user) { + return null; + } + + return ( +
+ + +
+

+ Profil & Einstellungen +

+ + {/* ── Card 1: Passwort aendern ─────────────────────────────────── */} + + + Passwort aendern + + +
+
+ + setCurrentPw(e.target.value)} + required + autoComplete="current-password" + aria-label="Aktuelles Passwort" + /> +
+
+ + setNewPw(e.target.value)} + required + minLength={8} + autoComplete="new-password" + aria-label="Neues Passwort" + /> +
+
+ + setConfirmPw(e.target.value)} + required + minLength={8} + autoComplete="new-password" + aria-label="Neues Passwort bestaetigen" + /> +
+ {pwError && ( + + {pwError} + + )} + {pwSuccess && ( + + {pwSuccess} + + )} + +
+
+
+ + {/* ── Card 2: E-Mail aendern ───────────────────────────────────── */} + + + E-Mail-Adresse aendern + + +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + aria-label="E-Mail-Adresse" + /> +
+ {emailError && ( + + {emailError} + + )} + {emailSuccess && ( + + {emailSuccess} + + )} + +
+
+
+ + {/* ── Card 3: Zwei-Faktor-Authentifizierung ────────────────────── */} + + + + Zwei-Faktor-Authentifizierung (2FA) + {totpEnabled ? ( + + Aktiv + + ) : ( + Inaktiv + )} + + + + {totpError && ( + + {totpError} + + )} + {totpSuccess && ( + + {totpSuccess} + + )} + + {!totpEnabled && !showSetup && ( +
+

+ Schuetzen Sie Ihr Konto mit einem Einmalpasswort (TOTP). + Kompatibel mit Google Authenticator, Authy und anderen + TOTP-Apps. +

+ +
+ )} + + {showSetup && ( +
+

+ Scannen Sie den QR-Code mit Ihrer Authenticator-App und geben + Sie den angezeigten Code ein. +

+ {qrSvg && ( +
+ )} + {secret && ( +
+ + + {secret} + +
+ )} +
+
+ + setTotpCode(e.target.value)} + required + autoComplete="one-time-code" + aria-label="TOTP Bestaetigungscode" + /> +
+
+ + +
+
+
+ )} + + {totpEnabled && ( +
+

+ 2FA ist aktiv. Zum Deaktivieren benoetigen Sie einen + aktuellen Code aus Ihrer Authenticator-App. +

+ +
+ )} + + {/* Disable TOTP dialog */} + + + + 2FA deaktivieren + +
+

+ Geben Sie einen aktuellen Code aus Ihrer Authenticator-App + ein, um 2FA zu deaktivieren. +

+
+ + setDisableCode(e.target.value)} + autoComplete="one-time-code" + aria-label="TOTP-Code zum Deaktivieren" + /> +
+ {disableError && ( + + {disableError} + + )} +
+ + + + +
+
+ + +
+
+ ); +} diff --git a/src/components/UserNav.tsx b/src/components/UserNav.tsx new file mode 100644 index 0000000..570dbf6 --- /dev/null +++ b/src/components/UserNav.tsx @@ -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 ( + + + + + + router.push("/settings")}> + Profil & Einstellungen + + + + Abmelden + + + + ); +} diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index 9f602d8..fe1271b 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -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 ( ); diff --git a/src/lib/api.ts b/src/lib/api.ts index 04bd476..b96eedb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 }), + }); +}