From 4175608772b3aed54caf98e54b0bd0b082937e47 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sat, 25 Jul 2026 21:20:54 +0100 Subject: [PATCH] feat(adminsite): staff licence history, audit and plan editing Licences and audit are both filterable client-side: the endpoints cap at 500 rows and staff are narrowing a list already in front of them. Plans carry both guard rails spec 4 asks for. The confirmation names each field that changes and states how many licences are already issued and unaffected -- existing licences snapshotted their plan at issue time, and saying so is what stops a well-meaning edit being followed by a panicked reissue. Deployment is displayed and never editable, because moving a tier between cloud and self-hosted would break the cloud-only rule spec 1 leans on; that is a code review, not a form field. The two edit buttons are the concrete changes staff need on day one. A general-purpose limits editor waits until somebody asks for it. Co-Authored-By: Claude Opus 5 --- adminsite/app/(staff)/staff/audit/page.tsx | 48 ++++++++ adminsite/app/(staff)/staff/licenses/page.tsx | 96 +++++++++++++++ adminsite/app/(staff)/staff/plans/page.tsx | 112 ++++++++++++++++++ adminsite/components/ConfirmPlanChange.tsx | 77 ++++++++++++ 4 files changed, 333 insertions(+) create mode 100644 adminsite/app/(staff)/staff/audit/page.tsx create mode 100644 adminsite/app/(staff)/staff/licenses/page.tsx create mode 100644 adminsite/app/(staff)/staff/plans/page.tsx create mode 100644 adminsite/components/ConfirmPlanChange.tsx 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

+
+ + +
+
+ + + + + + + + + + + + + {rows.map((l) => ( + + + + + + + + + ))} + +
IssuedInstanceTierReasonExpiresState
+ {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. +

+
+ + +
+
+ ); +}