feat(adminsite): licence delivery, paste instructions and relink

The blob is shown inline as well as offered as a file, because a licence is
signed public data bound to one instance -- useless anywhere else -- and a
blocked download must never leave a paying customer stuck. Admin now returns
it to its owner for the same reason.

Relink shows the remaining allowance from the backend's max_relinks rather
than a hardcoded 3, and at zero it disables and says to contact support
instead of failing at the API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 21:06:21 +01:00
co-authored by Claude Opus 5
parent 242a587340
commit 92ac1eeb62
6 changed files with 282 additions and 1 deletions
@@ -0,0 +1,104 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api } from "@/lib/api";
import { NotConnectedPanel } from "@/components/NotConnected";
import { LicenceDelivery } from "@/components/LicenceDelivery";
import { RelinkPanel } from "@/components/RelinkPanel";
import { StatePill } from "@/components/StatePill";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
export default function InstancePage() {
const id = String(useParams().id);
const router = useRouter();
const qc = useQueryClient();
const [relinkError, setRelinkError] = useState<string | undefined>();
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
const licence = useQuery({
queryKey: ["license", id],
queryFn: () => api.license(id),
retry: false,
});
const relink = useMutation({
mutationFn: (newId: string) => api.relink(id, newId),
onSuccess: (lic) => {
qc.invalidateQueries({ queryKey: ["account"] });
router.replace(`/instances/${lic.instance_id}`);
},
onError: (err) =>
setRelinkError(err instanceof ApiError ? err.message : "Relink failed. Try again."),
});
if (account.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
const instance = account.data?.instances.find((i) => i.instance_id === id);
if (account.isLoading) return <p className="text-ink-3">Loading</p>;
if (!instance) {
// Says "not on your account" rather than "does not exist": the backend
// answers 404 for another account's instance, and confirming existence
// here would undo that.
return <p className="text-ink-2">That instance is not on your account.</p>;
}
const lic = licence.data;
const state = licenceState(lic?.expires_at, Boolean(lic));
return (
<div className="grid gap-8">
<header className="grid gap-3">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl">{instance.name}</h1>
<StatePill state={state} />
</div>
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">
{instance.instance_id}
</p>
</header>
{lic ? (
<>
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Fact label="Tier" value={lic.tier.replace("_", " ")} />
<Fact label="Expires" value={formatDate(lic.expires_at)} />
<Fact label="Servers" value={limitLabel(lic.limits.max_servers)} />
<Fact label="Features" value={lic.features.join(", ") || "none"} />
</dl>
{instance.deployment === "self_hosted" && (
<>
<LicenceDelivery
instanceId={instance.instance_id}
blob={lic.blob ?? ""}
downloadUrl={api.licenseBlobUrl(instance.instance_id)}
/>
<RelinkPanel
instanceId={instance.instance_id}
used={instance.relink_count}
max={account.data?.max_relinks ?? 3}
error={relinkError}
onRelink={(newId) => relink.mutate(newId)}
/>
</>
)}
</>
) : (
<p className="text-ink-2">No licence has been issued for this instance yet.</p>
)}
</div>
);
}
function Fact({ label, value }: { label: string; value: string }) {
return (
<div className="grid gap-1">
<dt className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
{label}
</dt>
<dd className="font-mono tabular-nums">{value}</dd>
</div>
);
}
@@ -0,0 +1,20 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LicenceDelivery } from "./LicenceDelivery";
describe("LicenceDelivery", () => {
it("offers the file and always shows the blob as a fallback", () => {
render(
<LicenceDelivery
instanceId="6a0fe3f0-49d2-4aa1-967c-a3094b200b5d"
blob="VANTAGE-LIC abc123"
downloadUrl="https://admin.example.com/api/instances/x/license/download"
/>,
);
const link = screen.getByRole("link", { name: /download licence/i });
expect(link).toHaveAttribute("href", expect.stringContaining("/license/download"));
// A blocked download must never leave a paying customer stuck.
expect(screen.getByText(/VANTAGE-LIC abc123/)).toBeInTheDocument();
expect(screen.getByText(/Settings → Licence/)).toBeInTheDocument();
});
});
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { Button } from "./Button";
/*
* A licence blob is signed public data, not a secret — it is useless on any
* instance other than the one it names. So it is safe to show inline, and
* showing it is what stops a blocked download from blocking a paying customer.
*/
export function LicenceDelivery({
instanceId,
blob,
downloadUrl,
}: {
instanceId: string;
blob: string;
downloadUrl: string;
}) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(blob);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const steps = [
<>
Open <code className="rounded-sm bg-accent-wash px-1">Settings Licence</code> on your
install.
</>,
<>Paste the licence into the box and save.</>,
<>
The page reports <code className="rounded-sm bg-accent-wash px-1">Valid</code> straight
away no restart.
</>,
];
return (
<section className="grid gap-3">
<h2 className="text-xl">Your licence</h2>
<div className="flex flex-wrap items-center gap-3">
<a
href={downloadUrl}
download={`vantage-${instanceId}.lic`}
className="inline-flex items-center gap-2 rounded border border-accent bg-accent px-4 py-2.5 text-[0.94rem] font-semibold text-accent-ink"
>
Download licence
</a>
<Button variant="line" type="button" onClick={copy}>
{copied ? "Copied" : "Copy to clipboard"}
</Button>
</div>
<pre className="overflow-x-auto rounded border border-dashed border-rule bg-panel-2 p-3 font-mono text-[0.72rem] text-ink-2">
{blob}
</pre>
<ol className="grid gap-2">
{steps.map((body, i) => (
<li
key={i}
className="grid grid-cols-[1.6rem_1fr] gap-3 text-[0.82rem] text-ink-2"
>
<span className="h-6 rounded-sm border border-rule text-center font-mono text-[0.72rem] leading-6 text-accent">
{i + 1}
</span>
<span>{body}</span>
</li>
))}
</ol>
</section>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RelinkPanel } from "./RelinkPanel";
describe("RelinkPanel", () => {
it("shows the remaining allowance", () => {
render(<RelinkPanel instanceId="i1" used={1} max={3} onRelink={vi.fn()} />);
expect(screen.getByText("2 of 3 relinks left this term")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /relink/i })).toBeEnabled();
});
it("disables at zero and says what to do instead", () => {
render(<RelinkPanel instanceId="i1" used={3} max={3} onRelink={vi.fn()} />);
expect(screen.getByRole("button", { name: /relink/i })).toBeDisabled();
expect(screen.getByText(/contact support/i)).toBeInTheDocument();
});
});
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useState } from "react";
import { Button } from "./Button";
import { Field } from "./Field";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function RelinkPanel({
used,
max,
onRelink,
error,
}: {
instanceId: string;
used: number;
max: number;
onRelink: (newId: string) => void;
error?: string;
}) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
const remaining = Math.max(0, max - used);
const exhausted = remaining === 0;
return (
<section className="grid gap-3 border-t border-rule-soft pt-5">
<h2 className="text-xl">Moved to a new server?</h2>
<p className="text-[0.82rem] text-ink-2">
Relinking issues a replacement licence for the new install, covering the rest of
your current term.
</p>
{open && !exhausted && (
<Field
label="New instance ID"
value={value}
onChange={(e) => setValue(e.target.value)}
error={error}
hint="From Settings → Licence on the new install."
/>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="line"
disabled={exhausted || (open && !UUID_RE.test(value.trim()))}
onClick={() => (open ? onRelink(value.trim()) : setOpen(true))}
>
Relink to a new install
</Button>
<span className="text-[0.82rem] text-ink-3">
{exhausted
? "You have used every relink for this term — contact support and we will sort it out."
: `${remaining} of ${max} relinks left this term`}
</span>
</div>
</section>
);
}