# Spec 4 — Admin Site

Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 3 (`admin-backend`)
Ships: with spec 3. Can be developed in parallel with spec 5 once spec 3's API
is stable.

## Context

A fifth Next.js app, `adminsite/`, serving two audiences from one codebase:

- **Staff** — internal operators. Accounts, instances, licence history, plan
  editing, injection health, audit.
- **Customers** — their own account, instances, licences and subscription state.

They share auth plumbing and a component library but almost no screens. The
split is by route group, so a customer route can never accidentally render a
staff view.

## Goals

1. A customer can buy, link a self-hosted instance, download a licence, and see
   when it expires — without contacting anyone.
2. Staff can answer "why did this customer's instance stop working" in one screen.
3. Nothing about the marketing site or the control-plane UI changes.

## Non-goals

- Rebuilding billing management. Card details, invoices, payment methods and
  cancellation all deep-link into Paddle's customer portal.
- Server management. This is not a second control plane; there is exactly one
  link out to the instance and no data about servers, keys or workflows.
- Public signup for cloud. That stays on the marketing site (moving to admin's
  backend in spec 5, but the *form* stays where customers already find it).

## Design

### App

Built exactly like `web/` and `site/`: Next.js 16 App Router, React 18, Tailwind
3, TanStack Query, `output: "standalone"`, `node:26-alpine`, listening on `3000`,
published as `3002`. In `docker-compose.site.yml` only.

`ADMIN_API_URL` is baked in at build time, as `API_URL` is for `web/`. It must be
**browser-reachable** and must appear in the backend's `ADMIN_ORIGIN`. Getting
this wrong is the single most common deployment failure in this repo's history —
`SITE_API_URL` has the same footgun documented in `CLAUDE.md` — so the app
renders an explicit "not connected" state rather than failing silently.

```
adminsite/
├── app/
│   ├── login/
│   ├── signup/                    # self-hosted customer account creation
│   ├── verify/
│   ├── (customer)/
│   │   ├── page.tsx               # account overview
│   │   ├── instances/[id]/
│   │   ├── instances/link/
│   │   ├── billing/
│   │   └── layout.tsx             # customer nav, account guard
│   └── (staff)/staff/
│       ├── page.tsx               # operations dashboard
│       ├── accounts/[id]/
│       ├── instances/[id]/
│       ├── licenses/
│       ├── plans/
│       └── layout.tsx             # staff nav, staff guard
├── components/
└── lib/
```

Route-group layouts do the guarding. A customer session hitting `/staff/*` gets
redirected, not a 403 page — there is nothing to tell them about.

### Customer screens

**Overview** — the account, its instances as cards. Each card: name, cloud or
self-hosted, tier, licence state, expiry with days remaining, and a link either
to the instance's subdomain (cloud) or to its licence page (self-hosted).

Licence state is colour-coded and blunt: green valid, amber under 14 days, red
expired. An expired card says what still works — "servers and monitors are still
running; changes are disabled" — because that is the first thing a worried
customer wants to know.

**Instance detail** — tier, limits, features, subscription status, next renewal
date. For self-hosted: the linked UUID, a **Download licence** button, the blob
in a copy-to-clipboard box, and step-by-step paste instructions with the target
route named (`Settings → Licence` on their own install). A **Relink** action
showing the remaining allowance ("2 of 3 relinks remaining this term").

**Link an instance** — the self-hosted activation screen. Explains where to find
the UUID (shown on `/setup`, and permanently on `/settings/license`), takes the
paste, validates the format client-side, and on success issues the licence and
lands the customer directly on the download.

The whole flow — buy, link, download, paste — should be completable without
reading documentation. That is the bar for this screen.

**Billing** — subscription list with status and renewal date, plus a button to
Paddle's portal. Deliberately thin.

### Staff screens

**Dashboard** — the operational answers, not vanity metrics: licences expiring
in the next 14 days, subscriptions `past_due`, instances `awaiting_link` for more
than 48 hours, and **failed injections** from the reconciliation job. Each row
links straight to the thing that needs doing.

**Accounts** — searchable by name, email, Paddle customer ID and instance UUID.
Searching by UUID matters: a support email arrives containing a UUID and nothing
else.

**Account detail** — instances, subscriptions, customer users, audit trail.

**Instance detail** — everything about one instance, with the **full licence
history as a timeline**: issued, superseded, renewed, relinked, each with a
timestamp, reason and who did it. This is the screen that answers "why did this
stop working on the 14th". Actions: issue, reissue, relink without limit, and a
live view of the control-plane injection state for cloud instances.

**Licences** — global history, filterable by tier, deployment, expiry window and
issuance reason.

**Plans** — edit limits and features per tier. Two guard rails, because this
screen changes what every future customer gets:

- A confirmation step naming exactly what changes and stating that existing
  licences are unaffected until reissued.
- The deployment field is not editable. Moving Free to `self_hosted` would break
  the cloud-only rule that spec 1 leans on; changing it is a code review, not a
  form field.

**Audit** — every mutating action, filterable.

### Design language

Visually distinct from `web/`. Staff regularly have both open, and a moment of
"which app am I in" before clicking Reissue is worth designing out. Different
accent colour and a persistent environment badge in the header (sandbox or
production, from a build-time flag) — clicking Issue against the wrong Paddle
environment should be hard.

Shared component patterns with `web/` where they exist; this is not a reason to
invent a second design system.

### Error and empty states

- Backend unreachable: a page-level "not connected" state naming
  `ADMIN_API_URL`, matching the pattern the marketing site already uses.
- No instances yet: a customer-facing explanation of the two paths — buy cloud,
  or buy self-hosted and link.
- `awaiting_link`: a prominent prompt on the overview, since a customer who has
  paid and not linked is a customer who has paid for nothing yet.
- Licence download failure: show the blob inline as a fallback so the customer is
  never blocked by a file download.

## Testing

Component and integration tests with mocked API responses. The repo has no
frontend test setup today; this is where one starts, scoped to the flows that
lose money or leak data when broken.

1. Customer session on `/staff/*` redirects; staff session reaches it.
2. Instance card renders correctly for each licence state, including expired,
   and the expired copy names what still works.
3. Link flow: valid UUID succeeds and lands on download; malformed UUID is caught
   client-side; already-linked UUID surfaces the backend's message.
4. Relink shows the remaining allowance and disables at zero with the support
   message.
5. Licence download failure falls back to the inline blob.
6. Not-connected state renders when the API is unreachable.
7. Staff dashboard renders each alert category and links to the right resource.
8. Plan edit requires confirmation and shows the "existing licences unaffected"
   wording.
9. Instance search by UUID returns the instance.
10. Licence history timeline renders every reason type in order.

## Verification before merge

1. Test suite green.
2. Full manual pass, self-hosted purchase to working licence, using only the UI
   and no documentation — timed, and if it takes more than five minutes the flow
   needs work.
3. Full manual pass, cloud: buy, confirm the licence appears in the control plane
   within a minute, confirm the instance's own settings page agrees.
4. Staff pass: find an account by instance UUID, read its licence history,
   reissue, confirm the control plane picks it up.
5. Responsive check at mobile width — a customer hit by an expiry email will open
   this on a phone.
6. `docker build` from the repo root succeeds and the image runs with
   `ADMIN_API_URL` baked in.

## Risks

| Risk | Mitigation |
|---|---|
| `ADMIN_API_URL` misconfigured at build | Explicit not-connected state; documented alongside the existing `SITE_API_URL` footgun |
| Staff action taken against the wrong environment | Persistent environment badge; confirmation on destructive actions |
| Customer confused by the self-hosted flow | Step-by-step link screen; five-minute bar in verification |
| Customer session reaching staff data | Route-group guards plus backend scoping (spec 3, test 20). Two layers, because one is not enough for this |
