149 lines
4.0 KiB
TypeScript
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' }),
|
|
},
|
|
}
|