# 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 ```json { "sub": "user_", "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 `. - 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//login` and `/v1/auth/sso//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_id` → `individual_id` mappings. - **Generate invite code:** button → POST → returns code + `t.me/?start=` 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=` (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 verification** — `admind` 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`).