Files
parking_solution/apps/web/src/api.ts
T
julian 0375227a16 Setup wizard: Test connection + Save & configure
Two-step device setup so the admin verifies before committing — and never touches
the device's own web UI.

- POST /api/setup/test (admin-only): healthCheck + checkPreconditions, no save and
  no device change. Returns device health + precondition issues.
- assign (Save) now also runs fixPreconditions (e.g. disables input_link_relay so
  a button press doesn't auto-fire its relay) before configuring the input push.
  Closes a gap where an assigned device could still auto-open. Fails the save with
  no DB row if device configuration fails (no orphan/half-configured rows).
- SetupWizard: wires config fields -> Test connection (health badge + precondition
  warnings) -> Save & configure; editing config resets prior test/save status.

Verified in-browser against the real device: Test -> ● ready + preconditions OK;
Save -> row persisted AND the device's Input Link URL written (push path matches
the saved device id). wiki/first-run-setup updated.
2026-06-14 16:59:36 +02:00

150 lines
4.5 KiB
TypeScript

// Thin API client for the operator/admin UI.
//
// Auth is cookie-based: the JWT lives in an HttpOnly cookie the browser sends
// automatically (credentials: 'include'). For mutations we echo the readable
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
// wiki/entities/local-jwt-auth.md.
const CSRF_COOKIE = "parking_csrf";
const CSRF_HEADER = "X-CSRF-Token";
function readCookie(name: string): string | null {
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return m ? decodeURIComponent(m[1]!) : null;
}
/** fetch wrapper: sends cookies, adds CSRF header on mutations, parses errors. */
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? "GET").toUpperCase();
const headers = new Headers(init.headers);
if (init.body && !headers.has("content-type")) {
headers.set("content-type", "application/json");
}
if (method !== "GET" && method !== "HEAD") {
const csrf = readCookie(CSRF_COOKIE);
if (csrf) headers.set(CSRF_HEADER, csrf);
}
const res = await fetch(path, { ...init, headers, credentials: "include" });
if (!res.ok) {
const msg = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(msg.error ?? `${path}: ${res.status}`, res.status);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
// --- Auth -----------------------------------------------------------------
export type Role = "admin" | "operator" | "cashier" | "readonly";
export interface SessionUser {
id: string;
username: string;
role: Role;
}
export function login(username: string, password: string): Promise<SessionUser> {
return apiFetch<SessionUser>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
export function logout(): Promise<{ ok: boolean }> {
return apiFetch("/api/auth/logout", { method: "POST" });
}
/** Returns the current user, or null if not authenticated. */
export async function fetchMe(): Promise<SessionUser | null> {
try {
return await apiFetch<SessionUser>("/api/auth/me");
} catch (e) {
if (e instanceof ApiError && (e.status === 401 || e.status === 403)) return null;
throw e;
}
}
// --- Device setup ---------------------------------------------------------
export interface ConfigField {
key: string;
label: string;
type: "string" | "number" | "boolean" | "host" | "port" | "secret" | "select";
required: boolean;
default?: string | number | boolean;
options?: { value: string; label: string }[];
help?: string;
}
export interface CatalogEntry {
id: string;
label: string;
description: string;
transports: string[];
configFields: ConfigField[];
}
export type DeviceCategory = "access" | "reader" | "camera" | "printer";
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
/** Driver ids that support LAN discovery. */
discoverable: string[];
};
export function fetchCatalog(): Promise<Catalog> {
return apiFetch<Catalog>("/api/setup/catalog");
}
export interface DiscoveredDevice {
id: string;
label: string;
config: Record<string, string | number | boolean>;
info?: Record<string, string>;
health: { status: string; detail?: string };
}
/** Scan the LAN for devices a driver can discover. Admin-only. */
export async function discoverDevices(driverId: string): Promise<DiscoveredDevice[]> {
const body = await apiFetch<{ devices: DiscoveredDevice[] }>(
`/api/setup/discover/${driverId}`,
);
return body.devices;
}
export type DeviceConfig = Record<string, string | number | boolean>;
export interface TestResult {
health: { status: string; detail?: string };
preconditions: {
ok: boolean;
issues: { key: string; message: string; fixable: boolean }[];
};
}
/** Test a device config (reachability + preconditions) without saving. */
export function testDevice(driverId: string, config: DeviceConfig): Promise<TestResult> {
return apiFetch<TestResult>("/api/setup/test", {
method: "POST",
body: JSON.stringify({ driverId, config }),
});
}
export interface AssignBody {
lane: number;
category: DeviceCategory;
driverId: string;
config: DeviceConfig;
}
/** Save + configure the device (preconditions, push setup), then persist. */
export function assignDevice(body: AssignBody): Promise<{ id: string }> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
}