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

@@ -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 (
<ConfirmDialog
open={open}
title="Cancel this code?"
danger
busy={cancel.isPending}
confirmLabel="Cancel code"
cancelLabel="Keep it"
onConfirm={onConfirm}
onCancel={onClose}
body={
code ? (
<p>
This permanently revokes <strong className="mono">{code.code}</strong>.{" "}
{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."}
</p>
) : null
}
/>
);
}

View File

@@ -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<SupportCode | null>(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 (
<Modal
open={open}
title="Support code"
onClose={onClose}
footer={
<Button variant="primary" onClick={onClose}>
Done
</Button>
}
>
{generate.isPending && !result ? (
<div className="codegen__loading">
<Spinner label="Generating code…" />
</div>
) : generate.isError ? (
<ErrorState
title="Could not generate a code"
message={
generate.error instanceof ApiError
? generate.error.message
: "The relay did not return a code. Check the relay status, then try again."
}
action={
<Button
variant="primary"
onClick={() =>
void generate
.mutateAsync({ technician_name: technicianName })
.then(setResult)
.catch(() => {})
}
>
Try again
</Button>
}
/>
) : result ? (
<>
<p className="codegen__lede">
Read this code to the end user. It starts an attended support session
and can be used once.
</p>
<div className="codegen__codewrap">
<output className="codegen__code" aria-label={`Support code ${spell(result.code)}`}>
{result.code}
</output>
<Button
variant="ghost"
size="sm"
onClick={() => void copy(result.code)}
aria-label={copied ? "Code copied to clipboard" : "Copy code to clipboard"}
>
<CopyIcon width={14} height={14} />
{copied ? "Copied" : "Copy"}
</Button>
</div>
<p className="codegen__hint">
It stays active until the user redeems it or you cancel it. Once
redeemed it cannot be used again.
</p>
</>
) : null}
</Modal>
);
}
/**
* 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(" ");
}

View File

@@ -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<SupportCode | null>(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<SupportCode>[] = [
{
key: "code",
header: "Code",
render: (c) => <span className="cdt__code">{c.code}</span>,
},
{
key: "status",
header: "Status",
render: (c) => (
<Badge tone={codeTone(c.status)} dot>
{codeLabel(c.status)}
</Badge>
),
},
{
key: "bound",
header: "Bound to",
render: (c) =>
c.client_machine || c.client_name ? (
<div className="cdt__bound">
<span className="dt__strong">
{c.client_machine ?? c.client_name}
</span>
{c.client_machine && c.client_name && (
<span className="cdt__boundsub">{c.client_name}</span>
)}
</div>
) : (
<span className="dt__muted">Not redeemed</span>
),
},
{
key: "created_by",
header: "Created by",
render: (c) => <span className="dt__strong">{c.created_by}</span>,
},
{
key: "created",
header: "Created",
render: (c) => (
<span className="dt__mono" title={absoluteTime(c.created_at)}>
{relativeTime(c.created_at)}
</span>
),
},
{
key: "actions",
header: "",
cellClass: "dt__actions",
render: (c) => {
const cancellable = canCancel(c.status);
return (
<span className="dt__rowactions" onClick={(e) => e.stopPropagation()}>
<Button
variant="danger"
size="sm"
onClick={() => setCancelFor(c)}
disabled={!cancellable}
title={
cancellable
? undefined
: `${codeLabel(c.status)} codes cannot be cancelled`
}
aria-label={`Cancel code ${c.code}`}
>
<TrashIcon width={14} height={14} />
Cancel
</Button>
</span>
);
},
},
];
return (
<div className="page">
<PageHeader
title="Support codes"
subtitle="One-time codes for attended support. Generate a code, read it to the end user, and they redeem it to start a session."
actions={
<>
<Button
variant="ghost"
onClick={() => void codesQuery.refetch()}
loading={codesQuery.isFetching}
>
<RefreshIcon width={15} height={15} />
Refresh
</Button>
<Button variant="primary" onClick={() => setGenerating(true)}>
<PlusIcon width={15} height={15} />
Generate code
</Button>
</>
}
/>
<Panel flush>
<div style={{ padding: "14px 16px 0" }}>
<div className="toolbar">
<div className="searchbox">
<span className="searchbox__icon">
<SearchIcon width={15} height={15} />
</span>
<Input
placeholder="Filter by code, machine, or creator"
value={filter}
onChange={(e) => setFilter(e.target.value)}
aria-label="Filter support codes"
/>
</div>
<div className="toolbar__count">
<span className="mono">{pendingCount}</span> awaiting redeem ·{" "}
<span className="mono">{codes.length}</span> active
</div>
</div>
</div>
{codesQuery.isLoading ? (
<>
<span className="visually-hidden" role="status">
Loading support codes
</span>
<TableSkeleton
headers={[
"Code",
"Status",
"Bound to",
"Created by",
"Created",
"",
]}
/>
</>
) : codesQuery.isError ? (
<ErrorState
title="Could not load support codes"
message={
codesQuery.error instanceof ApiError
? codesQuery.error.message
: "The GuruConnect relay did not respond. Check the relay status, then retry."
}
action={
<Button variant="primary" onClick={() => void codesQuery.refetch()}>
Retry
</Button>
}
/>
) : filtered.length === 0 ? (
filter ? (
<EmptyState
title="No matching codes"
message={`Nothing matches "${filter}". Clear the filter to see every active code.`}
action={
<Button variant="ghost" onClick={() => setFilter("")}>
Clear filter
</Button>
}
/>
) : (
<EmptyState
title="No active codes"
message="Generate a code, read it to the end user over the phone, and they redeem it to start an attended session. Each code works once."
action={
<Button variant="primary" onClick={() => setGenerating(true)}>
<PlusIcon width={15} height={15} />
Generate code
</Button>
}
/>
)
) : (
<Table columns={columns} rows={filtered} rowKey={(c) => c.code} />
)}
</Panel>
<GenerateCodeModal
open={generating}
technicianName={user?.username}
onClose={() => setGenerating(false)}
/>
<CancelCodeDialog code={cancelFor} onClose={() => setCancelFor(null)} />
</div>
);
}

View File

@@ -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;
}

View File

@@ -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 });
},
});
}