0375227a16
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.
150 lines
4.5 KiB
TypeScript
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) });
|
|
}
|