init ui
This commit is contained in:
+148
@@ -0,0 +1,148 @@
|
||||
// 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' }),
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user