diff --git a/adminsite/app/(staff)/staff/audit/page.tsx b/adminsite/app/(staff)/staff/audit/page.tsx
new file mode 100644
index 0000000..ab277ee
--- /dev/null
+++ b/adminsite/app/(staff)/staff/audit/page.tsx
@@ -0,0 +1,48 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { useState } from "react";
+import { api } from "@/lib/api";
+import { formatDate, formatStamp } from "@/lib/format";
+import { Field } from "@/components/Field";
+
+export default function AuditPage() {
+ const [filter, setFilter] = useState("");
+ const { data } = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() });
+
+ const rows = (data ?? []).filter((e) =>
+ filter
+ ? `${e.action} ${e.actor} ${e.target ?? ""}`.toLowerCase().includes(filter.toLowerCase())
+ : true,
+ );
+
+ return (
+
+
Audit
+
setFilter(e.target.value)}
+ hint="Action, actor or target."
+ />
+
+ {rows.map((e, n) => (
+ -
+
+ {formatDate(e.created_at)} {formatStamp(e.created_at)}
+
+
+ {e.action} · {e.actor}
+ {e.target && ` · ${e.target}`}
+ {e.detail && ` · ${e.detail}`}
+
+
+ ))}
+ {rows.length === 0 && - Nothing matches that.
}
+
+
+ );
+}
diff --git a/adminsite/app/(staff)/staff/licenses/page.tsx b/adminsite/app/(staff)/staff/licenses/page.tsx
new file mode 100644
index 0000000..809705b
--- /dev/null
+++ b/adminsite/app/(staff)/staff/licenses/page.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import Link from "next/link";
+import { useState } from "react";
+import { api, type Tier } from "@/lib/api";
+import { formatDate } from "@/lib/format";
+
+export default function LicensesPage() {
+ const [tier, setTier] = useState<"" | Tier>("");
+ const [reason, setReason] = useState("");
+ const { data } = useQuery({ queryKey: ["staff-licenses"], queryFn: () => api.staff.licenses() });
+
+ // Filtered here rather than server-side: the endpoint caps at 500 rows and
+ // staff are narrowing a list they can already see.
+ const rows = (data ?? []).filter(
+ (l) => (!tier || l.tier === tier) && (!reason || l.reason === reason),
+ );
+
+ return (
+
+
Licences
+
+
+
+
+
+
+
+
+ | Issued |
+ Instance |
+ Tier |
+ Reason |
+ Expires |
+ State |
+
+
+
+ {rows.map((l) => (
+
+ |
+ {formatDate(l.issued_at)}
+ |
+
+
+ {l.instance_id.slice(0, 8)}
+
+ |
+ {l.tier.replace("_", " ")} |
+ {l.reason.replace("_", " ")} |
+
+ {formatDate(l.expires_at)}
+ |
+
+ {l.superseded_by ? "superseded" : "current"}
+ |
+
+ ))}
+
+
+ {rows.length === 0 && (
+
No licences match those filters.
+ )}
+
+
+ );
+}
diff --git a/adminsite/app/(staff)/staff/plans/page.tsx b/adminsite/app/(staff)/staff/plans/page.tsx
new file mode 100644
index 0000000..32ee76d
--- /dev/null
+++ b/adminsite/app/(staff)/staff/plans/page.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+import { api, type Plan } from "@/lib/api";
+import { Button } from "@/components/Button";
+import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
+import { limitLabel } from "@/lib/format";
+
+export default function PlansPage() {
+ const qc = useQueryClient();
+ const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans });
+ const licenses = useQuery({ queryKey: ["staff-licenses"], queryFn: () => api.staff.licenses() });
+ const [draft, setDraft] = useState(null);
+
+ const save = useMutation({
+ mutationFn: (p: Plan) =>
+ api.staff.updatePlan(p.tier, {
+ name: p.name,
+ limits: p.limits,
+ features: p.features,
+ paddle_product_id: p.paddle_product_id,
+ paddle_price_ids: p.paddle_price_ids,
+ active: p.active,
+ }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["plans"] });
+ setDraft(null);
+ },
+ });
+
+ const original = plans.data?.find((p) => p.tier === draft?.tier);
+
+ return (
+
+
Plans
+
+ {draft && original && (
+
l.tier === draft.tier).length}
+ onConfirm={() => save.mutate(draft)}
+ onCancel={() => setDraft(null)}
+ />
+ )}
+
+
+ {(plans.data ?? []).map((p) => (
+
+ {p.name}
+
+
+
- servers
+ - {limitLabel(p.limits.max_servers)}
+
+
+
- secret groups
+ - {limitLabel(p.limits.max_secret_groups)}
+
+
+
- channels
+ - {limitLabel(p.limits.max_channels)}
+
+
+
- features
+ - {p.features.join(", ") || "none"}
+
+
+
+ {/* Guard rail two: deployment is shown, never edited. */}
+
+ 🔒
+
+ Deployment is fixed at{" "}
+ {p.deployment}. Moving a tier between
+ cloud and self-hosted is a code change, not a form field.
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/adminsite/components/ConfirmPlanChange.tsx b/adminsite/components/ConfirmPlanChange.tsx
new file mode 100644
index 0000000..a98fb34
--- /dev/null
+++ b/adminsite/components/ConfirmPlanChange.tsx
@@ -0,0 +1,77 @@
+import type { Plan } from "@/lib/api";
+import { limitLabel } from "@/lib/format";
+import { Button } from "./Button";
+
+/*
+ * Editing a plan changes what every future customer gets, so the confirmation
+ * names each field rather than asking "are you sure". Existing licences
+ * snapshotted their plan at issue time and are genuinely unaffected — saying so
+ * is what stops a well-meaning edit being followed by a panicked reissue.
+ */
+export function ConfirmPlanChange({
+ plan,
+ next,
+ issuedCount,
+ onConfirm,
+ onCancel,
+}: {
+ plan: Plan;
+ next: Plan;
+ issuedCount: number;
+ onConfirm: () => void;
+ onCancel: () => void;
+}) {
+ const rows: { field: string; was: string; now: string }[] = [];
+ if (plan.limits.max_servers !== next.limits.max_servers)
+ rows.push({
+ field: "max_servers",
+ was: limitLabel(plan.limits.max_servers),
+ now: limitLabel(next.limits.max_servers),
+ });
+ if (plan.limits.max_secret_groups !== next.limits.max_secret_groups)
+ rows.push({
+ field: "max_secret_groups",
+ was: limitLabel(plan.limits.max_secret_groups),
+ now: limitLabel(next.limits.max_secret_groups),
+ });
+ if (plan.limits.max_channels !== next.limits.max_channels)
+ rows.push({
+ field: "max_channels",
+ was: limitLabel(plan.limits.max_channels),
+ now: limitLabel(next.limits.max_channels),
+ });
+ if (plan.features.join(",") !== next.features.join(","))
+ rows.push({
+ field: "features",
+ was: plan.features.join(", ") || "none",
+ now: next.features.join(", ") || "none",
+ });
+
+ return (
+
+
Change what {plan.name} grants?
+
+ {rows.map((r) => (
+ -
+ {r.field}
+ {r.was}
+ → {r.now}
+
+ ))}
+ {rows.length === 0 && - Nothing would change.
}
+
+
+ This applies to licences issued from now on. The {issuedCount} licences already
+ issued keep what they were signed with until each is reissued.
+
+
+
+
+
+
+ );
+}