Separate from the user dashboard at web/ so admin and end-user surfaces never get confused. Same stack (React 19 + Vite + Tailwind v4 + shadcn) for consistency; differentiated visually via: - 3px amber top stripe on every authenticated page - persistent ADMIN badge in the sidebar header and login card - amber-tinted sidebar with diagonal pattern - amber accent on active nav items and primary buttons - env pill (red/amber/emerald for prod/staging/dev) in the topbar - 'Admin · Warmbly' title and amber-stroked shield favicon Pages with real data wiring: Overview, Workers (list+detail with SSH actions), Egresses, Audit Log, Settings (Encryption / Storage / Messaging / Cache / Transports) backed by /admin/settings/backends Stub pages with placeholder bodies (nav exists, no 404): Mailboxes, Users, Organizations, Plans, Warmup, Campaigns, Analytics Auth reuses backend session cookies. Dev server runs on port 5174 to coexist with the dashboard on 5173. pnpm install && pnpm build → clean pnpm typecheck && pnpm lint → clean (2 pre-existing fast-refresh warnings in shadcn primitives kept verbatim from the dashboard)
web-admin
Warmbly's internal admin control plane. Separate Vite + React app, parallel to web/, that drives the /admin/* endpoints on the same backend.
Why a separate app
The dashboard at web/ is the product surface for customers. The admin app is the surface for the Warmbly team running the platform. Splitting them gives us:
- a smaller, faster admin bundle (no tiptap, no marketing chrome, no onboarding flow)
- independent deployment cadence (admin can ship without touching customer code)
- different origin in production, so a stolen dashboard session can't quietly use admin endpoints
- a clear visual marker (the amber ADMIN badge + stripe + sidebar tint) so anyone with both tabs open knows which one is which
Both apps share the same backend, the same Bearer-token auth shape, and the same shadcn primitives.
Run it locally
pnpm install
pnpm dev # boots on http://localhost:5174
pnpm build # production bundle into ./dist
pnpm typecheck # tsc -b
pnpm lint
The dev server defaults to port 5174 so it coexists with the dashboard's 5173.
Set up .env.local from .env.example:
cp .env.example .env.local
| Variable | Purpose |
|---|---|
VITE_API_URL |
Same Warmbly backend the dashboard talks to. Reuses /admin/*. |
VITE_ENV_LABEL |
Drives the Production / Staging / Development pill in the topbar. |
VITE_DASHBOARD_URL |
Used by the "Open dashboard" link in the user menu. |
Visual differentiation (do not strip)
This app is intentionally tinted differently from the dashboard. If you find yourself "cleaning up" the amber accent, stop and read this section first.
- ADMIN badge in the sidebar header and on the login card. Amber pill,
ShieldAlerticon. Always visible. - 3px stripe along the top of the app shell (
admin-stripeutility). First thing the eye lands on. - Sidebar tint (
--sidebarshifted warm + faint diagonal pattern viaadmin-sidebar-pattern) so the rail reads as a different surface than the dashboard's near-white sidebar. - Amber active-nav state instead of the dashboard's blue.
- Env pill in the topbar — different colour per environment.
- Title prefix:
index.htmlships<title>Admin · Warmbly</title>and the favicon is an amber-bordered shield (public/admin-icon.svg).
These signals are layered on purpose. A single one (e.g. just the badge) is easy to overlook in a tab strip. Stacked, they make it obvious that the user is in the privileged surface.
What's wired vs. stubbed
Real data:
- Overview —
/admin/analytics/overviewplus/admin/workers/managedfor the fleet card - Workers list —
/admin/workers/managed - Worker detail —
/admin/workers/:id/managed,/admin/workers/:id/live-status,/admin/workers/:id/logs, plus the SSH lifecycle mutations (test,install,restart,uninstall) - Egresses — wired to
/admin/workers/managedre-framed as sending identities (TODO when/admin/egressesexists) - Audit Log —
/admin/audit-logs - Settings (Encryption, Storage, Messaging, Cache, Transports) —
/admin/settings/backendswithkindfilter; renders an "endpoint pending" placeholder when the registry isn't wired yet
Stubs (page exists, no backend wire-up yet):
- Mailboxes
- Users
- Organizations
- Plans & Billing
- Warmup pools
- Campaigns
- Analytics (cross-platform charts; the Overview page already feeds from the same family of endpoints)
Layout
web-admin/
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig*.json
├── eslint.config.js
├── components.json # shadcn config, mirrors web/
├── public/
│ └── admin-icon.svg # amber-stroked shield favicon
└── src/
├── main.tsx # router + query client + providers
├── global.css # design tokens (mirror of web/) + admin-only tokens
├── app/
│ ├── auth/LoginPage.tsx
│ ├── dashboard/ # Overview, Workers, Egresses, Audit, stubs
│ └── settings/ # Encryption/Storage/Messaging/Cache/Transports
├── components/
│ ├── layout/ # AppShell, Sidebar, Topbar, AdminBadge, EnvPill, …
│ └── ui/ # shadcn primitives copied from web/src/components/ui
├── hooks/
│ └── useMe.ts
└── lib/
├── env.ts
├── utils.ts
├── auth/storage.ts # Bearer token persistence
└── api/
├── client.ts # axios instance + Request<T>
├── client/
│ ├── auth/ # login, getMe, logout
│ └── admin/ # workers, audit, analytics, settings
└── models/