Files
warmbly/admin
Matthew Meszaros 1c4fcff558 feat: make the admin panel's broken queries run: seven statements referenced schema that does not exist and failed 100% of the time, so a force-stop wrote status = 'stopped' and stopped_at to a campaign_status enum and a campaigns table that have neither, the plan writes named a duration column that became duration_id long ago, the user rate-limit read and write named a daily_email_limit column that user_rate_limits has never had, and the user preview compared email_accounts.user_id (uuid) against a text parameter and then swallowed the error so every operator saw an empty mailbox list; the same uuid = text defect in GetUserEmails, which the prepare sweep cannot see because that WHERE clause is assembled at runtime, was a live 500 on GET /admin/users/:id/emails; a stop now parks the campaign at 'paused' like the owner-facing stop and records the reason the UI has always sent and the backend has always dropped into both the audit log and the owner's campaign feed, the plan writes resolve durations.title to duration_id and answer 400 rather than a constraint violation on an unknown period, the rate-limit editor now covers the seven real limit columns instead of one that never existed and patches insert-then-update in a transaction because every column is NOT NULL, AdminWorkerEmail.LastSyncedAt is a pointer so a never-synced mailbox stops being silently dropped from every admin list, and TestLiveEveryQueryPrepares now fails on undefined columns, tables, operators and enum values instead of only reporting them
2026-08-27 03:09:40 -07:00
..
feat: make the admin panel's broken queries run: seven statements referenced schema that does not exist and failed 100% of the time, so a force-stop wrote status = 'stopped' and stopped_at to a campaign_status enum and a campaigns table that have neither, the plan writes named a duration column that became duration_id long ago, the user rate-limit read and write named a daily_email_limit column that user_rate_limits has never had, and the user preview compared email_accounts.user_id (uuid) against a text parameter and then swallowed the error so every operator saw an empty mailbox list; the same uuid = text defect in GetUserEmails, which the prepare sweep cannot see because that WHERE clause is assembled at runtime, was a live 500 on GET /admin/users/:id/emails; a stop now parks the campaign at 'paused' like the owner-facing stop and records the reason the UI has always sent and the backend has always dropped into both the audit log and the owner's campaign feed, the plan writes resolve durations.title to duration_id and answer 400 rather than a constraint violation on an unknown period, the rate-limit editor now covers the seven real limit columns instead of one that never existed and patches insert-then-update in a transaction because every column is NOT NULL, AdminWorkerEmail.LastSyncedAt is a pointer so a never-synced mailbox stops being silently dropped from every admin list, and TestLiveEveryQueryPrepares now fails on undefined columns, tables, operators and enum values instead of only reporting them
2026-08-27 03:09:40 -07:00
2026-05-27 16:17:47 +00:00
2026-05-27 16:17:47 +00:00
2026-05-27 16:17:47 +00:00
2026-05-27 16:17:47 +00:00
2026-05-27 16:17:47 +00:00
2026-05-27 16:17:47 +00:00

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

From the repo root you can also use make admin, which is a shortcut for cd admin && pnpm dev. make app does not start this app — admin lives outside the docker compose stack so it can ship on its own cadence.

The dev server defaults to port 5174 so it coexists with the dashboard's 5173.

First admin (local dev)

Admin access is gated by users.admin_permissions (bitmask) on the backend. Nothing in the codebase seeds the first admin — sign up through the dashboard as normal, then promote yourself from the repo root:

make grant-admin EMAIL=you@example.com               # super-admin
make grant-admin EMAIL=you@example.com ROLE=support  # or ops, analyst
make revoke-admin EMAIL=you@example.com              # drop back to 0

Role bitmasks mirror AdminRolePermissions in internal/models/admin_permission.go. For one-off permission combinations, pass a raw BITMASK=N instead of ROLE.

Once a super-admin exists they can grant the rest through the in-app user management screen, which goes through the audited GrantAdminPermissions path instead of raw SQL.

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, ShieldAlert icon. Always visible.
  • 3px stripe along the top of the app shell (admin-stripe utility). First thing the eye lands on.
  • Sidebar tint (--sidebar shifted warm + faint diagonal pattern via admin-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.html ships <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/overview plus /admin/workers/managed for 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)
  • Audit Log — /admin/audit-logs
  • Settings (Encryption, Storage, Messaging, Cache, Transports) — /admin/settings/backends with kind filter; 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

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, 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/