diff --git a/adminsite/app/(customer)/page.tsx b/adminsite/app/(customer)/page.tsx new file mode 100644 index 0000000..aa7eac9 --- /dev/null +++ b/adminsite/app/(customer)/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useQueries, useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { API_BASE, NotConnected, api, type License } from "@/lib/api"; +import { NotConnectedPanel } from "@/components/NotConnected"; +import { InstanceCard } from "@/components/InstanceCard"; + +export default function OverviewPage() { + const { data, error, isLoading } = useQuery({ queryKey: ["account"], queryFn: api.account }); + + const licences = useQueries({ + queries: (data?.instances ?? []) + .filter((i) => i.current_license) + .map((i) => ({ + queryKey: ["license", i.instance_id], + queryFn: () => api.license(i.instance_id), + })), + }); + + if (error instanceof NotConnected) return ; + if (isLoading || !data) return

Loading your account…

; + + const byInstance = new Map(); + licences.forEach((q) => { + if (q.data) byInstance.set(q.data.instance_id, q.data); + }); + + const unlinked = data.instances.filter((i) => i.status === "awaiting_link"); + + return ( +
+
+

{data.account.name}

+

{data.account.billing_email}

+
+ + {unlinked.length > 0 && ( +
+

Finish setting up your licence

+

+ {unlinked.length === 1 + ? "One purchase is" + : `${unlinked.length} purchases are`}{" "} + not attached to an install yet, so no licence has been issued for{" "} + {unlinked.length === 1 ? "it" : "them"}. +

+ + Link an install + +
+ )} + + {data.instances.length === 0 ? ( +
+

No instances yet

+

+ There are two ways to run Vantage. Buy a cloud instance and we host it, and + your licence is applied automatically. Or buy a self-hosted licence, install + Vantage on your own server, and link it here to get your licence file. +

+
+ ) : ( +
+ {data.instances.map((i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/adminsite/components/InstanceCard.test.tsx b/adminsite/components/InstanceCard.test.tsx new file mode 100644 index 0000000..e4219de --- /dev/null +++ b/adminsite/components/InstanceCard.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { InstanceCard } from "./InstanceCard"; +import type { Instance, License } from "@/lib/api"; + +const base: Instance = { + instance_id: "6a0fe3f0-49d2-4aa1-967c-a3094b200b5d", + account_id: "a1", + name: "Acme Production", + slug: "acme", + deployment: "cloud", + tier: "professional", + status: "active", + current_license: "l1", + relink_count: 0, + created_at: "2026-01-01T00:00:00Z", +}; + +function licence(daysFromNow: number): License { + return { + license_id: "l1", + instance_id: base.instance_id, + account_id: "a1", + tier: "professional", + deployment: "cloud", + limits: { max_servers: -1, max_secret_groups: -1, max_channels: -1 }, + features: ["console", "oidc"], + issued_at: "2026-01-01T00:00:00Z", + expires_at: new Date(Date.now() + daysFromNow * 86_400_000).toISOString(), + issued_by: "staff@example.com", + reason: "new", + }; +} + +describe("InstanceCard", () => { + it("shows a valid licence with days remaining", () => { + render(); + expect(screen.getByText("Valid")).toBeInTheDocument(); + expect(screen.getByText(/367 days remaining/)).toBeInTheDocument(); + }); + + it("warns inside fourteen days", () => { + render(); + expect(screen.getByText("Expiring")).toBeInTheDocument(); + expect(screen.getByText(/9 days remaining/)).toBeInTheDocument(); + }); + + it("names what still works when expired", () => { + render(); + expect(screen.getByText("Expired")).toBeInTheDocument(); + // The reassurance is the point: this is the first thing a worried + // customer needs, and the backend really does keep these running. + expect(screen.getByText(/servers and monitors are still running/i)).toBeInTheDocument(); + expect(screen.getByText(/changes are disabled/i)).toBeInTheDocument(); + }); + + it("prompts to link when paid but never linked", () => { + render( + , + ); + expect(screen.getByText("Awaiting link")).toBeInTheDocument(); + expect(screen.getByText(/not attached to an install yet/i)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /link an install/i })).toBeInTheDocument(); + }); + + it("links to the instance's own subdomain for cloud", () => { + render(); + expect(screen.getByRole("link", { name: /open/i })).toBeInTheDocument(); + }); +}); diff --git a/adminsite/components/InstanceCard.tsx b/adminsite/components/InstanceCard.tsx new file mode 100644 index 0000000..44221b4 --- /dev/null +++ b/adminsite/components/InstanceCard.tsx @@ -0,0 +1,89 @@ +import Link from "next/link"; +import clsx from "clsx"; +import type { Instance, License } from "@/lib/api"; +import { daysRemaining, formatDate, licenceState } from "@/lib/format"; +import { StatePill } from "./StatePill"; + +const STRIPE = { + valid: "before:bg-valid", + warn: "before:bg-warn", + expired: "before:bg-expired", + none: "before:bg-accent", +} as const; + +export function InstanceCard({ instance, license }: { instance: Instance; license?: License }) { + const state = licenceState(license?.expires_at, Boolean(license)); + const days = license ? daysRemaining(license.expires_at) : 0; + const cloud = instance.deployment === "cloud"; + + return ( +
+
+
+

{instance.name || "Unnamed instance"}

+

+ {cloud ? "Cloud" : "Self-hosted"} + {instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""} +

+
+ +
+ + {state === "expired" && ( +

+ Servers and monitors are still running, and your agents keep their keys. Changes + are disabled until you renew. +

+ )} + + {state === "none" && ( +

+ You have paid for this but it is not attached to an install yet, so no licence + has been issued. Linking takes a minute. +

+ )} + + {license && state !== "expired" && ( +
+ {days} days remaining +
+
+
+ Renews {formatDate(license.expires_at)} +
+ )} + + {state === "none" ? ( + + Link an install + + ) : cloud && instance.slug ? ( + + Open {instance.slug}.vantage.hostxtra.co.uk + + ) : ( + + {state === "expired" ? "Renew and download" : "Licence and download"} + + )} +
+ ); +} diff --git a/adminsite/components/StatePill.tsx b/adminsite/components/StatePill.tsx new file mode 100644 index 0000000..7448636 --- /dev/null +++ b/adminsite/components/StatePill.tsx @@ -0,0 +1,42 @@ +import clsx from "clsx"; +import type { LicenceState } from "@/lib/format"; + +const LABEL: Record = { + valid: "Valid", + warn: "Expiring", + expired: "Expired", + none: "Awaiting link", +}; + +/* + * State reads three ways: this pill's colour, the pill's SHAPE, and the label. + * Colour alone would fail a colourblind reader on the one screen where getting + * it wrong costs money. + */ +const SHAPE: Record = { + valid: "rounded-full", + warn: "[clip-path:polygon(50%_0,100%_100%,0_100%)]", + expired: "[clip-path:polygon(20%_0,80%_0,100%_20%,100%_80%,80%_100%,20%_100%,0_80%,0_20%)]", + none: "rounded-none", +}; + +const TONE: Record = { + valid: "border-valid text-valid", + warn: "border-warn text-warn", + expired: "border-expired text-expired", + none: "border-accent text-accent", +}; + +export function StatePill({ state }: { state: LicenceState }) { + return ( + + + {LABEL[state]} + + ); +}