feat(PROJ-1): httpOnly Cookie, Auditor-Guard, Nutzer-Aktionen (C)

Backend:
- Login setzt httpOnly SameSite=Strict Cookie (archivmail_session)
- Logout löscht Cookie + blacklistet Token
- authMiddleware: Cookie first, Bearer als Fallback (CLI kompatibel)

Frontend:
- api.ts: credentials: include statt localStorage/Bearer Token
- updateUser(), deleteUser() hinzugefügt
- useAuth: kein localStorage mehr, nur /api/auth/me
  requireRole: "admin" | "auditor" | undefined
- Login-Seite: kein localStorage
- Navbar: kein localStorage
- Admin: Nutzer-Aktionen (Sperren/Freischalten, Löschen, Passwort-Reset)
  Löschen verhindert wenn letzter Admin (HTTP 409)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-15 19:57:13 +01:00
parent a94b1d3e52
commit 7e165c8eed
6 changed files with 213 additions and 62 deletions
+36 -30
View File
@@ -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<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
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<T>(
// 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<User> {
});
}
export async function updateUser(id: number, data: UpdateUserRequest): Promise<User> {
return request<User>(`/api/users/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
export async function deleteUser(id: number): Promise<void> {
await request<void>(`/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<ServiceStatus[]> {
@@ -397,9 +408,8 @@ export async function getSystemStats(): Promise<SystemStats> {
// ── 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() };
}