async function parseError(res, text) { try { const j = JSON.parse(text); if (j.message) { const base = Array.isArray(j.message) ? j.message.join(', ') : j.message; const parts = [base]; if (j.detail && String(j.detail).trim()) { parts.push(String(j.detail).trim()); } if (j.hint && String(j.hint).trim()) { parts.push(String(j.hint).trim()); } return parts.join(' — '); } } catch { /* ignore */ } return text || res.statusText; } function redirectToLogin() { if ( !location.hash.startsWith('#/login') && !location.hash.startsWith('#/bootstrap') ) { location.hash = '#/login'; } } /** Geschützte REST-API liegt unter /api (Root-URLs bleiben für die SPA frei). */ function apiUrl(path) { if (path.startsWith('/auth/')) return path; return `/api${path.startsWith('/') ? path : `/${path}`}`; } function onUnauthorized(path) { if (path.startsWith('/auth/')) return; redirectToLogin(); } /** Wird bei 401 geworfen: Aufrufer sollen keine Fehlerseite rendern. */ export function isAuthRedirectError(e) { return Boolean(e && (e.authRedirect === true || e.name === 'AuthRedirect')); } function authRedirectError() { const err = new Error('SESSION'); err.name = 'AuthRedirect'; err.authRedirect = true; return err; } function parseJsonBody(text) { if (!text) return null; const trimmed = text.trim(); if (trimmed.startsWith('<')) { throw new Error( 'Server lieferte HTML statt JSON (API-URL/Proxy prüfen oder Server neu starten).', ); } try { return JSON.parse(text); } catch (e) { throw new Error( e && String(e.message || e).includes('JSON') ? 'Ungültige Server-Antwort (kein JSON).' : e.message || 'Ungültige Server-Antwort', ); } } async function apiRequest(method, path, body) { const url = apiUrl(path); const opt = { method, credentials: 'include', headers: {} }; if (body !== undefined) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); } const res = await fetch(url, opt); const text = await res.text(); if (res.status === 401) { if (path.startsWith('/auth/')) { throw new Error((await parseError(res, text)) || 'Anmeldung fehlgeschlagen'); } onUnauthorized(path); throw authRedirectError(); } if (!res.ok) throw new Error(await parseError(res, text)); return parseJsonBody(text); } export async function apiGet(path) { return apiRequest('GET', path); } export async function apiPost(path, body) { return apiRequest('POST', path, body); } export async function apiPut(path, body) { return apiRequest('PUT', path, body); } export async function apiDelete(path) { return apiRequest('DELETE', path); } /** Öffentlich: keine Session nötig, kein Redirect bei 401 */ export async function authFetchStatus() { const res = await fetch('/auth/status', { credentials: 'include' }); const text = await res.text(); if (!res.ok) throw new Error(await parseError(res, text)); return parseJsonBody(text); }