# 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_<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_id` → `individual_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 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`).
