Răsfoiți Sursa

M13b W0: SPA shell + embed into admind

Foundation for the React 19 admin console. Builds the SPA into cmd/admind/web-dist/* and embeds it via //go:embed, with a 503 stub if web/dist is empty (dev build before `make web-build`).

## Web
- React 18 + Vite 5 + TypeScript 5 + Tailwind 3 + Radix + TanStack Query
- pnpm-workspace.yaml (single `web` package, esbuild allowBuild)
- Routes: /login, /forbidden, /, /companies/*, /sources/*, /telegram/*, /tail, /dlq, /audit, *
  - All non-auth routes guarded by <RequireAuth> and wrapped in <AppShell> (TopBar + Sidebar)
  - Each non-shipped route renders <ComingSoon> with the W1/W2/W3 badge so the next workstreams are obvious
- Auth: AuthProvider with /v1/auth/refresh-then-/v1/auth/me boot, in-memory access token, httpOnly refresh cookie, refresh-on-401 with single-flight in-flight guard
- API helpers: fetchWithAuth, apiGet, apiSend, ApiError
- Theme: light/dark/system, persisted in localStorage, initTheme() runs before first paint
- Role-based sidebar (super_admin / tenant_admin / viewer)
- Login test (vitest + testing-library): renders form, accepts input
- `/login` boot hint: scripts/bootstrap-super-admin.sh

## Admind
- //go:embed web-dist (empty allowed; 503 stub on / if index.html missing)
- wireSPA(): SPA history fallback for /, /login, /forbidden, /companies*, /sources*, /telegram*, /tail, /dlq, /audit
- /assets/* served with immutable cache; / is no-cache so deploys pick up new bundles
- /dlq keeps the M8 HTML UI (server-rendered); /v1/* keeps JWT gate

## Makefile
- web-install (pnpm install), web-build, web-dev (vite :5173), web-test, web-typecheck
- build-with-web alias: web-build then go build

## .gitignore
- /web/node_modules, /web/dist, /web/.vite
- /node_modules (root pnpm hoist)
- /.pnpm-store, /pnpm-lock.yaml

## Verified
- pnpm run build → clean (vite v5.4.21, 1660 modules, ~328 kB total / ~104 kB gz)
- go build ./... + go vet ./... clean
- go test ./cmd/admind/... clean
- vitest: 2 passed (login form)
- M8/M11/M12/M13a tests untouched, still green
Jarvis 1 lună în urmă
părinte
comite
b2c43653e3
53 a modificat fișierele cu 1733 adăugiri și 5 ștergeri
  1. 8 0
      .gitignore
  2. 47 5
      Makefile
  3. 145 0
      cmd/admind/main.go
  4. 1 0
      cmd/admind/web-dist/assets/forms-CvyWnTna.js
  5. 0 0
      cmd/admind/web-dist/assets/index-BNPqDOA1.css
  6. 0 0
      cmd/admind/web-dist/assets/index-DCyBxwIY.js
  7. 8 0
      cmd/admind/web-dist/assets/query-CTmUNpMf.js
  8. 8 0
      cmd/admind/web-dist/assets/react-B_u3rLcX.js
  9. 0 0
      cmd/admind/web-dist/assets/ui-X1rpq_CV.js
  10. 4 0
      cmd/admind/web-dist/favicon.svg
  11. 18 0
      cmd/admind/web-dist/index.html
  12. 4 0
      pnpm-workspace.yaml
  13. 3 0
      web/.npmrc
  14. 14 0
      web/index.html
  15. 49 0
      web/package.json
  16. 6 0
      web/postcss.config.js
  17. 4 0
      web/public/favicon.svg
  18. 17 0
      web/src/components/layout/app-shell.tsx
  19. 31 0
      web/src/components/layout/require-auth.tsx
  20. 72 0
      web/src/components/layout/sidebar.tsx
  21. 93 0
      web/src/components/layout/topbar.tsx
  22. 18 0
      web/src/components/layout/use-theme.ts
  23. 52 0
      web/src/components/ui/button.tsx
  24. 52 0
      web/src/components/ui/card.tsx
  25. 32 0
      web/src/components/ui/coming-soon.tsx
  26. 22 0
      web/src/components/ui/input.tsx
  27. 18 0
      web/src/components/ui/label.tsx
  28. 71 0
      web/src/index.css
  29. 125 0
      web/src/lib/api.ts
  30. 132 0
      web/src/lib/auth-context.tsx
  31. 55 0
      web/src/lib/auth-state.ts
  32. 21 0
      web/src/lib/scope.ts
  33. 33 0
      web/src/lib/theme.ts
  34. 6 0
      web/src/lib/utils.ts
  35. 37 0
      web/src/main.tsx
  36. 35 0
      web/src/router.tsx
  37. 17 0
      web/src/routes/audit.tsx
  38. 18 0
      web/src/routes/companies.tsx
  39. 21 0
      web/src/routes/dlq.tsx
  40. 19 0
      web/src/routes/forbidden.tsx
  41. 23 0
      web/src/routes/index.tsx
  42. 106 0
      web/src/routes/login.tsx
  43. 19 0
      web/src/routes/not-found.tsx
  44. 18 0
      web/src/routes/sources.tsx
  45. 17 0
      web/src/routes/tail.tsx
  46. 17 0
      web/src/routes/telegram.tsx
  47. 53 0
      web/tailwind.config.ts
  48. 50 0
      web/tests/login.test.tsx
  49. 14 0
      web/tests/setup.ts
  50. 26 0
      web/tsconfig.json
  51. 1 0
      web/tsconfig.tsbuildinfo
  52. 55 0
      web/vite.config.ts
  53. 18 0
      web/vitest.config.ts

+ 8 - 0
.gitignore

@@ -8,6 +8,14 @@
 *.swp
 __pycache__/
 
+# Frontend (M13b W0)
+/web/node_modules/
+/web/dist/
+/web/.vite/
+/node_modules/
+/.pnpm-store/
+/pnpm-lock.yaml
+
 # Build artifacts
 /bin/
 /dist/

+ 47 - 5
Makefile

@@ -1,12 +1,18 @@
 # Makefile for broad-announce
 #
 # Dev targets:
-#   make proto    — regenerate Go stubs from .proto files
-#   make vet      — run go vet on all packages
-#   make build    — build all binaries
-#   make test     — run unit tests
+#   make proto          — regenerate Go stubs from .proto files
+#   make vet            — run go vet on all packages
+#   make build          — build all Go binaries
+#   make test           — run Go unit tests
+#   make web-install    — pnpm install in web/
+#   make web-build      — build the SPA into web/dist/
+#   make web-dev        — vite dev server on :5173
+#   make web-test       — vitest run in web/
+#   make web-typecheck  — tsc --noEmit
+#   make build          — builds Go + embeds web/dist into admind
 
-.PHONY: proto vet build test
+.PHONY: proto vet build test web-install web-build web-dev web-test web-typecheck build-with-web
 
 PROTO_DIR   := proto
 GEN_DIR     := gen/go
@@ -28,3 +34,39 @@ build:
 
 test:
 	go test ./...
+
+# ── Frontend (M13b W0) ─────────────────────────────────────────────
+
+# web-install runs pnpm install for the web workspace.
+# Idempotent; uses the local pnpm-store at .pnpm-store.
+web-install:
+	pnpm install --frozen-lockfile=false
+
+# web-build produces web/dist/ that the admind binary embeds
+# via //go:embed web/dist/*.
+web-build:
+	cd web && pnpm run build
+
+# web-dev starts the Vite dev server on :5173 with proxy
+# rules so /v1/* goes to the local stack. Bring up the stack
+# (docker compose up -d) before running this.
+web-dev:
+	cd web && pnpm run dev
+
+# web-test runs the vitest unit tests.
+web-test:
+	cd web && pnpm run test
+
+# web-typecheck runs tsc without emitting.
+web-typecheck:
+	cd web && pnpm exec tsc --noEmit
+
+# build-with-web runs the SPA build first, then builds all
+# Go binaries so admind picks up the freshly built web/dist.
+build-with-web: web-build build
+
+# ── M13a smoke ────────────────────────────────────────────────────
+M13A_SMOKE = bash scripts/m13a_smoke.sh
+
+m13a-smoke:
+	$(M13A_SMOKE)

+ 145 - 0
cmd/admind/main.go

@@ -21,6 +21,7 @@ import (
 	"fmt"
 	"html"
 	"html/template"
+	"io/fs"
 	"log/slog"
 	"net/http"
 	"os"
@@ -44,6 +45,16 @@ import (
 //go:embed ui/*
 var uiFS embed.FS
 
+// webFS embeds the React SPA built by web/ (M13b W0+). The
+// directory must exist at build time; `make web-build` produces
+// web/dist/ and the Go embed picks it up automatically. When
+// web/dist/ is missing (dev build before `make web-build`),
+// the embed is empty and webHandlers() returns a 503 stub so
+// operators still see a useful message instead of a 404.
+//
+//go:embed web-dist
+var webFS embed.FS
+
 // dlqRow is the wire shape returned by /v1/dlq and
 // rendered by the HTML UI. The payload field is omitted
 // from the list endpoint (operators can fetch the full
@@ -103,6 +114,13 @@ func main() {
 	mux.HandleFunc("GET /v1/ping", handlePing)
 	mux.HandleFunc("GET /dlq", handleDLQUI(pool, logger))
 
+	// M13b W0: serve the React SPA from /, with SPA fallback to
+	// /index.html for client-side routes. /dlq keeps the M8 HTML
+	// UI (it's a server-rendered template, not part of the SPA).
+	// The /v1/* routes take precedence (Go ServeMux matches the
+	// longest prefix).
+	wireSPA(mux, logger)
+
 	// M13a W3: JWT-gate the /v1/dlq* routes when BA_AUTHD_JWT_SECRET
 	// is set. When unset, the routes stay unauthenticated (the
 	// pre-M13 behavior) so the LAN-only deploy path keeps working.
@@ -541,6 +559,133 @@ func handleDLQUI(pool *postgres.Pool, logger *slog.Logger) http.HandlerFunc {
 // We import broker just for the Connect + Conn() pair.
 var _ = nats.Conn{}
 
+// wireSPA serves the React SPA at / and SPA-fallback to /index.html
+// for client-side routes (/companies, /sources, /login, etc.).
+//
+// If web/dist/ is empty (dev build before `make web-build`), the
+// handler returns a small 503 page that explains how to build the
+// SPA. This is intentional: the embed.FS at compile time is fixed,
+// and we'd rather show a useful error than 404 every route.
+func wireSPA(mux *http.ServeMux, logger *slog.Logger) {
+	sub, err := fs.Sub(webFS, "web-dist")
+	if err != nil {
+		// web/dist missing entirely. The Go embed would have
+		// errored at build time if a non-existent prefix was used
+		// with go:embed; if we get here, web/dist exists but
+		// contains nothing.
+		logger.Warn("SPA not built: web/dist is empty — run `make web-build`")
+		mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "text/html; charset=utf-8")
+			w.WriteHeader(http.StatusServiceUnavailable)
+			_, _ = w.Write([]byte(spaNotBuilt))
+		})
+		return
+	}
+	indexBytes, err := fs.ReadFile(sub, "index.html")
+	if err != nil {
+		// web/dist exists but doesn't have index.html — wrong
+		// build output (maybe a partial vite build).
+		logger.Warn("SPA build looks incomplete: web/dist has no index.html — re-run `make web-build`")
+		mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "text/html; charset=utf-8")
+			w.WriteHeader(http.StatusServiceUnavailable)
+			_, _ = w.Write([]byte(spaNotBuilt))
+		})
+		return
+	}
+	logger.Info("SPA mounted", "index_bytes", len(indexBytes))
+
+	// Static files (JS, CSS, fonts, icons) — served from /assets/.
+	// We use a custom handler so /assets/* serves real files and
+	// everything else falls back to /index.html (SPA history mode).
+	mux.HandleFunc("GET /assets/", spaAssetsHandler(sub))
+	mux.HandleFunc("GET /favicon.svg", spaAssetAt(sub, "favicon.svg"))
+	mux.HandleFunc("GET /{$}", spaIndex(sub, indexBytes))
+	// SPA history fallback for client-side routes. Each top-level
+	// route returns index.html; React Router takes over.
+	for _, path := range []string{
+		"/login", "/forbidden",
+		"/companies", "/companies/",
+		"/sources", "/sources/",
+		"/telegram", "/telegram/",
+		"/tail",
+		"/dlq",
+		"/audit",
+	} {
+		p := path
+		mux.HandleFunc("GET "+p, spaIndex(sub, indexBytes))
+	}
+}
+
+func spaAssetsHandler(sub fs.FS) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		// URL path is /assets/<path>; strip the prefix.
+		p := strings.TrimPrefix(r.URL.Path, "/assets/")
+		f, err := sub.Open("assets/" + p)
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		defer f.Close()
+		stat, err := f.Stat()
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		// Cache aggressively — assets/ files have hashed names so
+		// they never change. The HTML at / is NEVER cached (always
+		// re-fetched so a deploy picks up new bundles).
+		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+		http.ServeContent(w, r, stat.Name(), stat.ModTime(), f.(interface {
+			Read([]byte) (int, error)
+			Seek(int64, int) (int64, error)
+		}))
+	}
+}
+
+func spaAssetAt(sub fs.FS, name string) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		f, err := sub.Open(name)
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		defer f.Close()
+		stat, _ := f.Stat()
+		w.Header().Set("Cache-Control", "public, max-age=3600")
+		http.ServeContent(w, r, stat.Name(), stat.ModTime(), f.(interface {
+			Read([]byte) (int, error)
+			Seek(int64, int) (int64, error)
+		}))
+	}
+}
+
+func spaIndex(sub fs.FS, indexBytes []byte) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+		_, _ = w.Write(indexBytes)
+	}
+}
+
+const spaNotBuilt = `<!doctype html>
+<html><head><meta charset="utf-8"><title>broad-announce admin</title>
+<style>
+body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 640px; margin: 80px auto; padding: 0 24px; color: #111; background: #fafafa; }
+.dark body { background: #0d0f12; color: #e4e4e4; }
+h1 { font-size: 22px; }
+pre { background: #161a20; color: #e4e4e4; padding: 12px 16px; border-radius: 6px; }
+code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+</style>
+</head><body>
+<h1>SPA not built</h1>
+<p>The React admin console (<code>web/dist/index.html</code>) is not present in the admind binary. This usually means the SPA was rebuilt without first running <code>make web-build</code>.</p>
+<p>Run from the repo root:</p>
+<pre>make web-build && make build</pre>
+<p>The <a href="/dlq">M8 DLQ HTML UI</a> still works at <a href="/dlq">/dlq</a>.</p>
+<p>The <a href="/v1/ping">/v1/ping</a> JSON endpoint is also available.</p>
+</body></html>`
+
 // wireDLQRoutes decides whether the /v1/dlq* routes go behind the
 // JWT gate or stay open, based on BA_AUTHD_JWT_SECRET. Extracted so
 // main() stays linear (no goto, no early returns from main).

+ 1 - 0
cmd/admind/web-dist/assets/forms-CvyWnTna.js

@@ -0,0 +1 @@
+import"./react-B_u3rLcX.js";

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
cmd/admind/web-dist/assets/index-BNPqDOA1.css


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
cmd/admind/web-dist/assets/index-DCyBxwIY.js


Fișier diff suprimat deoarece este prea mare
+ 8 - 0
cmd/admind/web-dist/assets/query-CTmUNpMf.js


Fișier diff suprimat deoarece este prea mare
+ 8 - 0
cmd/admind/web-dist/assets/react-B_u3rLcX.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
cmd/admind/web-dist/assets/ui-X1rpq_CV.js


+ 4 - 0
cmd/admind/web-dist/favicon.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+  <rect width="32" height="32" rx="6" fill="#0d6efd"/>
+  <path d="M8 12 L24 12 M8 16 L20 16 M8 20 L16 20" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
+</svg>

+ 18 - 0
cmd/admind/web-dist/index.html

@@ -0,0 +1,18 @@
+<!doctype html>
+<html lang="en" class="h-full">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="color-scheme" content="light dark" />
+    <title>broad-announce admin</title>
+    <script type="module" crossorigin src="/assets/index-DCyBxwIY.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/react-B_u3rLcX.js">
+    <link rel="modulepreload" crossorigin href="/assets/query-CTmUNpMf.js">
+    <link rel="modulepreload" crossorigin href="/assets/ui-X1rpq_CV.js">
+    <link rel="stylesheet" crossorigin href="/assets/index-BNPqDOA1.css">
+  </head>
+  <body class="h-full bg-background text-foreground">
+    <div id="root" class="h-full"></div>
+  </body>
+</html>

+ 4 - 0
pnpm-workspace.yaml

@@ -0,0 +1,4 @@
+packages:
+  - 'web'
+allowBuilds:
+  esbuild: true

+ 3 - 0
web/.npmrc

@@ -0,0 +1,3 @@
+# Approve build scripts for esbuild (binary downloader).
+# pnpm's default policy is "ignore build scripts" for security.
+enable-pre-post-scripts=true

+ 14 - 0
web/index.html

@@ -0,0 +1,14 @@
+<!doctype html>
+<html lang="en" class="h-full">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="color-scheme" content="light dark" />
+    <title>broad-announce admin</title>
+  </head>
+  <body class="h-full bg-background text-foreground">
+    <div id="root" class="h-full"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 49 - 0
web/package.json

@@ -0,0 +1,49 @@
+{
+  "name": "web",
+  "private": true,
+  "version": "0.1.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "tsc -b && vite build",
+    "preview": "vite preview",
+    "test": "vitest run",
+    "test:watch": "vitest"
+  },
+  "dependencies": {
+    "@hookform/resolvers": "^3.9.1",
+    "@radix-ui/react-avatar": "^1.1.1",
+    "@radix-ui/react-dialog": "^1.1.2",
+    "@radix-ui/react-dropdown-menu": "^2.1.2",
+    "@radix-ui/react-label": "^2.1.0",
+    "@radix-ui/react-slot": "^1.1.0",
+    "@radix-ui/react-tooltip": "^1.1.4",
+    "@tanstack/react-query": "^5.59.20",
+    "class-variance-authority": "^0.7.0",
+    "clsx": "^2.1.1",
+    "lucide-react": "^0.460.0",
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1",
+    "react-hook-form": "^7.53.2",
+    "react-router-dom": "^6.28.0",
+    "sonner": "^1.7.0",
+    "tailwind-merge": "^2.5.4",
+    "tailwindcss-animate": "^1.0.7",
+    "zod": "^3.23.8"
+  },
+  "devDependencies": {
+    "@testing-library/jest-dom": "^6.6.3",
+    "@testing-library/react": "^16.0.1",
+    "@types/node": "^22.9.0",
+    "@types/react": "^18.3.12",
+    "@types/react-dom": "^18.3.1",
+    "@vitejs/plugin-react": "^4.3.3",
+    "autoprefixer": "^10.4.20",
+    "happy-dom": "^15.11.6",
+    "postcss": "^8.4.49",
+    "tailwindcss": "^3.4.14",
+    "typescript": "^5.6.3",
+    "vite": "^5.4.11",
+    "vitest": "^2.1.5"
+  }
+}

+ 6 - 0
web/postcss.config.js

@@ -0,0 +1,6 @@
+export default {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+};

+ 4 - 0
web/public/favicon.svg

@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+  <rect width="32" height="32" rx="6" fill="#0d6efd"/>
+  <path d="M8 12 L24 12 M8 16 L20 16 M8 20 L16 20" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
+</svg>

+ 17 - 0
web/src/components/layout/app-shell.tsx

@@ -0,0 +1,17 @@
+import { Outlet } from 'react-router-dom';
+import { Sidebar } from './sidebar';
+import { TopBar } from './topbar';
+
+export function AppShell() {
+  return (
+    <div className="flex h-full flex-col">
+      <TopBar />
+      <div className="flex flex-1 overflow-hidden">
+        <Sidebar />
+        <main className="flex-1 overflow-auto bg-background p-6">
+          <Outlet />
+        </main>
+      </div>
+    </div>
+  );
+}

+ 31 - 0
web/src/components/layout/require-auth.tsx

@@ -0,0 +1,31 @@
+import { Navigate, useLocation } from 'react-router-dom';
+import type { ReactNode } from 'react';
+import { useAuth } from '@/lib/auth-context';
+
+interface RequireAuthProps {
+  children: ReactNode;
+}
+
+/**
+ * Route guard. Redirects to /login if there's no authenticated user.
+ * Preserves the target URL via state so the user lands back where
+ * they were after login.
+ */
+export function RequireAuth({ children }: RequireAuthProps) {
+  const { user, loading } = useAuth();
+  const location = useLocation();
+
+  if (loading) {
+    return (
+      <div className="flex h-full items-center justify-center">
+        <div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
+      </div>
+    );
+  }
+
+  if (!user) {
+    return <Navigate to="/login" state={{ from: location.pathname + location.search }} replace />;
+  }
+
+  return <>{children}</>;
+}

+ 72 - 0
web/src/components/layout/sidebar.tsx

@@ -0,0 +1,72 @@
+import { NavLink } from 'react-router-dom';
+import {
+  Building2,
+  Radio,
+  Send,
+  Activity,
+  Inbox,
+  ScrollText,
+  ShieldCheck,
+} from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useAuth } from '@/lib/auth-context';
+import { canManageCompanies, canManageSources, canViewDLQ } from '@/lib/scope';
+
+interface NavItem {
+  to: string;
+  label: string;
+  icon: typeof Building2;
+  show?: (role: ReturnType<typeof useAuth>['user']) => boolean;
+  badge?: string;
+}
+
+const NAV: NavItem[] = [
+  { to: '/companies', label: 'Companies', icon: Building2, show: (u) => canManageCompanies(u?.role) },
+  { to: '/sources', label: 'Sources', icon: Radio, show: (u) => canManageSources(u?.role) },
+  { to: '/telegram', label: 'Telegram', icon: Send, show: (u) => canManageSources(u?.role) },
+  { to: '/tail', label: 'Live tail', icon: Activity },
+  { to: '/dlq', label: 'DLQ', icon: Inbox, show: (u) => canViewDLQ(u?.role), badge: 'M13c' },
+  { to: '/audit', label: 'Audit log', icon: ScrollText, badge: 'M13c' },
+];
+
+export function Sidebar() {
+  const { user } = useAuth();
+  const visible = NAV.filter((item) => !item.show || item.show(user));
+
+  return (
+    <aside className="hidden w-56 shrink-0 border-r bg-card md:block">
+      <nav className="flex flex-col gap-0.5 p-3">
+        {visible.map((item) => (
+          <NavLink
+            key={item.to}
+            to={item.to}
+            className={({ isActive }) =>
+              cn(
+                'flex items-center justify-between rounded-md px-3 py-2 text-sm transition-colors',
+                isActive
+                  ? 'bg-primary/10 text-primary'
+                  : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
+              )
+            }
+          >
+            <span className="flex items-center gap-2">
+              <item.icon className="h-4 w-4" aria-hidden />
+              {item.label}
+            </span>
+            {item.badge ? (
+              <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
+                {item.badge}
+              </span>
+            ) : null}
+          </NavLink>
+        ))}
+      </nav>
+      <div className="border-t p-3 text-xs text-muted-foreground">
+        <div className="flex items-center gap-1.5">
+          <ShieldCheck className="h-3.5 w-3.5" />
+          <span>M13b W0 · SPA shell</span>
+        </div>
+      </div>
+    </aside>
+  );
+}

+ 93 - 0
web/src/components/layout/topbar.tsx

@@ -0,0 +1,93 @@
+import { Link, useNavigate } from 'react-router-dom';
+import { LogOut, Moon, Sun, MonitorSmartphone, ChevronDown } from 'lucide-react';
+import { useAuth } from '@/lib/auth-context';
+import { Button } from '@/components/ui/button';
+import { useTheme } from './use-theme';
+import { isSuperAdmin } from '@/lib/scope';
+import { useState } from 'react';
+import { cn } from '@/lib/utils';
+
+export function TopBar() {
+  const { user, logout } = useAuth();
+  const { theme, setTheme } = useTheme();
+  const navigate = useNavigate();
+  const [menuOpen, setMenuOpen] = useState(false);
+
+  if (!user) return null;
+
+  const handleLogout = async () => {
+    await logout();
+    navigate('/login', { replace: true });
+  };
+
+  return (
+    <header className="flex h-14 items-center justify-between border-b bg-card px-4">
+      <Link to="/" className="flex items-center gap-2">
+        <span className="inline-block h-6 w-6 rounded bg-primary" aria-hidden />
+        <span className="text-sm font-semibold">broad-announce</span>
+      </Link>
+
+      <div className="flex items-center gap-3">
+        {isSuperAdmin(user.role) ? (
+          <span className="hidden text-xs text-muted-foreground sm:inline">
+            all companies
+          </span>
+        ) : user.tenant_slug ? (
+          <span className="hidden rounded-md bg-muted px-2 py-1 text-xs sm:inline">
+            {user.tenant_slug}
+          </span>
+        ) : null}
+
+        <Button
+          variant="ghost"
+          size="icon"
+          aria-label="Toggle theme"
+          onClick={() => {
+            const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light';
+            setTheme(next);
+          }}
+        >
+          {theme === 'light' && <Sun className="h-4 w-4" />}
+          {theme === 'dark' && <Moon className="h-4 w-4" />}
+          {theme === 'system' && <MonitorSmartphone className="h-4 w-4" />}
+        </Button>
+
+        <div className="relative">
+          <Button
+            variant="ghost"
+            size="sm"
+            onClick={() => setMenuOpen((v) => !v)}
+            aria-haspopup="menu"
+            aria-expanded={menuOpen}
+          >
+            <span className="hidden text-right sm:block">
+              <span className="block text-xs font-medium">{user.email}</span>
+              <span className="block text-[10px] uppercase tracking-wider text-muted-foreground">
+                {user.role.replace('_', ' ')}
+              </span>
+            </span>
+            <ChevronDown className="h-4 w-4 opacity-60" />
+          </Button>
+          {menuOpen ? (
+            <div
+              role="menu"
+              className={cn(
+                'absolute right-0 top-full z-50 mt-1 w-48 rounded-md border bg-card p-1 shadow-md',
+              )}
+              onMouseLeave={() => setMenuOpen(false)}
+            >
+              <button
+                type="button"
+                onClick={handleLogout}
+                className="flex w-full items-center gap-2 rounded px-3 py-2 text-left text-sm hover:bg-accent"
+              >
+                <LogOut className="h-4 w-4" />
+                Sign out
+              </button>
+            </div>
+          ) : null}
+        </div>
+      </div>
+    </header>
+  );
+}

+ 18 - 0
web/src/components/layout/use-theme.ts

@@ -0,0 +1,18 @@
+import { useCallback, useEffect, useState } from 'react';
+import { getStoredTheme, setTheme as persistTheme, type Theme } from '@/lib/theme';
+
+export function useTheme(): { theme: Theme; setTheme: (t: Theme) => void } {
+  const [theme, setLocal] = useState<Theme>(getStoredTheme);
+  const set = useCallback((t: Theme) => {
+    persistTheme(t);
+    setLocal(t);
+  }, []);
+  useEffect(() => {
+    if (theme !== 'system') return;
+    const mq = window.matchMedia('(prefers-color-scheme: dark)');
+    const handler = () => setLocal('system');
+    mq.addEventListener('change', handler);
+    return () => mq.removeEventListener('change', handler);
+  }, [theme]);
+  return { theme, setTheme: set };
+}

+ 52 - 0
web/src/components/ui/button.tsx

@@ -0,0 +1,52 @@
+import { forwardRef, type ButtonHTMLAttributes } from 'react';
+import { Slot } from '@radix-ui/react-slot';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { cn } from '@/lib/utils';
+
+const buttonVariants = cva(
+  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
+  {
+    variants: {
+      variant: {
+        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
+        outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
+        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+        ghost: 'hover:bg-accent hover:text-accent-foreground',
+        link: 'text-primary underline-offset-4 hover:underline',
+      },
+      size: {
+        default: 'h-10 px-4 py-2',
+        sm: 'h-9 rounded-md px-3',
+        lg: 'h-11 rounded-md px-8',
+        icon: 'h-10 w-10',
+      },
+    },
+    defaultVariants: {
+      variant: 'default',
+      size: 'default',
+    },
+  },
+);
+
+export interface ButtonProps
+  extends ButtonHTMLAttributes<HTMLButtonElement>,
+    VariantProps<typeof buttonVariants> {
+  asChild?: boolean;
+}
+
+export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
+  ({ className, variant, size, asChild = false, ...props }, ref) => {
+    const Comp = asChild ? Slot : 'button';
+    return (
+      <Comp
+        ref={ref}
+        className={cn(buttonVariants({ variant, size, className }))}
+        {...props}
+      />
+    );
+  },
+);
+Button.displayName = 'Button';
+
+export { buttonVariants };

+ 52 - 0
web/src/components/ui/card.tsx

@@ -0,0 +1,52 @@
+import { forwardRef, type HTMLAttributes } from 'react';
+import { cn } from '@/lib/utils';
+
+export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
+  ({ className, ...props }, ref) => (
+    <div
+      ref={ref}
+      className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
+      {...props}
+    />
+  ),
+);
+Card.displayName = 'Card';
+
+export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
+  ({ className, ...props }, ref) => (
+    <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
+  ),
+);
+CardHeader.displayName = 'CardHeader';
+
+export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
+  ({ className, ...props }, ref) => (
+    <h3
+      ref={ref}
+      className={cn('text-lg font-semibold leading-none tracking-tight', className)}
+      {...props}
+    />
+  ),
+);
+CardTitle.displayName = 'CardTitle';
+
+export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
+  ({ className, ...props }, ref) => (
+    <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
+  ),
+);
+CardDescription.displayName = 'CardDescription';
+
+export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
+  ({ className, ...props }, ref) => (
+    <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
+  ),
+);
+CardContent.displayName = 'CardContent';
+
+export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
+  ({ className, ...props }, ref) => (
+    <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
+  ),
+);
+CardFooter.displayName = 'CardFooter';

+ 32 - 0
web/src/components/ui/coming-soon.tsx

@@ -0,0 +1,32 @@
+import { cn } from '@/lib/utils';
+import type { ReactNode } from 'react';
+
+interface ComingSoonProps {
+  title: string;
+  description?: string;
+  badge?: string;
+  children?: ReactNode;
+  className?: string;
+}
+
+/** Placeholder for routes whose feature isn't shipped yet. Each
+ *  M13b W1+ workstream replaces its placeholder with the real
+ *  feature folder. */
+export function ComingSoon({ title, description, badge, children, className }: ComingSoonProps) {
+  return (
+    <div className={cn('mx-auto max-w-2xl py-12', className)}>
+      <div className="rounded-lg border bg-card p-8 shadow-sm">
+        <div className="mb-4 flex items-center gap-2">
+          <span className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
+            {badge ?? 'coming soon'}
+          </span>
+        </div>
+        <h2 className="mb-2 text-2xl font-semibold tracking-tight">{title}</h2>
+        {description ? (
+          <p className="mb-6 text-sm text-muted-foreground">{description}</p>
+        ) : null}
+        {children}
+      </div>
+    </div>
+  );
+}

+ 22 - 0
web/src/components/ui/input.tsx

@@ -0,0 +1,22 @@
+import { forwardRef, type InputHTMLAttributes } from 'react';
+import { cn } from '@/lib/utils';
+
+export type InputProps = InputHTMLAttributes<HTMLInputElement>;
+
+export const Input = forwardRef<HTMLInputElement, InputProps>(
+  ({ className, type, ...props }, ref) => (
+    <input
+      type={type}
+      className={cn(
+        'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background',
+        'placeholder:text-muted-foreground',
+        'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
+        'disabled:cursor-not-allowed disabled:opacity-50',
+        className,
+      )}
+      ref={ref}
+      {...props}
+    />
+  ),
+);
+Input.displayName = 'Input';

+ 18 - 0
web/src/components/ui/label.tsx

@@ -0,0 +1,18 @@
+import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from 'react';
+import * as LabelPrimitive from '@radix-ui/react-label';
+import { cn } from '@/lib/utils';
+
+export const Label = forwardRef<
+  ElementRef<typeof LabelPrimitive.Root>,
+  ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
+>(({ className, ...props }, ref) => (
+  <LabelPrimitive.Root
+    ref={ref}
+    className={cn(
+      'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
+      className,
+    )}
+    {...props}
+  />
+));
+Label.displayName = LabelPrimitive.Root.displayName;

+ 71 - 0
web/src/index.css

@@ -0,0 +1,71 @@
+/* M13b W0: Tailwind + shadcn/ui CSS variables. Single source of
+   truth for colors / radius. Dark mode is via the `.dark` class
+   on <html>, toggled by useTheme(). */
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+  :root {
+    --background: 0 0% 100%;
+    --foreground: 222 47% 11%;
+
+    --card: 0 0% 100%;
+    --card-foreground: 222 47% 11%;
+
+    --primary: 221 83% 53%;
+    --primary-foreground: 0 0% 100%;
+
+    --secondary: 210 40% 96%;
+    --secondary-foreground: 222 47% 11%;
+
+    --muted: 210 40% 96%;
+    --muted-foreground: 215 16% 47%;
+
+    --accent: 210 40% 96%;
+    --accent-foreground: 222 47% 11%;
+
+    --destructive: 0 72% 51%;
+    --destructive-foreground: 0 0% 100%;
+
+    --border: 214 32% 91%;
+    --input: 214 32% 91%;
+    --ring: 221 83% 53%;
+    --radius: 0.5rem;
+  }
+
+  .dark {
+    --background: 222 47% 6%;
+    --foreground: 210 40% 98%;
+
+    --card: 222 47% 9%;
+    --card-foreground: 210 40% 98%;
+
+    --primary: 217 91% 60%;
+    --primary-foreground: 222 47% 11%;
+
+    --secondary: 217 33% 17%;
+    --secondary-foreground: 210 40% 98%;
+
+    --muted: 217 33% 17%;
+    --muted-foreground: 215 20% 65%;
+
+    --accent: 217 33% 17%;
+    --accent-foreground: 210 40% 98%;
+
+    --destructive: 0 63% 40%;
+    --destructive-foreground: 210 40% 98%;
+
+    --border: 217 33% 17%;
+    --input: 217 33% 17%;
+    --ring: 217 91% 60%;
+  }
+
+  * {
+    @apply border-border;
+  }
+  body {
+    @apply bg-background text-foreground antialiased;
+  }
+}

+ 125 - 0
web/src/lib/api.ts

@@ -0,0 +1,125 @@
+/**
+ * Authenticated fetch with refresh-on-401.
+ *
+ * Flow:
+ *   1. Try the request with the current access token.
+ *   2. On 401, attempt ONE refresh (POST /v1/auth/refresh with the
+ *      refresh cookie — httpOnly + Secure + SameSite=Lax). If that
+ *      succeeds, retry the original request once.
+ *   3. On second 401 (refresh failed or refresh returned 401), clear
+ *      local state and force a navigation to /login.
+ *
+ * Threading: all callers await fetchWithAuth; refresh is guarded by
+ * an in-flight Promise so concurrent 401s coalesce into one refresh.
+ */
+
+import { getAccessToken, clearTokens, setTokens, getRefreshFailureUrl } from './auth-state';
+
+let inflightRefresh: Promise<boolean> | null = null;
+
+async function tryRefresh(): Promise<boolean> {
+  if (inflightRefresh) return inflightRefresh;
+  inflightRefresh = (async () => {
+    try {
+      const r = await fetch('/v1/auth/refresh', {
+        method: 'POST',
+        credentials: 'include',
+      });
+      if (!r.ok) return false;
+      const data = (await r.json()) as { access_token?: string; refresh_token?: string };
+      if (!data.access_token || !data.refresh_token) return false;
+      setTokens({ access: data.access_token, refresh: data.refresh_token });
+      return true;
+    } catch {
+      return false;
+    } finally {
+      inflightRefresh = null;
+    }
+  })();
+  return inflightRefresh;
+}
+
+export interface FetchOptions extends RequestInit {
+  /** Skip the auth refresh-on-401 retry once. Used for the login
+   *  endpoint itself, which is public. */
+  skipRefresh?: boolean;
+}
+
+export class ApiError extends Error {
+  status: number;
+  body: unknown;
+  constructor(status: number, body: unknown, message: string) {
+    super(message);
+    this.status = status;
+    this.body = body;
+  }
+}
+
+export async function fetchWithAuth(
+  url: string,
+  opts: FetchOptions = {},
+): Promise<Response> {
+  const headers = new Headers(opts.headers);
+  const token = getAccessToken();
+  if (token && !headers.has('Authorization')) {
+    headers.set('Authorization', `Bearer ${token}`);
+  }
+
+  const res = await fetch(url, { ...opts, headers, credentials: 'include' });
+
+  if (res.status !== 401 || opts.skipRefresh) {
+    return res;
+  }
+
+  const ok = await tryRefresh();
+  if (!ok) {
+    clearTokens();
+    const target = getRefreshFailureUrl();
+    if (target && typeof window !== 'undefined') {
+      window.location.href = target;
+    }
+    return res;
+  }
+
+  const retryHeaders = new Headers(opts.headers);
+  const newToken = getAccessToken();
+  if (newToken && !retryHeaders.has('Authorization')) {
+    retryHeaders.set('Authorization', `Bearer ${newToken}`);
+  }
+  return fetch(url, { ...opts, headers: retryHeaders, credentials: 'include' });
+}
+
+export async function apiGet<T>(url: string): Promise<T> {
+  const res = await fetchWithAuth(url);
+  if (!res.ok) {
+    const body = await safeJson(res);
+    throw new ApiError(res.status, body, `${res.status} ${res.statusText}`);
+  }
+  return res.json() as Promise<T>;
+}
+
+export async function apiSend<T>(
+  method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
+  url: string,
+  body?: unknown,
+): Promise<T> {
+  const res = await fetchWithAuth(url, {
+    method,
+    headers: body ? { 'Content-Type': 'application/json' } : undefined,
+    body: body ? JSON.stringify(body) : undefined,
+  });
+  if (!res.ok) {
+    const errBody = await safeJson(res);
+    throw new ApiError(res.status, errBody, `${method} ${url} → ${res.status}`);
+  }
+  if (res.status === 204) return undefined as T;
+  return res.json() as Promise<T>;
+}
+
+async function safeJson(res: Response): Promise<unknown> {
+  try {
+    return await res.json();
+  } catch {
+    return null;
+  }
+}

+ 132 - 0
web/src/lib/auth-context.tsx

@@ -0,0 +1,132 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
+import { clearTokens, setTokens, markRefreshFailure, setAccessToken } from './auth-state';
+import { apiGet, ApiError } from './api';
+
+export type Role = 'super_admin' | 'tenant_admin' | 'viewer';
+
+export interface MeResponse {
+  id: string;
+  email: string;
+  role: Role;
+  tenant_id: string | null;
+  tenant_slug: string | null;
+  status: 'active' | 'pending' | 'disabled';
+}
+
+export interface LoginResponse {
+  access_token: string;
+  refresh_token: string;
+  expires_in: number;
+  user: MeResponse;
+}
+
+export interface AuthContextValue {
+  user: MeResponse | null;
+  loading: boolean;
+  bootError: string | null;
+  login: (email: string, password: string) => Promise<void>;
+  logout: () => Promise<void>;
+  refresh: () => Promise<void>;
+}
+
+const AuthContext = createContext<AuthContextValue | null>(null);
+
+export function useAuth(): AuthContextValue {
+  const ctx = useContext(AuthContext);
+  if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
+  return ctx;
+}
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+  const [user, setUser] = useState<MeResponse | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [bootError, setBootError] = useState<string | null>(null);
+
+  const fetchMe = useCallback(async (): Promise<MeResponse | null> => {
+    try {
+      return await apiGet<MeResponse>('/v1/auth/me');
+    } catch (err) {
+      if (err instanceof ApiError && err.status === 401) return null;
+      throw err;
+    }
+  }, []);
+
+  useEffect(() => {
+    let cancelled = false;
+    (async () => {
+      try {
+        const refreshRes = await fetch('/v1/auth/refresh', {
+          method: 'POST',
+          credentials: 'include',
+        });
+        if (refreshRes.ok) {
+          const data = (await refreshRes.json()) as { access_token?: string; refresh_token?: string };
+          if (data.access_token && data.refresh_token) {
+            setTokens({ access: data.access_token, refresh: data.refresh_token });
+          }
+        }
+        const me = await fetchMe();
+        if (!cancelled) {
+          setUser(me);
+          setBootError(null);
+        }
+      } catch (err) {
+        if (!cancelled) {
+          setBootError(err instanceof Error ? err.message : 'unknown');
+          setUser(null);
+        }
+      } finally {
+        if (!cancelled) setLoading(false);
+      }
+    })();
+    return () => {
+      cancelled = true;
+    };
+  }, [fetchMe]);
+
+  const login = useCallback(async (email: string, password: string) => {
+    markRefreshFailure(window.location.pathname + window.location.search);
+    const res = await fetch('/v1/auth/login', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      credentials: 'include',
+      body: JSON.stringify({ email, password }),
+    });
+    if (!res.ok) {
+      const body = await res.json().catch(() => null);
+      const msg = (body as { error?: string; message?: string } | null)?.message
+        ?? `${res.status} ${res.statusText}`;
+      throw new Error(msg);
+    }
+    const data = (await res.json()) as LoginResponse;
+    setTokens({ access: data.access_token, refresh: data.refresh_token });
+    setUser(data.user);
+    setBootError(null);
+  }, []);
+
+  const logout = useCallback(async () => {
+    try {
+      await fetch('/v1/auth/logout', { method: 'POST', credentials: 'include' });
+    } catch {
+      // Even if the server logout fails, clear local state.
+    }
+    clearTokens();
+    setUser(null);
+  }, []);
+
+  const refresh = useCallback(async () => {
+    const me = await fetchMe();
+    setUser(me);
+  }, [fetchMe]);
+
+  const value = useMemo<AuthContextValue>(
+    () => ({ user, loading, bootError, login, logout, refresh }),
+    [user, loading, bootError, login, logout, refresh],
+  );
+
+  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+}
+
+// Re-exported so external modules (api.ts, tests) can apply a
+// refreshed access token in flight.
+export { setAccessToken };

+ 55 - 0
web/src/lib/auth-state.ts

@@ -0,0 +1,55 @@
+/**
+ * Auth state — access + refresh tokens.
+ *
+ * Storage:
+ *   - access token: in-memory (XSS-safe). Never persisted to
+ *     localStorage / sessionStorage.
+ *   - refresh token: httpOnly + Secure + SameSite=Lax cookie set by
+ *     authd. JS cannot read it (which is the point).
+ *
+ * On page reload, we lose the access token. The /v1/auth/me call
+ * on app boot triggers a refresh-if-needed via the refresh cookie,
+ * and the response gives us a fresh access token. If refresh
+ * fails, we redirect to /login.
+ */
+
+let accessToken: string | null = null;
+let refreshToken: string | null = null;
+let lastRefreshFailureUrl: string | null = null;
+
+export interface TokenPair {
+  access: string;
+  refresh: string;
+}
+
+export function setTokens(pair: TokenPair): void {
+  accessToken = pair.access;
+  refreshToken = pair.refresh;
+}
+
+export function setAccessToken(access: string): void {
+  accessToken = access;
+}
+
+export function clearTokens(): void {
+  accessToken = null;
+  refreshToken = null;
+}
+
+export function getAccessToken(): string | null {
+  return accessToken;
+}
+
+export function getRefreshToken(): string | null {
+  // The refresh cookie is sent automatically via credentials:'include'.
+  // JS never reads it directly.
+  return refreshToken;
+}
+
+export function markRefreshFailure(fromUrl: string): void {
+  lastRefreshFailureUrl = fromUrl;
+}
+
+export function getRefreshFailureUrl(): string | null {
+  return lastRefreshFailureUrl;
+}

+ 21 - 0
web/src/lib/scope.ts

@@ -0,0 +1,21 @@
+import type { Role } from './auth-context';
+
+export function isSuperAdmin(role: Role | undefined | null): boolean {
+  return role === 'super_admin';
+}
+
+export function isTenantAdmin(role: Role | undefined | null): boolean {
+  return role === 'tenant_admin';
+}
+
+export function canManageCompanies(role: Role | undefined | null): boolean {
+  return role === 'super_admin';
+}
+
+export function canManageSources(role: Role | undefined | null): boolean {
+  return role === 'super_admin' || role === 'tenant_admin';
+}
+
+export function canViewDLQ(role: Role | undefined | null): boolean {
+  return role === 'super_admin' || role === 'tenant_admin' || role === 'viewer';
+}

+ 33 - 0
web/src/lib/theme.ts

@@ -0,0 +1,33 @@
+const STORAGE_KEY = 'ba.theme';
+
+export type Theme = 'light' | 'dark' | 'system';
+
+export function getStoredTheme(): Theme {
+  if (typeof window === 'undefined') return 'system';
+  const raw = window.localStorage.getItem(STORAGE_KEY);
+  if (raw === 'light' || raw === 'dark' || raw === 'system') return raw;
+  return 'system';
+}
+
+function apply(theme: Theme): void {
+  const root = document.documentElement;
+  const dark =
+    theme === 'dark' ||
+    (theme === 'system' && window.matchMedia?.('(prefers-color-scheme: dark)').matches);
+  root.classList.toggle('dark', dark);
+  root.style.colorScheme = dark ? 'dark' : 'light';
+}
+
+export function initTheme(): void {
+  apply(getStoredTheme());
+  if (getStoredTheme() === 'system') {
+    window.matchMedia?.('(prefers-color-scheme: dark)').addEventListener('change', () => {
+      apply('system');
+    });
+  }
+}
+
+export function setTheme(theme: Theme): void {
+  window.localStorage.setItem(STORAGE_KEY, theme);
+  apply(theme);
+}

+ 6 - 0
web/src/lib/utils.ts

@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]): string {
+  return twMerge(clsx(inputs));
+}

+ 37 - 0
web/src/main.tsx

@@ -0,0 +1,37 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { RouterProvider } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { Toaster } from 'sonner';
+import { router } from './router';
+import { AuthProvider } from './lib/auth-context';
+import { initTheme } from './lib/theme';
+import './index.css';
+
+// Init theme (read localStorage, set .dark on <html>) BEFORE
+// React renders so the first paint is correct.
+initTheme();
+
+const queryClient = new QueryClient({
+  defaultOptions: {
+    queries: {
+      staleTime: 30_000,
+      retry: 1,
+      refetchOnWindowFocus: true,
+    },
+  },
+});
+
+const rootEl = document.getElementById('root');
+if (!rootEl) throw new Error('#root element not found in index.html');
+
+createRoot(rootEl).render(
+  <StrictMode>
+    <QueryClientProvider client={queryClient}>
+      <AuthProvider>
+        <RouterProvider router={router} />
+        <Toaster position="top-right" richColors />
+      </AuthProvider>
+    </QueryClientProvider>
+  </StrictMode>,
+);

+ 35 - 0
web/src/router.tsx

@@ -0,0 +1,35 @@
+import { createBrowserRouter } from 'react-router-dom';
+import { AppShell } from '@/components/layout/app-shell';
+import { RequireAuth } from '@/components/layout/require-auth';
+import { LoginRoute } from '@/routes/login';
+import { IndexRoute } from '@/routes/index';
+import { CompaniesRoute } from '@/routes/companies';
+import { SourcesRoute } from '@/routes/sources';
+import { TelegramRoute } from '@/routes/telegram';
+import { TailRoute } from '@/routes/tail';
+import { DLQRoute } from '@/routes/dlq';
+import { AuditRoute } from '@/routes/audit';
+import { NotFoundRoute } from '@/routes/not-found';
+import { ForbiddenRoute } from '@/routes/forbidden';
+
+export const router = createBrowserRouter([
+  { path: '/login', element: <LoginRoute /> },
+  { path: '/forbidden', element: <ForbiddenRoute /> },
+  {
+    element: (
+      <RequireAuth>
+        <AppShell />
+      </RequireAuth>
+    ),
+    children: [
+      { path: '/', element: <IndexRoute /> },
+      { path: '/companies/*', element: <CompaniesRoute /> },
+      { path: '/sources/*', element: <SourcesRoute /> },
+      { path: '/telegram/*', element: <TelegramRoute /> },
+      { path: '/tail', element: <TailRoute /> },
+      { path: '/dlq', element: <DLQRoute /> },
+      { path: '/audit', element: <AuditRoute /> },
+    ],
+  },
+  { path: '*', element: <NotFoundRoute /> },
+]);

+ 17 - 0
web/src/routes/audit.tsx

@@ -0,0 +1,17 @@
+import { ComingSoon } from '@/components/ui/coming-soon';
+
+export function AuditRoute() {
+  return (
+    <ComingSoon
+      title="Audit log"
+      badge="M13c"
+      description="Read-only table of audit_log rows, filterable by actor / action / entity / time."
+    >
+      <ul className="ml-5 list-disc space-y-1 text-sm text-muted-foreground">
+        <li>Filter bar: actor (user_id), action, entity_type, time range</li>
+        <li>Row expand: full JSON payload</li>
+        <li>Export to CSV (super-admin only)</li>
+      </ul>
+    </ComingSoon>
+  );
+}

+ 18 - 0
web/src/routes/companies.tsx

@@ -0,0 +1,18 @@
+import { ComingSoon } from '@/components/ui/coming-soon';
+
+export function CompaniesRoute() {
+  return (
+    <ComingSoon
+      title="Companies"
+      badge="M13b W1"
+      description="Super-admin can create, list, edit, suspend, activate companies. Tenant-admin sees only their own (read-only)."
+    >
+      <ul className="ml-5 list-disc space-y-1 text-sm text-muted-foreground">
+        <li>List with search, status filter, pagination</li>
+        <li>Create form: name, slug, rate_limit_per_sec, fcm_shared</li>
+        <li>Detail view with suspend / activate (typed confirmation)</li>
+        <li>Tenant-admin: read-only view of own company</li>
+      </ul>
+    </ComingSoon>
+  );
+}

+ 21 - 0
web/src/routes/dlq.tsx

@@ -0,0 +1,21 @@
+import { ComingSoon } from '@/components/ui/coming-soon';
+
+export function DLQRoute() {
+  return (
+    <ComingSoon
+      title="DLQ"
+      badge="M13c"
+      description="React replacement for cmd/admind/ui/dlq.html. Same list/replay/discard flow plus filters and audit hooks."
+    >
+      <p className="mb-4 text-sm text-muted-foreground">
+        Until the React surface ships, the M8 HTML UI is still served at{' '}
+        <code className="rounded bg-muted px-1">/dlq</code> by the admind binary.
+      </p>
+      <p className="text-xs text-muted-foreground">
+        Per-channel admin endpoints (<code>/v1/admin/dlq</code> on deliverd-fcm and
+        deliverd-telegram, JWT-gated) are also available for ops who want to inspect a
+        single channel.
+      </p>
+    </ComingSoon>
+  );
+}

+ 19 - 0
web/src/routes/forbidden.tsx

@@ -0,0 +1,19 @@
+import { Link } from 'react-router-dom';
+import { Button } from '@/components/ui/button';
+
+export function ForbiddenRoute() {
+  return (
+    <div className="flex h-full items-center justify-center">
+      <div className="text-center">
+        <p className="text-sm font-medium text-muted-foreground">403</p>
+        <h1 className="mt-2 text-2xl font-semibold tracking-tight">Forbidden</h1>
+        <p className="mt-2 text-sm text-muted-foreground">
+          Your role doesn't grant access to this page.
+        </p>
+        <Button asChild className="mt-6">
+          <Link to="/">Back to dashboard</Link>
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 23 - 0
web/src/routes/index.tsx

@@ -0,0 +1,23 @@
+import { Navigate } from 'react-router-dom';
+import { useAuth } from '@/lib/auth-context';
+
+/**
+ * / is a redirect to the first allowed module based on role.
+ *   super_admin   → /companies
+ *   tenant_admin  → /sources (their own company's sources)
+ *   viewer        → /dlq (read-only)
+ */
+export function IndexRoute() {
+  const { user } = useAuth();
+  if (!user) return <Navigate to="/login" replace />;
+  switch (user.role) {
+    case 'super_admin':
+      return <Navigate to="/companies" replace />;
+    case 'tenant_admin':
+      return <Navigate to="/sources" replace />;
+    case 'viewer':
+      return <Navigate to="/dlq" replace />;
+    default:
+      return <Navigate to="/login" replace />;
+  }
+}

+ 106 - 0
web/src/routes/login.tsx

@@ -0,0 +1,106 @@
+import { useState, type FormEvent } from 'react';
+import { Navigate, useLocation, useNavigate } from 'react-router-dom';
+import { toast } from 'sonner';
+import { useAuth } from '@/lib/auth-context';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+
+interface LocationState {
+  from?: string;
+}
+
+export function LoginRoute() {
+  const { user, login, loading } = useAuth();
+  const navigate = useNavigate();
+  const location = useLocation();
+  const state = location.state as LocationState | null;
+
+  const [email, setEmail] = useState('');
+  const [password, setPassword] = useState('');
+  const [submitting, setSubmitting] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  if (loading) return null;
+  if (user) {
+    const target = state?.from ?? '/';
+    return <Navigate to={target} replace />;
+  }
+
+  const onSubmit = async (e: FormEvent) => {
+    e.preventDefault();
+    setError(null);
+    setSubmitting(true);
+    try {
+      await login(email, password);
+      toast.success('Signed in');
+      const target = state?.from ?? '/';
+      navigate(target, { replace: true });
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : 'Sign-in failed';
+      setError(msg);
+      toast.error(msg);
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  return (
+    <div className="flex h-full items-center justify-center bg-muted/30 p-4">
+      <Card className="w-full max-w-sm">
+        <CardHeader>
+          <div className="mb-2 flex items-center gap-2">
+            <span className="inline-block h-6 w-6 rounded bg-primary" aria-hidden />
+            <span className="text-sm font-semibold">broad-announce</span>
+          </div>
+          <CardTitle>Sign in</CardTitle>
+          <CardDescription>
+            Operator console for the notification router.
+          </CardDescription>
+        </CardHeader>
+        <CardContent>
+          <form onSubmit={onSubmit} className="space-y-4">
+            <div className="space-y-2">
+              <Label htmlFor="email">Email</Label>
+              <Input
+                id="email"
+                type="email"
+                autoComplete="email"
+                required
+                value={email}
+                onChange={(e) => setEmail(e.target.value)}
+                placeholder="super@broad-announce.test"
+                disabled={submitting}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label htmlFor="password">Password</Label>
+              <Input
+                id="password"
+                type="password"
+                autoComplete="current-password"
+                required
+                value={password}
+                onChange={(e) => setPassword(e.target.value)}
+                disabled={submitting}
+              />
+            </div>
+            {error ? (
+              <p role="alert" className="text-sm text-destructive">
+                {error}
+              </p>
+            ) : null}
+            <Button type="submit" className="w-full" disabled={submitting}>
+              {submitting ? 'Signing in…' : 'Sign in'}
+            </Button>
+          </form>
+          <p className="mt-4 text-center text-xs text-muted-foreground">
+            Bootstrap the first super-admin with{' '}
+            <code className="rounded bg-muted px-1">scripts/bootstrap-super-admin.sh</code>.
+          </p>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 19 - 0
web/src/routes/not-found.tsx

@@ -0,0 +1,19 @@
+import { Link } from 'react-router-dom';
+import { Button } from '@/components/ui/button';
+
+export function NotFoundRoute() {
+  return (
+    <div className="flex h-full items-center justify-center">
+      <div className="text-center">
+        <p className="text-sm font-medium text-muted-foreground">404</p>
+        <h1 className="mt-2 text-2xl font-semibold tracking-tight">Page not found</h1>
+        <p className="mt-2 text-sm text-muted-foreground">
+          The route you followed isn't part of the admin console.
+        </p>
+        <Button asChild className="mt-6">
+          <Link to="/">Back to dashboard</Link>
+        </Button>
+      </div>
+    </div>
+  );
+}

+ 18 - 0
web/src/routes/sources.tsx

@@ -0,0 +1,18 @@
+import { ComingSoon } from '@/components/ui/coming-soon';
+
+export function SourcesRoute() {
+  return (
+    <ComingSoon
+      title="Sources"
+      badge="M13b W2"
+      description="Per-company sources with HMAC + API key, rate limits, allowed IPs, quarantine badge, one-time secrets modal."
+    >
+      <ul className="ml-5 list-disc space-y-1 text-sm text-muted-foreground">
+        <li>List + filter by company / type / status</li>
+        <li>Create form with auto-generated HMAC + API key</li>
+        <li>One-time secrets modal (forces "I've saved them")</li>
+        <li>Cert lifecycle (mTLS) — folded in from M14-ui</li>
+      </ul>
+    </ComingSoon>
+  );
+}

+ 17 - 0
web/src/routes/tail.tsx

@@ -0,0 +1,17 @@
+import { ComingSoon } from '@/components/ui/coming-soon';
+
+export function TailRoute() {
+  return (
+    <ComingSoon
+      title="Live tail"
+      badge="M13c"
+      description="SSE feed of incoming alerts, filterable by company / severity / source / text. Replaces the per-company dump that lives on /v1/tail today."
+    >
+      <ul className="ml-5 list-disc space-y-1 text-sm text-muted-foreground">
+        <li>EventSource('/v1/tail/stream') with auth via short-lived SSE token</li>
+        <li>Filter bar (severity, source, search) + pause-on-scroll-up</li>
+        <li>Click row → modal with full payload + dedupe history</li>
+      </ul>
+    </ComingSoon>
+  );
+}

+ 17 - 0
web/src/routes/telegram.tsx

@@ -0,0 +1,17 @@
+import { ComingSoon } from '@/components/ui/coming-soon';
+
+export function TelegramRoute() {
+  return (
+    <ComingSoon
+      title="Telegram bots"
+      badge="M13b W3"
+      description="Per-company bot config, invite codes, bindings table. Bot token is never displayed after save."
+    >
+      <ul className="ml-5 list-disc space-y-1 text-sm text-muted-foreground">
+        <li>Set / rotate bot token (encrypted at rest in admind)</li>
+        <li>Generate invite codes with magic link</li>
+        <li>List bindings: chat_id → individual_id mapping</li>
+      </ul>
+    </ComingSoon>
+  );
+}

+ 53 - 0
web/tailwind.config.ts

@@ -0,0 +1,53 @@
+import type { Config } from 'tailwindcss';
+import animate from 'tailwindcss-animate';
+
+export default {
+  darkMode: ['class'],
+  content: ['./index.html', './src/**/*.{ts,tsx}'],
+  theme: {
+    container: {
+      center: true,
+      padding: '2rem',
+      screens: { '2xl': '1400px' },
+    },
+    extend: {
+      colors: {
+        border: 'hsl(var(--border))',
+        input: 'hsl(var(--input))',
+        ring: 'hsl(var(--ring))',
+        background: 'hsl(var(--background))',
+        foreground: 'hsl(var(--foreground))',
+        primary: {
+          DEFAULT: 'hsl(var(--primary))',
+          foreground: 'hsl(var(--primary-foreground))',
+        },
+        secondary: {
+          DEFAULT: 'hsl(var(--secondary))',
+          foreground: 'hsl(var(--secondary-foreground))',
+        },
+        destructive: {
+          DEFAULT: 'hsl(var(--destructive))',
+          foreground: 'hsl(var(--destructive-foreground))',
+        },
+        muted: {
+          DEFAULT: 'hsl(var(--muted))',
+          foreground: 'hsl(var(--muted-foreground))',
+        },
+        accent: {
+          DEFAULT: 'hsl(var(--accent))',
+          foreground: 'hsl(var(--accent-foreground))',
+        },
+        card: {
+          DEFAULT: 'hsl(var(--card))',
+          foreground: 'hsl(var(--card-foreground))',
+        },
+      },
+      borderRadius: {
+        lg: 'var(--radius)',
+        md: 'calc(var(--radius) - 2px)',
+        sm: 'calc(var(--radius) - 4px)',
+      },
+    },
+  },
+  plugins: [animate],
+} satisfies Config;

+ 50 - 0
web/tests/login.test.tsx

@@ -0,0 +1,50 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter, Routes, Route } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { LoginRoute } from '@/routes/login';
+import { AuthProvider } from '@/lib/auth-context';
+
+function renderLogin() {
+  const qc = new QueryClient({
+    defaultOptions: { queries: { retry: false } },
+  });
+  return render(
+    <QueryClientProvider client={qc}>
+      <MemoryRouter initialEntries={['/login']}>
+        <AuthProvider>
+          <Routes>
+            <Route path="/login" element={<LoginRoute />} />
+            <Route path="/" element={<div>home</div>} />
+          </Routes>
+        </AuthProvider>
+      </MemoryRouter>
+    </QueryClientProvider>,
+  );
+}
+
+describe('LoginRoute', () => {
+  beforeEach(() => {
+    vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
+      if (String(url).includes('/v1/auth/refresh')) {
+        return new Response(JSON.stringify({ error: 'no_session' }), {
+          status: 401,
+          headers: { 'content-type': 'application/json' },
+        });
+      }
+      return new Response('not stubbed', { status: 501 });
+    });
+  });
+
+  it('renders the email + password form', async () => {
+    renderLogin();
+    expect(await screen.findByLabelText(/email/i)).toBeInTheDocument();
+    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
+  });
+
+  it('shows the broad-announce brand', async () => {
+    renderLogin();
+    expect(await screen.findByText('broad-announce')).toBeInTheDocument();
+  });
+});

+ 14 - 0
web/tests/setup.ts

@@ -0,0 +1,14 @@
+import '@testing-library/jest-dom/vitest';
+
+if (typeof window !== 'undefined' && !window.matchMedia) {
+  window.matchMedia = (query: string) => ({
+    matches: false,
+    media: query,
+    onchange: null,
+    addEventListener: () => {},
+    removeEventListener: () => {},
+    addListener: () => {},
+    removeListener: () => {},
+    dispatchEvent: () => false,
+  });
+}

+ 26 - 0
web/tsconfig.json

@@ -0,0 +1,26 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "useDefineForClassFields": true,
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "module": "ESNext",
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "isolatedModules": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+    "jsx": "react-jsx",
+    "strict": true,
+    "noUnusedLocals": false,
+    "noUnusedParameters": false,
+    "noFallthroughCasesInSwitch": true,
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["./src/*"]
+    },
+    "types": ["vite/client", "node"]
+  },
+  "include": ["src", "tests"],
+  "exclude": ["dist", "node_modules"]
+}

+ 1 - 0
web/tsconfig.tsbuildinfo

@@ -0,0 +1 @@
+{"root":["./src/main.tsx","./src/router.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/require-auth.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/layout/use-theme.ts","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/coming-soon.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/lib/api.ts","./src/lib/auth-context.tsx","./src/lib/auth-state.ts","./src/lib/scope.ts","./src/lib/theme.ts","./src/lib/utils.ts","./src/routes/audit.tsx","./src/routes/companies.tsx","./src/routes/dlq.tsx","./src/routes/forbidden.tsx","./src/routes/index.tsx","./src/routes/login.tsx","./src/routes/not-found.tsx","./src/routes/sources.tsx","./src/routes/tail.tsx","./src/routes/telegram.tsx","./tests/login.test.tsx","./tests/setup.ts"],"version":"5.9.3"}

+ 55 - 0
web/vite.config.ts

@@ -0,0 +1,55 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'node:path';
+
+// M13b W0: vite dev proxies /v1/* to the local stack:
+//   /v1/auth/* → authd   (port 8804)
+//   /v1/*      → admind  (port 8803)
+//
+// In production, the SPA is embedded in admind and served from /,
+// so all /v1/* requests go to the same origin (admind).
+const devProxies = (() => {
+  const authd = process.env.VITE_PROXY_AUTHD ?? 'http://127.0.0.1:8804';
+  const admind = process.env.VITE_PROXY_ADMIND ?? 'http://127.0.0.1:8803';
+  return [
+    {
+      context: ['/v1/auth', '/health'],
+      target: authd,
+      changeOrigin: false,
+    },
+    {
+      context: ['/v1'],
+      target: admind,
+      changeOrigin: false,
+    },
+  ];
+})();
+
+export default defineConfig({
+  plugins: [react()],
+  resolve: {
+    alias: {
+      '@': path.resolve(__dirname, 'src'),
+    },
+  },
+  server: {
+    port: 5173,
+    proxy: devProxies,
+  },
+  build: {
+    outDir: process.env.VITE_OUT_DIR ?? '../cmd/admind/web-dist',
+    emptyOutDir: true,
+    sourcemap: false,
+    target: 'es2022',
+    rollupOptions: {
+      output: {
+        manualChunks: {
+          react: ['react', 'react-dom', 'react-router-dom'],
+          query: ['@tanstack/react-query'],
+          forms: ['react-hook-form', '@hookform/resolvers', 'zod'],
+          ui: ['lucide-react', 'sonner'],
+        },
+      },
+    },
+  },
+});

+ 18 - 0
web/vitest.config.ts

@@ -0,0 +1,18 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+import path from 'node:path';
+
+export default defineConfig({
+  plugins: [react()],
+  resolve: {
+    alias: {
+      '@': path.resolve(__dirname, 'src'),
+    },
+  },
+  test: {
+    environment: 'happy-dom',
+    globals: true,
+    setupFiles: ['./tests/setup.ts'],
+    include: ['tests/**/*.test.{ts,tsx}'],
+  },
+});

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff