From fa75d704752eada28f3643202a507a67d032a292 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sun, 26 Jul 2026 20:35:31 +0100 Subject: [PATCH] fix(web): rebuild the licence page as the document it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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. --- web/app/(app)/settings/license/page.tsx | 315 +++++++++++++++++++----- web/app/(app)/settings/page.tsx | 14 +- web/components/settings/Group.tsx | 14 ++ 3 files changed, 266 insertions(+), 77 deletions(-) create mode 100644 web/components/settings/Group.tsx diff --git a/web/app/(app)/settings/license/page.tsx b/web/app/(app)/settings/license/page.tsx index 7a6776a..60f2729 100644 --- a/web/app/(app)/settings/license/page.tsx +++ b/web/app/(app)/settings/license/page.tsx @@ -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 = { + 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 = { + 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 = { + 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 ( +
+

{label}

+
{children}
+
+ ); +} + +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 ( + +

{label}

+

+ {used} + {unlimited ? "used" : `of ${limit}`} +

+ {unlimited ? ( +

No limit

+ ) : ( +
+
+
+ )} + + ); +} + +function Feature({ label, included }: { label: string; included: boolean }) { + return ( +
+ + {included ? "✓" : "–"} + + {label} + + {included ? "Included" : "Not included"} + +
+ ); +} + +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 ( + +
+ {/* 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. */} +

{s.label}

+ {license.tier &&

{license.tier} tier

} +
+ + {license.reason &&

{REASON[license.reason] ?? license.reason}

} + +
+ + {license.expires_at ? ( + <> + {new Date(license.expires_at).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" })} + {typeof days === "number" && days >= 0 && ( + + ({days} day{days === 1 ? "" : "s"}) + + )} + + ) : ( + No expiry + )} + + + + {SOURCE[license.source] ?? license.source} + + +
+ +
+ {license.instance_id} + +
+
+

Quote this when buying or activating a licence.

+
+
+
+ ); } 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 ( +
+
+
+ ); + } + + if (!license) { + return ( +
+ +

Licence unavailable

+

The licence state could not be read. Reload the page, and check the server logs if it keeps failing.

+
+
+ ); + } return ( -
-

Licence

- -
-

- State: {license.state} - {license.tier ? <> · Tier: {license.tier} : null} - {license.expires_at ? <> · Expires {new Date(license.expires_at).toLocaleDateString()} : null} -

-

- Instance ID — quote this when buying or activating a licence -

-
- {license.instance_id} - +
+
+
+

Licence

+

What this instance is entitled to, and how much of it is in use.

-
-
-

Usage

-
    -
  • Servers: {license.usage.servers} of {cap(license.limits.max_servers)}
  • -
  • Secret groups: {license.usage.secret_groups} of {cap(license.limits.max_secret_groups)}
  • -
  • Notification channels: {license.usage.channels} of {cap(license.limits.max_channels)}
  • -
  • Browser console: {license.features.console ? "Included" : "Not included"}
  • -
  • Single sign-on: {license.features.oidc ? "Included" : "Not included"}
  • -
-
+ -
-

Add or replace a licence

-