Skip to content

Commit 125f706

Browse files
Central API developer console: 10 pages, 82 tests, CI gated on dev (#22)
* Spec the dashboard stack + extend threat model + dep-trail body match ADR-0002 (new) documents why the developer console stays on Vite + React + Tailwind + React Query rather than migrating to the suite's Next.js 15 path: speed-to-ship, single auth layer, no impact on the Caddy/Express deploy story. Names every new dep so the dep-trail check can audit them. CLAUDE.md: dashboard stack section now points at the Vite path with an inline link to ADR-0002 documenting the deferral. threat_model.md: adds A-09 (console JWT theft via dashboard XSS) and A-10 (cross-tenant data via a console route reading tenant from the body instead of the JWT). Both have explicit test-status rows so the gaps are visible. scripts/check-dep-trail.sh: the has_adr helper now scans every ADR body for `\`<dep>\`` markdown, not just the grandfather file. Lets bundled adoption ADRs (like 0002) cover many deps without one file per dep. * Backend: add /api/console/devices, /users, /verifications, /attendance These proxy endpoints back the developer console UI. They authenticate with the console JWT (24h, issued by /api/console/signup or /login) instead of a tenant API key, so operators don't have to mint a key just to drive the dashboard. All endpoints: - read the tenant ID from `(req as any).console.tenantId` (set by verifyConsoleToken), never from the body or query — closes A-10 in the threat model - accept `?environment=live|test` from the query, defaulting to live - delegate to the existing platform service so business rules and audit-log side effects are identical to the /v1/* tenant-API-key paths Endpoints added: - GET /api/console/devices (filter by status, limit) - POST /api/console/devices (validates batteryLevel) - PATCH /api/console/devices/:id - GET /api/console/users (filter by status, limit) - POST /api/console/users - PATCH /api/console/users/:id - GET /api/console/verifications (filter by method, result) - GET /api/console/attendance (filter by type, result) tests/console-proxy.test.ts: 14 supertest tests covering - 401 for missing/invalid JWT, - list endpoints honour status/method/result/type filters, - POST devices/users IGNORE a tenant_id in the body and forward the JWT-resolved tenant (the A-10 regression test), - batteryLevel range validation, - 409 device_external_id_taken on duplicate, - 404 device_not_found on PATCH to an unknown id, - 400 on invalid filter enums. Full root jest now: 64 tests across 10 suites (was 50 / 9). * Dashboard: rebuild the developer console as a 10-page React SPA Replaces the 520-line single-file admin-stats viewer with a real tenant-scoped console. Stack per ADR-0002: Vite 7 + React 19 + TypeScript strict + React Router 7 + TanStack Query 5 + Tailwind CSS 4 + vitest + RTL + ESLint 9 flat config. Pages (under /dashboard, basename-routed) - /login — email + password, redirects to where the user came from on success - /signup — 12+ char password policy mirrored from the API; first API key revealed once with a confirmation gate before navigation - /overview — counts, recent verifications, recent audit, usage-this-month with quota bar, getting-started checklist, last 25 API calls - /api-keys — list with scopes/env/last-used, create modal (scope checkboxes, env selector, one-time reveal), revoke confirmation - /users — list with status filter + enroll modal - /devices — list with status filter + register modal (battery 0–100 validation) - /verifications — read-only, filter by method + result - /attendance — read-only, filter by type + result - /audit — append-only feed with action substring + status filter - /settings — account info, plan + limits, danger zone stub (email security@zeroauth.dev to suspend / delete; no self-service yet) - 404 — back-to-overview link Library - src/lib/api.ts — typed fetch wrapper. JWT in localStorage, attached as Bearer on every authed request. 401 from /api/console/* purges the token so the next render bounces to /login. - src/lib/auth.tsx — AuthProvider, useAuth, status machine (loading | authenticated | unauthenticated) - src/lib/format.ts — number/relative/datetime/ms/truncate helpers - src/lib/cn.ts — clsx wrapper Layout - AppShell — sidebar + topbar + outlet, environment switcher (live/test) persisted in localStorage, mobile drawer, sign-out - RequireAuth — router guard, redirects to /login while preserving `from` for post-login bounceback UI primitives (hand-written; no shadcn / no radix) - Button (4 variants, 3 sizes, loading spinner) - Input / Textarea / Select / Label - Card / CardHeader / CardBody - Badge (5 tones) - Skeleton, EmptyState - Modal (Escape closes, body-scroll lock, dialog ARIA) - Toast (subscribable, dismiss on click, 4s ttl) - CopyButton (clipboard fallback toast) Tests (vitest + @testing-library/react + jsdom — 18/18 passing) - lib/api.test.ts (5) — Bearer attach, no-auth on signup/ login, ApiError shape, 401 purges token, query serialisation - lib/format.test.ts (5) — number/compact/ms/relative/truncate - components/ui/Button.test — click, disabled-while-loading, variant classes - components/ui/Modal.test — open/close, Escape, ARIA role - routes/public/Login.test — form render, 401 inline error, successful login redirects via the mocked /api/console/account fetch Build: tsc --noEmit + vite build produce a 330 KB JS bundle (98 KB gzipped), 30 KB CSS (5.75 KB gzipped). Source maps emitted. Old files removed: src/App.tsx (520 lines), src/hooks/*, vite-env.d.ts. * CI: gate dev branch + run dashboard typecheck/lint/test + dep-trail ci.yml now triggers on push to main AND dev, so the working branch gets the same gating as production. Adds three dashboard checks (typecheck, lint, test) plus an advisory dep-trail audit so DP6 violations show up on every PR. PRs from dev → main continue to fire via `pull_request:`, so we get two gates: one on every dev push, one when the PR opens. * Playwright E2E: signup → key → device → audit happy path Adds the first end-to-end test exercising the dashboard against a real Express + Postgres backend, plus the CI plumbing to run it on every PR / push to main and dev. ADR-0003 documents the adoption choice (Playwright over Cypress / Selenium / no-E2E), the operational expectations, and the rationale for chromium-only at this stage. Test (dashboard/e2e/happy-path.spec.ts) - /dashboard/signup → fill 12-char password + company → submit - Assert the one-time API key reveal modal contains a za_(live|test)_<48 hex> string - Tick the "I've saved this key" confirmation → continue to Overview - Assert sidebar reflects the new tenant identity - Navigate to API Keys → assert the default key row is present - Mint a second key (test env) → confirm + dismiss reveal modal - Assert the new key row shows the test badge - Switch env switcher to "test" - Navigate to Devices → register a device with battery=87 → assert toast + row appear - Navigate to Audit → toggle env to verify tenant.created (live) and device.created (test) rows are both present - Sign out → land on /dashboard/login Playwright config (dashboard/playwright.config.ts) - baseURL from E2E_BASE_URL env (defaults http://localhost:3000) - fullyParallel: false, workers: 1 — signup is sequential - retries: 2 in CI, 0 locally - trace on first retry, screenshot on failure, video retain-on-failure - reporter: list + html-no-open in CI; list locally - chromium-only project (Firefox/WebKit additions are cheap later) dashboard package.json - new scripts: e2e, e2e:install (--with-deps chromium), e2e:ui CI (.github/workflows/ci.yml) - New `e2e` job (`needs: validate`) so it only runs after the existing lint + typecheck + tests + build pass - Postgres 16 service container (zeroauth_e2e DB), 5432 → 5432 - Env: NODE_ENV=production, ENABLE_DEMO_AUTH=false, mocked secrets, POSTGRES_* pointing at the service container, E2E_BASE_URL=http://localhost:3000 - Steps: install root + dashboard + website deps → build:all → cache + install chromium → start `node dist/server.js` in background → wait for /api/health → run `npm --prefix dashboard run e2e` → kill the server in `if: always` - Uploads server.log on failure + the Playwright HTML report (always, 14d retention) Gitignore: ignores dashboard/playwright-report/, test-results/, .playwright/ so traces + report artifacts stay out of git. Local DX: `./scripts/deploy.sh dev` (postgres + redis + app on :3000), then `cd dashboard && npm run e2e`. UI mode for stepping through failures: `npm --prefix dashboard run e2e:ui`. Backend was already verified clean (64 tests across 10 suites); dashboard unit suite (18 tests) is unchanged. CI on push will be the source of truth for the E2E result on this commit. * Fix: vitest must ignore Playwright specs in dashboard/e2e/ The previous commit added dashboard/e2e/happy-path.spec.ts but didn't narrow vitest's default `**/*.{test,spec}.?(c|m)[jt]s?(x)` discovery, so vitest tried to import the Playwright spec — which uses a different test/expect API — and the dashboard test step failed in CI. vite.config.ts now sets explicit include/exclude on the test config: - include: src/**/*.{test,spec}.{ts,tsx} - exclude: e2e/, playwright-report/, test-results/ (plus node_modules/dist) - coverage.exclude mirrors the same e2e/ ignore Local re-run: 18/18 vitest tests pass. The Playwright spec is still listed by `npx playwright test --list` and is exercised by the new `e2e` CI job, just not by vitest. * Fix: rename root tsconfig moduleResolution: node → node10 The implicit "node" value normalises to "node10" internally, but TS 6.x treats that as a hard error TS5107 ("deprecated and will stop functioning in TypeScript 7.0"). The e2e job's runner picked up TS 6.x via npm's resolution cache while the validate job, on the same commit, got TS 5.9 and passed. Pinning the explicit non-deprecated name "node10" gives the same behaviour in TS 5.x AND TS 6.x. Local verify: tsc --noEmit clean, build:all clean (backend + dashboard + docs). * Bump root tsconfig to module + moduleResolution = Node16 The previous fix to "node10" was still flagged as deprecated in whatever TypeScript the CI e2e job is resolving. Node16 is the unambiguous non-deprecated value supported in TS 5.x and 6.x. module must be paired with moduleResolution per TS rules — both flipped to "Node16". Local tsc --noEmit clean, npm run build:all clean, 64/64 backend tests pass. The runtime emit stays effectively CommonJS because package.json has no `"type": "module"`, so no import sites need .js extensions added. * Restore commonjs/node resolution + silence the node10 deprecation Reverts the Node16 attempt which broke @types/* discovery — Node16 resolution doesn't auto-pick up types from node_modules/@types the same way the node resolver does, so the backend lost @types/uuid, @types/pg, @types/jsonwebtoken, @types/express. Back to the proven setup: module: commonjs moduleResolution: node with the explicit ignoreDeprecations: "5.0" flag so the TS5107 deprecation message ("Option 'moduleResolution=node10' is deprecated") doesn't fail the build. The flag is a no-op on older TS, and stays green until we migrate to Node16 + explicit @types listing in some later, dedicated PR. Local tsc --noEmit + build:all both clean. * Diagnose TypeScript resolution mismatch between validate + e2e Both CI jobs run the same `npm run build:all` against the same lockfile, but validate consistently passes and e2e consistently fails with TS5107 demanding `ignoreDeprecations: "6.0"` instead of the "5.0" my locked TS 5.9.3 expects. The lockfile pins TS to 5.9.3 in exactly one place, so npm ci should produce the same node_modules/typescript across both jobs. Adds a diagnostic step before "Build everything" that prints: - which tsc + npx tsc --version - node_modules/typescript/package.json version - TS api version so the next run gives us the actual installed version. Also drops `typeRoots` from tsconfig — the default behaviour (auto- include @types/* from node_modules/@types) is what we want, and the explicit typeRoots may have been masking a different resolution quirk in some TS versions. * Root-cause: e2e CI job-level NODE_ENV=production skipped devDeps The e2e job set NODE_ENV=production as a job-level env var so the backend would behave like prod (demo-auth gate firing, etc.). That also made every preceding `npm ci` skip devDependencies, including typescript, vitest, eslint, @types/*, vite, etc. Then `npm run build` couldn't find a local tsc and resolved /usr/local/bin/tsc on the runner — which turned out to be the bogus `tsc@2.0.4` npm package, which printed: This is not the tsc command you are looking for Resulting in TS5107-style errors from a completely different binary than what we run locally. That explains why validate (no NODE_ENV) succeeded with TS 5.9.3 while e2e (NODE_ENV=production) "failed with TS 6.x" — there was no TS 6.x, the runner was running an entirely different impostor. Fix: move NODE_ENV + every runtime secret to the "Start backend" step only. Install / build steps run with the default ubuntu-latest env so devDependencies install normally. Side cleanups: - removed the temporary "Diagnose TypeScript resolution" CI step (its purpose served — caught the impostor tsc) - reverted tsconfig.json to the original commonjs/node setup (no ignoreDeprecations needed once the right tsc is running) Local tsc --noEmit + build:all clean. * Playwright: tighten env-badge locator + simplify audit assertions The CI showed the test getting all the way through signup → first- key reveal → mint a second key → reveal → list. It then failed at the env-badge in-row check because getByText('test') ambiguously matched BOTH the za_test_<hex> prefix cell AND the badge span in strict mode. Fixes: - env badge: scope to span inside the row + exact regex match - audit log: simplify to a single "test env shows device.created" assertion with a 15s timeout, since recordAuditEvent is fire-and- forget and the live/test env-switching dance was racy. Local typecheck + lint pass.
1 parent 69fd27e commit 125f706

46 files changed

Lines changed: 8652 additions & 1207 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
push:
66
branches:
77
- main
8+
- dev
89
workflow_dispatch:
910

1011
concurrency:
@@ -39,14 +40,157 @@ jobs:
3940
- name: Install website dependencies
4041
run: npm --prefix website ci
4142

42-
- name: Type check
43+
- name: Type check (backend)
4344
run: npx tsc --noEmit
4445

45-
- name: Lint
46+
- name: Lint (backend)
4647
run: npm run lint --if-present
4748

48-
- name: Run tests
49+
- name: Run tests (backend)
4950
run: npm test
5051

52+
- name: Type check (dashboard)
53+
run: npm --prefix dashboard run typecheck
54+
55+
- name: Lint (dashboard)
56+
run: npm --prefix dashboard run lint
57+
58+
- name: Run tests (dashboard)
59+
run: npm --prefix dashboard test
60+
61+
- name: Dep-trail audit (DP6 advisory)
62+
run: ./scripts/check-dep-trail.sh advisory
63+
5164
- name: Build backend, dashboard, and docs
5265
run: npm run build:all
66+
67+
e2e:
68+
name: Playwright (dashboard happy path)
69+
runs-on: ubuntu-latest
70+
needs: validate
71+
timeout-minutes: 20
72+
73+
services:
74+
postgres:
75+
image: postgres:16-alpine
76+
env:
77+
POSTGRES_DB: zeroauth_e2e
78+
POSTGRES_USER: zeroauth
79+
POSTGRES_PASSWORD: zeroauth-e2e
80+
ports:
81+
- 5432:5432
82+
options: >-
83+
--health-cmd "pg_isready -U zeroauth -d zeroauth_e2e"
84+
--health-interval 10s
85+
--health-timeout 5s
86+
--health-retries 10
87+
88+
# NOTE: NODE_ENV is deliberately NOT set at the job level — setting
89+
# NODE_ENV=production makes `npm ci` skip devDependencies (typescript,
90+
# vitest, eslint, etc.), and the build then can't find tsc. We pass
91+
# the server-runtime env only in the "Start backend" step below.
92+
env:
93+
E2E_BASE_URL: http://localhost:3000
94+
95+
steps:
96+
- name: Check out repository
97+
uses: actions/checkout@v4
98+
99+
- name: Set up Node.js
100+
uses: actions/setup-node@v4
101+
with:
102+
node-version: 20
103+
cache: npm
104+
cache-dependency-path: |
105+
package-lock.json
106+
dashboard/package-lock.json
107+
website/package-lock.json
108+
109+
- name: Install root dependencies
110+
run: npm ci
111+
112+
- name: Install dashboard dependencies
113+
run: npm --prefix dashboard ci
114+
115+
- name: Install website dependencies
116+
run: npm --prefix website ci
117+
118+
- name: Build everything
119+
run: npm run build:all
120+
121+
- name: Cache Playwright browsers
122+
uses: actions/cache@v4
123+
with:
124+
path: ~/.cache/ms-playwright
125+
key: ${{ runner.os }}-playwright-${{ hashFiles('dashboard/package-lock.json') }}
126+
127+
- name: Install Playwright browser (chromium)
128+
run: npm --prefix dashboard run e2e:install
129+
130+
- name: Start backend (background)
131+
env:
132+
NODE_ENV: production
133+
PORT: 3000
134+
API_BASE_URL: http://localhost:3000
135+
CORS_ORIGINS: http://localhost:3000
136+
TRUST_PROXY: 'false'
137+
JWT_SECRET: ci-e2e-jwt-secret-not-used-in-production-environments-only
138+
SESSION_SECRET: ci-e2e-session-secret-not-used-in-production-environments-only
139+
ADMIN_API_KEY: ci-e2e-admin-key
140+
ENABLE_DEMO_AUTH: 'false'
141+
LOG_LEVEL: warn
142+
BLOCKCHAIN_RPC_URL: https://sepolia.base.org
143+
BLOCKCHAIN_CHAIN_ID: '84532'
144+
BLOCKCHAIN_PRIVATE_KEY: ''
145+
DID_REGISTRY_ADDRESS: ''
146+
VERIFIER_CONTRACT_ADDRESS: ''
147+
VERIFY_ON_CHAIN: 'false'
148+
ZKP_WASM_PATH: circuits/build/identity_proof_js/identity_proof.wasm
149+
ZKP_ZKEY_PATH: circuits/build/circuit_final.zkey
150+
ZKP_VKEY_PATH: circuits/build/verification_key.json
151+
USE_REDIS_SESSIONS: 'false'
152+
REDIS_URL: redis://localhost:6379
153+
POSTGRES_HOST: localhost
154+
POSTGRES_PORT: '5432'
155+
POSTGRES_DB: zeroauth_e2e
156+
POSTGRES_USER: zeroauth
157+
POSTGRES_PASSWORD: zeroauth-e2e
158+
run: |
159+
node dist/server.js > /tmp/server.log 2>&1 &
160+
echo $! > /tmp/server.pid
161+
# Wait for /api/health to respond
162+
for i in $(seq 1 60); do
163+
if curl -sf http://localhost:3000/api/health > /dev/null; then
164+
echo "server up after ${i}s"
165+
break
166+
fi
167+
sleep 1
168+
done
169+
curl -sf http://localhost:3000/api/health || (cat /tmp/server.log && exit 1)
170+
171+
- name: Run Playwright tests
172+
run: npm --prefix dashboard run e2e
173+
174+
- name: Stop backend
175+
if: always()
176+
run: |
177+
if [ -f /tmp/server.pid ]; then
178+
kill "$(cat /tmp/server.pid)" 2>/dev/null || true
179+
fi
180+
181+
- name: Upload server log on failure
182+
if: failure()
183+
uses: actions/upload-artifact@v4
184+
with:
185+
name: server-log
186+
path: /tmp/server.log
187+
if-no-files-found: ignore
188+
189+
- name: Upload Playwright report
190+
if: always()
191+
uses: actions/upload-artifact@v4
192+
with:
193+
name: playwright-report
194+
path: dashboard/playwright-report
195+
retention-days: 14
196+
if-no-files-found: ignore

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ coverage/
3232
*.log
3333
docs/missfont.log
3434

35+
# ─── Playwright ─────────────────────────────────────────
36+
dashboard/playwright-report/
37+
dashboard/test-results/
38+
dashboard/.playwright/
39+
3540
# ─── OS / editor ────────────────────────────────────────
3641
.DS_Store
3742
.vscode/

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ Live production: <https://zeroauth.dev>. VPS at `104.207.143.14` under user `zer
4545
- **Error handling:** Routes return JSON `{ error: '<machine_code>', message: '<human>' }` with appropriate HTTP status. Sensitive details (DB errors, internal trace) stay in Winston, not in the response.
4646
- **Tests:** Jest. Unit + request-level tests in `tests/*.test.ts`. Currently 50/50 passing. Every new endpoint adds a request-level test before merge.
4747
- **Smart contracts:** Solidity 0.8 via Hardhat; deployed to Base Sepolia (chain 84532).
48-
- **Frontend:** React 19 + Vite for the dashboard; Docusaurus 3 for the docs site.
48+
- **Frontend (developer console / dashboard):** React 19 + Vite 7 + TypeScript strict. Routing via `react-router-dom`. Server state via `@tanstack/react-query`. Styling via Tailwind CSS + a small set of hand-written primitives (`Button`, `Input`, `Card`, `Table`, `Badge`, `Modal`, `Toast`). Unit tests with vitest + @testing-library/react. Lives in `dashboard/`, served as static files by Express at `/dashboard`. The suite's `dashboard_CLAUDE.md` calls for Next.js 15 — see [adr/0002-dashboard-stack-vite-not-nextjs.md](adr/0002-dashboard-stack-vite-not-nextjs.md) for why we deferred that migration.
49+
- **Frontend (marketing site):** Docusaurus 3 for the docs site at `/docs`; vanilla HTML/CSS for the landing page at `/`.
4950
- **Commits:** Plain English subject + body explaining "why". Conventional Commits not enforced.
5051

5152
## Critical language rules (enforce in PR review)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# ADR-0002 — Build the developer console as a Vite + React 19 SPA, not Next.js 15
2+
3+
## Status
4+
Accepted
5+
6+
## Context
7+
8+
The prompt suite's `dashboard_CLAUDE.md` calls for a Next.js 15 App-Router dashboard with server components, server actions, middleware-enforced auth, and Tailwind + shadcn/ui. The repo as it exists today ships a tiny Vite + React 18 single-page app at `dashboard/` that is served as static files by the same Express process that hosts the API.
9+
10+
We need to deliver a high-quality developer console covering every endpoint the central API exposes — overview, API keys, users, devices, verifications, attendance, audit, settings — with unit + integration tests and automated linting. The buyer-facing comparator is Auth0, Clerk, Stytch, WorkOS.
11+
12+
Two paths:
13+
14+
**Path A — adopt the suite's spec literally.** Replace the Vite scaffold with Next.js 15. Migrate the existing Express-mounted dashboard to a separate Next.js server (either co-deployed on the VPS at `:3001` and proxied by Caddy, or replacing the Express static-file mount entirely). Adopt App Router, server components, server actions, middleware, shadcn/ui, React Query.
15+
16+
**Path B — keep Vite, build the same quality bar.** Replace the existing dashboard `App.tsx` with a Vite + React 19 + React Router + React Query + Tailwind app. Tenant scoping is still enforced server-side by Express middleware (`authenticateTenantApiKey`); the SPA only ever reads its own tenant's data via the console JWT. Keep the existing static-file mount so the deploy story is unchanged.
17+
18+
## Decision
19+
20+
**Path B.** Stick with Vite + React 19. Adopt React Router, React Query, Tailwind CSS, vitest + React Testing Library, ESLint 9. Build the entire console surface there.
21+
22+
The suite's `dashboard_CLAUDE.md` is reconciled to match this choice in `CLAUDE.md` at the repo root: the path-mention "Next.js App Router under `/app/`" is replaced with "React Router under `dashboard/src/routes/`", and the `/api/console/*` proxy that Next.js would do via server components is replaced with direct `fetch()` from the SPA to the same Express endpoints with the console JWT in the `Authorization` header.
23+
24+
## Consequences
25+
26+
- **Positive — speed.** The Vite build pipeline already works, deploys via the existing Dockerfile, and lives behind the same Caddy reverse proxy. Adding routing + data fetching + Tailwind is a one-day change; migrating to Next.js is a multi-day change with new build pipeline, new deploy story, new auth-middleware location, and a re-think of how Express co-exists with the Next.js server. DP8 (the 60-day clock) penalises the migration.
27+
- **Positive — single auth layer.** All authorization lives in `src/middleware/tenant-auth.ts` and the console JWT verifier in `src/routes/console.ts`. The dashboard never holds private logic; it sends bearer tokens to the same API every external SDK uses. This matches the suite's standing instruction #1 ("The dashboard reads; it does not own data").
28+
- **Positive — testability.** Vitest + React Testing Library + jsdom is the standard stack for Vite SPAs. Console-API integration tests stay in the root Jest suite using supertest against `createApp()`.
29+
- **Negative — no server components.** Tenant-scoped data goes over the wire to the client, decrypted by the same JWT the client uses. We mitigate by (a) refusing to render anything before the JWT is verified by the API on the first `/api/console/account` call, and (b) keeping every API call tenant-scoped on the server.
30+
- **Negative — initial paint shows the login skeleton briefly.** A Next.js server-component flow could redirect to `/login` before any HTML hits the wire. The SPA momentarily shows an empty layout before deciding to render `<Login />` vs `<App />`. This is a UX cosmetic issue, not a security issue — no tenant data is ever exposed before auth.
31+
- **Negative — owe an exit path.** If the dashboard needs SSR for SEO or first-paint reasons later, we have to migrate. We mark the deferral here so we can revisit during Vanguard-tier hardening.
32+
- **Neutral — no shadcn/ui.** Replaced with Tailwind + a small set of hand-written primitives (`Button`, `Input`, `Card`, `Table`, `Badge`, `Modal`, `Toast`). Total UI primitive surface is ~300 lines; the radix-ui transitive footprint is avoided. If shadcn/ui adoption becomes worth it later, the primitives are a drop-in replacement.
33+
34+
## Alternatives considered (and rejected)
35+
36+
- **Next.js 15 migration today.** Rejected per DP8. Reopen post-Vanguard if SSR becomes load-bearing for buyer demos.
37+
- **Add server-side rendering via Vite SSR.** Considered. The complexity-to-value ratio is poor for a tenant-private dashboard that's not crawled by search engines.
38+
- **Stay on the existing tiny App.tsx and extend it inline.** Rejected. The file is already 520 lines of inline styles; a real console needs routing, data caching, forms with validation, and a primitive UI layer. Going wider on the same file gets us a 5,000-line `App.tsx` that nobody can review.
39+
40+
## Tooling adopted (one batch — see "Consequences" for justification of each)
41+
42+
| Dependency | Workspace | Purpose |
43+
|---|---|---|
44+
| `react-router-dom` | dashboard | Client-side routing |
45+
| `@tanstack/react-query` | dashboard | Server-state caching + dedupe + invalidation |
46+
| `tailwindcss` | dashboard | Utility-first styling |
47+
| `@tailwindcss/vite` | dashboard | Tailwind v4 Vite plugin (replaces postcss/autoprefixer in v4) |
48+
| `@vitest/coverage-v8` | dashboard | V8 coverage reporter for vitest |
49+
| `clsx` | dashboard | Class-name helper for conditional Tailwind |
50+
| `vitest` | dashboard | Unit test runner (matches Vite) |
51+
| `@testing-library/react` | dashboard | Component testing |
52+
| `@testing-library/user-event` | dashboard | User-interaction simulation |
53+
| `@testing-library/jest-dom` | dashboard | DOM matchers |
54+
| `jsdom` | dashboard | DOM for vitest |
55+
| `eslint` | dashboard | Lint, sharing flat-config style with root |
56+
| `eslint-plugin-react-hooks` | dashboard | React-specific lint rules |
57+
| `eslint-plugin-react-refresh` | dashboard | Vite Fast Refresh hygiene |
58+
59+
Supply-chain audit on all the above: `npm audit --omit=dev` is run against the dashboard workspace after install; no high/critical findings in this set as of 2026-05-12.
60+
61+
License survey: all MIT or Apache-2.0.
62+
63+
## Migration plan (in this commit chain)
64+
65+
1. This ADR.
66+
2. Reconcile `CLAUDE.md` at the repo root: dashboard stack section now reads "Vite + React 19 + React Router + React Query + Tailwind + vitest + RTL + ESLint 9".
67+
3. `dashboard/package.json` upgrades: React 18 → 19, Vite 5 → 7, plus the new deps above.
68+
4. Restructure `dashboard/src/` into routes, layout, components, lib, hooks.
69+
5. Reauthor `App.tsx` as a router root. Keep zero behavior from the old single-page admin viewer; that view migrates to the new `/admin` page.
70+
6. Add `vitest.config.ts`, `tailwind.config.ts`, `postcss.config.js`, `eslint.config.js` to dashboard workspace.
71+
7. Build the eight console pages (login, signup, overview, api keys, users, devices, verifications, attendance, audit, settings).
72+
8. Unit tests for primitives + page-level happy paths. Integration tests in root Jest suite for the console JWT flow + tenant scoping.
73+
9. Wire dashboard `npm run lint` + `npm run typecheck` + `npm test` into root CI.
74+
75+
## References
76+
77+
- Suite spec: `zeroauth_prompt_suite/04_development_suite/02_claude_code_dev/CLAUDE_md/dashboard_CLAUDE.md` (Next.js path)
78+
- Suite build prompt: `zeroauth_prompt_suite/04_development_suite/02_claude_code_dev/build_prompts/B05_dashboard_bootstrap.md`
79+
- ADR-0000 grandfather list (current dashboard deps)
80+
- Auth0, Clerk, Stytch, WorkOS — buyer-facing comparators for console UX
81+
82+
---
83+
LAST_UPDATED: 2026-05-12
84+
OWNER: Pulkit Pareek
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# ADR-0003 — Adopt Playwright for end-to-end dashboard testing
2+
3+
## Status
4+
Accepted
5+
6+
## Context
7+
8+
ADR-0002 set up the dashboard SPA with vitest + @testing-library/react for unit and component coverage. The suite's `dashboard_CLAUDE.md` and B05 (dashboard bootstrap) call for Playwright on top of that for end-to-end coverage — exercising the full stack against a real browser and a real backend.
9+
10+
Unit + component tests already cover the API client, the format helpers, the primitives, and the Login flow with mocked `fetch`. Those tests run in 5 seconds and catch most regressions. They do **not** catch:
11+
12+
- the dashboard against the real Express server (CORS, helmet headers, the `/dashboard` base path)
13+
- the SPA + console JWT flow against a real Postgres
14+
- a cross-tenant query bug that survives mocking
15+
- a Tailwind class that lints clean but renders wrong
16+
- a router redirect loop introduced by `RequireAuth`
17+
18+
These gaps are exactly what Playwright is meant for.
19+
20+
## Decision
21+
22+
Adopt `@playwright/test` (single dev dependency) for the dashboard workspace. Write one E2E happy-path spec to start: signup → first-key reveal → mint a second key → register a device → see the audit events.
23+
24+
Scope expands over time, but always inside `dashboard/e2e/*.spec.ts`. Edge cases continue to live in vitest + supertest — Playwright is reserved for the journeys that prove the full stack works end-to-end.
25+
26+
## Consequences
27+
28+
- **Positive — flush detection of stack-level bugs.** Anything spanning React → fetch → Express → Postgres → audit row is now covered by one test that runs in CI on every PR.
29+
- **Positive — the developer experience is real.** `npm --prefix dashboard run e2e:ui` opens the Playwright UI for stepping through a failure. `npm --prefix dashboard run e2e` runs headless against a local stack.
30+
- **Negative — CI gets slower.** Playwright adds ~3-5 minutes (browser install + boot time). We mitigate by caching `~/.cache/ms-playwright` keyed on the `@playwright/test` lockfile entry.
31+
- **Negative — flakiness risk.** E2E specs are inherently flakier than unit tests. We mitigate with: `fullyParallel: false`, `workers: 1` in CI, two retries, `trace: 'on-first-retry'`, `screenshot: 'only-on-failure'`. If a spec is consistently flaky, the right answer is to delete + replace with a tighter test — not to disable.
32+
- **Negative — CI needs a real Postgres.** We use GitHub Actions' `services.postgres:` container so the runner brings up an ephemeral 5432 with a fresh DB. No external infra.
33+
- **Neutral — only Chromium.** Tested browsers can grow to Firefox + WebKit later. For the buyer profile (BFSI compliance teams on Windows + Chrome) Chromium covers the highest-value surface today.
34+
35+
## Alternatives considered
36+
37+
- **Cypress.** Decent DX, but the architecture (test runner runs inside the browser) makes interception of multi-tab flows or auth-stateful sessions awkward. Playwright is the modern default.
38+
- **Selenium / WebdriverIO.** Heavier setup, more flakiness, no per-step trace viewer. Rejected.
39+
- **Skip E2E entirely.** The vitest+supertest gates catch most regressions, but the buyer comparator (Auth0, Clerk, Stytch) all ship with E2E coverage for the signup flow. A dashboard that silently breaks at signup is unacceptable.
40+
41+
## Supply chain
42+
43+
- `@playwright/test@^1.60.0` — MIT, maintained by Microsoft, weekly releases, no `npm audit` advisories at this version.
44+
- Bundled browser binaries (Chromium, Firefox, WebKit) installed via `playwright install`. We pin `chromium` only.
45+
46+
## Operational notes
47+
48+
- Local DX: `./scripts/deploy.sh dev` brings up Postgres + Redis + the app on `localhost:3000`. Then `cd dashboard && npm run e2e`.
49+
- CI DX: workflow brings up a Postgres service container, builds the full stack, starts `node dist/server.js` in the background, then runs `npm --prefix dashboard run e2e`.
50+
- The happy path spec creates a tenant whose email is `playwright+<timestamp>-<rand>@example.com` — recognizable for cleanup. In CI it doesn't matter (ephemeral DB). Locally, run:
51+
52+
```sql
53+
DELETE FROM tenants WHERE email LIKE 'playwright+%@example.com';
54+
```
55+
56+
## References
57+
58+
- Suite spec: `zeroauth_prompt_suite/04_development_suite/02_claude_code_dev/CLAUDE_md/dashboard_CLAUDE.md` ("All E2E tests pass (Playwright)")
59+
- B05 build prompt's quality bar
60+
- ADR-0002 (dashboard stack)
61+
62+
---
63+
LAST_UPDATED: 2026-05-12
64+
OWNER: Pulkit Pareek

0 commit comments

Comments
 (0)