diff --git a/adminsite/components/NotConnected.test.tsx b/adminsite/components/NotConnected.test.tsx
new file mode 100644
index 0000000..6a1143b
--- /dev/null
+++ b/adminsite/components/NotConnected.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { NotConnectedPanel } from "./NotConnected";
+
+describe("NotConnectedPanel", () => {
+ it("names the variable that is wrong and where it is set", () => {
+ render();
+
+ expect(screen.getByRole("heading")).toHaveTextContent(
+ /not connected to the licensing service/i,
+ );
+ expect(screen.getByText(/ADMIN_API_URL/)).toBeInTheDocument();
+ expect(screen.getByText(/https:\/\/admin\.example\.com/)).toBeInTheDocument();
+ // The two mistakes that actually cause this, both named.
+ expect(screen.getByText(/reachable from your browser/i)).toBeInTheDocument();
+ expect(screen.getByText(/ADMIN_ORIGIN/)).toBeInTheDocument();
+ });
+
+ it("says the value is missing when no URL was baked in", () => {
+ render();
+ expect(screen.getByText(/was not set when this app was built/i)).toBeInTheDocument();
+ });
+});
diff --git a/adminsite/components/NotConnected.tsx b/adminsite/components/NotConnected.tsx
new file mode 100644
index 0000000..21d1f12
--- /dev/null
+++ b/adminsite/components/NotConnected.tsx
@@ -0,0 +1,28 @@
+/*
+ * The deployment failure this repo makes most often, made legible. It names the
+ * variable, the value baked in, and both reasons it fails — unreachable from
+ * the browser, or missing from admin's ADMIN_ORIGIN.
+ */
+export function NotConnectedPanel({ url }: { url: string }) {
+ return (
+
+
Not connected to the licensing service
+ {url ? (
+
+ This build points at ADMIN_API_URL ={" "}
+ {url}, which did not respond.
+
+ ) : (
+
+ ADMIN_API_URL was not set when this app was
+ built, so there is nowhere to send requests.
+
+ )}
+
+ The value is baked in when the image is built and has to be reachable from your
+ browser, not just from the server. It also has to appear in the licensing
+ service’s ADMIN_ORIGIN, or the browser blocks every request.
+
+
+ );
+}
diff --git a/adminsite/lib/api.ts b/adminsite/lib/api.ts
new file mode 100644
index 0000000..724f1ee
--- /dev/null
+++ b/adminsite/lib/api.ts
@@ -0,0 +1,224 @@
+/*
+ * The typed client for the licensing service.
+ *
+ * The browser calls admin directly, so every request carries credentials and
+ * every failure mode is one of three: the API is unreachable (NotConnected),
+ * the caller is not signed in (ApiError 401, which layouts redirect on), or the
+ * request was refused (ApiError with the backend's own message, which is
+ * customer-facing and should be shown verbatim).
+ */
+
+export const API_BASE = (process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "").replace(/\/$/, "");
+
+export class NotConnected extends Error {
+ constructor() {
+ super("not connected");
+ this.name = "NotConnected";
+ }
+}
+
+export class ApiError extends Error {
+ status: number;
+ constructor(status: number, message: string) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ }
+}
+
+async function req(path: string, init?: RequestInit): Promise {
+ if (!API_BASE) throw new NotConnected();
+
+ let res: Response;
+ try {
+ res = await fetch(`${API_BASE}${path}`, {
+ ...init,
+ credentials: "include",
+ headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
+ });
+ } catch {
+ // Network-level failure, DNS, or a CORS preflight the browser refused.
+ throw new NotConnected();
+ }
+
+ if (res.status === 204) return undefined as T;
+
+ const body = await res.json().catch(() => null);
+ if (!res.ok) {
+ throw new ApiError(res.status, body?.error ?? `request failed (${res.status})`);
+ }
+ return body as T;
+}
+
+const post = (path: string, payload?: unknown) =>
+ req(path, { method: "POST", body: payload ? JSON.stringify(payload) : undefined });
+
+// --- types ---------------------------------------------------------------
+
+export type Deployment = "cloud" | "self_hosted";
+export type Tier = "free" | "professional" | "self_hosted";
+export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled";
+
+export interface Session {
+ kind: "staff" | "customer";
+ email: string;
+ account_id?: string;
+}
+
+export interface Limits {
+ max_servers: number;
+ max_secret_groups: number;
+ max_channels: number;
+}
+
+export interface Account {
+ account_id: string;
+ name: string;
+ billing_email: string;
+ paddle_customer_id?: string;
+ status: "active" | "suspended";
+ created_at: string;
+}
+
+export interface Instance {
+ instance_id: string;
+ account_id: string;
+ name: string;
+ slug?: string;
+ deployment: Deployment;
+ tier?: Tier;
+ status: InstanceStatus;
+ current_license?: string;
+ relink_count: number;
+ inject_failed_at?: string | null;
+ created_at: string;
+}
+
+export interface License {
+ license_id: string;
+ instance_id: string;
+ account_id: string;
+ tier: Tier;
+ deployment: Deployment;
+ limits: Limits;
+ features: string[];
+ issued_at: string;
+ expires_at: string;
+ superseded_by?: string;
+ issued_by: string;
+ reason: "new" | "renewal" | "tier_change" | "relink" | "manual";
+}
+
+export interface Subscription {
+ subscription_id: string;
+ account_id: string;
+ instance_id?: string;
+ tier: Tier;
+ term: string;
+ status: string;
+ current_period_end: string;
+}
+
+export interface Plan {
+ tier: Tier;
+ name: string;
+ deployment: Deployment;
+ limits: Limits;
+ features: string[];
+ paddle_product_id?: string;
+ paddle_price_ids?: Record;
+ active: boolean;
+}
+
+export interface CustomerUser {
+ user_id: string;
+ account_id: string;
+ email: string;
+ verified_at?: string | null;
+ created_at: string;
+}
+
+export interface AuditEntry {
+ actor: string;
+ action: string;
+ account_id?: string;
+ target?: string;
+ detail?: string;
+ ip?: string;
+ created_at: string;
+}
+
+export interface AccountResponse {
+ account: Account;
+ instances: Instance[];
+ max_relinks: number;
+}
+
+export interface StaffAccountResponse {
+ account: Account;
+ instances: Instance[];
+ subscriptions: Subscription[];
+ users: CustomerUser[];
+ audit: AuditEntry[];
+}
+
+export type InjectionState = "current" | "stale" | "missing" | "none_issued";
+
+export interface StaffInstanceResponse {
+ instance: Instance;
+ account: Account;
+ licenses: License[];
+ injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null };
+}
+
+// --- calls ---------------------------------------------------------------
+
+export const api = {
+ me: () => req("/auth/me"),
+ login: (email: string, password: string) => post("/auth/login", { email, password }),
+ staffLogin: (email: string, password: string) =>
+ post("/auth/staff/login", { email, password }),
+ logout: () => post<{ ok: boolean }>("/auth/logout"),
+ signup: (payload: { name: string; email: string; password: string; website?: string }) =>
+ post<{ pending: boolean }>("/auth/signup", payload),
+ verify: (token: string) =>
+ req<{ verified: boolean }>(`/auth/verify?token=${encodeURIComponent(token)}`),
+
+ account: () => req("/api/account"),
+ link: (instance_id: string, name: string) =>
+ post("/api/instances/link", { instance_id, name }),
+ relink: (id: string, instance_id: string) =>
+ post(`/api/instances/${id}/relink`, { instance_id }),
+ license: (id: string) => req(`/api/instances/${id}/license`),
+ licenseBlobUrl: (id: string) => `${API_BASE}/api/instances/${id}/license/download`,
+ subscriptions: () => req("/api/subscriptions"),
+
+ staff: {
+ accounts: (q?: string) =>
+ req(`/api/staff/accounts${q ? `?q=${encodeURIComponent(q)}` : ""}`),
+ account: (id: string) => req(`/api/staff/accounts/${id}`),
+ instances: (params?: Record) =>
+ req(
+ `/api/staff/instances${params ? `?${new URLSearchParams(params)}` : ""}`,
+ ),
+ instance: (id: string) => req(`/api/staff/instances/${id}`),
+ issue: (id: string, payload: { tier: Tier; term?: string; reason?: string }) =>
+ post(`/api/staff/instances/${id}/issue`, payload),
+ relink: (id: string, instance_id: string) =>
+ post(`/api/staff/instances/${id}/relink`, { instance_id }),
+ licenses: (params?: Record) =>
+ req(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
+ plans: () => req("/api/staff/plans"),
+ updatePlan: (tier: Tier, plan: Omit) =>
+ req<{ updated: boolean }>(`/api/staff/plans/${tier}`, {
+ method: "PUT",
+ body: JSON.stringify(plan),
+ }),
+ audit: (accountId?: string) =>
+ req(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`),
+ injectionHealth: () =>
+ req<{ failed: Instance[]; count: number }>("/api/staff/health/injection"),
+ subscriptions: (status?: string) =>
+ req(`/api/staff/subscriptions${status ? `?status=${status}` : ""}`),
+ },
+};
diff --git a/adminsite/lib/format.ts b/adminsite/lib/format.ts
new file mode 100644
index 0000000..e6c154a
--- /dev/null
+++ b/adminsite/lib/format.ts
@@ -0,0 +1,33 @@
+export type LicenceState = "valid" | "warn" | "expired" | "none";
+
+/** Amber inside 14 days, matching the window staff chase renewals on. */
+export const EXPIRY_WARNING_DAYS = 14;
+
+export function daysRemaining(iso: string): number {
+ const ms = new Date(iso).getTime() - Date.now();
+ return Math.ceil(ms / 86_400_000);
+}
+
+export function licenceState(expiresAt: string | undefined, hasLicence: boolean): LicenceState {
+ if (!hasLicence || !expiresAt) return "none";
+ const days = daysRemaining(expiresAt);
+ if (days <= 0) return "expired";
+ if (days <= EXPIRY_WARNING_DAYS) return "warn";
+ return "valid";
+}
+
+export function formatDate(iso: string): string {
+ return new Date(iso).toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ });
+}
+
+export function formatStamp(iso: string): string {
+ return `${new Date(iso).toISOString().slice(11, 19)} UTC`;
+}
+
+export function limitLabel(n: number): string {
+ return n === -1 ? "unlimited" : String(n);
+}
diff --git a/adminsite/lib/query-client.ts b/adminsite/lib/query-client.ts
new file mode 100644
index 0000000..7b36461
--- /dev/null
+++ b/adminsite/lib/query-client.ts
@@ -0,0 +1,16 @@
+"use client";
+
+import { QueryClient } from "@tanstack/react-query";
+import { ApiError, NotConnected } from "./api";
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ // Retrying a 401 or a missing API URL just delays the redirect and
+ // the not-connected panel.
+ retry: (count, error) =>
+ error instanceof NotConnected || error instanceof ApiError ? false : count < 1,
+ },
+ },
+});
diff --git a/adminsite/test/setup.ts b/adminsite/test/setup.ts
new file mode 100644
index 0000000..0aee9a2
--- /dev/null
+++ b/adminsite/test/setup.ts
@@ -0,0 +1,8 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach, vi } from "vitest";
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
diff --git a/adminsite/vitest.config.ts b/adminsite/vitest.config.ts
new file mode 100644
index 0000000..2693258
--- /dev/null
+++ b/adminsite/vitest.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+import { resolve } from "node:path";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: { alias: { "@": resolve(__dirname, ".") } },
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./test/setup.ts"],
+ include: ["**/*.test.{ts,tsx}"],
+ },
+});