// Typed client for the radapi FreeRADIUS REST API. // Base URL is /api in dev (proxied by Vite to the real API) so the browser // makes same-origin requests and the API key travels only as a header. const BASE = import.meta.env.VITE_API_BASE ?? '/api' const KEY_STORAGE = 'radui.apiKey' // ---- API key storage ---- export function getApiKey(): string | null { return localStorage.getItem(KEY_STORAGE) } export function setApiKey(key: string) { localStorage.setItem(KEY_STORAGE, key) } export function clearApiKey() { localStorage.removeItem(KEY_STORAGE) } // A 401 anywhere means the stored key is bad — notify the app to log out. let onUnauthorized: (() => void) | null = null export function setUnauthorizedHandler(fn: () => void) { onUnauthorized = fn } export class ApiError extends Error { status: number constructor(status: number, message: string) { super(message) this.status = status this.name = 'ApiError' } } type ValidationItem = { loc: (string | number)[]; msg: string } function messageFromDetail(detail: unknown, fallback: string): string { if (typeof detail === 'string') return detail if (Array.isArray(detail)) { return (detail as ValidationItem[]) .map((d) => { const field = d.loc?.filter((p) => p !== 'body').join('.') return field ? `${field}: ${d.msg}` : d.msg }) .join('; ') } return fallback } async function request(path: string, options: RequestInit = {}): Promise { const key = getApiKey() const headers = new Headers(options.headers) if (key) headers.set('X-API-Key', key) if (options.body) headers.set('Content-Type', 'application/json') const res = await fetch(`${BASE}${path}`, { ...options, headers }) if (res.status === 401) { onUnauthorized?.() throw new ApiError(401, 'Invalid or missing API key') } if (res.status === 204) return undefined as T let payload: unknown = null const text = await res.text() if (text) { try { payload = JSON.parse(text) } catch { payload = text } } if (!res.ok) { const detail = (payload as { detail?: unknown })?.detail ?? payload throw new ApiError(res.status, messageFromDetail(detail, `Request failed (${res.status})`)) } return payload as T } // ---- Types ---- export type DeviceStatus = 'new' | 'paid' | 'unpaid' export interface Device { mac_address: string group: string | null status: DeviceStatus | null name: string | null phone: string | null alias: string | null } export interface DeviceCreate { mac_address: string group: string name: string phone: string alias?: string | null } export interface DeviceEdit { mac_address: string group?: string status?: DeviceStatus name?: string phone?: string alias?: string } export interface Vlan { alias: string vlanid: number } export interface Page { total: number limit: number offset: number items: T[] } // ---- Endpoints ---- export const api = { // health check to validate the API key on login ping: () => request('/vlan/'), devices: { list: (limit = 50, offset = 0) => request>(`/device/?limit=${limit}&offset=${offset}`), get: (mac: string) => request(`/device/${encodeURIComponent(mac)}`), add: (body: DeviceCreate) => request('/device/add', { method: 'POST', body: JSON.stringify(body) }), edit: (body: DeviceEdit) => request('/device/edit', { method: 'POST', body: JSON.stringify(body) }), remove: (mac: string) => request(`/device/${encodeURIComponent(mac)}`, { method: 'DELETE' }), }, vlans: { list: () => request('/vlan/'), add: (vlanid: number, alias: string) => request('/vlan/add', { method: 'POST', body: JSON.stringify({ vlanid, alias }) }), edit: (vlanid: number, alias: string) => request('/vlan/edit', { method: 'POST', body: JSON.stringify({ vlanid, alias }) }), remove: (vlanid: number) => request(`/vlan/${vlanid}`, { method: 'DELETE' }), }, }