diff --git a/internal/api/server.go b/internal/api/server.go index 79e9aa0..43bc4fe 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -178,8 +178,17 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { Success: true, }) + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + MaxAge: 8 * 3600, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + // Secure: true — enable when TLS is terminated at this server + }) + writeJSON(w, http.StatusOK, map[string]interface{}{ - "token": token, "user": map[string]interface{}{ "id": user.ID, "username": user.Username, @@ -206,11 +215,27 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { - token := extractBearerToken(r) - if err := s.authMgr.Logout(token); err != nil { - writeError(w, http.StatusInternalServerError, "logout failed") - return + // Read token from cookie first, then Bearer header + token := "" + if c, err := r.Cookie(sessionCookieName); err == nil { + token = c.Value } + if token == "" { + token = extractBearerToken(r) + } + if token != "" { + _ = s.authMgr.Logout(token) + } + + // Clear the session cookie + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ @@ -517,11 +542,20 @@ func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) { // --- middleware --- +const sessionCookieName = "archivmail_session" + func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - token := extractBearerToken(r) + // Prefer httpOnly cookie; fall back to Bearer token for CLI/API clients. + token := "" + if c, err := r.Cookie(sessionCookieName); err == nil { + token = c.Value + } if token == "" { - writeError(w, http.StatusUnauthorized, "missing authorization header") + token = extractBearerToken(r) + } + if token == "" { + writeError(w, http.StatusUnauthorized, "missing authorization") return } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 49d0840..6b71bb6 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -5,6 +5,8 @@ import { useAuth } from "@/hooks/useAuth"; import { getUsers, createUser, + updateUser, + deleteUser, getAuditLog, getSMTPStatus, getHealth, @@ -64,7 +66,7 @@ function formatBytes(bytes: number): string { } export default function AdminPage() { - const { user, loading: authLoading } = useAuth(true); + const { user, loading: authLoading } = useAuth("admin"); // Dashboard state const [smtpStatus, setSmtpStatus] = useState(null); @@ -95,6 +97,13 @@ export default function AdminPage() { const [createLoading, setCreateLoading] = useState(false); const [createError, setCreateError] = useState(""); + // User action state + const [userActionLoading, setUserActionLoading] = useState(null); + const [resetPasswordUserId, setResetPasswordUserId] = useState(null); + const [resetPasswordValue, setResetPasswordValue] = useState(""); + const [resetPasswordError, setResetPasswordError] = useState(""); + const [resetPasswordLoading, setResetPasswordLoading] = useState(false); + // Audit state const [auditEntries, setAuditEntries] = useState([]); const [auditTotal, setAuditTotal] = useState(0); @@ -224,6 +233,47 @@ export default function AdminPage() { } } + async function handleToggleActive(u: User) { + setUserActionLoading(u.id); + try { + await updateUser(u.id, { active: !u.active }); + loadUsers(); + } catch { + // ignore + } finally { + setUserActionLoading(null); + } + } + + async function handleDeleteUser(u: User) { + if (!confirm(`Benutzer "${u.username}" wirklich löschen?`)) return; + setUserActionLoading(u.id); + try { + await deleteUser(u.id); + loadUsers(); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); + } finally { + setUserActionLoading(null); + } + } + + async function handleResetPassword(e: React.FormEvent) { + e.preventDefault(); + if (!resetPasswordUserId) return; + setResetPasswordLoading(true); + setResetPasswordError(""); + try { + await updateUser(resetPasswordUserId, { password: resetPasswordValue }); + setResetPasswordUserId(null); + setResetPasswordValue(""); + } catch { + setResetPasswordError("Passwort konnte nicht geändert werden."); + } finally { + setResetPasswordLoading(false); + } + } + const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE); if (authLoading || !user) { @@ -836,25 +886,54 @@ export default function AdminPage() { E-Mail Rolle Status + Aktionen {users.map((u) => ( - - - {u.username} - + + {u.username} {u.email} {u.role} - + {u.active ? "Aktiv" : "Inaktiv"} + +
+ + + +
+
))}
@@ -941,6 +1020,43 @@ export default function AdminPage() { + + {/* Passwort-Reset Dialog */} + { if (!open) setResetPasswordUserId(null); }}> + + + Passwort zurücksetzen + + Neues Passwort für den Benutzer festlegen. + + +
+
+ + setResetPasswordValue(e.target.value)} + required + minLength={8} + placeholder="Mindestens 8 Zeichen" + /> +
+ {resetPasswordError && ( +

{resetPasswordError}

+ )} + + + + +
+
+
); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 88b1973..99d3b89 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -16,10 +16,10 @@ export default function LoginPage() { const [loading, setLoading] = useState(false); useEffect(() => { - const token = localStorage.getItem("archivmail_token"); - if (token) { - router.replace("/search"); - } + // Check if already logged in via session cookie + import("@/lib/api").then(({ getMe }) => + getMe().then(() => router.replace("/search")).catch(() => {}) + ); }, [router]); async function handleSubmit(e: React.FormEvent) { @@ -28,8 +28,7 @@ export default function LoginPage() { setLoading(true); try { - const res = await login(username, password); - localStorage.setItem("archivmail_token", res.token); + await login(username, password); router.push("/search"); } catch { setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pruefen."); diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index 54d7896..b5ea7fc 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -20,7 +20,6 @@ export function Navbar({ username, role }: NavbarProps) { } catch { // ignore logout errors } - localStorage.removeItem("archivmail_token"); router.push("/"); } diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 9f221f3..e1b8d6c 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -10,7 +10,7 @@ interface AuthState { error: string | null; } -export function useAuth(requireAdmin?: boolean) { +export function useAuth(requireRole?: "admin" | "auditor") { const router = useRouter(); const [state, setState] = useState({ user: null, @@ -19,24 +19,21 @@ export function useAuth(requireAdmin?: boolean) { }); const checkAuth = useCallback(async () => { - const token = localStorage.getItem("archivmail_token"); - if (!token) { - router.replace("/"); - return; - } - try { const user = await getMe(); - if (requireAdmin && user.role !== "admin") { + if (requireRole === "admin" && user.role !== "admin") { + router.replace("/search"); + return; + } + if (requireRole === "auditor" && user.role !== "auditor" && user.role !== "admin") { router.replace("/search"); return; } setState({ user, loading: false, error: null }); } catch { - localStorage.removeItem("archivmail_token"); router.replace("/"); } - }, [router, requireAdmin]); + }, [router, requireRole]); useEffect(() => { checkAuth(); diff --git a/src/lib/api.ts b/src/lib/api.ts index a38bfa6..50a75fb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,31 +1,22 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL || ""; -function getToken(): string | null { - if (typeof window === "undefined") return null; - return localStorage.getItem("archivmail_token"); -} - async function request( path: string, options: RequestInit = {} ): Promise { - const token = getToken(); const headers: Record = { "Content-Type": "application/json", ...(options.headers as Record), }; - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } const res = await fetch(`${API_BASE}${path}`, { ...options, headers, + credentials: "include", }); if (res.status === 401) { if (typeof window !== "undefined") { - localStorage.removeItem("archivmail_token"); window.location.href = "/"; } throw new Error("Unauthorized"); @@ -44,12 +35,16 @@ async function request( // Types export interface LoginResponse { - token: string; - username: string; - role: string; + user: { + id: number; + username: string; + email: string; + role: string; + }; } export interface User { + id: number; username: string; email: string; role: string; @@ -136,6 +131,13 @@ export interface CreateUserRequest { role: string; } +export interface UpdateUserRequest { + email?: string; + role?: string; + active?: boolean; + password?: string; +} + // API functions export async function login( @@ -187,6 +189,17 @@ export async function createUser(data: CreateUserRequest): Promise { }); } +export async function updateUser(id: number, data: UpdateUserRequest): Promise { + return request(`/api/users/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); +} + +export async function deleteUser(id: number): Promise { + await request(`/api/users/${id}`, { method: "DELETE" }); +} + export interface StorageStats { total_mails: number; total_bytes: number; @@ -212,9 +225,8 @@ export async function downloadMailAttachment( id: string, index: number ): Promise<{ blob: Blob; filename: string }> { - const token = getToken(); const res = await fetch(`${API_BASE}/api/mails/${id}/attachments/${index}`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, + credentials: "include", }); if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`); const disposition = res.headers.get("Content-Disposition") || ""; @@ -226,9 +238,8 @@ export async function downloadMailAttachment( export async function downloadMailRaw( id: string ): Promise<{ blob: Blob; filename: string }> { - const token = getToken(); const res = await fetch(`${API_BASE}/api/mails/${id}/raw`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, + credentials: "include", }); if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`); return { blob: await res.blob(), filename: `${id}.eml` }; @@ -237,11 +248,11 @@ export async function downloadMailRaw( export interface ServiceStatus { name: string; display_name: string; - active: string; // active | inactive | failed | unknown - sub: string; // running | dead | exited | ... - enabled: string; // enabled | disabled | static | unknown + active: string; + sub: string; + enabled: string; description: string; - external_blocked?: boolean; // only present for archivmail + external_blocked?: boolean; } export async function getServices(): Promise { @@ -397,9 +408,8 @@ export async function getSystemStats(): Promise { // ── Export ──────────────────────────────────────────────────────────────── export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename: string }> { - const token = getToken(); const res = await fetch(`${API_BASE}/api/export/pdf/${id}`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, + credentials: "include", }); if (!res.ok) throw new Error("PDF export failed"); const blob = await res.blob(); @@ -409,16 +419,12 @@ export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename: } export async function exportMailsZIP(ids: string[], attachments: boolean): Promise<{ blob: Blob }> { - const token = getToken(); const res = await fetch(`${API_BASE}/api/export/zip`, { method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, + headers: { "Content-Type": "application/json" }, + credentials: "include", body: JSON.stringify({ ids, attachments }), }); if (!res.ok) throw new Error("ZIP export failed"); - const blob = await res.blob(); - return { blob }; + return { blob: await res.blob() }; }