Generate, list, and cancel attended-support codes (XXX-XXX-XXX), built on the v2 codes API and existing UI primitives. - Codes table: code in mono, status badge (pending+pulse/connected/ completed/cancelled), bound client/machine, created-by, created (relative + absolute tooltip). Sticky header, skeleton load, actionable empty/error states. - Generate opens a focused reveal modal showing the code large in JetBrains Mono with copy and a read-aloud instruction; the code is announced character-by-character for screen readers. Mint is ref- guarded so it creates exactly one code per open (no StrictMode dupe). - Cancel via confirm dialog (POST /api/codes/:code/cancel), disabled for non-cancellable statuses; invalidates the codes query. List polls 7s. - Shared API client now tolerates non-JSON 200 bodies, so the cancel endpoint's plain-text "Code cancelled" success no longer surfaces as a failure. Error-envelope handling unchanged. Passed Code Review (no blockers after fixes) and local gates (tsc/lint/build green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
139 lines
4.4 KiB
TypeScript
139 lines
4.4 KiB
TypeScript
// Typed fetch wrapper for the GuruConnect API.
|
|
//
|
|
// Responsibilities:
|
|
// - Resolve the base URL (VITE_API_URL, default same-origin).
|
|
// - Attach `Authorization: Bearer <token>` from a pluggable token provider.
|
|
// - Normalize the *two* inconsistent server error envelopes into one
|
|
// ApiError shape so callers/UI never have to branch on which one came back.
|
|
|
|
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
|
|
|
/** A normalized API error. `code` is present only for the structured envelope. */
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly code?: string;
|
|
|
|
constructor(message: string, status: number, code?: string) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
// The token lives in memory in the auth layer. We read it through a provider so
|
|
// the client has no hard dependency on React state and stays testable.
|
|
let tokenProvider: () => string | null = () => null;
|
|
|
|
export function setTokenProvider(provider: () => string | null): void {
|
|
tokenProvider = provider;
|
|
}
|
|
|
|
// Called when any request returns 401 — lets the auth layer tear down session
|
|
// state and bounce to /login. Set by AuthProvider.
|
|
let onUnauthorized: (() => void) | null = null;
|
|
|
|
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
|
onUnauthorized = handler;
|
|
}
|
|
|
|
interface RequestOptions {
|
|
method?: string;
|
|
body?: unknown;
|
|
// Suppress the global 401 handler (used by the login call itself).
|
|
skipAuthRedirect?: boolean;
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
/** The server's two error envelopes, unioned. We extract a message from either. */
|
|
interface ErrorEnvelope {
|
|
error?: string;
|
|
detail?: string;
|
|
error_code?: string;
|
|
status_code?: number;
|
|
}
|
|
|
|
function buildUrl(path: string): string {
|
|
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
|
return `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
|
}
|
|
|
|
async function extractError(res: Response): Promise<ApiError> {
|
|
let message = `Request failed (${res.status})`;
|
|
let code: string | undefined;
|
|
|
|
const raw = await res.text();
|
|
if (raw) {
|
|
try {
|
|
const env = JSON.parse(raw) as ErrorEnvelope;
|
|
// Handle BOTH envelopes: `{error}` and `{detail, error_code, status_code}`.
|
|
const msg = env.detail ?? env.error;
|
|
if (typeof msg === "string" && msg.length > 0) message = msg;
|
|
if (typeof env.error_code === "string") code = env.error_code;
|
|
} catch {
|
|
// Non-JSON body (e.g. the machines routes return plain &'static str on
|
|
// error). Use the trimmed text as the message if it looks sane.
|
|
const trimmed = raw.trim();
|
|
if (trimmed && trimmed.length < 300) message = trimmed;
|
|
}
|
|
}
|
|
|
|
return new ApiError(message, res.status, code);
|
|
}
|
|
|
|
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
|
const headers: Record<string, string> = {};
|
|
const token = tokenProvider();
|
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
|
|
let body: BodyInit | undefined;
|
|
if (opts.body !== undefined) {
|
|
headers["Content-Type"] = "application/json";
|
|
body = JSON.stringify(opts.body);
|
|
}
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(buildUrl(path), {
|
|
method: opts.method ?? "GET",
|
|
headers,
|
|
body,
|
|
signal: opts.signal,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
|
throw new ApiError("Network error — could not reach the server.", 0);
|
|
}
|
|
|
|
if (res.status === 401 && !opts.skipAuthRedirect) {
|
|
onUnauthorized?.();
|
|
}
|
|
|
|
if (!res.ok) {
|
|
throw await extractError(res);
|
|
}
|
|
|
|
// 204 No Content / empty body.
|
|
if (res.status === 204) return undefined as T;
|
|
const text = await res.text();
|
|
if (!text) return undefined as T;
|
|
// Most success responses are JSON, but some routes return a plain-text body
|
|
// on 200 (e.g. cancel returns "Code cancelled"). Tolerate non-JSON so a
|
|
// successful call isn't surfaced as a SyntaxError failure.
|
|
try {
|
|
return JSON.parse(text) as T;
|
|
} catch {
|
|
return undefined as T;
|
|
}
|
|
}
|
|
|
|
export const http = {
|
|
get: <T>(path: string, signal?: AbortSignal) =>
|
|
request<T>(path, { method: "GET", signal }),
|
|
post: <T>(path: string, body?: unknown, opts?: Partial<RequestOptions>) =>
|
|
request<T>(path, { method: "POST", body, ...opts }),
|
|
put: <T>(path: string, body?: unknown) =>
|
|
request<T>(path, { method: "PUT", body }),
|
|
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
|
};
|