From 664f33d5aba6b1967a3301fe7f3bab6ddf4ea575 Mon Sep 17 00:00:00 2001
From: Mike Swanson
Date: Sat, 30 May 2026 13:59:18 -0700
Subject: [PATCH] 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)
---
dashboard/src/App.tsx | 4 +-
dashboard/src/api/client.ts | 9 +-
dashboard/src/api/codes.ts | 39 +++
dashboard/src/api/index.ts | 1 +
dashboard/src/api/stubs.ts | 5 -
dashboard/src/api/types.ts | 52 ++++
dashboard/src/components/layout/Sidebar.tsx | 2 +-
dashboard/src/components/layout/icons.tsx | 8 +
dashboard/src/components/ui/status.ts | 40 +++
.../src/features/codes/CancelCodeDialog.tsx | 61 +++++
.../src/features/codes/GenerateCodeModal.tsx | 153 +++++++++++
.../src/features/codes/SupportCodesPage.tsx | 249 ++++++++++++++++++
dashboard/src/features/codes/codes.css | 80 ++++++
dashboard/src/features/codes/hooks.ts | 55 ++++
14 files changed, 750 insertions(+), 8 deletions(-)
create mode 100644 dashboard/src/api/codes.ts
create mode 100644 dashboard/src/features/codes/CancelCodeDialog.tsx
create mode 100644 dashboard/src/features/codes/GenerateCodeModal.tsx
create mode 100644 dashboard/src/features/codes/SupportCodesPage.tsx
create mode 100644 dashboard/src/features/codes/codes.css
create mode 100644 dashboard/src/features/codes/hooks.ts
diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
index fe83402..d2920b7 100644
--- a/dashboard/src/App.tsx
+++ b/dashboard/src/App.tsx
@@ -5,6 +5,7 @@ import { ProtectedRoute } from "./auth/ProtectedRoute";
import { AppShell } from "./components/layout/AppShell";
import { ToastProvider } from "./components/ui/toast";
import { LoginPage } from "./features/auth/LoginPage";
+import { SupportCodesPage } from "./features/codes/SupportCodesPage";
import { MachinesPage } from "./features/machines/MachinesPage";
import { SessionsPage } from "./features/sessions/SessionsPage";
@@ -29,7 +30,8 @@ export function App() {
}>
} />
} />
- {/* Codes / Users land in later passes. */}
+ } />
+ {/* Users lands in a later pass. */}
} />
diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts
index d4b1550..716cd0c 100644
--- a/dashboard/src/api/client.ts
+++ b/dashboard/src/api/client.ts
@@ -117,7 +117,14 @@ async function request(path: string, opts: RequestOptions = {}): Promise {
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 = {
diff --git a/dashboard/src/api/codes.ts b/dashboard/src/api/codes.ts
new file mode 100644
index 0000000..b93e071
--- /dev/null
+++ b/dashboard/src/api/codes.ts
@@ -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 {
+ return http.get("/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 {
+ return http.post("/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 {
+ return http.post(`/api/codes/${encodeURIComponent(code)}/cancel`);
+}
diff --git a/dashboard/src/api/index.ts b/dashboard/src/api/index.ts
index ac61c66..c0f2cf2 100644
--- a/dashboard/src/api/index.ts
+++ b/dashboard/src/api/index.ts
@@ -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";
diff --git a/dashboard/src/api/stubs.ts b/dashboard/src/api/stubs.ts
index 26013fe..cc5fef1 100644
--- a/dashboard/src/api/stubs.ts
+++ b/dashboard/src/api/stubs.ts
@@ -12,11 +12,6 @@ export function listSessions(signal?: AbortSignal): Promise {
return http.get("/api/sessions", signal);
}
-/** GET /api/codes — one-time support codes. Pass 2. */
-export function listCodes(signal?: AbortSignal): Promise {
- return http.get("/api/codes", signal);
-}
-
/** GET /api/users — dashboard users (admin). Pass 2. */
export function listUsers(signal?: AbortSignal): Promise {
return http.get("/api/users", signal);
diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts
index 004b677..a7723b3 100644
--- a/dashboard/src/api/types.ts
+++ b/dashboard/src/api/types.ts
@@ -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)
// ---------------------------------------------------------------------------
diff --git a/dashboard/src/components/layout/Sidebar.tsx b/dashboard/src/components/layout/Sidebar.tsx
index 8dca59a..4686866 100644
--- a/dashboard/src/components/layout/Sidebar.tsx
+++ b/dashboard/src/components/layout/Sidebar.tsx
@@ -18,7 +18,7 @@ interface NavItem {
const NAV: NavItem[] = [
{ to: "/machines", label: "Machines", Icon: MachinesIcon, enabled: true },
{ to: "/sessions", label: "Sessions", Icon: SessionsIcon, enabled: true },
- { to: "/codes", label: "Codes", Icon: CodesIcon, enabled: false },
+ { to: "/codes", label: "Codes", Icon: CodesIcon, enabled: true },
{ to: "/users", label: "Users", Icon: UsersIcon, enabled: false },
];
diff --git a/dashboard/src/components/layout/icons.tsx b/dashboard/src/components/layout/icons.tsx
index d3cde36..bc232af 100644
--- a/dashboard/src/components/layout/icons.tsx
+++ b/dashboard/src/components/layout/icons.tsx
@@ -127,3 +127,11 @@ export function StopIcon(props: IconProps) {
);
}
+
+export function PlusIcon(props: IconProps) {
+ return (
+
+ );
+}
diff --git a/dashboard/src/components/ui/status.ts b/dashboard/src/components/ui/status.ts
index fd8a535..712b598 100644
--- a/dashboard/src/components/ui/status.ts
+++ b/dashboard/src/components/ui/status.ts
@@ -46,3 +46,43 @@ export function consentLabel(state: string): string {
return state;
}
}
+
+/**
+ * Map a support-code lifecycle status to a tone. `pending` is the live,
+ * waiting-to-be-redeemed state and gets the same `warn` pulse the
+ * awaiting-consent state uses — it reads as "active, watch this". A redeemed
+ * (`connected`) code is a positive terminal-for-the-tech outcome -> `ok`.
+ * `completed`/`cancelled` are spent and read as muted `neutral`.
+ */
+export function codeTone(status: string): StatusTone {
+ switch (status) {
+ case "pending":
+ return "warn";
+ case "connected":
+ return "ok";
+ case "completed":
+ case "cancelled":
+ default:
+ return "neutral";
+ }
+}
+
+/**
+ * Human label for a support-code status. Next to `codeTone` so wording and
+ * color never drift. `pending` is phrased as the active wait (the tech is
+ * watching for the end user to redeem it).
+ */
+export function codeLabel(status: string): string {
+ switch (status) {
+ case "pending":
+ return "Awaiting redeem";
+ case "connected":
+ return "Redeemed";
+ case "completed":
+ return "Completed";
+ case "cancelled":
+ return "Cancelled";
+ default:
+ return status;
+ }
+}
diff --git a/dashboard/src/features/codes/CancelCodeDialog.tsx b/dashboard/src/features/codes/CancelCodeDialog.tsx
new file mode 100644
index 0000000..166e34a
--- /dev/null
+++ b/dashboard/src/features/codes/CancelCodeDialog.tsx
@@ -0,0 +1,61 @@
+import { ApiError } from "../../api/client";
+import type { SupportCode } from "../../api/types";
+import { ConfirmDialog } from "../../components/ui/ConfirmDialog";
+import { useToast } from "../../components/ui/toast-context";
+import { useCancelCode } from "./hooks";
+
+interface CancelCodeDialogProps {
+ /** The code to cancel, or null when the dialog is closed. */
+ code: SupportCode | null;
+ onClose: () => void;
+}
+
+/**
+ * Confirm + cancel a support code. Cancelling is consequential: a code cannot
+ * be un-cancelled, and if it has not been redeemed yet the end user can no
+ * longer use it. We confirm first, then invalidate the list so the row drops.
+ */
+export function CancelCodeDialog({ code, onClose }: CancelCodeDialogProps) {
+ const toast = useToast();
+ const cancel = useCancelCode();
+ const open = code != null;
+
+ function onConfirm() {
+ if (!code) return;
+ cancel.mutate(code.code, {
+ onSuccess: () => {
+ toast.success("Code cancelled", `${code.code} can no longer be used.`);
+ onClose();
+ },
+ onError: (err) => {
+ toast.error(
+ "Could not cancel code",
+ err instanceof ApiError ? err.message : "The relay did not respond.",
+ );
+ },
+ });
+ }
+
+ return (
+
+ This permanently revokes {code.code}.{" "}
+ {code.status === "connected"
+ ? "An attended session is bound to it; cancelling ends that connection."
+ : "The end user will not be able to redeem it. This cannot be undone."}
+
+ ) : null
+ }
+ />
+ );
+}
diff --git a/dashboard/src/features/codes/GenerateCodeModal.tsx b/dashboard/src/features/codes/GenerateCodeModal.tsx
new file mode 100644
index 0000000..eb3bfe8
--- /dev/null
+++ b/dashboard/src/features/codes/GenerateCodeModal.tsx
@@ -0,0 +1,153 @@
+import { useEffect, useRef, useState } from "react";
+import { ApiError } from "../../api/client";
+import type { SupportCode } from "../../api/types";
+import { Button } from "../../components/ui/Button";
+import { Modal } from "../../components/ui/Modal";
+import { Spinner } from "../../components/ui/Spinner";
+import { ErrorState } from "../../components/ui/States";
+import { CopyIcon } from "../../components/layout/icons";
+import { useClipboard } from "../../lib/useClipboard";
+import { useGenerateCode } from "./hooks";
+import "./codes.css";
+
+interface GenerateCodeModalProps {
+ /** Whether the generate dialog is open. */
+ open: boolean;
+ /** Operator name to attribute the code to (server stamps `created_by`). */
+ technicianName?: string;
+ onClose: () => void;
+}
+
+/**
+ * Generate-a-code flow. Opening the dialog mints a fresh code immediately, then
+ * reveals it large in JetBrains Mono so the tech can read it to the end user
+ * over the phone. The code is the single high-signal element on this surface;
+ * everything else is secondary. There is no per-second countdown — the
+ * SupportCode the API returns has no `expires_at`, and a redeem/cancel surfaces
+ * through the table's poll, so a timer here would be both impossible to source
+ * accurately and a needless render storm.
+ */
+export function GenerateCodeModal({
+ open,
+ technicianName,
+ onClose,
+}: GenerateCodeModalProps) {
+ const generate = useGenerateCode();
+ const { copied, copy } = useClipboard();
+ const [result, setResult] = useState(null);
+ // Minting a code is a durable single-use side effect. Guard it behind a ref so
+ // StrictMode's mount->cleanup->mount double-invoke can't fire two real POSTs
+ // per open; re-arm on close so the next open mints fresh.
+ const mintedFor = useRef(false);
+
+ // Mint once when the dialog opens; reset on close so a re-open mints a fresh
+ // code. Minting in an effect (not on a button click) lets the dialog own the
+ // loading/error/success states cleanly, mirroring JoinSessionModal.
+ useEffect(() => {
+ if (!open) {
+ setResult(null);
+ generate.reset();
+ mintedFor.current = false; // re-arm for the next open
+ return;
+ }
+ if (mintedFor.current) return; // StrictMode remount: already minted
+ mintedFor.current = true;
+ let cancelled = false;
+ generate
+ .mutateAsync({ technician_name: technicianName })
+ .then((res) => {
+ if (!cancelled) setResult(res);
+ })
+ .catch(() => {
+ // Surfaced via generate.isError below.
+ });
+ return () => {
+ cancelled = true;
+ };
+ // Mint exactly once per open. The mutation object is stable.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open]);
+
+ if (!open) return null;
+
+ return (
+
+ Done
+
+ }
+ >
+ {generate.isPending && !result ? (
+
c.code} />
+ )}
+
+
+ setGenerating(false)}
+ />
+ setCancelFor(null)} />
+
+ );
+}
diff --git a/dashboard/src/features/codes/codes.css b/dashboard/src/features/codes/codes.css
new file mode 100644
index 0000000..04febe8
--- /dev/null
+++ b/dashboard/src/features/codes/codes.css
@@ -0,0 +1,80 @@
+/* ===================================================== Support codes table */
+
+/* The code in the row: mono, accent, slightly larger than body so it reads as
+ the identifier it is. Tracks the table's mono idiom but with brand color. */
+.cdt__code {
+ font-family: var(--font-mono);
+ font-feature-settings: "ss01", "zero";
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ color: var(--accent);
+ white-space: nowrap;
+}
+
+/* Bound-to cell: machine over a dimmer client name, the same two-line idiom
+ the sessions table uses for machine/agent-id. */
+.cdt__bound {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+.cdt__boundsub {
+ font-size: 11px;
+ color: var(--text-faint);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 220px;
+}
+
+/* ===================================================== Generate-code dialog */
+
+.codegen__loading {
+ display: flex;
+ justify-content: center;
+ padding: 32px 0;
+}
+
+.codegen__lede {
+ margin: 0 0 18px;
+ font-size: 13px;
+ color: var(--text-muted);
+ line-height: 1.5;
+}
+
+/* The hero: the code itself, large, mono, accent, with a copy button. This is
+ read aloud over the phone, so it is the single dominant element on the
+ surface and is sized for unmistakable legibility. */
+.codegen__codewrap {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 22px 20px;
+ border-radius: var(--radius);
+ background: var(--accent-soft);
+ border: 1px solid var(--accent-ring);
+}
+.codegen__code {
+ font-family: var(--font-mono);
+ /* ss01 = stylistic alt; zero = slashed zero. The unambiguous alphabet has no
+ 0, but the feature is harmless and keeps mono rendering consistent. */
+ font-feature-settings: "ss01", "zero";
+ font-size: clamp(28px, 7vw, 38px);
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ line-height: 1.1;
+ color: var(--accent);
+ user-select: all;
+ /* Never wrap the grouped code across lines — it must read as one token. */
+ white-space: nowrap;
+}
+
+.codegen__hint {
+ margin: 16px 0 0;
+ font-size: 12px;
+ color: var(--text-muted);
+ line-height: 1.5;
+}
diff --git a/dashboard/src/features/codes/hooks.ts b/dashboard/src/features/codes/hooks.ts
new file mode 100644
index 0000000..dde76ea
--- /dev/null
+++ b/dashboard/src/features/codes/hooks.ts
@@ -0,0 +1,55 @@
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
+import * as codesApi from "../../api/codes";
+import type { CreateCodeRequest } from "../../api/types";
+
+const CODES_KEY = ["codes"] as const;
+
+/**
+ * List the active support codes. Polls on a short interval because codes are
+ * short-lived: a `pending` code can be redeemed (-> `connected`) or expire out
+ * of the active set at any moment, and a tech who just read a code aloud is
+ * watching for exactly that transition. The interval is tight (like the
+ * sessions poll) so the redeem shows up on its own without a manual refresh.
+ */
+export function useSupportCodes() {
+ return useQuery({
+ queryKey: CODES_KEY,
+ queryFn: ({ signal }) => codesApi.listCodes(signal),
+ refetchInterval: 7_000,
+ staleTime: 3_500,
+ });
+}
+
+/**
+ * Generate a new support code, then invalidate the list so the new `pending`
+ * code appears in the table. The created code is returned to the caller so the
+ * generate flow can surface it prominently (it is read to the end user).
+ */
+export function useGenerateCode() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (body: CreateCodeRequest) => codesApi.createCode(body),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: CODES_KEY });
+ },
+ });
+}
+
+/**
+ * Cancel (revoke) a support code, then invalidate the list so the row drops out
+ * of the active set. Cancelling an un-redeemed code is irreversible, so the UI
+ * confirms first; this hook is the action behind that confirmation.
+ */
+export function useCancelCode() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (code: string) => codesApi.cancelCode(code),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: CODES_KEY });
+ },
+ });
+}