fix(web): rebuild the licence page as the document it is
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user