feat(dashboard): GuruConnect v2 Support Codes view
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m27s
Build and Test / Build Agent (Windows) (push) Successful in 7m11s
Build and Test / Security Audit (push) Successful in 4m32s
Build and Test / Build Summary (push) Has been skipped

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>
This commit is contained in:
2026-05-30 13:59:18 -07:00
parent 67f3722b3c
commit 664f33d5ab
14 changed files with 750 additions and 8 deletions

View File

@@ -117,7 +117,14 @@ async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
if (res.status === 204) return undefined as T;
const text = await res.text();
if (!text) return undefined as T;
return JSON.parse(text) 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 = {

View File

@@ -0,0 +1,39 @@
import { http } from "./client";
import type { CreateCodeRequest, SupportCode } from "./types";
/**
* GET /api/codes — the active support codes (server returns only `pending` and
* `connected`, newest first is NOT guaranteed by the in-memory map, so the view
* sorts). Requires an authenticated dashboard JWT; any authenticated user may
* list. (See server/src/main.rs::list_codes.)
*/
export function listCodes(signal?: AbortSignal): Promise<SupportCode[]> {
return http.get<SupportCode[]>("/api/codes", signal);
}
/**
* POST /api/codes — generate a new one-time support code. The server creates an
* in-memory `pending` code (and persists a durable row for the single-use
* guard) and returns the full `SupportCode`, including the `XXX-XXX-XXX` value
* the tech reads to the end user. `technician_name` attributes the code to the
* operator. Requires an authenticated dashboard JWT.
* (See server/src/main.rs::create_code.)
*/
export function createCode(body: CreateCodeRequest): Promise<SupportCode> {
return http.post<SupportCode>("/api/codes", body);
}
/**
* POST /api/codes/:code/cancel — revoke an un-redeemed (or connected) code. The
* server flips a `pending`/`connected` code to `cancelled` and returns 200
* "Code cancelled"; a code that cannot be cancelled (already completed /
* cancelled / unknown) returns 400 "Cannot cancel code", which the typed client
* surfaces as an ApiError with that message. Requires an authenticated JWT.
* (See server/src/main.rs::cancel_code.)
*
* The path segment is the code itself; it can contain hyphens, so it is
* URL-encoded defensively even though the unambiguous alphabet is path-safe.
*/
export function cancelCode(code: string): Promise<void> {
return http.post<void>(`/api/codes/${encodeURIComponent(code)}/cancel`);
}

View File

@@ -1,5 +1,6 @@
export * from "./types";
export { ApiError, http, setTokenProvider, setUnauthorizedHandler } from "./client";
export * as authApi from "./auth";
export * as codesApi from "./codes";
export * as machinesApi from "./machines";
export * as stubsApi from "./stubs";

View File

@@ -12,11 +12,6 @@ export function listSessions(signal?: AbortSignal): Promise<unknown[]> {
return http.get<unknown[]>("/api/sessions", signal);
}
/** GET /api/codes — one-time support codes. Pass 2. */
export function listCodes(signal?: AbortSignal): Promise<unknown[]> {
return http.get<unknown[]>("/api/codes", signal);
}
/** GET /api/users — dashboard users (admin). Pass 2. */
export function listUsers(signal?: AbortSignal): Promise<unknown[]> {
return http.get<unknown[]>("/api/users", signal);

View File

@@ -157,6 +157,58 @@ export interface ViewerTokenResponse {
access: ViewerAccess | string;
}
// ---------------------------------------------------------------------------
// Support codes (attended-support, one-time)
// ---------------------------------------------------------------------------
/**
* Lifecycle state of a support code. Mirrors `support_codes::CodeStatus`
* (`#[serde(rename_all = "lowercase")]`), serialized as the `status` field:
* pending — generated, waiting for an end user to redeem it (single-use).
* connected — redeemed; an attended session is now bound to it.
* completed — that session ended normally.
* cancelled — revoked by a tech before it was redeemed.
* `GET /api/codes` returns only `pending` and `connected` (the active set);
* `completed`/`cancelled` are modeled for completeness and defensive rendering.
*/
export type CodeStatus =
| "pending"
| "connected"
| "completed"
| "cancelled";
/**
* A support code as returned by `POST /api/codes` and `GET /api/codes`. Field
* names mirror `support_codes::SupportCode` (serde default snake_case) exactly.
*
* NOTE: the in-memory `SupportCode` the API serializes has NO `expires_at`
* field (only the durable DB row does); codes are short-lived and the dashboard
* surfaces liveness via the poll + status, not an absolute expiry. `code` is the
* grouped `XXX-XXX-XXX` value the tech reads to the end user.
*/
export interface SupportCode {
code: string;
session_id: string; // UUID
created_by: string;
created_at: string; // RFC3339
status: CodeStatus | string;
client_name: string | null;
client_machine: string | null;
connected_at: string | null; // RFC3339, set when redeemed
}
/**
* Body for `POST /api/codes`. Mirrors `support_codes::CreateCodeRequest`. Both
* fields are optional; the server stamps `created_by` from `technician_name`
* (falling back to "Unknown"). `technician_id` is accepted but currently unused
* server-side. We send `technician_name` so the code is attributed to the
* signed-in operator.
*/
export interface CreateCodeRequest {
technician_id?: string;
technician_name?: string;
}
// ---------------------------------------------------------------------------
// Per-agent keys (admin plane)
// ---------------------------------------------------------------------------