M13_FRONTEND_SPEC.md 20 KB

M13 — Frontend Admin Spec (v1)

Multi-tenant admin UI for broad-announce. React + Vite SPA, embedded in the admind Go binary via embed.FS. Companion to SPEC.md (the backend), M13_API_CONTRACT.md (the wire), and the per-milestone plans M13a_PLAN.md, M13b_PLAN.md, M13c_PLAN.md.

Status: planning (post-M11, parallel to M12) Target: ship a v1 admin UI that replaces dlq.html and adds the CRUD + live-tail + audit + auth surfaces for super-admin and tenant-admin personas.


0. Recap — what M13 must prove

Surface Acceptance criteria
Auth Super-admin and tenant-admin can log in (username + password, argon2id). Tenant-admins are invited via magic-link (or psql bootstrap if email infra is not yet ready).
Tenants / Companies CRUD Super-admin can create, list, edit, suspend. Tenant-admin sees only their own company.
Sources CRUD Both personas can CRUD sources within their scope. Super-admin sees all, tenant-admin sees only their company's.
Telegram bots Per-company bots: token, invite code generation, chat_id mapping table view.
Live tail SSE feed of incoming alerts on a single page. Filterable by company (super-admin) or scoped to tenant (tenant-admin).
DLQ surface Replaces cmd/admind/ui/dlq.html with the same list/replay/discard flow, plus filters.
Audit log viewer Read-only table of audit_log rows, filterable by actor / action / entity / time.
Single binary admind Go binary embeds the SPA via embed.FS. One build, one deploy. No CORS, no separate frontend server.

What M13 is NOT:

  • Not a customer-facing end-user app (out of scope per survey 1.3).
  • Not a tenant-admin invite flow beyond email/PSQL bootstrap. SSO is post-v1 (survey 2.3).
  • Not a Grafana replacement. Dashboards stay in Grafana (M9). The frontend surfaces the control plane, not observability charts.

1. Personas

1.1 Super-admin (role=super_admin)

  • Scope: every company in the system.
  • Use cases: onboarding a new company, troubleshooting cross-tenant issues, audit review, killing misbehaving sources.
  • Login: username + password. Bootstrap account seeded by env vars at first deploy (BA_AUTH_BOOTSTRAP_ADMIN=...).
  • UI affordances: company switcher in the top bar; all data tables have a "company" column.

1.2 Tenant-admin (role=tenant_admin)

  • Scope: exactly one company (their own). Backend rejects any cross-company request with 403.
  • Use cases: managing their sources, viewing their alerts, configuring their telegram bot, seeing their DLQ.
  • Login: username + password. Invited by super-admin via magic-link.
  • UI affordances: no company switcher; all data tables filtered to their company automatically.

1.3 End-user — NOT v1

Deferred. The 1.3 survey response excluded mobile/PWA. End-user is the recipient of alerts, manages quiet hours. Out of scope.


2. Tenancy & auth

2.1 New service: authd

Decision: cmd/authd/, port 8804, dedicated HTTP service. NOT a module inside admind. Rationale:

  • Clean security boundary — auth holds the JWT secret, never touches business data.
  • Reusable from ingestd, routerd, deliverd-*, gRPC server in the future (M14+). Today only admind and the frontend use it.
  • Independent scaling and deployment.
  • Uses Postgres for users/memberships (same DB, separate schema — see M13_API_CONTRACT.md §1.10 for the schema sketch).

2.2 Auth flow

  1. Login (POST /v1/auth/login): username + password → verify argon2id hash → issue access JWT (15min, HS256) + refresh JWT (7d, opaque, server-side store).
  2. Refresh (POST /v1/auth/refresh): rotate refresh token, return new access + refresh. Old refresh invalidated.
  3. Logout (POST /v1/auth/logout): invalidate refresh token server-side.
  4. Invite (super-admin only, POST /v1/auth/invites): create user row with status=pending, generate magic-link token, return URL. Email send is best-effort: if SMTP env vars are unset, the API returns the URL directly so super-admin can paste it. (This is the "B with fallback to A" answer for survey 2.4.)
  5. Magic-link accept (POST /v1/auth/invites/accept): user submits token + new password → password hashed → user status=active.
  6. Me (GET /v1/auth/me): returns current user with role + scoped company_id. Frontend uses this for routing and nav.

2.3 Session storage

  • Access token (15min): stored in memory by the SPA. Never in localStorage (XSS exposure).
  • Refresh token (7d): stored in httpOnly, Secure, SameSite=Lax cookie. Sent automatically on refresh.
  • Logout: clears the cookie + calls POST /v1/auth/logout to invalidate the server-side row.

2.4 JWT shape

{
  "sub": "user_<uuid>",
  "role": "super_admin" | "tenant_admin",
  "company_id": "acme-001" | null,
  "iat": 1749819000,
  "exp": 1749822600
}
  • HS256, secret from env BA_AUTH_JWT_SECRET (32+ bytes, rotated by supporting both old and new during rotation).
  • company_id is null for super-admin (cross-tenant access).

2.5 Backend enforcement

  • Every admind endpoint checks Authorization: Bearer <jwt>.
  • For tenant-admin: server appends WHERE company_id = $1 from the JWT to every query. No way to bypass from the frontend.
  • Audit log row written for every state-changing call, signed with the actor's user_id.

2.6 SSO — explicitly post-v1

A cmd/authd/sso/ directory will be created (empty in v1) and the /v1/auth/sso/<provider>/login and /v1/auth/sso/<provider>/callback routes will return 501. SSO is added when the first enterprise customer asks for it.


3. v1 modules

3.1 Tenants / Companies CRUD

  • List: table of companies, columns: name, slug, status, created_at, fcm_shared, rate_limit_per_sec, alert_count_24h.
  • Detail: edit form (name, slug, fcm_shared toggle, rate_limit_per_sec, telegram_bot_token_enc, status).
  • Create: form (name, slug auto-derived; auto-checks uniqueness).
  • Suspend/activate: button on detail; sets status=suspended or active. Suspended companies cannot ingest.
  • Tenant-admin view: read-only "Your company" page (no edit, no create).
  • Backend endpoints: GET/POST/PATCH /v1/companies, GET /v1/companies/:id.

3.2 Sources CRUD

  • List: per-company table, columns: name, type, hmac_secret (masked), api_key_hash (masked), rate_limit_per_sec, allowed_ips, status, alerts_24h.
  • Detail: edit form (name, type, hmac_secret, api_key, rate_limit_per_sec, allowed_ips[], topic_prefix, status). The secret field is masked with "show" toggle.
  • Create: same form, generates a fresh hmac_secret and api_key on save and shows them ONCE in a "save these now" modal (same pattern as GitHub PAT).
  • Status: active / quarantined (per layer-7 from M9). Show quarantine reason if quarantined.
  • Backend endpoints: GET/POST/PATCH /v1/sources, GET /v1/sources/:id.

3.3 Telegram bots

  • Per-company bot config: set the bot token (encrypted at rest by admind using a master key from env), view invite codes, list of bound chat_idindividual_id mappings.
  • Generate invite code: button → POST → returns code + t.me/<bot>?start=<code> URL. Status (active / expired / used).
  • List individuals bound: table of individual_id, telegram_user_id, telegram_chat_id, status, last_seen_at.
  • Backend endpoints: GET/PUT /v1/companies/:id/telegram, GET/POST /v1/companies/:id/telegram/invites, GET /v1/companies/:id/telegram/bindings.

3.4 Live tail (SSE)

  • Page: full-width feed of incoming alerts, newest at top. Auto-scrolls; pause-on-scroll-up.
  • Filter bar: company (super-admin only), severity (>= X), category, source, text search on title_key.
  • Row columns: timestamp, severity, company, source, title_key, dedupe_count, actions (drill-in).
  • Click row: modal with full alert payload (data JSON, dedupe history).
  • Backpressure: server keeps last 200 events in memory; new connection catches up. No persistence — this is live, not history.
  • Transport: EventSource('/v1/tail/stream') with Authorization header workaround (EventSource doesn't support headers natively — token passed as query param ?access_token=***, validated once, upgraded to a short-lived SSE token by authd).
  • Backend endpoints: GET /v1/tail/stream (SSE), GET /v1/tail/recent?since=<ts> (HTTP fallback).

3.5 DLQ surface

  • List: table of DLQ rows, columns: id, alert_id, channel, target, attempts, last_error, next_attempt_at, created_at, status (pending / replayed / discarded).
  • Detail: full alert payload + delivery history (each attempt's error, response code, latency).
  • Replay: button → POST → re-publishes to deliverd NATS subject. Confirmation modal showing "this will redeliver to N recipients".
  • Discard: button → POST → marks status=discarded (hidden from default list). Audit row written.
  • Bulk actions (v1.1 — not v1): multi-select replay/discard. v1 is single-row only.
  • Backend endpoints: GET /v1/dlq, GET /v1/dlq/:id, POST /v1/dlq/:id/replay, POST /v1/dlq/:id/discard. Migrates the M8 endpoints that already exist.

3.6 Audit log viewer

  • List: table of audit_log rows, columns: ts, actor (user_id or "system"), action, entity_type, entity_id, ip, user_agent, metadata (collapsed JSON).
  • Filter: actor, action, entity_type, time range.
  • Read-only. No actions from this page (audit is immutable).
  • Backend endpoints: GET /v1/audit?actor=&action=&since=&until=. New endpoint — M8 didn't have it.

3.7 Auth pages

  • Login: username + password → access + refresh.
  • Magic-link accept: token + new password form.
  • Forbidden / 403 page: clean message, link to login.
  • Logout: button in top bar.

3.8 Out of scope for v1 (deferred, recorded so we don't forget)

Survey responses were explicit. Recorded here so a future reader doesn't re-litigate:

Module Why deferred Trigger to revisit
Groups + group members Excluded from survey 1.2 When 2nd customer asks for self-service user mgmt
Individuals CRUD Excluded from survey 1.2 Same as groups
Subscriptions UI Excluded from survey 1.2 When end-user (D from 1.1) becomes a real persona
Routing rules editor Excluded from survey 1.2 When power users complain about SQL-only
SSO / OIDC per customer 2.3 chose in-house first First enterprise customer with own IdP
Mobile app / PWA Excluded from 1.3 After end-user (D) is a real persona
Real-time collab editing Excluded from 1.3 When >5 operators use it concurrently
Billing / quota UI Excluded from 1.3 When pricing model exists
Theme/branding per company Implicit deferral When 2nd customer with brand requirements signs
Visual rule editor (drag/drop) Implicit deferral When customers request low-code routing
Density toggle in UI 3.1 chose shadcn default When ops users complain about density
SLO dashboards in UI They live in Grafana (M9) When operators ask to leave Grafana

4. Milestones — the implementation grouping

M13 is split into 3 milestones (M13a, M13b, M13c) plus the spec & API contract already written. Each milestone has its own PLAN.md with workstreams, estimates, and exit criteria.

Milestone Goal Plan Blocked by Estimate
M13.0 Spec + API contract (this file + M13_API_CONTRACT.md) inline M11 ✅ done (this PR)
M13a authd service + web skeleton (login works end-to-end) M13a_PLAN.md M13.0 ~5-6 days
M13b Companies + Sources + Telegram CRUD M13b_PLAN.md M13a ~7-8 days
M13c Live tail + DLQ + Audit + K8s + Playwright E2E M13c_PLAN.md M13a, M13b ~7-9 days

Each milestone ships independently and flips one row of SPEC.md. You can stop after any milestone and have something useful:

  • After M13a: nothing visible to the end-user yet, but auth is in place and the SPA shell loads. Useful for "is the design right?" review before committing to the rest.
  • After M13b: super-admin can do real work — create companies, onboard a source, set up telegram. ~80% of operator value.
  • After M13c: full v1 done. Replaces dlq.html, adds live tail and audit, has K8s manifests and a Playwright gate.

4.1 What ships in M13a

  • cmd/authd/ — JWT IdP service, all 7 endpoints.
  • web/ skeleton — Vite + React 19 + shadcn/ui + TanStack Query.
  • Login page → /me → logout, all working against real authd.
  • Top bar + sidebar with empty pages (companies/sources/telegram/etc.) rendered with "coming soon" placeholders.
  • admind modified to require JWT on every endpoint (except /health, /metrics, the auth passthroughs).
  • SPEC.md M13a row flipped to ✅.

4.2 What ships in M13b

  • Companies CRUD (super-admin full, tenant-admin read-only).
  • Sources CRUD with one-time secrets modal.
  • Telegram bot config + invite codes + bindings list.
  • All forms validate, audit log writes, error toasts.
  • SPEC.md M13b row flipped to ✅.

4.3 What ships in M13c

  • Live tail page (SSE) with filter bar + drill-in modal.
  • DLQ list/detail/replay/discard, replaces dlq.html.
  • Audit log viewer.
  • K8s manifests for admind and authd (carry from M12 W1).
  • Playwright E2E happy path green in CI.
  • M13_VERIFICATION.md with screenshots + run logs.
  • SPEC.md M13c row flipped to ✅.

5. Stack

Layer Choice Why
Build Vite 5 Fast HMR, the SPEC literally says "React + Vite"
UI framework React 19 Current, shadcn-compatible
Components shadcn/ui (Radix + Tailwind) Accessible, copy-paste, no version lock-in
Server state TanStack Query v5 Cache, retries, devtools. Standard for v1 SPA
Client state React useState/useReducer. No Redux. Not needed
Forms React Hook Form + Zod Standard, type-safe
Tables TanStack Table (headless) + shadcn Data Table wrapper Powerful, accessible
Routing React Router v6 (data routers) Stable, type-safe loaders
Live tail Server-Sent Events via native EventSource Simpler than WS, one-direction
HTTP client openapi-fetch + openapi-typescript generated client Type-safe from Go OpenAPI spec
Testing Vitest (units) + Playwright (1 E2E happy path) Fast + high-signal
i18n None in v1. All copy in src/i18n/en.ts Future i18next migration is mechanical
Package manager pnpm with workspace Fast, saves disk
Lint ESLint + Prettier Standard
Node version 22 LTS Current LTS

Not in v1:

  • Redux / Zustand / MobX (overkill for this scope)
  • next-intl / i18next (English only v1)
  • Storybook (defer until we have 10+ components in flux)
  • Sentry / OpenTelemetry browser (defer to v2)
  • Workbox / PWA manifest (defer — not in scope per 1.3)

6. Repo layout

broad-announce/
├── cmd/
│   ├── admind/                  # existing — gets embed.FS for the SPA
│   │   ├── main.go
│   │   └── web/dist/            # generated by `pnpm --filter web build`
│   └── authd/                   # new in M13a — JWT IdP service, port 8804
│       ├── main.go
│       └── ...
├── internal/                    # existing backend code
├── web/                         # new in M13a — Vite + React 19 SPA
│   ├── package.json
│   ├── pnpm-workspace.yaml
│   ├── vite.config.ts
│   ├── index.html
│   ├── src/
│   │   ├── main.tsx
│   │   ├── App.tsx
│   │   ├── routes/              # React Router routes
│   │   ├── components/          # shadcn/ui + custom
│   │   ├── features/            # companies/, sources/, telegram/, tail/, dlq/, audit/
│   │   ├── lib/                 # api client, auth, hooks
│   │   ├── i18n/en.ts
│   │   └── types/               # generated openapi types
│   ├── public/
│   └── tests/                   # vitest + playwright
├── M13_FRONTEND_SPEC.md (this file)
├── M13_API_CONTRACT.md
├── M13a_PLAN.md
├── M13b_PLAN.md
├── M13c_PLAN.md
└── ...

7. Embedding & deployment

7.1 Build pipeline

  1. cd web && pnpm install (first time or on dep change).
  2. pnpm --filter web run build → outputs web/dist/.
  3. cp -r web/dist/* cmd/admind/web/dist/ (or use a Makefile target).
  4. go build ./cmd/admind/... → Go's //go:embed web/dist directive packages the static files into the binary.
  5. go build ./cmd/authd/... → authd binary.
  6. Docker image: existing Dockerfile copies both binaries.

7.2 Serving the SPA from admind

  • admind registers mux.Handle("/", http.FileServer(http.FS(staticFS))) with the embed.FS rooted at web/dist.
  • SPA fallback: any path that doesn't match an API route serves index.html (so React Router can handle the route).
  • API routes are prefixed /v1/, /health, /metrics. Everything else → SPA.
  • Cache-Control: long for /assets/* (hashed), no-cache for /.

7.3 Auth and CORS

  • No CORS needed — same-origin, the SPA is served by admind itself.
  • authd is on port 8804; the SPA talks to it via the authd Service in K8s / via localhost:8804 in dev. The admind API forwards Authorization: Bearer *** from the SPA's cookie to upstream checks by calling authd POST /v1/auth/verify (or by sharing the JWT secret — see M13_API_CONTRACT.md §3 for the design call).

8. Testing strategy (per-milestone detail in each PLAN)

  • Unit (Vitest): forms, auth helpers, data transformers, one happy-path per feature.
  • E2E (Playwright, 1 happy path in M13c): super-admin logs in → creates a company → creates a source → triggers an alert via curl to ingestd → sees the alert in Live Tail within 2s.
  • What we are NOT testing in v1: visual regression, cross-browser matrix, SPA load testing, axe-core audit (shadcn/Radix gets us 90% for free).

9. Open questions (flagged for the W1 walk-through)

Default is documented; if a reviewer disagrees, they edit the spec before M13a W1 starts.

  1. Users table location — same Postgres DB as everything else, or a separate authd DB? Default: same DB, separate schema (auth). Pro: one backup story. Con: blast radius.
  2. JWT verificationadmind calls authd on every request, or shares the secret? Default: shared secret (HS256) for v1; flip to authd JWKS endpoint when K8s multi-cluster lands.
  3. Refresh token storage — server-side Postgres row, or stateless JWT? Default: server-side row, 7d TTL, rotated on use. Revocable.
  4. Audit log writes — every service writes its own rows, or admind is the proxy? Default: every service writes its own (M9 already has the schema). Simpler, more accurate, no proxy needed.
  5. authd HTTP port — 8804 (next slot after admind 8803). Flag for review if there's a convention I'm missing.

10. Risks (carried forward to each milestone's PLAN)

Risk Likelihood Impact Mitigation
Bundle size grows with features Medium Medium (slow first paint) Code-split per route, lazy-load heavy tables
embed.FS blows the Go binary to 100+ MB Low Low Use //go:embed with gzip; check size in CI
JWT secret rotation breaks live sessions Medium Medium Support 2 secrets during rotation; rotation playbook in authd/README.md
Magic-link emails go to spam High Low (we have psql fallback) Document SPF/DKIM; v1 returns URL in API response if SMTP unconfigured
Tenant-admin bypass via direct API call Low High Backend enforcement of WHERE company_id = $1, tested in Playwright
SSE drops through corporate proxies Medium Medium Long-poll fallback (/v1/tail/recent?since=) is already in the API
React 19 + shadcn interop bugs Low Low shadcn already supports React 19; pin in pnpm-lock
Two builds per release (web + Go) Certain Low Makefile target, single make build in CI
OpenAPI drift between Go and TS types Medium High Generate client in CI on every Go API change; PR that drifts fails

Next step: review this spec + M13_API_CONTRACT.md, mark up the Open Questions in §9, then start M13a W1 (authd).