Files
radadmin/src/lib/api.ts
T
2026-07-31 23:12:07 +05:00

149 lines
4.0 KiB
TypeScript

// 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<T>(path: string, options: RequestInit = {}): Promise<T> {
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<T> {
total: number
limit: number
offset: number
items: T[]
}
// ---- Endpoints ----
export const api = {
// health check to validate the API key on login
ping: () => request<unknown>('/vlan/'),
devices: {
list: (limit = 50, offset = 0) =>
request<Page<Device>>(`/device/?limit=${limit}&offset=${offset}`),
get: (mac: string) => request<Device>(`/device/${encodeURIComponent(mac)}`),
add: (body: DeviceCreate) =>
request<Device>('/device/add', { method: 'POST', body: JSON.stringify(body) }),
edit: (body: DeviceEdit) =>
request<Device>('/device/edit', { method: 'POST', body: JSON.stringify(body) }),
remove: (mac: string) =>
request<void>(`/device/${encodeURIComponent(mac)}`, { method: 'DELETE' }),
},
vlans: {
list: () => request<Vlan[]>('/vlan/'),
add: (vlanid: number, alias: string) =>
request<Vlan>('/vlan/add', { method: 'POST', body: JSON.stringify({ vlanid, alias }) }),
edit: (vlanid: number, alias: string) =>
request<Vlan>('/vlan/edit', { method: 'POST', body: JSON.stringify({ vlanid, alias }) }),
remove: (vlanid: number) => request<void>(`/vlan/${vlanid}`, { method: 'DELETE' }),
},
}