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.
+
+ ) : (
+
+
+
+
+ | Plan |
+ Term |
+ Status |
+ Renews |
+
+
+
+ {data.map((s) => (
+
+ | {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 (
+
+ );
+}
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 (
+
+
+
{
+ 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}`);
+ }}
+ />
+
+ );
+}