export const API_URL = (process.env.NEXT_PUBLIC_API_URL || "/api/backend").replace(/\/+$/, ""); const SESSION_TOKEN_KEY = "sm_presenter_access_token"; const SESSION_TOKEN_EXP_KEY = "sm_presenter_access_exp_ms"; const REFRESH_TOKEN_KEY = "sm_presenter_refresh_token"; export type AuthTokenResponse = { access_token: string; refresh_token?: string | null; expires_in?: number | null; user?: { id: number; email: string; is_admin?: boolean }; }; export class ApiError extends Error { status: number; constructor(status: number, message: string) { super(message); this.name = "ApiError"; this.status = status; } } let accessToken: string | null = null; let accessTokenExpMs: number | null = null; let refreshTokenMem: string | null = null; let refreshPromise: Promise | null = null; const readStorageItem = (key: string): string | null => { if (typeof window === "undefined") return null; try { const localVal = window.localStorage.getItem(key); if (localVal) return localVal; } catch { // Ignore localStorage sandbox restrictions } try { return window.sessionStorage.getItem(key); } catch { return null; } }; const writeStorageItem = (key: string, value: string | null) => { if (typeof window === "undefined") return; for (const store of ["localStorage", "sessionStorage"] as const) { try { if (value) { window[store].setItem(key, value); } else { window[store].removeItem(key); } } catch { // Ignore storage quota or sandbox restrictions } } }; const decodeBase64Url = (value: string) => { const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); if (typeof atob === "function") { return atob(padded); } return Buffer.from(padded, "base64").toString("utf8"); }; const parseTokenExpMs = (token: string): number | null => { const parts = token.split("."); if (parts.length < 2) return null; try { const payload = JSON.parse(decodeBase64Url(parts[1])) as { exp?: number }; return typeof payload.exp === "number" ? payload.exp * 1000 : null; } catch { return null; } }; export const getToken = () => { if (!accessToken) { const stored = readStorageItem(SESSION_TOKEN_KEY); if (stored) { accessToken = stored; } } return accessToken; }; const getTokenExpMs = (token: string): number | null => { if (accessTokenExpMs !== null) { return accessTokenExpMs; } const storedExp = readStorageItem(SESSION_TOKEN_EXP_KEY); if (storedExp) { const parsed = Number(storedExp); if (Number.isFinite(parsed) && parsed > 0) { accessTokenExpMs = parsed; return parsed; } } return parseTokenExpMs(token); }; export const getRefreshToken = () => { if (!refreshTokenMem) { const stored = readStorageItem(REFRESH_TOKEN_KEY); if (stored) { refreshTokenMem = stored; } } return refreshTokenMem; }; export const hasValidToken = () => { const token = getToken(); if (!token) return false; const expMs = getTokenExpMs(token); if (!expMs) return false; return expMs - 5_000 > Date.now(); }; export const setToken = ( token: string, expiresInSeconds?: number | null, refreshToken?: string | null ) => { accessToken = token; writeStorageItem(SESSION_TOKEN_KEY, token); if (typeof expiresInSeconds === "number" && expiresInSeconds > 0) { accessTokenExpMs = Date.now() + expiresInSeconds * 1000; writeStorageItem(SESSION_TOKEN_EXP_KEY, String(accessTokenExpMs)); } else { accessTokenExpMs = null; writeStorageItem(SESSION_TOKEN_EXP_KEY, null); } if (refreshToken) { refreshTokenMem = refreshToken; writeStorageItem(REFRESH_TOKEN_KEY, refreshToken); } }; export const clearToken = () => { accessToken = null; accessTokenExpMs = null; refreshTokenMem = null; writeStorageItem(SESSION_TOKEN_KEY, null); writeStorageItem(SESSION_TOKEN_EXP_KEY, null); writeStorageItem(REFRESH_TOKEN_KEY, null); }; const parseErrorMessage = async (response: Response) => { const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { const payload = await response.json().catch(() => null); if (payload && typeof payload.detail === "string") { return payload.detail; } } const message = await response.text().catch(() => ""); return message || `Request failed (${response.status})`; }; export const refreshAccessToken = async (): Promise => { if (refreshPromise) { return refreshPromise; } refreshPromise = (async () => { try { const headers = new Headers(); const storedRefresh = getRefreshToken(); let body: string | undefined; if (storedRefresh) { headers.set("X-Refresh-Token", storedRefresh); headers.set("Content-Type", "application/json"); body = JSON.stringify({ refresh_token: storedRefresh }); } const response = await fetch(`${API_URL}/auth/refresh`, { method: "POST", headers, body, credentials: "include", }); if (!response.ok) { clearToken(); return null; } const payload = (await response.json()) as AuthTokenResponse; if (!payload.access_token) { clearToken(); return null; } setToken(payload.access_token, payload.expires_in, payload.refresh_token); return payload.access_token; } catch { clearToken(); return null; } finally { refreshPromise = null; } })(); return refreshPromise; }; export const ensureAccessToken = async () => { const current = getToken(); if (current && hasValidToken()) { return current; } return refreshAccessToken(); }; type FetchMode = "json" | "blob" | "response"; const performFetch = async ( path: string, options: RequestInit = {}, allowRefresh = true ): Promise => { const headers = new Headers(options.headers || {}); const skipAuth = path.startsWith("/auth/login") || path.startsWith("/auth/signup") || path.startsWith("/auth/refresh") || path.startsWith("/setup"); const token = skipAuth ? null : getToken() || (allowRefresh ? await refreshAccessToken() : null); let targetUrl = `${API_URL}${path}`; if (token) { headers.set("Authorization", `Bearer ${token}`); headers.set("X-Presenter-Token", token); const separator = targetUrl.includes("?") ? "&" : "?"; targetUrl = `${targetUrl}${separator}_pt=${encodeURIComponent(token)}`; } if (!headers.has("Content-Type") && !(options.body instanceof FormData) && options.body !== undefined) { headers.set("Content-Type", "application/json"); } const response = await fetch(targetUrl, { ...options, headers, credentials: "include", }); if (response.status === 401 && allowRefresh && !skipAuth) { const refreshed = await refreshAccessToken(); if (refreshed) { return performFetch(path, options, false); } } if (!response.ok) { throw new ApiError(response.status, await parseErrorMessage(response)); } return response; }; export async function apiFetch(path: string, options: RequestInit = {}, mode: FetchMode = "json"): Promise { const response = await performFetch(path, options); if (mode === "response") { return response as T; } if (mode === "blob") { return (await response.blob()) as T; } if (response.status === 204) { return undefined as T; } return (await response.json()) as T; } export const authApi = { setupStatus: () => apiFetch<{ setup_required: boolean }>("/setup/status"), setup: (email: string, password: string) => apiFetch("/setup", { method: "POST", body: JSON.stringify({ email, password }), }), login: (email: string, password: string) => apiFetch("/auth/login", { method: "POST", body: JSON.stringify({ email, password }), }), signup: (email: string, password: string) => apiFetch("/auth/signup", { method: "POST", body: JSON.stringify({ email, password }), }), logout: () => { const storedRefresh = getRefreshToken(); const headers: Record = {}; let body: string | undefined; if (storedRefresh) { headers["X-Refresh-Token"] = storedRefresh; body = JSON.stringify({ refresh_token: storedRefresh }); } return apiFetch<{ status: string }>("/auth/logout", { method: "POST", headers, body, }); }, me: () => apiFetch<{ id: number; email: string; is_admin: boolean }>("/auth/me"), };