Multi-tenant admin UI for broad-announce. React + Vite SPA, embedded in the
admindGo binary viaembed.FS. Companion toSPEC.md(the backend),M13_API_CONTRACT.md(the wire), and the per-milestone plansM13a_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.
| 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:
role=super_admin)BA_AUTH_BOOTSTRAP_ADMIN=...).role=tenant_admin)Deferred. The 1.3 survey response excluded mobile/PWA. End-user is the recipient of alerts, manages quiet hours. Out of scope.
authdDecision: cmd/authd/, port 8804, dedicated HTTP service. NOT
a module inside admind. Rationale:
ingestd, routerd, deliverd-*, gRPC server in
the future (M14+). Today only admind and the frontend use it.M13_API_CONTRACT.md §1.10 for the schema sketch).POST /v1/auth/login): username + password → verify
argon2id hash → issue access JWT (15min, HS256) + refresh JWT
(7d, opaque, server-side store).POST /v1/auth/refresh): rotate refresh token, return
new access + refresh. Old refresh invalidated.POST /v1/auth/logout): invalidate refresh token
server-side.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.)POST /v1/auth/invites/accept): user submits
token + new password → password hashed → user status=active.GET /v1/auth/me): returns current user with role + scoped
company_id. Frontend uses this for routing and nav.POST /v1/auth/logout to
invalidate the server-side row.{
"sub": "user_<uuid>",
"role": "super_admin" | "tenant_admin",
"company_id": "acme-001" | null,
"iat": 1749819000,
"exp": 1749822600
}
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).admind endpoint checks Authorization: Bearer <jwt>.WHERE company_id = $1 from the JWT
to every query. No way to bypass from the frontend.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.
status=suspended or
active. Suspended companies cannot ingest.GET/POST/PATCH /v1/companies,
GET /v1/companies/:id.GET/POST/PATCH /v1/sources,
GET /v1/sources/:id.admind using a master key from env), view invite codes, list of
bound chat_id → individual_id mappings.t.me/<bot>?start=<code> URL. Status (active / expired / used).individual_id, telegram_user_id,
telegram_chat_id, status, last_seen_at.GET/PUT /v1/companies/:id/telegram,
GET/POST /v1/companies/:id/telegram/invites,
GET /v1/companies/:id/telegram/bindings.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).GET /v1/tail/stream (SSE),
GET /v1/tail/recent?since=<ts> (HTTP fallback).status=discarded (hidden from
default list). Audit row written.GET /v1/dlq, GET /v1/dlq/:id,
POST /v1/dlq/:id/replay, POST /v1/dlq/:id/discard. Migrates the
M8 endpoints that already exist.GET /v1/audit?actor=&action=&since=&until=.
New endpoint — M8 didn't have it.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 |
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:
dlq.html, adds live tail
and audit, has K8s manifests and a Playwright gate.cmd/authd/ — JWT IdP service, all 7 endpoints.web/ skeleton — Vite + React 19 + shadcn/ui + TanStack Query./me → logout, all working against real authd.admind modified to require JWT on every endpoint (except
/health, /metrics, the auth passthroughs).M13a row flipped to ✅.M13b row flipped to ✅.dlq.html.admind and authd (carry from M12 W1).M13_VERIFICATION.md with screenshots + run logs.M13c row flipped to ✅.| 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:
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
└── ...
cd web && pnpm install (first time or on dep change).pnpm --filter web run build → outputs web/dist/.cp -r web/dist/* cmd/admind/web/dist/ (or use a Makefile target).go build ./cmd/admind/... → Go's //go:embed web/dist directive
packages the static files into the binary.go build ./cmd/authd/... → authd binary.admindadmind registers mux.Handle("/", http.FileServer(http.FS(staticFS)))
with the embed.FS rooted at web/dist.index.html (so React Router can handle the route)./v1/, /health, /metrics. Everything
else → SPA./assets/* (hashed), no-cache for /.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).Default is documented; if a reviewer disagrees, they edit the spec before M13a W1 starts.
authd DB? Default: same DB, separate schema
(auth). Pro: one backup story. Con: blast radius.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.admind is the proxy? Default: every service writes its own
(M9 already has the schema). Simpler, more accurate, no proxy
needed.authd HTTP port — 8804 (next slot after admind 8803). Flag
for review if there's a convention I'm missing.| 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).