Skip to content

Commit 949b714

Browse files
w3(dashboard): QR-proof sign-in demo page + tests
The desktop side of the W3 QR-proof wrapper demo (ADR-0009). The page mounts at /demo/qr-proof-login under the existing RequireAuth + AppShell chain so it's only reachable from inside the developer console, where the console JWT already gates access. State machine in useReducer with five phases: idle → creating → pending (QR shown) → awaiting_proof (paste-fallback for the proof QR) → success (auto-redirects to /overview) or expired/error (Start over). The SSE stream and the submit response are both treated as canonical paths to 'success' — whichever lands first wins; the reducer is idempotent. api.pairing extends the dashboard's existing fetch client with the four endpoints + a tiny pub/sub wrapper around EventSource (returns a stream with .on(type, cb) and .close(); withCredentials: true so the zeroauth_pair_bind cookie ships on the SSE GET). Discriminated union PairingStreamEvent on .type mirrors the four SSE event names the backend emits. VITE_PAIRING_MOCK=1 swaps in a client-side mock store keyed on session id. Mock-mode shows "Trigger mock claim" / "Trigger mock expire" buttons inside the cards so the W3 demo deck reviewer can walk the state machine without a live backend. QR rendering: api.qrserver.com via <img> (no new JS dep — kept the bundle 10.65 kB / 3.61 kB gzip for the lazy chunk). The raw QR payload is always exposed as a paste-fallback so a CSP/network block can't lock the operator out of the demo. AppShell nav gains a "Demos" entry pointing at the new route. Five vitest cases cover render-after-create, SSE expired transition, SSE bound transition, EventSource cleanup on unmount, and the Start-over flow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2f6d55d commit 949b714

6 files changed

Lines changed: 1191 additions & 0 deletions

File tree

‎dashboard/src/App.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { lazy, Suspense } from 'react';
12
import { BrowserRouter, Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
23
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
34
import { AuthProvider, useAuth } from './lib/auth';
@@ -17,6 +18,25 @@ import { Audit } from './routes/Audit';
1718
import { Settings } from './routes/Settings';
1819
import { NotFound } from './routes/NotFound';
1920

21+
// The QR-proof demo is the only consumer of the qrserver.com image
22+
// host today; lazy-loading keeps it (and any future scanner deps) out
23+
// of the main bundle until the operator opens /demo/qr-proof-login.
24+
const QrProofLogin = lazy(() => import('./routes/demo/QrProofLogin'));
25+
26+
function RouteSuspense({ children }: { children: React.ReactNode }) {
27+
return (
28+
<Suspense
29+
fallback={
30+
<div className="flex min-h-[40vh] items-center justify-center">
31+
<div className="size-6 animate-spin rounded-full border-2 border-current border-r-transparent text-[var(--color-text-dim)]" />
32+
</div>
33+
}
34+
>
35+
{children}
36+
</Suspense>
37+
);
38+
}
39+
2040
/**
2141
* Router basename has to track where the dashboard is mounted, which
2242
* differs by host:
@@ -89,6 +109,14 @@ export function App() {
89109
<Route path="/attendance" element={<Attendance />} />
90110
<Route path="/audit" element={<Audit />} />
91111
<Route path="/settings" element={<Settings />} />
112+
<Route
113+
path="/demo/qr-proof-login"
114+
element={
115+
<RouteSuspense>
116+
<QrProofLogin />
117+
</RouteSuspense>
118+
}
119+
/>
92120
<Route path="*" element={<NotFound />} />
93121
</Route>
94122
</Route>

‎dashboard/src/components/layout/AppShell.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ const NAV = [
5252
{ to: '/verifications', label: 'Verifications', icon: 'shield' },
5353
{ to: '/attendance', label: 'Attendance', icon: 'clock' },
5454
{ to: '/audit', label: 'Audit Log', icon: 'list' },
55+
// W3 wrapper demo: desktop QR-proof sign-in (ADR-0009). Sits under
56+
// its own /demo/ namespace so additional wrapper demos can co-locate
57+
// without polluting the primary console nav.
58+
{ to: '/demo/qr-proof-login', label: 'Demos', icon: 'qr' },
5559
{ to: '/settings', label: 'Settings', icon: 'gear' },
5660
] as const;
5761

@@ -66,6 +70,7 @@ function Icon({ name, className }: { name: string; className?: string }) {
6670
case 'shield': return <svg viewBox="0 0 24 24" width={16} height={16} {...common}><path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6l-8-3z"/><path d="m9 12 2 2 4-4"/></svg>;
6771
case 'clock': return <svg viewBox="0 0 24 24" width={16} height={16} {...common}><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>;
6872
case 'list': return <svg viewBox="0 0 24 24" width={16} height={16} {...common}><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>;
73+
case 'qr': return <svg viewBox="0 0 24 24" width={16} height={16} {...common}><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 14h3v3M20 14v3M14 17v4h4M20 20v1"/></svg>;
6974
case 'gear': return <svg viewBox="0 0 24 24" width={16} height={16} {...common}><circle cx="12" cy="12" r="3"/><path d="m19.4 15-1.4-.8.2-1.6-.2-1.6 1.4-.8-2-3.4-1.6.6-1.2-1-.4-1.6h-4l-.4 1.6-1.2 1-1.6-.6-2 3.4 1.4.8.2 1.6-.2 1.6-1.4.8 2 3.4 1.6-.6 1.2 1 .4 1.6h4l.4-1.6 1.2-1 1.6.6z"/></svg>;
7075
default: return null;
7176
}

0 commit comments

Comments
 (0)