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:
@@ -5,6 +5,7 @@ import { ProtectedRoute } from "./auth/ProtectedRoute";
|
|||||||
import { AppShell } from "./components/layout/AppShell";
|
import { AppShell } from "./components/layout/AppShell";
|
||||||
import { ToastProvider } from "./components/ui/toast";
|
import { ToastProvider } from "./components/ui/toast";
|
||||||
import { LoginPage } from "./features/auth/LoginPage";
|
import { LoginPage } from "./features/auth/LoginPage";
|
||||||
|
import { SupportCodesPage } from "./features/codes/SupportCodesPage";
|
||||||
import { MachinesPage } from "./features/machines/MachinesPage";
|
import { MachinesPage } from "./features/machines/MachinesPage";
|
||||||
import { SessionsPage } from "./features/sessions/SessionsPage";
|
import { SessionsPage } from "./features/sessions/SessionsPage";
|
||||||
|
|
||||||
@@ -29,7 +30,8 @@ export function App() {
|
|||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route path="/machines" element={<MachinesPage />} />
|
<Route path="/machines" element={<MachinesPage />} />
|
||||||
<Route path="/sessions" element={<SessionsPage />} />
|
<Route path="/sessions" element={<SessionsPage />} />
|
||||||
{/* Codes / Users land in later passes. */}
|
<Route path="/codes" element={<SupportCodesPage />} />
|
||||||
|
{/* Users lands in a later pass. */}
|
||||||
<Route path="/" element={<Navigate to="/machines" replace />} />
|
<Route path="/" element={<Navigate to="/machines" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -117,7 +117,14 @@ async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
|||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (!text) return undefined as T;
|
if (!text) return undefined 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;
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const http = {
|
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 * from "./types";
|
||||||
export { ApiError, http, setTokenProvider, setUnauthorizedHandler } from "./client";
|
export { ApiError, http, setTokenProvider, setUnauthorizedHandler } from "./client";
|
||||||
export * as authApi from "./auth";
|
export * as authApi from "./auth";
|
||||||
|
export * as codesApi from "./codes";
|
||||||
export * as machinesApi from "./machines";
|
export * as machinesApi from "./machines";
|
||||||
export * as stubsApi from "./stubs";
|
export * as stubsApi from "./stubs";
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ export function listSessions(signal?: AbortSignal): Promise<unknown[]> {
|
|||||||
return http.get<unknown[]>("/api/sessions", signal);
|
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. */
|
/** GET /api/users — dashboard users (admin). Pass 2. */
|
||||||
export function listUsers(signal?: AbortSignal): Promise<unknown[]> {
|
export function listUsers(signal?: AbortSignal): Promise<unknown[]> {
|
||||||
return http.get<unknown[]>("/api/users", signal);
|
return http.get<unknown[]>("/api/users", signal);
|
||||||
|
|||||||
@@ -157,6 +157,58 @@ export interface ViewerTokenResponse {
|
|||||||
access: ViewerAccess | string;
|
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)
|
// Per-agent keys (admin plane)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface NavItem {
|
|||||||
const NAV: NavItem[] = [
|
const NAV: NavItem[] = [
|
||||||
{ to: "/machines", label: "Machines", Icon: MachinesIcon, enabled: true },
|
{ to: "/machines", label: "Machines", Icon: MachinesIcon, enabled: true },
|
||||||
{ to: "/sessions", label: "Sessions", Icon: SessionsIcon, 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 },
|
{ to: "/users", label: "Users", Icon: UsersIcon, enabled: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -127,3 +127,11 @@ export function StopIcon(props: IconProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PlusIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...base(props)}>
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,3 +46,43 @@ export function consentLabel(state: string): string {
|
|||||||
return state;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
61
dashboard/src/features/codes/CancelCodeDialog.tsx
Normal file
61
dashboard/src/features/codes/CancelCodeDialog.tsx
Normal 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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
dashboard/src/features/codes/GenerateCodeModal.tsx
Normal file
153
dashboard/src/features/codes/GenerateCodeModal.tsx
Normal 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(" ");
|
||||||
|
}
|
||||||
249
dashboard/src/features/codes/SupportCodesPage.tsx
Normal file
249
dashboard/src/features/codes/SupportCodesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
dashboard/src/features/codes/codes.css
Normal file
80
dashboard/src/features/codes/codes.css
Normal 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;
|
||||||
|
}
|
||||||
55
dashboard/src/features/codes/hooks.ts
Normal file
55
dashboard/src/features/codes/hooks.ts
Normal 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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user