Files
parking_solution/apps/web/src/api.ts
T
julian f5fd61984a Dingtian web password: set the admin's chosen password, verified
Fix two bugs found running the real assign flow: the saved web password
didn't match the device (login stayed admin/admin), and the UDP2 warning
never reached the admin.

Web password:
- Split the conflated field into webPassword (the DESIRED login; blank ->
  auto-generate) and webPasswordCurrent (the device's EXISTING password used
  as the old cred, default admin). Before, an admin typing a desired password
  made harden send it as the old cred -> rotation failed -> but the DB still
  saved the typed value, so it claimed a password the device never accepted.
- harden() now rotates current -> desired, VERIFIES by re-authenticating with
  the new password, and only returns secrets.webPassword on success (else a
  warning, nothing saved). Stores webPasswordCurrent for future re-runs.
- assign strips the typed webPassword/webPasswordCurrent and persists only the
  verified secret -- the DB never claims an unapplied password.

Warnings to the UI:
- assignDevice returns warnings[]; SetupWizard shows them in an amber
  "saved, but action needed" banner per category. This is how the admin learns
  the firmware wouldn't disable UDP2 (finish in the device web UI).

Verified on hardware: after harden the device rejects admin/admin and accepts
the chosen password; the UDP2 warning surfaces.
2026-06-15 12:26:34 +02:00

198 lines
6.0 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 BackendIpCandidate {
ip: string;
iface: string;
onDeviceSubnet: boolean;
}
/** Local IPs the device could push to (on-subnet first), for the wizard to
* pre-fill/override. Matters on multi-NIC hosts. */
export function fetchBackendIps(
host: string,
): Promise<{ candidates: BackendIpCandidate[]; port: number }> {
return apiFetch(`/api/setup/backend-ips?host=${encodeURIComponent(host)}`);
}
export interface AssignBody {
lane: number;
category: DeviceCategory;
driverId: string;
config: DeviceConfig;
/** Backend IP the device should push to (overrides auto-pick). */
backendIp?: string;
}
/** Save + configure the device (preconditions, push setup), then persist. */
export function assignDevice(body: AssignBody): Promise<AssignResult> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
}
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
export interface Assignment {
id: string;
lane: number;
category: DeviceCategory;
driverId: string;
config: DeviceConfig;
enabled: boolean;
createdAt?: string;
}
/** Assign response = the saved assignment plus any residual-risk warnings
* (e.g. "string protocol could not be disabled — finish in the device web UI"). */
export interface AssignResult extends Assignment {
warnings?: string[];
}
export interface SetupState {
completedAt: string | null;
assignments: Assignment[];
}
/** Current setup status + all assigned device instances. */
export function fetchState(): Promise<SetupState> {
return apiFetch<SetupState>("/api/setup/state");
}
/** Remove one assigned device instance by id. */
export function unassignDevice(id: string): Promise<void> {
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
}