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:
@@ -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 (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Audit</h1>
|
||||
<Field
|
||||
label="Filter"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
hint="Action, actor or target."
|
||||
/>
|
||||
<ul className="grid gap-2 rounded border border-rule bg-panel p-5 font-mono text-[0.82rem]">
|
||||
{rows.map((e, n) => (
|
||||
<li
|
||||
key={n}
|
||||
className="grid gap-1 border-b border-rule-soft pb-2 last:border-0 sm:grid-cols-[11rem_1fr]"
|
||||
>
|
||||
<span className="tabular-nums text-ink-3">
|
||||
{formatDate(e.created_at)} {formatStamp(e.created_at)}
|
||||
</span>
|
||||
<span className="text-ink-2">
|
||||
<b className="text-ink">{e.action}</b> · {e.actor}
|
||||
{e.target && ` · ${e.target}`}
|
||||
{e.detail && ` · ${e.detail}`}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{rows.length === 0 && <li className="text-ink-3">Nothing matches that.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Licences</h1>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as Tier | "")}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
aria-label="Filter by tier"
|
||||
>
|
||||
<option value="">All tiers</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="self_hosted">Self Hosted</option>
|
||||
</select>
|
||||
<select
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2"
|
||||
aria-label="Filter by reason"
|
||||
>
|
||||
<option value="">All reasons</option>
|
||||
<option value="new">New</option>
|
||||
<option value="renewal">Renewal</option>
|
||||
<option value="tier_change">Tier change</option>
|
||||
<option value="relink">Relink</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
<th className="px-4 py-2.5">Issued</th>
|
||||
<th className="px-4 py-2.5">Instance</th>
|
||||
<th className="px-4 py-2.5">Tier</th>
|
||||
<th className="px-4 py-2.5">Reason</th>
|
||||
<th className="px-4 py-2.5">Expires</th>
|
||||
<th className="px-4 py-2.5">State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((l) => (
|
||||
<tr
|
||||
key={l.license_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(l.issued_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/staff/instances/${l.instance_id}`}
|
||||
className="font-mono text-[0.82rem] text-accent underline"
|
||||
>
|
||||
{l.instance_id.slice(0, 8)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">{l.tier.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3">{l.reason.replace("_", " ")}</td>
|
||||
<td className="px-4 py-3 font-mono tabular-nums">
|
||||
{formatDate(l.expires_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-ink-3">
|
||||
{l.superseded_by ? "superseded" : "current"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{rows.length === 0 && (
|
||||
<p className="px-4 py-6 text-ink-3">No licences match those filters.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Plan | null>(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 (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Plans</h1>
|
||||
|
||||
{draft && original && (
|
||||
<ConfirmPlanChange
|
||||
plan={original}
|
||||
next={draft}
|
||||
issuedCount={(licenses.data ?? []).filter((l) => l.tier === draft.tier).length}
|
||||
onConfirm={() => save.mutate(draft)}
|
||||
onCancel={() => setDraft(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{(plans.data ?? []).map((p) => (
|
||||
<section
|
||||
key={p.tier}
|
||||
className="grid gap-3 rounded border border-rule bg-panel p-5"
|
||||
>
|
||||
<h2 className="text-xl">{p.name}</h2>
|
||||
<dl className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>servers</dt>
|
||||
<dd>{limitLabel(p.limits.max_servers)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>secret groups</dt>
|
||||
<dd>{limitLabel(p.limits.max_secret_groups)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>channels</dt>
|
||||
<dd>{limitLabel(p.limits.max_channels)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>features</dt>
|
||||
<dd>{p.features.join(", ") || "none"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* Guard rail two: deployment is shown, never edited. */}
|
||||
<p className="flex items-center gap-2 rounded border border-rule bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-3">
|
||||
<span aria-hidden="true">🔒</span>
|
||||
<span>
|
||||
Deployment is fixed at{" "}
|
||||
<b className="font-mono">{p.deployment}</b>. Moving a tier between
|
||||
cloud and self-hosted is a code change, not a form field.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
onClick={() =>
|
||||
setDraft({ ...p, limits: { ...p.limits, max_servers: 7 } })
|
||||
}
|
||||
>
|
||||
Cap servers at 7
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
...p,
|
||||
features: p.features.filter((f) => f !== "oidc"),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove OIDC
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user