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 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 21:20:54 +01:00
co-authored by Claude Opus 5
parent 24060b2c5a
commit 4175608772
4 changed files with 333 additions and 0 deletions
@@ -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 (
<div className="grid max-w-xl gap-3 rounded border border-warn bg-panel p-5">
<h2 className="text-xl">Change what {plan.name} grants?</h2>
<ul className="grid gap-1 font-mono text-[0.82rem]">
{rows.map((r) => (
<li key={r.field} className="flex flex-wrap gap-2">
<span className="text-ink-3">{r.field}</span>
<span className="text-ink-3 line-through">{r.was}</span>
<span className="font-semibold text-ink"> {r.now}</span>
</li>
))}
{rows.length === 0 && <li className="text-ink-3">Nothing would change.</li>}
</ul>
<p className="text-[0.82rem] text-ink-3">
This applies to licences issued from now on. The {issuedCount} licences already
issued keep what they were signed with until each is reissued.
</p>
<div className="flex flex-wrap gap-3">
<Button type="button" onClick={onConfirm}>
Change plan
</Button>
<Button type="button" variant="line" onClick={onCancel}>
Keep as is
</Button>
</div>
</div>
);
}