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 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 21:08:46 +01:00
co-authored by Claude Opus 5
parent 92ac1eeb62
commit b583e9803f
4 changed files with 209 additions and 0 deletions
+62
View File
@@ -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 <NotConnectedPanel url={API_BASE} />;
if (isLoading) return <p className="text-ink-3">Loading</p>;
return (
<div className="grid gap-6">
<h1 className="text-3xl">Billing</h1>
{!data || data.length === 0 ? (
<p className="text-ink-2">
You have no subscriptions. Cloud instances and self-hosted licences are both
bought from the pricing page.
</p>
) : (
<div className="overflow-x-auto rounded border border-rule bg-panel">
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-ink-3">
<th className="px-4 py-2.5">Plan</th>
<th className="px-4 py-2.5">Term</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5">Renews</th>
</tr>
</thead>
<tbody>
{data.map((s) => (
<tr
key={s.subscription_id}
className="border-b border-rule-soft last:border-0"
>
<td className="px-4 py-3">{s.tier.replace("_", " ")}</td>
<td className="px-4 py-3">{s.term}</td>
<td className="px-4 py-3">{s.status}</td>
<td className="px-4 py-3 font-mono tabular-nums">
{formatDate(s.current_period_end)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="max-w-xl text-[0.82rem] text-ink-3">
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.
</p>
</div>
);
}
@@ -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<typeof import("@/lib/api")>("@/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(<LinkForm onLinked={vi.fn()} />);
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(<LinkForm onLinked={onLinked} />);
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(<LinkForm onLinked={vi.fn()} />);
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();
});
});
@@ -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<string | undefined>();
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 (
<form onSubmit={submit} className="grid gap-4" noValidate>
<Field
label="Instance ID"
value={id}
onChange={(e) => setId(e.target.value)}
error={error}
hint={
<>
Find this on your install&rsquo;s <code>Settings Licence</code> page, or on
the setup screen just after you first sign in. It looks like{" "}
<code>6a0fe3f0-49d2-4aa1-967c-a3094b200b5d</code>.
</>
}
/>
<Field
label="Name it (optional)"
value={name}
onChange={(e) => setName(e.target.value)}
hint="So you can tell it apart from your other installs."
/>
<Button type="submit" disabled={busy} className="justify-self-start">
{busy ? "Linking…" : "Link and issue licence"}
</Button>
</form>
);
}
@@ -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 (
<div className="grid max-w-2xl gap-6">
<header className="grid gap-2">
<h1 className="text-3xl">Link an install</h1>
<p className="text-ink-2">
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.
</p>
</header>
<LinkForm
onLinked={(instanceId) => {
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}`);
}}
/>
</div>
);
}