import { t } from './i18n.js'; const BASE = '/api'; // Paths where a 401 is a normal answer rather than an expired session, so the // auto-logout reload must not fire (it would loop on a failed SSO handoff). const NO_RELOAD_ON_401 = ['/auth/login', '/auth/oidc/complete', '/auth/oidc/exchange', '/auth/oidc/link-pending']; async function request(method, path, body = null, formEncoded = false) { const token = localStorage.getItem('token'); const headers = {}; if (token) headers['Authorization'] = `Bearer ${token}`; let bodyStr = null; if (body !== null) { if (formEncoded) { headers['Content-Type'] = 'application/x-www-form-urlencoded'; bodyStr = new URLSearchParams(body).toString(); } else { headers['Content-Type'] = 'application/json'; bodyStr = JSON.stringify(body); } } const res = await fetch(`${BASE}${path}`, { method, headers, body: bodyStr }); if (res.status === 401 && !NO_RELOAD_ON_401.some(p => path.startsWith(p))) { localStorage.removeItem('token'); localStorage.removeItem('user'); window.location.reload(); return null; } if (!res.ok) { const err = await res.json().catch(() => ({ detail: t('unknown_error') })); const e = new Error(err.detail || `HTTP ${res.status}`); // Keep the status so callers can map it to a translated message instead of // showing the backend's German detail to someone running the app in Finnish. e.status = res.status; throw e; } if (res.status === 204) return null; return res.json(); } async function uploadRequest(path, formData) { const token = localStorage.getItem('token'); const headers = {}; if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(`${BASE}${path}`, { method: 'POST', headers, body: formData }); if (res.status === 401) { localStorage.removeItem('token'); localStorage.removeItem('user'); window.location.reload(); return null; } if (!res.ok) { // Upload errors may be non-JSON (e.g. an nginx 413/502 HTML page); fall back // to the HTTP status so the message is diagnostic, not "unknown error". const err = await res.json().catch(() => null); const detail = (err && err.detail) ? err.detail : (res.status === 413 ? t('upload_too_large') : `HTTP ${res.status} ${res.statusText || ''}`.trim()); const e = new Error(detail); e.status = res.status; throw e; } if (res.status === 204) return null; return res.json(); } async function downloadRequest(path, fallbackName) { const token = localStorage.getItem('token'); const headers = {}; if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(`${BASE}${path}`, { method: 'GET', headers }); if (res.status === 401) { localStorage.removeItem('token'); localStorage.removeItem('user'); window.location.reload(); return null; } if (!res.ok) { const err = await res.json().catch(() => ({ detail: t('unknown_error') })); const e = new Error(err.detail || `HTTP ${res.status}`); // Keep the status so callers can map it to a translated message instead of // showing the backend's German detail to someone running the app in Finnish. e.status = res.status; throw e; } // Derive filename from Content-Disposition if present. let filename = fallbackName || 'calendar.ics'; const cd = res.headers.get('Content-Disposition') || ''; const m = cd.match(/filename="?([^"]+)"?/); if (m) filename = m[1]; const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } export const api = { get: (path) => request('GET', path), post: (path, body) => request('POST', path, body), put: (path, body) => request('PUT', path, body), delete: (path) => request('DELETE', path), upload: (path, form) => uploadRequest(path, form), download: (path, name) => downloadRequest(path, name), login: (username, password, totp_code = null, remember_me = false) => request('POST', '/auth/login', { username, password, totp_code, remember_me }), setupRequired: () => request('GET', '/auth/setup-required'), setup: (data) => request('POST', '/auth/setup', data), // Single sign-on. oidcComplete() trades the one-time HttpOnly handoff cookie // set by the OIDC callback for the access token. oidcProviders: () => request('GET', '/auth/oidc/providers'), oidcComplete: () => request('POST', '/auth/oidc/complete', {}), oidcIdentities: () => request('GET', '/auth/oidc/identities'), oidcLinkPending: () => request('POST', '/auth/oidc/link-pending', {}), oidcUnlink: (id) => request('DELETE', `/auth/oidc/identities/${id}`), };