From 92ac1eeb62673b797c13dd36851104268c919f75 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sat, 25 Jul 2026 21:06:21 +0100 Subject: [PATCH] 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 --- admin/internal/api/customer.go | 10 +- .../app/(customer)/instances/[id]/page.tsx | 104 ++++++++++++++++++ adminsite/components/LicenceDelivery.test.tsx | 20 ++++ adminsite/components/LicenceDelivery.tsx | 73 ++++++++++++ adminsite/components/RelinkPanel.test.tsx | 17 +++ adminsite/components/RelinkPanel.tsx | 59 ++++++++++ 6 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 adminsite/app/(customer)/instances/[id]/page.tsx create mode 100644 adminsite/components/LicenceDelivery.test.tsx create mode 100644 adminsite/components/LicenceDelivery.tsx create mode 100644 adminsite/components/RelinkPanel.test.tsx create mode 100644 adminsite/components/RelinkPanel.tsx diff --git a/admin/internal/api/customer.go b/admin/internal/api/customer.go index 8c25056..a7f16f5 100644 --- a/admin/internal/api/customer.go +++ b/admin/internal/api/customer.go @@ -146,7 +146,15 @@ func getInstanceLicense(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "no licence issued yet"}) return } - c.JSON(http.StatusOK, lic) + // The owner gets the blob itself: it is signed public data bound to their + // own instance, and the download endpoint hands over the same bytes. The + // struct tag hides it, so the fields are listed explicitly. + c.JSON(http.StatusOK, gin.H{ + "license_id": lic.LicenseID, "instance_id": lic.InstanceID, "tier": lic.Tier, + "deployment": lic.Deployment, "limits": lic.Limits, "features": lic.Features, + "issued_at": lic.IssuedAt, "expires_at": lic.ExpiresAt, "reason": lic.Reason, + "issued_by": lic.IssuedBy, "blob": lic.Blob, + }) } func downloadInstanceLicense(c *gin.Context) { diff --git a/adminsite/app/(customer)/instances/[id]/page.tsx b/adminsite/app/(customer)/instances/[id]/page.tsx new file mode 100644 index 0000000..6cac19c --- /dev/null +++ b/adminsite/app/(customer)/instances/[id]/page.tsx @@ -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(); + + 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 ; + + const instance = account.data?.instances.find((i) => i.instance_id === id); + if (account.isLoading) return

Loading…

; + 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

That instance is not on your account.

; + } + + const lic = licence.data; + const state = licenceState(lic?.expires_at, Boolean(lic)); + + return ( +
+
+
+

{instance.name}

+ +
+

+ {instance.instance_id} +

+
+ + {lic ? ( + <> +
+ + + + +
+ + {instance.deployment === "self_hosted" && ( + <> + + relink.mutate(newId)} + /> + + )} + + ) : ( +

No licence has been issued for this instance yet.

+ )} +
+ ); +} + +function Fact({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} diff --git a/adminsite/components/LicenceDelivery.test.tsx b/adminsite/components/LicenceDelivery.test.tsx new file mode 100644 index 0000000..322f34f --- /dev/null +++ b/adminsite/components/LicenceDelivery.test.tsx @@ -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( + , + ); + 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(); + }); +}); diff --git a/adminsite/components/LicenceDelivery.tsx b/adminsite/components/LicenceDelivery.tsx new file mode 100644 index 0000000..5746ea4 --- /dev/null +++ b/adminsite/components/LicenceDelivery.tsx @@ -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 Settings → Licence on your + install. + , + <>Paste the licence into the box and save., + <> + The page reports Valid straight + away — no restart. + , + ]; + + return ( +
+

Your licence

+
+ + Download licence + + +
+
+                {blob}
+            
+
    + {steps.map((body, i) => ( +
  1. + + {i + 1} + + {body} +
  2. + ))} +
+
+ ); +} diff --git a/adminsite/components/RelinkPanel.test.tsx b/adminsite/components/RelinkPanel.test.tsx new file mode 100644 index 0000000..8e24d71 --- /dev/null +++ b/adminsite/components/RelinkPanel.test.tsx @@ -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(); + 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(); + expect(screen.getByRole("button", { name: /relink/i })).toBeDisabled(); + expect(screen.getByText(/contact support/i)).toBeInTheDocument(); + }); +}); diff --git a/adminsite/components/RelinkPanel.tsx b/adminsite/components/RelinkPanel.tsx new file mode 100644 index 0000000..01bd1c9 --- /dev/null +++ b/adminsite/components/RelinkPanel.tsx @@ -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 ( +
+

Moved to a new server?

+

+ Relinking issues a replacement licence for the new install, covering the rest of + your current term. +

+ {open && !exhausted && ( + setValue(e.target.value)} + error={error} + hint="From Settings → Licence on the new install." + /> + )} +
+ + + {exhausted + ? "You have used every relink for this term — contact support and we will sort it out." + : `${remaining} of ${max} relinks left this term`} + +
+
+ ); +}