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:
+41
-7
@@ -178,8 +178,17 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
Success: true,
|
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{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"token": token,
|
|
||||||
"user": map[string]interface{}{
|
"user": map[string]interface{}{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"username": user.Username,
|
"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) {
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
token := extractBearerToken(r)
|
// Read token from cookie first, then Bearer header
|
||||||
if err := s.authMgr.Logout(token); err != nil {
|
token := ""
|
||||||
writeError(w, http.StatusInternalServerError, "logout failed")
|
if c, err := r.Cookie(sessionCookieName); err == nil {
|
||||||
return
|
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())
|
sess := sessionFromCtx(r.Context())
|
||||||
s.audlog.Log(audit.Entry{
|
s.audlog.Log(audit.Entry{
|
||||||
@@ -517,11 +542,20 @@ func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// --- middleware ---
|
// --- middleware ---
|
||||||
|
|
||||||
|
const sessionCookieName = "archivmail_session"
|
||||||
|
|
||||||
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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 == "" {
|
if token == "" {
|
||||||
writeError(w, http.StatusUnauthorized, "missing authorization header")
|
token = extractBearerToken(r)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "missing authorization")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+124
-8
@@ -5,6 +5,8 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import {
|
import {
|
||||||
getUsers,
|
getUsers,
|
||||||
createUser,
|
createUser,
|
||||||
|
updateUser,
|
||||||
|
deleteUser,
|
||||||
getAuditLog,
|
getAuditLog,
|
||||||
getSMTPStatus,
|
getSMTPStatus,
|
||||||
getHealth,
|
getHealth,
|
||||||
@@ -64,7 +66,7 @@ function formatBytes(bytes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const { user, loading: authLoading } = useAuth(true);
|
const { user, loading: authLoading } = useAuth("admin");
|
||||||
|
|
||||||
// Dashboard state
|
// Dashboard state
|
||||||
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
|
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
|
||||||
@@ -95,6 +97,13 @@ export default function AdminPage() {
|
|||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [createError, setCreateError] = useState("");
|
const [createError, setCreateError] = useState("");
|
||||||
|
|
||||||
|
// User action state
|
||||||
|
const [userActionLoading, setUserActionLoading] = useState<number | null>(null);
|
||||||
|
const [resetPasswordUserId, setResetPasswordUserId] = useState<number | null>(null);
|
||||||
|
const [resetPasswordValue, setResetPasswordValue] = useState("");
|
||||||
|
const [resetPasswordError, setResetPasswordError] = useState("");
|
||||||
|
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||||||
|
|
||||||
// Audit state
|
// Audit state
|
||||||
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
|
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
|
||||||
const [auditTotal, setAuditTotal] = useState(0);
|
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);
|
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
||||||
|
|
||||||
if (authLoading || !user) {
|
if (authLoading || !user) {
|
||||||
@@ -836,25 +886,54 @@ export default function AdminPage() {
|
|||||||
<TableHead>E-Mail</TableHead>
|
<TableHead>E-Mail</TableHead>
|
||||||
<TableHead>Rolle</TableHead>
|
<TableHead>Rolle</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Aktionen</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<TableRow key={u.username}>
|
<TableRow key={u.id}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">{u.username}</TableCell>
|
||||||
{u.username}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{u.email}</TableCell>
|
<TableCell>{u.email}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="secondary">{u.role}</Badge>
|
<Badge variant="secondary">{u.role}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge variant={u.active ? "default" : "destructive"}>
|
||||||
variant={u.active ? "default" : "destructive"}
|
|
||||||
>
|
|
||||||
{u.active ? "Aktiv" : "Inaktiv"}
|
{u.active ? "Aktiv" : "Inaktiv"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={userActionLoading === u.id}
|
||||||
|
onClick={() => {
|
||||||
|
setResetPasswordUserId(u.id);
|
||||||
|
setResetPasswordValue("");
|
||||||
|
setResetPasswordError("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Passwort
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={userActionLoading === u.id}
|
||||||
|
onClick={() => handleToggleActive(u)}
|
||||||
|
>
|
||||||
|
{userActionLoading === u.id ? "..." : u.active ? "Sperren" : "Freischalten"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={userActionLoading === u.id}
|
||||||
|
onClick={() => handleDeleteUser(u)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -941,6 +1020,43 @@ export default function AdminPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Passwort-Reset Dialog */}
|
||||||
|
<Dialog open={resetPasswordUserId !== null} onOpenChange={(open) => { if (!open) setResetPasswordUserId(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Passwort zurücksetzen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Neues Passwort für den Benutzer festlegen.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleResetPassword} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-password">Neues Passwort</Label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
value={resetPasswordValue}
|
||||||
|
onChange={(e) => setResetPasswordValue(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
placeholder="Mindestens 8 Zeichen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{resetPasswordError && (
|
||||||
|
<p className="text-sm text-destructive">{resetPasswordError}</p>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setResetPasswordUserId(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={resetPasswordLoading}>
|
||||||
|
{resetPasswordLoading ? "Speichern..." : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-6
@@ -16,10 +16,10 @@ export default function LoginPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("archivmail_token");
|
// Check if already logged in via session cookie
|
||||||
if (token) {
|
import("@/lib/api").then(({ getMe }) =>
|
||||||
router.replace("/search");
|
getMe().then(() => router.replace("/search")).catch(() => {})
|
||||||
}
|
);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
@@ -28,8 +28,7 @@ export default function LoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await login(username, password);
|
await login(username, password);
|
||||||
localStorage.setItem("archivmail_token", res.token);
|
|
||||||
router.push("/search");
|
router.push("/search");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pruefen.");
|
setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pruefen.");
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export function Navbar({ username, role }: NavbarProps) {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore logout errors
|
// ignore logout errors
|
||||||
}
|
}
|
||||||
localStorage.removeItem("archivmail_token");
|
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-10
@@ -10,7 +10,7 @@ interface AuthState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth(requireAdmin?: boolean) {
|
export function useAuth(requireRole?: "admin" | "auditor") {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [state, setState] = useState<AuthState>({
|
const [state, setState] = useState<AuthState>({
|
||||||
user: null,
|
user: null,
|
||||||
@@ -19,24 +19,21 @@ export function useAuth(requireAdmin?: boolean) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const checkAuth = useCallback(async () => {
|
const checkAuth = useCallback(async () => {
|
||||||
const token = localStorage.getItem("archivmail_token");
|
|
||||||
if (!token) {
|
|
||||||
router.replace("/");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await getMe();
|
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");
|
router.replace("/search");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState({ user, loading: false, error: null });
|
setState({ user, loading: false, error: null });
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem("archivmail_token");
|
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
}
|
}
|
||||||
}, [router, requireAdmin]);
|
}, [router, requireRole]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
|||||||
+36
-30
@@ -1,31 +1,22 @@
|
|||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
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>(
|
async function request<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {}
|
options: RequestInit = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const token = getToken();
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(options.headers as Record<string, string>),
|
...(options.headers as Record<string, string>),
|
||||||
};
|
};
|
||||||
if (token) {
|
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
|
credentials: "include",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem("archivmail_token");
|
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
@@ -44,12 +35,16 @@ async function request<T>(
|
|||||||
// Types
|
// Types
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
token: string;
|
user: {
|
||||||
username: string;
|
id: number;
|
||||||
role: string;
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -136,6 +131,13 @@ export interface CreateUserRequest {
|
|||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
email?: string;
|
||||||
|
role?: string;
|
||||||
|
active?: boolean;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// API functions
|
// API functions
|
||||||
|
|
||||||
export async function login(
|
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 {
|
export interface StorageStats {
|
||||||
total_mails: number;
|
total_mails: number;
|
||||||
total_bytes: number;
|
total_bytes: number;
|
||||||
@@ -212,9 +225,8 @@ export async function downloadMailAttachment(
|
|||||||
id: string,
|
id: string,
|
||||||
index: number
|
index: number
|
||||||
): Promise<{ blob: Blob; filename: string }> {
|
): Promise<{ blob: Blob; filename: string }> {
|
||||||
const token = getToken();
|
|
||||||
const res = await fetch(`${API_BASE}/api/mails/${id}/attachments/${index}`, {
|
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}`);
|
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
|
||||||
const disposition = res.headers.get("Content-Disposition") || "";
|
const disposition = res.headers.get("Content-Disposition") || "";
|
||||||
@@ -226,9 +238,8 @@ export async function downloadMailAttachment(
|
|||||||
export async function downloadMailRaw(
|
export async function downloadMailRaw(
|
||||||
id: string
|
id: string
|
||||||
): Promise<{ blob: Blob; filename: string }> {
|
): Promise<{ blob: Blob; filename: string }> {
|
||||||
const token = getToken();
|
|
||||||
const res = await fetch(`${API_BASE}/api/mails/${id}/raw`, {
|
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}`);
|
if (!res.ok) throw new Error(`Download fehlgeschlagen: ${res.status}`);
|
||||||
return { blob: await res.blob(), filename: `${id}.eml` };
|
return { blob: await res.blob(), filename: `${id}.eml` };
|
||||||
@@ -237,11 +248,11 @@ export async function downloadMailRaw(
|
|||||||
export interface ServiceStatus {
|
export interface ServiceStatus {
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
active: string; // active | inactive | failed | unknown
|
active: string;
|
||||||
sub: string; // running | dead | exited | ...
|
sub: string;
|
||||||
enabled: string; // enabled | disabled | static | unknown
|
enabled: string;
|
||||||
description: string;
|
description: string;
|
||||||
external_blocked?: boolean; // only present for archivmail
|
external_blocked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServices(): Promise<ServiceStatus[]> {
|
export async function getServices(): Promise<ServiceStatus[]> {
|
||||||
@@ -397,9 +408,8 @@ export async function getSystemStats(): Promise<SystemStats> {
|
|||||||
// ── Export ────────────────────────────────────────────────────────────────
|
// ── Export ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename: string }> {
|
export async function exportMailPDF(id: string): Promise<{ blob: Blob; filename: string }> {
|
||||||
const token = getToken();
|
|
||||||
const res = await fetch(`${API_BASE}/api/export/pdf/${id}`, {
|
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");
|
if (!res.ok) throw new Error("PDF export failed");
|
||||||
const blob = await res.blob();
|
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 }> {
|
export async function exportMailsZIP(ids: string[], attachments: boolean): Promise<{ blob: Blob }> {
|
||||||
const token = getToken();
|
|
||||||
const res = await fetch(`${API_BASE}/api/export/zip`, {
|
const res = await fetch(`${API_BASE}/api/export/zip`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
credentials: "include",
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ ids, attachments }),
|
body: JSON.stringify({ ids, attachments }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("ZIP export failed");
|
if (!res.ok) throw new Error("ZIP export failed");
|
||||||
const blob = await res.blob();
|
return { blob: await res.blob() };
|
||||||
return { blob };
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user