Summary
Build the My Account SPA for real in source/account-dashboard-app, composing the published @wardnet/ui components, wired to the wardnet-cloud account API. Built MSW-first so it is fully developable with zero backend, then pointed at the real endpoints via a Vite dev proxy.
This is PR4 of the My Account initiative. It can be developed and merged in parallel with the backend PRs because it builds against the contract below via MSW (the contract is the shared interface). Refines #1.
Design reference (prototype)
High-fidelity prototype + spec, committed to the repo:
Recreate it with real framework/routing/data — compose @wardnet/ui, do not re-implement components. Where the prototype diverges from the backend, follow the reconciliation below.
Stack
source/account-dashboard-app, yarn 4.17, Vite + React 19 + TypeScript + react-router.
@wardnet/ui (+ @wardnet/styles, @wardnet/brand) from GitHub Packages (@wardnet:registry=https://npm.pkg.github.com). Import @wardnet/ui/styles.css + @wardnet/styles theme/typography. Components are exported from @wardnet/ui (Card/CardHeader/Title/Subtitle/Action/Content, Button, Text, Heading, Field, Input, Banner, Pill, StatTile, Modal*, AlertModal*, DropdownMenu*, Logo, …) — see the DS src/index.ts.
- Theming:
data-theme="dark" on <html> (so Radix portals inherit); sun/moon ghost toggle. Light default.
@wardnet/ui access (dev + CI)
- Local install: run
gh auth refresh -s read:packages once, then the .npmrc/.yarnrc.yml reads the token from env (never commit a token).
- CI: authenticate with the built-in
GITHUB_TOKEN + permissions: { contents: read, packages: read } — no PAT.
- Live DS co-dev (no release cycles): opt-in
yarn link to the sibling wardnet-design-system checkout (built with --watch); not committed → CI unaffected.
Routing (prototype state machine → routes)
- Auth (unauthenticated):
/signin, /register, /forgot, /confirm (6-box code; flow=signup|reset), /set-password.
- Account (authenticated, app shell with top bar):
/overview, /subscription, /security. Account menu + (dev-only) Demo switcher + theme toggle in the top bar.
- The Demo dropdown is a reviewer/dev affordance only — drives the MSW state in dev; removed in production builds.
Auth wiring
- Session is an httpOnly cookie the SPA cannot read. On load/resume call
POST /v1/auth/token (silent exchange) → 5-min USER JWT held in memory; attach as Authorization: Bearer to USER calls. 401 → treat as logged-out → /signin.
- OIDC: buttons navigate to
GET /v1/auth/oidc/{google|github}/start; callback returns to the app.
- Vite dev proxy
/v1 → backend so the SPA and API are same-origin (httpOnly cookie works; mirrors nginx in prod).
Endpoint contract (what the SPA calls)
| Screen / action |
Method + path |
Notes |
| Silent JWT |
POST /v1/auth/token |
session cookie → 5-min JWT |
| Sign in |
POST /v1/auth/password/login {email,password} |
sets session cookie |
| Sign up |
POST /v1/verification-codes {email,purpose:"signup"} → /confirm → POST /v1/auth/password/signup {email,code,password} |
PR3 |
| Forgot/reset |
POST /v1/verification-codes {email,purpose:"password_reset"} → /confirm → POST /v1/auth/password/reset {code,new_password} |
reset revokes all sessions → back to sign-in w/ banner |
| OIDC |
GET /v1/auth/oidc/{provider}/start |
redirect |
| Logout |
POST /v1/auth/logout |
current session |
| Identity bootstrap |
GET /v1/me |
{tenant_id,email,subscription} |
| Networks |
GET /v1/tenants/{id}/networks |
list |
| Devices/daemons |
GET /v1/tenants/{id}/daemons (+ GET /v1/networks/{id}/daemons) |
per-network device count = daemons grouped by network_id |
| Add payment / renew / reactivate |
POST /v1/tenants/{id}/billing/checkout-session {price_id} → redirect to url |
hosted Checkout |
| Update payment / manage |
POST /v1/tenants/{id}/billing/portal → redirect to url |
hosted Portal |
| Payment-method card |
GET /v1/tenants/{id}/billing/payment-method |
PR2; `{brand,last4,exp_*} |
| Invoice table |
GET /v1/tenants/{id}/billing/invoices |
PR2; download = hosted_url |
| Cancel subscription |
PATCH /v1/tenants/{id} {subscription_status:"canceled"} |
AlertModal → cancel at period end |
| Deregister account |
DELETE /v1/tenants/{id} |
(if surfaced) |
| Connected methods |
GET /v1/me/identities; link via GET /v1/auth/oidc/{provider}/start?mode=link; DELETE /v1/me/identities/{provider} |
PR3; ≥1 remains |
| Change password |
code-exchange (see Forgot/reset) |
PR3 |
| Sign out of all |
DELETE /v1/me/sessions |
PR3 |
Reconciliation vs the prototype (important)
- No in-app card Modal. Replace the prototype's Stripe-fields modal with a redirect to hosted Checkout (add/renew/reactivate) / Portal (update). PCI SAQ-A.
- Status mapping:
trialing→Trial, active→Active, past_due→Grace, canceled→Cancelled. Derive every pill/CTA/banner/blocked-action from subscription status (the prototype's "subscription state is the spine").
- Entitlement:
subscription.entitlement.{max_networks,max_daemons}; usage = networks count + daemons count. Warn tone near a limit.
- Network status:
provisioning_state → active=Online (ok), provisioning=Provisioning (info), deprovisioning=Deprovisioning (down). No "Degraded" health signal exists — drop it.
- Reactivate = a fresh Checkout (no dedicated endpoint).
MSW (local mock layer)
- Implement handlers for every endpoint above, returning the contract shapes.
- Driven by the prototype's Demo switcher: subscription state (
trial|active|grace|cancelled) and data state (ready|loading|error|empty). Sample data from the spec (Pedro, Pro $8/mo, networks 2/3, devices 24/25, Visa •••• 4242). Demo code 424242 for the confirm screen.
- MSW is the inner dev loop (no backend) and is reused in component/integration tests. A build/env flag switches between MSW and the real
/v1 dev proxy.
Component map (prototype → @wardnet/ui)
- AuthShell: centered
Logo + Card(padding 26), theme FAB, OIDC ghost Buttons + "or" divider, Field+Input, one emerald primary Button/view.
- Confirm: segmented 6-box code input (digits, auto-advance, backspace-prev, arrows, paste-fills-all, autofocus) — custom inputs styled with tokens;
Banner for notes; inline danger Text.
- Overview: status
Card (accent stripe + Pill + state CTA), two StatTiles (usage bars, warn near limit), networks list Card.
- Subscription: Plan
Card + Entitlements Card (equal height), grace Banner, lifecycle Card (state-specific), payment-method Card, billing-history table, AlertModal for cancel.
- Security: connected-methods rows, change-password
Fields, sessions buttons.
- Shell: sticky top bar (Logo, tab nav w/ emerald underline, Demo
DropdownMenu, theme toggle, account DropdownMenu). Loading=shimmer skeletons, error=card+Retry, empty states.
Acceptance criteria
References
wardnet-design-system packages/ui (src/index.ts), packages/styles.
- Backend contract: PR1 (refactor) / PR2 (billing reads) / PR3 (account+security) of this initiative.
Summary
Build the My Account SPA for real in
source/account-dashboard-app, composing the published@wardnet/uicomponents, wired to the wardnet-cloud account API. Built MSW-first so it is fully developable with zero backend, then pointed at the real endpoints via a Vite dev proxy.This is PR4 of the My Account initiative. It can be developed and merged in parallel with the backend PRs because it builds against the contract below via MSW (the contract is the shared interface). Refines #1.
Design reference (prototype)
High-fidelity prototype + spec, committed to the repo:
Recreate it with real framework/routing/data — compose
@wardnet/ui, do not re-implement components. Where the prototype diverges from the backend, follow the reconciliation below.Stack
source/account-dashboard-app, yarn 4.17, Vite + React 19 + TypeScript + react-router.@wardnet/ui(+@wardnet/styles,@wardnet/brand) from GitHub Packages (@wardnet:registry=https://npm.pkg.github.com). Import@wardnet/ui/styles.css+@wardnet/stylestheme/typography. Components are exported from@wardnet/ui(Card/CardHeader/Title/Subtitle/Action/Content,Button,Text,Heading,Field,Input,Banner,Pill,StatTile,Modal*,AlertModal*,DropdownMenu*,Logo, …) — see the DSsrc/index.ts.data-theme="dark"on<html>(so Radix portals inherit); sun/moon ghost toggle. Light default.@wardnet/uiaccess (dev + CI)gh auth refresh -s read:packagesonce, then the.npmrc/.yarnrc.ymlreads the token from env (never commit a token).GITHUB_TOKEN+permissions: { contents: read, packages: read }— no PAT.yarn linkto the siblingwardnet-design-systemcheckout (built with--watch); not committed → CI unaffected.Routing (prototype state machine → routes)
/signin,/register,/forgot,/confirm(6-box code; flow=signup|reset),/set-password./overview,/subscription,/security. Account menu + (dev-only) Demo switcher + theme toggle in the top bar.Auth wiring
POST /v1/auth/token(silent exchange) → 5-min USER JWT held in memory; attach asAuthorization: Bearerto USER calls.401→ treat as logged-out →/signin.GET /v1/auth/oidc/{google|github}/start; callback returns to the app./v1→ backend so the SPA and API are same-origin (httpOnly cookie works; mirrors nginx in prod).Endpoint contract (what the SPA calls)
POST /v1/auth/tokenPOST /v1/auth/password/login{email,password}POST /v1/verification-codes {email,purpose:"signup"}→/confirm→POST /v1/auth/password/signup {email,code,password}POST /v1/verification-codes {email,purpose:"password_reset"}→/confirm→POST /v1/auth/password/reset {code,new_password}GET /v1/auth/oidc/{provider}/startPOST /v1/auth/logoutGET /v1/me{tenant_id,email,subscription}GET /v1/tenants/{id}/networksGET /v1/tenants/{id}/daemons(+GET /v1/networks/{id}/daemons)network_idPOST /v1/tenants/{id}/billing/checkout-session {price_id}→ redirect tourlPOST /v1/tenants/{id}/billing/portal→ redirect tourlGET /v1/tenants/{id}/billing/payment-methodGET /v1/tenants/{id}/billing/invoiceshosted_urlPATCH /v1/tenants/{id} {subscription_status:"canceled"}DELETE /v1/tenants/{id}GET /v1/me/identities; link viaGET /v1/auth/oidc/{provider}/start?mode=link;DELETE /v1/me/identities/{provider}DELETE /v1/me/sessionsReconciliation vs the prototype (important)
trialing→Trial,active→Active,past_due→Grace,canceled→Cancelled. Derive every pill/CTA/banner/blocked-action from subscription status (the prototype's "subscription state is the spine").subscription.entitlement.{max_networks,max_daemons}; usage = networks count + daemons count. Warn tone near a limit.provisioning_state→active=Online (ok),provisioning=Provisioning (info),deprovisioning=Deprovisioning (down). No "Degraded" health signal exists — drop it.MSW (local mock layer)
trial|active|grace|cancelled) and data state (ready|loading|error|empty). Sample data from the spec (Pedro, Pro $8/mo, networks 2/3, devices 24/25, Visa •••• 4242). Demo code424242for the confirm screen./v1dev proxy.Component map (prototype →
@wardnet/ui)Logo+Card(padding 26), theme FAB, OIDC ghostButtons + "or" divider,Field+Input, one emerald primaryButton/view.Bannerfor notes; inline dangerText.Card(accent stripe +Pill+ state CTA), twoStatTiles (usage bars, warn near limit), networks listCard.Card+ EntitlementsCard(equal height), graceBanner, lifecycleCard(state-specific), payment-methodCard, billing-history table,AlertModalfor cancel.Fields, sessions buttons.DropdownMenu, theme toggle, accountDropdownMenu). Loading=shimmer skeletons, error=card+Retry, empty states.Acceptance criteria
source/account-dashboard-appscaffolded (yarn 4.17, Vite, React 19, TS, react-router);yarn build+yarn type-checkgreen.@wardnet/uiconsumed from GitHub Packages;.npmrc/.yarnrc.ymltoken via env; CI installs withGITHUB_TOKEN(packages: read).@wardnet/ui; light/dark; matches the prototype's tokens/copy/interactions (no invented colors/type)./v1→backend documented; the SPA runs against a locally-run backend with cookies working.References
wardnet-design-systempackages/ui(src/index.ts),packages/styles.