feat(dashboard): GuruConnect v2 Support Codes view
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:
@@ -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 = {
|
||||
|
||||
39
dashboard/src/api/codes.ts
Normal file
39
dashboard/src/api/codes.ts
Normal 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`);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user