feat(license): metered licensing — catalogue, entitlements, and enforcement
Server Deploy / deploy (push) Successful in 5m22s

Implements spec 7 tasks 2-10 on top of the six-plan payload from task 1.

Admin: plans re-keyed on (deployment, tier); new catalogue collection holds
every Paddle price ID (one row per priceable component); new entitlements
collection holds desired beside granted. admin/internal/catalogue owns both
folds — entitlement to licence limits, and entitlement to Paddle line items —
so the base allowance is subtracted in exactly one place. licensing.Issue now
snapshots the instance's granted entitlement, never desired. Free is enforced
per account AND deployment. Staff endpoints for plans, catalogue and
entitlements; Free self-hosted can be claimed and renewed on its annual term;
the reaper stays cloud-only.

Server: enforces the monitor cap, audit-log retention (daily sweep, skips
Unlimited and lapsed instances), and gates the OIDC callback. Unset limits are
filled from the seed plan at the single decode site so old blobs never read as
zero.

Frontends: adminsite gains a catalogue price-ID editor, six-plan allowance
screen, and a catalogue-driven PlanConfigurator mounted on the staff instance
page. web shows monitors, audit retention and support level on the licence page.

Docs: CLAUDE.md, spec index and plan 5 preamble updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 09:37:39 +01:00
co-authored by Claude Opus 5
parent 3fc726da9e
commit c4e6ad5485
35 changed files with 2241 additions and 222 deletions
@@ -0,0 +1,184 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { PageHeader } from "@/components/PageHeader";
import { PageFrame } from "@/components/PageFrame";
import { api, type CatalogueRow, type Term } from "@/lib/api";
const ENVS = ["sandbox", "production"] as const;
/* Self-hosted sells annual only, so the monthly cell is not rendered for it
* rather than rendered and rejected. The backend refuses one either way; this is
* so nobody types into a field that cannot be saved. */
function termsFor(deployment: string): Term[] {
return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
}
function componentLabel(r: CatalogueRow): string {
if (r.kind === "base") return "Base fee";
if (r.kind === "limit") return `Per ${r.limit_key?.replace("max_", "")}`;
return `Feature: ${r.feature_key}`;
}
function rowKey(r: CatalogueRow): string {
return [r.deployment, r.tier, r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/");
}
export default function CataloguePage() {
const qc = useQueryClient();
const { data: rows = [], isLoading } = useQuery({
queryKey: ["staff", "catalogue"],
queryFn: api.staff.catalogue,
});
const [drafts, setDrafts] = useState<Record<string, CatalogueRow["price_ids"]>>({});
const save = useMutation({
mutationFn: (r: CatalogueRow) => api.staff.updateCatalogue(r),
onSuccess: () => qc.invalidateQueries({ queryKey: ["staff", "catalogue"] }),
});
const groups = Array.from(new Set(rows.map((r) => `${r.deployment}/${r.tier}`)));
return (
<div className="grid gap-6">
<PageHeader
title="Catalogue"
back={{ href: "/staff", label: "Operations" }}
subtitle="Every priceable component. This is the only place a Paddle price ID lives."
/>
<PageFrame
aside={
<aside className="space-y-3 text-[0.82rem] text-ink-2">
<p>
A component with no price ID is free. A feature with no price is a
toggle a customer may take at no charge; giving it a price here is
all it takes to start charging for it.
</p>
<p>
Free is priced by nothing and has no rows. That absence is what
keeps it outside Paddle.
</p>
<p>
Changing a price affects the next checkout only. It cannot touch an
issued licence.
</p>
</aside>
}
>
{isLoading ? (
<p className="text-[0.85rem] text-ink-3">Loading</p>
) : (
<div className="space-y-6">
{groups.map((g) => {
const [deployment, tier] = g.split("/");
const terms = termsFor(deployment);
return (
<section key={g} className="space-y-2">
<h2 className="text-[0.95rem] font-medium text-ink">
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}{" "}
{tier}
</h2>
<div className="overflow-x-auto">
<table className="w-full min-w-[42rem] border-collapse text-[0.82rem]">
<thead>
<tr className="border-b border-rule text-left text-ink-3">
<th className="py-2 pr-3 font-normal">Component</th>
{ENVS.map((env) =>
terms.map((t) => (
<th
key={`${env}-${t}`}
className="py-2 pr-3 font-normal"
>
{env} / {t}
</th>
)),
)}
<th className="py-2 font-normal" />
</tr>
</thead>
<tbody>
{rows
.filter(
(r) =>
r.deployment === deployment &&
r.tier === tier,
)
.map((r) => {
const k = rowKey(r);
const ids = drafts[k] ?? r.price_ids ?? {};
const dirty =
JSON.stringify(ids) !==
JSON.stringify(r.price_ids ?? {});
return (
<tr
key={k}
className="border-b border-rule/60"
>
<td className="py-2 pr-3 text-ink">
{componentLabel(r)}
</td>
{ENVS.map((env) =>
terms.map((t) => (
<td
key={`${env}-${t}`}
className="py-2 pr-3"
>
<input
value={
ids[env]?.[t] ?? ""
}
placeholder="pri_…"
onChange={(e) =>
setDrafts({
...drafts,
[k]: {
...ids,
[env]: {
...(ids[
env
] ?? {}),
[t]: e
.target
.value,
},
},
})
}
className="w-40 rounded border border-rule bg-panel px-2 py-1 font-mono text-[0.78rem] text-ink"
/>
</td>
)),
)}
<td className="py-2">
<button
type="button"
disabled={
!dirty || save.isPending
}
onClick={() =>
save.mutate({
...r,
price_ids: ids,
})
}
className="rounded border border-accent/50 px-2.5 py-1 text-[0.78rem] text-accent disabled:opacity-40"
>
Save
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
);
})}
</div>
)}
</PageFrame>
</div>
);
}
@@ -8,13 +8,11 @@ import { Field } from "@/components/Field";
export function IssuePanel({
instanceId,
deployment,
}: {
instanceId: string;
deployment: string;
}) {
const qc = useQueryClient();
const [tier, setTier] = useState<Tier>(deployment === "cloud" ? "professional" : "self_hosted");
const [tier, setTier] = useState<Tier>("professional");
const [term, setTerm] = useState("annual");
const [newId, setNewId] = useState("");
const [error, setError] = useState<string | undefined>();
@@ -47,7 +45,7 @@ export function IssuePanel({
>
<option value="free">Free</option>
<option value="professional">Professional</option>
<option value="self_hosted">Self Hosted</option>
<option value="enterprise">Enterprise</option>
</select>
</label>
<label className="grid gap-1.5">
@@ -1,12 +1,14 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useState } from "react";
import Link from "next/link";
import clsx from "clsx";
import { api, type InjectionState } from "@/lib/api";
import { api, type Deployment, type InjectionState } from "@/lib/api";
import { Ledger } from "@/components/Ledger";
import { PageHeader } from "@/components/PageHeader";
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
import { IssuePanel } from "./IssuePanel";
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
@@ -71,11 +73,112 @@ export default function StaffInstancePage() {
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
<h2 className="text-xl">Licence history</h2>
<Ledger licenses={data.licenses} />
<IssuePanel
instanceId={data.instance.instance_id}
deployment={data.instance.deployment}
/>
<IssuePanel instanceId={data.instance.instance_id} />
</section>
<EntitlementSection
instanceId={data.instance.instance_id}
deployment={data.instance.deployment}
/>
</div>
);
}
function EntitlementSection({ instanceId, deployment }: { instanceId: string; deployment: Deployment }) {
const qc = useQueryClient();
const { data: plans = [] } = useQuery({
queryKey: ["staff", "plans"],
queryFn: api.staff.plans,
});
const { data: catalogue = [] } = useQuery({
queryKey: ["staff", "catalogue"],
queryFn: api.staff.catalogue,
});
const { data } = useQuery({
queryKey: ["staff", "entitlement", instanceId],
queryFn: () => api.staff.entitlement(instanceId),
retry: false,
});
const ent = data?.entitlement;
const [draft, setDraft] = useState<PlanChoice | null>(null);
const choice: PlanChoice =
draft ??
(ent
? {
tier: ent.tier,
term: ent.term,
servers: ent.desired.servers,
features: ent.desired.features ?? [],
}
: { tier: "professional", term: deployment === "self_hosted" ? "annual" : "monthly", servers: 3, features: [] });
const save = useMutation({
mutationFn: (grant: boolean) =>
api.staff.setEntitlement(instanceId, { ...choice, grant }),
onSuccess: () => {
setDraft(null);
qc.invalidateQueries({ queryKey: ["staff", "entitlement", instanceId] });
},
});
return (
<section className="rounded-lg border border-rule bg-panel p-4">
<header className="mb-3">
<h2 className="text-[0.95rem] font-medium text-ink">Entitlement</h2>
<p className="text-[0.78rem] text-ink-3">
What this instance is allowed. A licence is signed from{" "}
<em>granted</em>, never from <em>desired</em>.
</p>
</header>
{ent && data?.pending && (
<p className="mb-3 rounded border border-warn/50 bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-2">
Pending change currently granted {ent.granted.servers} servers,
configured for {ent.desired.servers}
{ent.scheduled_change_at
? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}`
: ""}
.
</p>
)}
<PlanConfigurator
deployment={deployment}
value={choice}
plans={plans}
catalogue={catalogue}
onChange={setDraft}
disabled={save.isPending}
/>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
disabled={save.isPending}
onClick={() => save.mutate(false)}
className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40"
>
Save as configured
</button>
<button
type="button"
disabled={save.isPending}
onClick={() => save.mutate(true)}
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
>
Save and grant
</button>
</div>
<p className="mt-2 text-[0.72rem] text-ink-3">
Granting takes effect on the next licence issued. It does not issue
one.
</p>
{save.error && (
<p className="mt-2 text-[0.82rem] text-expired">
{String((save.error as Error).message)}
</p>
)}
</section>
);
}
+1
View File
@@ -8,6 +8,7 @@ const LINKS: NavLink[] = [
{ href: "/staff/accounts", label: "Accounts" },
{ href: "/staff/licenses", label: "Licences" },
{ href: "/staff/plans", label: "Plans" },
{ href: "/staff/catalogue", label: "Catalogue" },
{ href: "/staff/audit", label: "Audit" },
];
@@ -35,7 +35,8 @@ export default function LicensesPage() {
<option value="">All tiers</option>
<option value="free">Free</option>
<option value="professional">Professional</option>
<option value="self_hosted">Self Hosted</option>
<option value="enterprise">Enterprise</option>
<option value="self_hosted">Self-Hosted (legacy)</option>
</select>
<select
value={reason}
+147 -85
View File
@@ -2,12 +2,115 @@
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 { api, type Deployment, type Plan, type Tier } from "@/lib/api";
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
import { limitLabel } from "@/lib/format";
import { PageHeader } from "@/components/PageHeader";
const SUPPORT_LEVELS = [
{ value: "community", label: "Community" },
{ value: "email_24_5", label: "Email, 24/5" },
{ value: "email_call_24_7", label: "Email + call, 24/7" },
] as const;
const LIMIT_FIELDS = [
{ key: "max_servers", label: "Servers" },
{ key: "max_monitors", label: "Monitors" },
{ key: "max_secret_groups", label: "Secret groups" },
{ key: "max_channels", label: "Channels" },
{ key: "audit_retention_days", label: "Audit history (days)" },
] as const;
/*
* -1 is Unlimited everywhere in the licence payload, so the form takes it
* literally rather than inventing a checkbox. A staff screen that hides the
* sentinel is a staff screen where nobody can tell whether a plan says
* unlimited or nothing at all.
*/
function AllowanceForm({
plan,
onSave,
saving,
}: {
plan: Plan;
onSave: (next: Plan) => void;
saving: boolean;
}) {
const [draft, setDraft] = useState<Plan>(plan);
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
return (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{LIMIT_FIELDS.map((f) => (
<label key={f.key} className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">
{f.label}
</span>
<input
type="number"
value={draft.base_limits[f.key]}
onChange={(e) =>
setDraft({
...draft,
base_limits: {
...draft.base_limits,
[f.key]: Number(e.target.value),
},
})
}
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
/>
<span className="mt-0.5 block text-[0.72rem] text-ink-3">
1 is unlimited
</span>
</label>
))}
<label className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">
Support level
</span>
<select
value={draft.support_level}
onChange={(e) =>
setDraft({ ...draft, support_level: e.target.value })
}
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
>
{SUPPORT_LEVELS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</label>
</div>
<label className="flex items-center gap-2 text-[0.85rem] text-ink-2">
<input
type="checkbox"
checked={draft.active}
onChange={(e) => setDraft({ ...draft, active: e.target.checked })}
/>
Offered to customers
</label>
<p className="text-[0.78rem] text-ink-3">
Changes apply to licences issued from now on. Existing licences
snapshotted their plan and are unaffected.
</p>
<button
type="button"
disabled={!dirty || saving}
onClick={() => onSave(draft)}
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
>
{saving ? "Saving…" : "Save allowances"}
</button>
</div>
);
}
export default function PlansPage() {
const qc = useQueryClient();
const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans });
@@ -16,30 +119,27 @@ export default function PlansPage() {
queryFn: () => api.staff.licenses(),
});
const [draft, setDraft] = useState<Plan | null>(null);
const [saving, setSaving] = useState<string | 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,
}),
mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["plans"] });
setDraft(null);
setSaving(null);
},
onError: () => setSaving(null),
});
const original = plans.data?.find((p) => p.tier === draft?.tier);
const original = plans.data?.find(
(p) => p.deployment === draft?.deployment && p.tier === draft?.tier,
);
return (
<div className="grid gap-6">
<PageHeader
title="Plans"
subtitle="The authoritative tier table. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
subtitle="The authoritative tier table — six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
/>
{draft && original && (
@@ -48,84 +148,46 @@ export default function PlansPage() {
next={draft}
issuedCount={
(licenses.data ?? []).filter(
(l) => l.tier === draft.tier,
(l) => l.tier === draft.tier && l.deployment === draft.deployment,
).length
}
onConfirm={() => save.mutate(draft)}
onConfirm={() => {
setSaving(`${draft.deployment}/${draft.tier}`);
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 },
})
}
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
<section key={deployment} className="space-y-3">
<h2 className="text-[0.95rem] font-medium text-ink">
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}
</h2>
{(plans.data ?? [])
.filter((p) => p.deployment === deployment)
.map((p) => (
<article
key={`${p.deployment}/${p.tier}`}
className="rounded-lg border border-rule bg-panel p-4"
>
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>
<header className="mb-3 flex items-baseline justify-between gap-3">
<h3 className="text-[0.9rem] font-medium text-ink">
{p.name}
</h3>
<span className="font-mono text-[0.75rem] text-ink-3">
{p.deployment}/{p.tier}
</span>
</header>
<AllowanceForm
plan={p}
saving={saving === `${p.deployment}/${p.tier}`}
onSave={(next: Plan) => setDraft(next)}
/>
</article>
))}
</section>
))}
</div>
);
}
+22 -19
View File
@@ -22,29 +22,32 @@ export function ConfirmPlanChange({
onCancel: () => void;
}) {
const rows: { field: string; was: string; now: string }[] = [];
if (plan.limits.max_servers !== next.limits.max_servers)
const fields = [
"max_servers",
"max_monitors",
"max_secret_groups",
"max_channels",
"audit_retention_days",
] as const;
for (const f of fields) {
if (plan.base_limits[f] !== next.base_limits[f])
rows.push({
field: f,
was: limitLabel(plan.base_limits[f]),
now: limitLabel(next.base_limits[f]),
});
}
if (plan.support_level !== next.support_level)
rows.push({
field: "max_servers",
was: limitLabel(plan.limits.max_servers),
now: limitLabel(next.limits.max_servers),
field: "support_level",
was: plan.support_level || "none",
now: next.support_level || "none",
});
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(","))
if (plan.base_features.join(",") !== next.base_features.join(","))
rows.push({
field: "features",
was: plan.features.join(", ") || "none",
now: next.features.join(", ") || "none",
was: plan.base_features.join(", ") || "none",
now: next.base_features.join(", ") || "none",
});
return (
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useMemo } from "react";
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
export interface PlanChoice {
tier: Tier;
term: Term;
servers: number;
features: string[];
}
/* Self-hosted sells annual only. The reason is in shared/license: an offline
* licence cannot be revoked, so the term length IS the revocation window. */
function termsFor(deployment: Deployment): Term[] {
return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"];
}
/*
* PlanConfigurator is the whole of "what is this instance allowed", driven
* entirely by the plans and catalogue it is handed.
*
* A feature appears because a catalogue row offers it, and shows a price because
* that row has one. Nothing here is hardcoded per tier, which is what lets a new
* paid add-on ship as a staff edit rather than a frontend release.
*
* It saves nothing and knows nothing about who is using it. Staff mount it to
* set an entitlement; the customer purchase flow mounts the same component and
* hands it a checkout.
*/
export default function PlanConfigurator({
deployment,
value,
plans,
catalogue,
onChange,
disabled,
}: {
deployment: Deployment;
value: PlanChoice;
plans: Plan[];
catalogue: CatalogueRow[];
onChange: (next: PlanChoice) => void;
disabled?: boolean;
}) {
const available = useMemo(
() => plans.filter((p) => p.deployment === deployment && p.active),
[plans, deployment],
);
const plan = available.find((p) => p.tier === value.tier);
const rows = useMemo(
() => catalogue.filter((r) => r.deployment === deployment && r.tier === value.tier),
[catalogue, deployment, value.tier],
);
const featureRows = rows.filter((r) => r.kind === "feature");
const base = plan?.base_limits.max_servers ?? 0;
const extra = Math.max(0, value.servers - base);
const priceOf = (r: CatalogueRow) =>
r.price_ids?.sandbox?.[value.term] ?? r.price_ids?.production?.[value.term] ?? "";
return (
<div className="space-y-4">
<fieldset className="space-y-1.5">
<legend className="text-[0.78rem] text-ink-3">Tier</legend>
<div className="flex flex-wrap gap-2">
{available.map((p) => (
<button
key={p.tier}
type="button"
disabled={disabled}
onClick={() =>
onChange({
...value,
tier: p.tier,
/* Moving tier moves the floor, so clamp up
* rather than leaving an invalid count the
* backend would refuse. */
servers: Math.max(value.servers, p.base_limits.max_servers),
})
}
className={`rounded border px-3 py-1.5 text-[0.85rem] ${
p.tier === value.tier
? "border-accent text-accent"
: "border-rule text-ink-2"
}`}
>
{p.name}
</button>
))}
</div>
</fieldset>
<fieldset className="space-y-1.5">
<legend className="text-[0.78rem] text-ink-3">Term</legend>
<div className="flex flex-wrap gap-2">
{termsFor(deployment).map((t) => (
<button
key={t}
type="button"
disabled={disabled}
onClick={() => onChange({ ...value, term: t })}
className={`rounded border px-3 py-1.5 text-[0.85rem] ${
t === value.term
? "border-accent text-accent"
: "border-rule text-ink-2"
}`}
>
{t === "monthly" ? "Monthly" : "Annual"}
</button>
))}
</div>
{deployment === "self_hosted" && (
<p className="text-[0.72rem] text-ink-3">
Self-hosted is annual only.
</p>
)}
</fieldset>
<label className="block">
<span className="mb-1 block text-[0.78rem] text-ink-3">Servers</span>
<input
type="number"
min={base}
value={value.servers}
disabled={disabled}
onChange={(e) =>
onChange({ ...value, servers: Number(e.target.value) })
}
className="w-28 rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
/>
<span className="ml-2 text-[0.78rem] text-ink-3">
{base} included{extra > 0 ? `, ${extra} extra` : ""}
</span>
</label>
{featureRows.length > 0 && (
<fieldset className="space-y-1.5">
<legend className="text-[0.78rem] text-ink-3">Features</legend>
{featureRows.map((r) => {
const key = r.feature_key!;
const on = value.features.includes(key);
const priced = priceOf(r) !== "";
return (
<label
key={key}
className="flex items-center gap-2 text-[0.85rem] text-ink-2"
>
<input
type="checkbox"
checked={on}
disabled={disabled}
onChange={(e) =>
onChange({
...value,
features: e.target.checked
? [...value.features, key]
: value.features.filter((f) => f !== key),
})
}
/>
<span>{key === "console" ? "Browser console" : "Single sign-on"}</span>
<span className="text-[0.72rem] text-ink-3">
{priced ? "paid add-on" : "included"}
</span>
</label>
);
})}
</fieldset>
)}
</div>
);
}
+56 -11
View File
@@ -61,7 +61,8 @@ const del = <T,>(path: string) => req<T>(path, { method: "DELETE" });
// --- types ---------------------------------------------------------------
export type Deployment = "cloud" | "self_hosted";
export type Tier = "free" | "professional" | "self_hosted";
export type Tier = "free" | "professional" | "enterprise";
export type Term = "monthly" | "annual";
export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted";
/*
@@ -103,8 +104,10 @@ export interface InstanceMember {
export interface Limits {
max_servers: number;
max_monitors: number;
max_secret_groups: number;
max_channels: number;
audit_retention_days: number;
}
export interface Account {
@@ -157,16 +160,48 @@ export interface Subscription {
}
export interface Plan {
deployment: Deployment;
tier: Tier;
name: string;
deployment: Deployment;
limits: Limits;
features: string[];
paddle_product_id?: string;
paddle_price_ids?: Record<string, string>;
/* The allowance BEFORE anything is bought. Not the total — a metered
* dimension adds to it. */
base_limits: Limits;
base_features: string[];
support_level: string;
active: boolean;
}
export interface CatalogueRow {
kind: "base" | "limit" | "feature";
deployment: Deployment;
tier: Tier;
limit_key?: string;
feature_key?: string;
/* environment -> term -> Paddle price ID. The running PADDLE_ENV picks the
* inner map; both environments are stored so promotion is a config change
* rather than a data migration. */
price_ids?: Record<string, Partial<Record<Term, string>>>;
}
export interface EntitlementConfig {
servers: number;
features: string[];
}
export interface Entitlement {
instance_id: string;
account_id: string;
deployment: Deployment;
tier: Tier;
term: Term;
desired: EntitlementConfig;
granted: EntitlementConfig;
resolved_limits: Limits;
scheduled_change_at?: string;
granted_at: string;
updated_at: string;
}
export interface CustomerUser {
user_id: string;
account_id: string;
@@ -274,11 +309,21 @@ export const api = {
licenses: (params?: Record<string, string>) =>
req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
plans: () => req<Plan[]>("/api/staff/plans"),
updatePlan: (tier: Tier, plan: Omit<Plan, "tier" | "deployment">) =>
req<{ updated: boolean }>(`/api/staff/plans/${tier}`, {
method: "PUT",
body: JSON.stringify(plan),
}),
updatePlan: (deployment: Deployment, tier: Tier, plan: Plan) =>
put<{ updated: boolean }>(`/api/staff/plans/${deployment}/${tier}`, plan),
catalogue: () => req<CatalogueRow[]>("/api/staff/catalogue"),
updateCatalogue: (row: CatalogueRow) =>
put<{ updated: boolean }>("/api/staff/catalogue", row),
entitlement: (id: string) =>
req<{ entitlement: Entitlement; pending: boolean }>(
`/api/staff/instances/${id}/entitlement`,
),
setEntitlement: (
id: string,
body: { tier: Tier; term: Term; servers: number; features: string[]; grant?: boolean },
) =>
put<{ entitlement: Entitlement; pending: boolean }>(
`/api/staff/instances/${id}/entitlement`, body),
audit: (accountId?: string) =>
req<AuditEntry[]>(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`),
injectionHealth: () =>