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