fix(web): rebuild the licence page as the document it is
Server Deploy / deploy (push) Successful in 40s

This screen was never finished. It had no page padding, so it sat flush
against the sidebar while every other screen has p-8; it used raw button,
textarea and file inputs instead of the design system; and it rendered
state as "State: valid" in a sentence.

It also dropped `reason` entirely. An instance with an invalid licence was
told "State: invalid" and nothing else — not that the signature failed, not
that it was issued against a different instance, not what to do. That was
the real defect, and no layout survives having nowhere to put the most
important thing on the page.

A licence is a document: issued, dated, signed, carrying a reference you
quote to support. The page now reads that way, which is also how adminsite/
presents the same object from the issuing side — state as a label with a
shape, the reference on a mono record line, entitlement as keyed fields.

- a record panel opens the page: the state as a statement, the reason
  underneath in this app's own words rather than the API's identifiers,
  then Expires / Source / Instance ID as keyed fields. `source` and
  `days_remaining` were being returned and never shown
- counted limits become meters, because that is already how the console
  shows headroom on a server's disks — same question, same reading. They
  turn amber at 80% and red at the cap
- an unlimited allowance gets no bar. A full-width one would read as "at
  the limit", which is the opposite of what it means
- features render as included/not with a glyph as well as a colour
- the file input is a styled label over a visually hidden input, and now
  reports which file it loaded

Two things found reviewing my own work: Card's p-6 is emitted after p-4, so
`<Card className="p-4">` silently rendered at p-6 — the allowance cards pass
padding={false} instead. And the state had a coloured dot next to a coloured
word on a colour-ruled card, which is one telling too many; the dot is gone.

`Group` moves to components/settings/ so this page and /settings share the
band label rather than growing a second copy.
This commit is contained in:
mrhid6
2026-07-26 20:35:31 +01:00
parent 653bd5a755
commit fa75d70475
3 changed files with 266 additions and 77 deletions
+251 -64
View File
@@ -2,94 +2,281 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { licence } from "@/lib/api";
import { licence, type LicenseInfo, type LicenseState } from "@/lib/api";
import { useLicense } from "@/lib/useLicense";
import { Button, Card } from "@/components/ui";
import { Group } from "@/components/settings/Group";
import { inputClass } from "@/components/settings/Field";
function cap(n: number) {
return n === -1 ? "Unlimited" : String(n);
/*
* A licence is a document — issued, dated, signed, and carrying a reference
* you quote to support. This page is built to read as that document rather
* than as another settings form, which is also how adminsite/ presents the
* same object from the issuing side: state as a pill that carries a label and
* a shape, the reference on a mono record line, and the entitlement as keyed
* fields.
*
* Counted limits are drawn as meters because that is already how the console
* shows headroom on a server's disks. Licence headroom and disk headroom are
* the same question, so they read the same way.
*/
const UNLIMITED = -1;
// Written as whole class names, never composed, so Tailwind's scanner finds them.
const STATE: Record<LicenseState, { label: string; rule: string; text: string }> = {
valid: { label: "Valid", rule: "border-l-success", text: "text-success" },
expired: { label: "Expired", rule: "border-l-warning", text: "text-warning" },
invalid: { label: "Invalid", rule: "border-l-danger", text: "text-danger" },
};
// The API returns stable identifiers rather than sentences, so the wording is
// this app's to choose. Each one says what is wrong and what to do about it.
const REASON: Record<string, string> = {
no_license: "No licence is installed on this instance. Paste one below to activate it.",
bad_signature: "This licence's signature could not be verified. It may have been edited or truncated in transit — paste the original again.",
deployment_mismatch: "This licence was issued for a different kind of deployment, so it cannot be used here.",
instance_mismatch: "This licence was issued for a different instance. Check the instance ID below against the one you bought against.",
expired: "This licence has passed its expiry date. Your servers and monitors keep running, but changes are disabled until it is renewed.",
};
const SOURCE: Record<string, string> = {
stored: "Stored on this instance",
env: "Provided by the VANTAGE_LICENSE environment variable",
none: "None installed",
};
/** A keyed field on the record, the way a certificate prints them. */
function Keyed({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="min-w-0">
<p className="font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-tertiary">{label}</p>
<div className="mt-1 text-sm text-text-primary">{children}</div>
</div>
);
}
function Allowance({ label, used, limit }: { label: string; used: number; limit: number }) {
const unlimited = limit === UNLIMITED;
// Guard the divide: a zero limit is a real answer from the API, not a bug.
const pct = unlimited || limit <= 0 ? 0 : Math.min(100, Math.round((used / limit) * 100));
const fill = pct >= 100 ? "bg-danger" : pct >= 80 ? "bg-warning" : "bg-accent";
// padding={false} because Card's own p-6 is emitted after p-4 and would win.
return (
<Card padding={false} className="p-4">
<p className="font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-tertiary">{label}</p>
<p className="mt-2 text-2xl font-extrabold tracking-[-0.03em] tabular-nums text-text-primary">
{used}
<span className="ml-1.5 text-sm font-medium tracking-normal text-text-secondary">{unlimited ? "used" : `of ${limit}`}</span>
</p>
{unlimited ? (
<p className="mt-3 font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-secondary">No limit</p>
) : (
<div
className="mt-3 h-1.5 overflow-hidden rounded-full bg-surface-2"
role="meter"
aria-valuenow={used}
aria-valuemin={0}
aria-valuemax={limit}
aria-label={`${label}: ${used} of ${limit} used`}
>
<div className={`h-full rounded-full ${fill}`} style={{ width: `${pct}%` }} />
</div>
)}
</Card>
);
}
function Feature({ label, included }: { label: string; included: boolean }) {
return (
<div className="flex items-center gap-3 py-2.5">
<span
aria-hidden
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm border text-[0.6rem] font-bold ${
included ? "border-success text-success" : "border-border text-text-tertiary"
}`}
>
{included ? "✓" : ""}
</span>
<span className="text-sm text-text-primary">{label}</span>
<span className={`ml-auto font-mono text-[0.62rem] uppercase tracking-[0.14em] ${included ? "text-success" : "text-text-tertiary"}`}>
{included ? "Included" : "Not included"}
</span>
</div>
);
}
function RecordPanel({ license }: { license: LicenseInfo }) {
const [copied, setCopied] = useState(false);
const s = STATE[license.state];
const days = license.days_remaining;
async function copyId() {
await navigator.clipboard.writeText(license.instance_id);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Card className={`border-l-2 ${s.rule}`}>
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
{/* No status dot: the word is the label, and the card's left rule
already carries the colour. A dot would be the third telling. */}
<h2 className={`text-2xl font-extrabold tracking-[-0.03em] ${s.text}`}>{s.label}</h2>
{license.tier && <p className="text-sm text-text-secondary">{license.tier} tier</p>}
</div>
{license.reason && <p className="mt-3 max-w-prose text-sm text-text-secondary">{REASON[license.reason] ?? license.reason}</p>}
<div className="mt-5 grid gap-5 border-t border-border-soft pt-5 sm:grid-cols-2 lg:grid-cols-3">
<Keyed label="Expires">
{license.expires_at ? (
<>
{new Date(license.expires_at).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" })}
{typeof days === "number" && days >= 0 && (
<span className="ml-1.5 text-text-secondary">
({days} day{days === 1 ? "" : "s"})
</span>
)}
</>
) : (
<span className="text-text-secondary">No expiry</span>
)}
</Keyed>
<Keyed label="Source">
<span className="text-text-secondary">{SOURCE[license.source] ?? license.source}</span>
</Keyed>
<div className="min-w-0 sm:col-span-2 lg:col-span-1">
<Keyed label="Instance ID">
<div className="flex items-center gap-2">
<code className="truncate font-mono text-xs text-text-primary">{license.instance_id}</code>
<button
type="button"
onClick={copyId}
className="flex-shrink-0 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[0.6rem] uppercase tracking-[0.1em] text-text-secondary transition-colors hover:border-text-tertiary hover:text-text-primary"
>
{copied ? "Copied" : "Copy"}
</button>
</div>
</Keyed>
<p className="mt-1.5 text-xs text-text-tertiary">Quote this when buying or activating a licence.</p>
</div>
</div>
</Card>
);
}
export default function LicensePage() {
const { license } = useLicense();
const { license, isLoading } = useLicense();
const queryClient = useQueryClient();
const [blob, setBlob] = useState("");
const [fileName, setFileName] = useState("");
const [error, setError] = useState("");
const save = useMutation({
mutationFn: () => licence.put(blob.trim()),
onSuccess: () => {
setBlob("");
setFileName("");
setError("");
queryClient.invalidateQueries({ queryKey: ["license"] });
},
onError: (e: Error) => setError(e.message),
});
if (!license) return null;
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
if (!license) {
return (
<div className="p-8">
<Card className="max-w-lg">
<h1 className="text-base font-bold text-text-primary">Licence unavailable</h1>
<p className="mt-1 text-sm text-text-secondary">The licence state could not be read. Reload the page, and check the server logs if it keeps failing.</p>
</Card>
</div>
);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-text-primary">Licence</h1>
<section className="rounded border border-border p-4">
<p className="text-sm text-text-secondary">
State: <b>{license.state}</b>
{license.tier ? <> · Tier: <b>{license.tier}</b></> : null}
{license.expires_at ? <> · Expires {new Date(license.expires_at).toLocaleDateString()}</> : null}
</p>
<p className="mt-2 text-xs text-text-tertiary">
Instance ID quote this when buying or activating a licence
</p>
<div className="mt-1 flex items-center gap-2">
<code className="text-sm">{license.instance_id}</code>
<button
type="button"
className="text-xs underline"
onClick={() => navigator.clipboard.writeText(license.instance_id)}
>
Copy
</button>
<div className="p-8">
<div className="mx-auto max-w-5xl space-y-10">
<div>
<h1 className="text-2xl font-extrabold tracking-[-0.03em] text-text-primary">Licence</h1>
<p className="mt-1 text-sm text-text-secondary">What this instance is entitled to, and how much of it is in use.</p>
</div>
</section>
<section className="rounded border border-border p-4">
<h2 className="font-semibold text-text-primary">Usage</h2>
<ul className="mt-2 space-y-1 text-sm text-text-secondary">
<li>Servers: {license.usage.servers} of {cap(license.limits.max_servers)}</li>
<li>Secret groups: {license.usage.secret_groups} of {cap(license.limits.max_secret_groups)}</li>
<li>Notification channels: {license.usage.channels} of {cap(license.limits.max_channels)}</li>
<li>Browser console: {license.features.console ? "Included" : "Not included"}</li>
<li>Single sign-on: {license.features.oidc ? "Included" : "Not included"}</li>
</ul>
</section>
<RecordPanel license={license} />
<section className="rounded border border-border p-4">
<h2 className="font-semibold text-text-primary">Add or replace a licence</h2>
<textarea
className="mt-2 h-32 w-full rounded border border-border bg-transparent p-2 font-mono text-xs"
placeholder="Paste your licence key"
value={blob}
onChange={(e) => setBlob(e.target.value)}
/>
<input
type="file"
accept=".lic,.txt"
className="mt-2 block text-xs"
onChange={async (e) => {
const f = e.target.files?.[0];
if (f) setBlob((await f.text()).trim());
}}
/>
{error ? <p className="mt-2 text-sm text-danger">{error}</p> : null}
<button
type="button"
className="mt-3 rounded bg-accent px-3 py-1.5 text-sm font-semibold text-accent-ink"
disabled={!blob.trim() || save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? "Checking…" : "Save licence"}
</button>
</section>
<Group label="Allowances">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Allowance label="Servers" used={license.usage.servers} limit={license.limits.max_servers} />
<Allowance label="Secret groups" used={license.usage.secret_groups} limit={license.limits.max_secret_groups} />
<Allowance label="Notification channels" used={license.usage.channels} limit={license.limits.max_channels} />
</div>
<Card padding={false} className="px-5 py-2">
<div className="divide-y divide-border-soft">
<Feature label="Browser console" included={Boolean(license.features.console)} />
<Feature label="Single sign-on" included={Boolean(license.features.oidc)} />
</div>
</Card>
</Group>
<Group label="Add or replace">
<Card>
<label htmlFor="licence-blob" className="mb-1.5 block text-sm font-medium text-text-secondary">
Licence key
</label>
<textarea
id="licence-blob"
className={`${inputClass} h-32 resize-y font-mono text-xs`}
placeholder="Paste your licence key"
value={blob}
onChange={(e) => {
setBlob(e.target.value);
setFileName("");
}}
/>
<div className="mt-3 flex flex-wrap items-center gap-3">
{/* A bare file input cannot be styled, so the button is the
label and the input itself is visually hidden. */}
<label className="inline-flex cursor-pointer items-center gap-2 rounded border border-border bg-surface px-3 py-1.5 text-sm font-semibold text-text-primary transition-colors hover:border-text-tertiary focus-within:ring-2 focus-within:ring-accent">
<input
type="file"
accept=".lic,.txt"
className="sr-only"
onChange={async (e) => {
const f = e.target.files?.[0];
if (!f) return;
setBlob((await f.text()).trim());
setFileName(f.name);
}}
/>
Choose a file
</label>
<span className="text-xs text-text-tertiary">{fileName ? `Loaded ${fileName}` : "or paste the key above"}</span>
</div>
{error && <p className="mt-4 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</p>}
<div className="mt-5 border-t border-border-soft pt-5">
<Button type="button" variant="primary" loading={save.isPending} disabled={!blob.trim()} onClick={() => save.mutate()}>
{save.isPending ? "Checking…" : "Save licence"}
</Button>
</div>
</Card>
</Group>
</div>
</div>
);
}
+1 -13
View File
@@ -7,6 +7,7 @@ import { api } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Button, Card } from "@/components/ui";
import { Field } from "@/components/settings/Field";
import { Group } from "@/components/settings/Group";
import { SectionCard } from "@/components/settings/SectionCard";
import { MembersCard } from "@/components/settings/MembersCard";
import { OIDCCard } from "@/components/settings/OIDCCard";
@@ -14,19 +15,6 @@ import { OIDCCard } from "@/components/settings/OIDCCard";
const numberInputClass =
"w-32 rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30";
/*
* Six cards on one page needs sorting into groups, or it reads as a pile. The
* eyebrow is site/'s .tag treatment: mono, tracked, on a hairline.
*/
function Group({ label, children }: { label: string; children: React.ReactNode }) {
return (
<section className="space-y-6">
<h2 className="flex items-center gap-3 border-b border-border-soft pb-2 font-mono text-[0.68rem] uppercase tracking-[0.15em] text-text-secondary">{label}</h2>
{children}
</section>
);
}
function BellIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
+14
View File
@@ -0,0 +1,14 @@
/*
* A labelled band of cards. site/'s .tag treatment on a hairline: mono,
* small, widely tracked. Shared by /settings and /settings/license so the
* two read as one section of the app rather than two pages that happen to
* be adjacent in the sidebar.
*/
export function Group({ label, children }: { label: string; children: React.ReactNode }) {
return (
<section className="space-y-6">
<h2 className="border-b border-border-soft pb-2 font-mono text-[0.68rem] uppercase tracking-[0.15em] text-text-secondary">{label}</h2>
{children}
</section>
);
}