/** * Modul: API-Client * Zweck: Fetch-Wrapper mit Session-Auth, einheitlicher Fehlerbehandlung und JSON-Parsing * Abhängigkeiten: keine */ const API_BASE = '/api/v1'; /** In-Memory CSRF-Token (zuverlaessiger als document.cookie auf iOS Safari/PWA). */ let _csrfToken = ''; /** Liest den CSRF-Token: bevorzugt In-Memory, Fallback auf Cookie. */ function getCsrfToken() { if (_csrfToken) return _csrfToken; return document.cookie.split(';') .map((c) => c.trim()) .find((c) => c.startsWith('csrf-token=')) ?.slice('csrf-token='.length) ?? ''; } /** * Zentraler Fetch-Wrapper. * Setzt Content-Type, handhabt 401-Redirects und parsed JSON-Fehler. * * @param {string} path - API-Pfad ohne /api/v1 (z.B. '/tasks') * @param {RequestInit} options - Fetch-Optionen * @returns {Promise} Geparstes JSON oder wirft einen Fehler */ async function apiFetch(path, options = {}, _retried = false) { const url = `${API_BASE}${path}`; const method = options.method ?? 'GET'; const stateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method); const response = await fetch(url, { credentials: 'same-origin', cache: 'no-store', headers: { 'Content-Type': 'application/json', ...(stateChanging ? { 'X-CSRF-Token': getCsrfToken() } : {}), ...options.headers, }, ...options, }); if (response.status === 401) { // Session abgelaufen → zur Login-Seite window.dispatchEvent(new CustomEvent('auth:expired')); throw new Error('Sitzung abgelaufen.'); } // CSRF-Token-Desync (haeufig nach iOS-PWA-Resume): einmal GET /auth/me // ausfuehren um den CSRF-Token zu erneuern, dann den Request wiederholen. if (response.status === 403 && stateChanging && !_retried) { const meRes = await fetch(`${API_BASE}/auth/me`, { credentials: 'same-origin', cache: 'no-store' }); if (meRes.status === 401) { window.dispatchEvent(new CustomEvent('auth:expired')); throw new Error('Sitzung abgelaufen.'); } const meData = await meRes.json().catch(() => null); if (meData?.csrfToken) _csrfToken = meData.csrfToken; return apiFetch(path, options, true); } const data = await response.json().catch(() => null); // CSRF-Token aus Response-Body extrahieren (zuverlaessiger als Cookie auf iOS) if (data?.csrfToken) _csrfToken = data.csrfToken; if (!response.ok) { const message = data?.error || `HTTP ${response.status}`; throw new ApiError(message, response.status, data); } return data; } /** * Strukturierter API-Fehler mit HTTP-Status-Code. */ class ApiError extends Error { constructor(message, status, data = null) { super(message); this.name = 'ApiError'; this.status = status; this.data = data; } } // -------------------------------------------------------- // Convenience-Methoden // -------------------------------------------------------- const api = { get: (path) => apiFetch(path, { method: 'GET' }), post: (path, body) => apiFetch(path, { method: 'POST', body: JSON.stringify(body), }), put: (path, body) => apiFetch(path, { method: 'PUT', body: JSON.stringify(body), }), patch: (path, body) => apiFetch(path, { method: 'PATCH', body: JSON.stringify(body), }), delete: (path) => apiFetch(path, { method: 'DELETE' }), }; // -------------------------------------------------------- // Auth-spezifische Methoden // -------------------------------------------------------- const auth = { login: (username, password) => api.post('/auth/login', { username, password }), logout: () => api.post('/auth/logout'), me: () => api.get('/auth/me'), getUsers: () => api.get('/auth/users'), createUser: (data) => api.post('/auth/users', data), deleteUser: (id) => api.delete(`/auth/users/${id}`), }; export { api, auth, ApiError };