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 ? ( +
+ +
+ ) : generate.isError ? ( + + void generate + .mutateAsync({ technician_name: technicianName }) + .then(setResult) + .catch(() => {}) + } + > + Try again + + } + /> + ) : result ? ( + <> +

+ Read this code to the end user. It starts an attended support session + and can be used once. +

+ +
+ + {result.code} + + +
+ +

+ It stays active until the user redeems it or you cancel it. Once + redeemed it cannot be used again. +

+ + ) : null} +
+ ); +} + +/** + * Spell a grouped code out for the screen-reader label so it is announced + * character by character ("K, 7, P, ...") instead of as a mangled word. The + * visible code stays the compact `XXX-XXX-XXX` form. + */ +function spell(code: string): string { + return code + .replace(/-/g, " ") + .split("") + .filter((c) => c !== " ") + .join(" "); +} diff --git a/dashboard/src/features/codes/SupportCodesPage.tsx b/dashboard/src/features/codes/SupportCodesPage.tsx new file mode 100644 index 0000000..34ed7ed --- /dev/null +++ b/dashboard/src/features/codes/SupportCodesPage.tsx @@ -0,0 +1,249 @@ +import { useMemo, useState } from "react"; +import { ApiError } from "../../api/client"; +import type { SupportCode } from "../../api/types"; +import { useAuth } from "../../auth/AuthContext"; +import { PageHeader } from "../../components/layout/PageHeader"; +import { PlusIcon, RefreshIcon, SearchIcon, TrashIcon } from "../../components/layout/icons"; +import { Badge } from "../../components/ui/Badge"; +import { Button } from "../../components/ui/Button"; +import { Input } from "../../components/ui/Input"; +import { Panel } from "../../components/ui/Panel"; +import { EmptyState, ErrorState } from "../../components/ui/States"; +import { codeLabel, codeTone } from "../../components/ui/status"; +import { Table, type Column } from "../../components/ui/Table"; +import { TableSkeleton } from "../../components/ui/TableSkeleton"; +import { absoluteTime, relativeTime } from "../../lib/time"; +import { CancelCodeDialog } from "./CancelCodeDialog"; +import { GenerateCodeModal } from "./GenerateCodeModal"; +import { useSupportCodes } from "./hooks"; +import "./codes.css"; + +/** A code is still cancellable only while it is pending or connected. */ +function canCancel(status: string): boolean { + return status === "pending" || status === "connected"; +} + +export function SupportCodesPage() { + const { user } = useAuth(); + const codesQuery = useSupportCodes(); + const [filter, setFilter] = useState(""); + const [generating, setGenerating] = useState(false); + const [cancelFor, setCancelFor] = useState(null); + + const { data } = codesQuery; + const codes = useMemo(() => data ?? [], [data]); + + // Newest first: the in-memory map the server returns has no guaranteed order, + // and the code a tech just generated should be at the top where they expect + // it. + const sorted = useMemo( + () => + [...codes].sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ), + [codes], + ); + + const filtered = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) return sorted; + return sorted.filter( + (c) => + c.code.toLowerCase().includes(q) || + c.created_by.toLowerCase().includes(q) || + (c.client_machine?.toLowerCase().includes(q) ?? false), + ); + }, [sorted, filter]); + + const pendingCount = useMemo( + () => codes.filter((c) => c.status === "pending").length, + [codes], + ); + + const columns: Column[] = [ + { + key: "code", + header: "Code", + render: (c) => {c.code}, + }, + { + key: "status", + header: "Status", + render: (c) => ( + + {codeLabel(c.status)} + + ), + }, + { + key: "bound", + header: "Bound to", + render: (c) => + c.client_machine || c.client_name ? ( +
+ + {c.client_machine ?? c.client_name} + + {c.client_machine && c.client_name && ( + {c.client_name} + )} +
+ ) : ( + Not redeemed + ), + }, + { + key: "created_by", + header: "Created by", + render: (c) => {c.created_by}, + }, + { + key: "created", + header: "Created", + render: (c) => ( + + {relativeTime(c.created_at)} + + ), + }, + { + key: "actions", + header: "", + cellClass: "dt__actions", + render: (c) => { + const cancellable = canCancel(c.status); + return ( + e.stopPropagation()}> + + + ); + }, + }, + ]; + + return ( +
+ + + + + } + /> + + +
+
+
+ + + + setFilter(e.target.value)} + aria-label="Filter support codes" + /> +
+
+ {pendingCount} awaiting redeem ·{" "} + {codes.length} active +
+
+
+ + {codesQuery.isLoading ? ( + <> + + Loading support codes + + + + ) : codesQuery.isError ? ( + void codesQuery.refetch()}> + Retry + + } + /> + ) : filtered.length === 0 ? ( + filter ? ( + setFilter("")}> + Clear filter + + } + /> + ) : ( + setGenerating(true)}> + + Generate code + + } + /> + ) + ) : ( + 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 }); + }, + }); +}