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,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>
);
}