Admin App
Local-only Next.js app for managing things in hosted dev and prod Supabase that the dashboard intentionally doesn't expose — with a two-tier dev→prod promotion flow and an audit trail.
apps/admin is a small Next.js app that manages configuration data that lives in Supabase but isn't safe to expose in the customer-facing dashboard — currently the Assistant model whitelist and user management (/users: search, create, edit, delete, and trial-reset users across hosted dev/prod), and any future cross-environment toggles. It is deliberately local-only: there is no deploy target, and the layout throws at runtime if NODE_ENV === "production" outside of a next build phase. The whole point is to keep the surface narrow and not be reachable from the internet.
It runs on port 3069 and is started with:
pnpm --filter admin devWhy it's a separate app
The dashboard is wired to a single Supabase project per environment: locally it points at 127.0.0.1:54321, in CI / preview / staging / production it points at the matching hosted project. Admin tooling needs the opposite shape — it must read and write to hosted dev and hosted prod from your laptop, side by side, so an operator can stage a change in dev, eyeball it, and then promote the same change to prod with a visible diff. Putting that capability inside apps/dashboard would either pollute its env namespace or force conditional UI that's easy to leak. Splitting the surface fixes both problems.
apps/admin must never be deployed. The layout has a guard that throws unless NODE_ENV !== "production" (or we're inside a next build). Don't add it to Vercel projects, don't add it to pnpm dev, don't ship it.
Two-tier state model
The Assistant model whitelist keeps three layers of state:
- Client local — what the operator is editing in the browser.
- Hosted dev — the staged truth, written by "Save".
- Hosted prod — the live truth, only updated by an explicit "Apply to prod" action.
The promotion flow is deliberately friction-y: client edits land in dev first, then a modal shows a precise per-row diff against prod, the operator confirms, and only then does the write hit prod. That gives you a chance to spot mistakes before they go live, and produces a meaningful audit row (see Audit log).
/users doesn't use this staged pattern — it switches between hosted dev and hosted prod directly and writes CRUD operations straight to whichever one is selected, with no client-local diff or promotion step. Mutations against prod still land in the same audit log.
Environment setup
Admin reads two parallel sets of Supabase credentials — one for hosted dev, one for hosted prod — plus the operator's email for audit attribution. The project URLs go through Referenta's custom domains rather than the raw *.supabase.co hosts, so they're not secret and live in apps/admin/.env; the service-role keys and operator email are secrets and live in apps/admin/.env.local (gitignored):
# apps/admin/.env
SUPABASE_DEV_URL="https://sb-dev.referenta.de"
SUPABASE_PROD_URL="https://sb.referenta.de"
# apps/admin/.env.local
SUPABASE_DEV_SERVICE_ROLE_KEY=...
SUPABASE_PROD_SERVICE_ROLE_KEY=...
# Operator email (auto-populated from git config when present)
ADMIN_OPERATOR_EMAIL=you@referenta.depnpm --filter admin dev runs tsx scripts/bootstrap-env.ts first, which reads your git user email and writes ADMIN_OPERATOR_EMAIL into .env.local if it's missing. Service-role keys are not auto-fetched — pull them from the Supabase dashboard for each project once and keep them out of files that get synced anywhere.
The service-role keys grant unrestricted access to each Supabase project. Keep apps/admin/.env.local on your machine only; it is gitignored, but don't paste those keys into Slack, shared notes, or chat with cloud assistants.
Dev → prod promotion flow
The day-to-day operator flow for the model whitelist:
Edit in the browser
Open the admin page (e.g. /assistant/models), make changes. Changes are kept as a client-local state diff against hosted dev — nothing is written until you press Save.
Save to hosted dev
Pressing Save writes the diff to the hosted dev Supabase project and shows a toast with the +N adds / -N removes counts. State now reads from dev as the source of truth; further edits compare against this.
Review the prod diff
The "Apply to prod" button glows red / yellow / green based on how far hosted prod has drifted from hosted dev. Clicking it opens a modal with a per-row checkbox diff against prod, plus a pre-flight schema probe that flags any table-missing / column-missing issues before the write.
Confirm and apply
Tick the rows to promote (default: all), confirm. The selected ops are written to the hosted prod Supabase project. Failed rows surface inline with a retry-failed handler. Success / partial / failed status is recorded to the audit log.
Audit log
Every "apply to prod" action writes a row into admin_apply_log on the prod project. The table is declared in supabase/schemas/90_admin.sql:
CREATE TABLE "public"."admin_apply_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"operator_email" text NOT NULL,
"section" text NOT NULL,
"resource_table" text NOT NULL,
"op_count" integer NOT NULL,
"op_summary" text NOT NULL,
"details" jsonb NOT NULL,
"status" text NOT NULL CHECK (status IN ('success', 'partial', 'failed')),
"error_details" jsonb,
"applied_at" timestamptz NOT NULL DEFAULT now()
);Inspect it with any prod-credentialed Supabase client, or with supabase db remote ... against the prod project ref. The audit write is best-effort — if it fails, the apply still succeeds and a warning logs to the admin console — because losing the actual prod change to a flaky audit insert would be the wrong tradeoff.
Build and lint hygiene
- The runtime guard skips during a
next buildphase (NEXT_PHASE === "phase-production-build") so type-checking and build-time page collection still pass. Don't relax this guard further. - There is no nested
apps/admin/biome.json— root config wins. Don't reintroduce one; it conflicts with lefthook + biome at the workspace level. - React, Next, and Sonner versions in
apps/admin/package.jsonmust matchpackages/ui. A mismatched React or Sonner copy will produce a runtime singleton split (toasts silently fail to render).