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 (
+
+ );
+ }
+
+ if (!user) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ Profil & Einstellungen
+
+
+ {/* ── Card 1: Passwort aendern ─────────────────────────────────── */}
+
+
+ Passwort aendern
+
+
+
+
+
+
+ {/* ── Card 2: E-Mail aendern ───────────────────────────────────── */}
+
+
+ E-Mail-Adresse aendern
+
+
+
+
+
+
+ {/* ── 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}
+
+
+ )}
+
+
+ )}
+
+ {totpEnabled && (
+
+
+ 2FA ist aktiv. Zum Deaktivieren benoetigen Sie einen
+ aktuellen Code aus Ihrer Authenticator-App.
+
+
+
+ )}
+
+ {/* Disable TOTP dialog */}
+
+
+
+
+
+ );
+}
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 }),
+ });
+}