fix(web): rebuild the licence page as the document it is
Server Deploy / deploy (push) Successful in 40s
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:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user