From b583e9803fbb01afd8e2a6bea8df6e53b83af56a Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sat, 25 Jul 2026 21:08:46 +0100 Subject: [PATCH] feat(adminsite): the self-hosted link flow and billing view The link screen carries the whole burden of the five-minute bar: it names where to find the instance ID, validates the format before asking the server so a typo is instant rather than a round trip, surfaces the backend's own message when a UUID is already linked, and on success lands the customer directly on the download rather than back on a list. Billing is deliberately thin and says plainly that billing changes go through support, rather than linking to a Paddle portal that does not exist until spec 5. Co-Authored-By: Claude Opus 5 --- adminsite/app/(customer)/billing/page.tsx | 62 ++++++++++++++++ .../instances/link/LinkForm.test.tsx | 45 ++++++++++++ .../(customer)/instances/link/LinkForm.tsx | 72 +++++++++++++++++++ .../app/(customer)/instances/link/page.tsx | 30 ++++++++ 4 files changed, 209 insertions(+) create mode 100644 adminsite/app/(customer)/billing/page.tsx create mode 100644 adminsite/app/(customer)/instances/link/LinkForm.test.tsx create mode 100644 adminsite/app/(customer)/instances/link/LinkForm.tsx create mode 100644 adminsite/app/(customer)/instances/link/page.tsx diff --git a/adminsite/app/(customer)/billing/page.tsx b/adminsite/app/(customer)/billing/page.tsx new file mode 100644 index 0000000..b8c0194 --- /dev/null +++ b/adminsite/app/(customer)/billing/page.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { API_BASE, NotConnected, api } from "@/lib/api"; +import { NotConnectedPanel } from "@/components/NotConnected"; +import { formatDate } from "@/lib/format"; + +export default function BillingPage() { + const { data, error, isLoading } = useQuery({ + queryKey: ["subscriptions"], + queryFn: api.subscriptions, + }); + + if (error instanceof NotConnected) return ; + if (isLoading) return

Loading…

; + + return ( +
+

Billing

+ + {!data || data.length === 0 ? ( +

+ You have no subscriptions. Cloud instances and self-hosted licences are both + bought from the pricing page. +

+ ) : ( +
+ + + + + + + + + + + {data.map((s) => ( + + + + + + + ))} + +
PlanTermStatusRenews
{s.tier.replace("_", " ")}{s.term}{s.status} + {formatDate(s.current_period_end)} +
+
+ )} + +

+ To change a card, download an invoice or cancel, email support and we will send you + a billing link. Self-service billing arrives with card payments. +

+
+ ); +} diff --git a/adminsite/app/(customer)/instances/link/LinkForm.test.tsx b/adminsite/app/(customer)/instances/link/LinkForm.test.tsx new file mode 100644 index 0000000..0ff6fd7 --- /dev/null +++ b/adminsite/app/(customer)/instances/link/LinkForm.test.tsx @@ -0,0 +1,45 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ApiError } from "@/lib/api"; +import { LinkForm } from "./LinkForm"; + +const link = vi.fn(); +vi.mock("@/lib/api", async () => { + const actual = await vi.importActual("@/lib/api"); + return { ...actual, api: { ...actual.api, link: (...a: unknown[]) => link(...a) } }; +}); + +const VALID = "6a0fe3f0-49d2-4aa1-967c-a3094b200b5d"; + +describe("LinkForm", () => { + it("catches a malformed id before asking the server", async () => { + render(); + await userEvent.type(screen.getByLabelText(/instance id/i), "not-a-uuid"); + await userEvent.click(screen.getByRole("button", { name: /link and issue/i })); + + expect(screen.getByText(/does not look like an instance id/i)).toBeInTheDocument(); + expect(link).not.toHaveBeenCalled(); + }); + + it("lands the customer on their licence on success", async () => { + const onLinked = vi.fn(); + link.mockResolvedValue({ instance_id: VALID }); + render(); + await userEvent.type(screen.getByLabelText(/instance id/i), VALID); + await userEvent.click(screen.getByRole("button", { name: /link and issue/i })); + + await waitFor(() => expect(onLinked).toHaveBeenCalledWith(VALID)); + }); + + it("shows the server's own message when the id is already linked", async () => { + link.mockRejectedValue( + new ApiError(409, "that instance ID is already linked to an account"), + ); + render(); + await userEvent.type(screen.getByLabelText(/instance id/i), VALID); + await userEvent.click(screen.getByRole("button", { name: /link and issue/i })); + + expect(await screen.findByText(/already linked to an account/i)).toBeInTheDocument(); + }); +}); diff --git a/adminsite/app/(customer)/instances/link/LinkForm.tsx b/adminsite/app/(customer)/instances/link/LinkForm.tsx new file mode 100644 index 0000000..361c9b7 --- /dev/null +++ b/adminsite/app/(customer)/instances/link/LinkForm.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState } from "react"; +import { ApiError, NotConnected, api } from "@/lib/api"; +import { Button } from "@/components/Button"; +import { Field } from "@/components/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 LinkForm({ onLinked }: { onLinked: (instanceId: string) => void }) { + const [id, setId] = useState(""); + const [name, setName] = useState(""); + const [error, setError] = useState(); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + const value = id.trim(); + + // Checked here so a typo costs nothing and the message is instant. + if (!UUID_RE.test(value)) { + setError( + "That does not look like an instance ID. It should look like the example below.", + ); + return; + } + + setBusy(true); + setError(undefined); + try { + const inst = await api.link(value, name.trim()); + onLinked(inst.instance_id); + } catch (err) { + setError( + err instanceof NotConnected + ? "The licensing service is not reachable from this page." + : err instanceof ApiError + ? err.message + : "Could not link that instance. Try again.", + ); + } finally { + setBusy(false); + } + } + + return ( +
+ setId(e.target.value)} + error={error} + hint={ + <> + Find this on your install’s Settings → Licence page, or on + the setup screen just after you first sign in. It looks like{" "} + 6a0fe3f0-49d2-4aa1-967c-a3094b200b5d. + + } + /> + setName(e.target.value)} + hint="So you can tell it apart from your other installs." + /> + + + ); +} diff --git a/adminsite/app/(customer)/instances/link/page.tsx b/adminsite/app/(customer)/instances/link/page.tsx new file mode 100644 index 0000000..e213a86 --- /dev/null +++ b/adminsite/app/(customer)/instances/link/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { LinkForm } from "./LinkForm"; + +export default function LinkPage() { + const router = useRouter(); + const qc = useQueryClient(); + + return ( +
+
+

Link an install

+

+ Every licence is tied to one install, so we need its ID before we can issue + yours. Paste it below and your licence is ready on the next screen. +

+
+ { + qc.invalidateQueries({ queryKey: ["account"] }); + // Straight to the download, not back to a list: the licence is + // the thing they came for. + router.push(`/instances/${instanceId}`); + }} + /> +
+ ); +}