diff --git a/adminsite/app/(customer)/billing/page.tsx b/adminsite/app/(customer)/billing/page.tsx index b8c0194..d482620 100644 --- a/adminsite/app/(customer)/billing/page.tsx +++ b/adminsite/app/(customer)/billing/page.tsx @@ -3,60 +3,109 @@ import { useQuery } from "@tanstack/react-query"; import { API_BASE, NotConnected, api } from "@/lib/api"; import { NotConnectedPanel } from "@/components/NotConnected"; +import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame"; +import { PageHeader } from "@/components/PageHeader"; import { formatDate } from "@/lib/format"; export default function BillingPage() { - const { data, error, isLoading } = useQuery({ - queryKey: ["subscriptions"], - queryFn: api.subscriptions, - }); + const subs = useQuery({ queryKey: ["subscriptions"], queryFn: api.subscriptions }); + const account = useQuery({ queryKey: ["account"], queryFn: api.account }); - if (error instanceof NotConnected) return ; - if (isLoading) return

Loading…

; + if (subs.error instanceof NotConnected) return ; + if (subs.isLoading) return

Loading…

; + + const rows = subs.data ?? []; + + // Each subscription names the instance it pays for, because tier and term + // are per-licence rather than per-account. Resolving the name here is the + // difference between "professional · annual" and knowing which install that is. + const nameFor = (instanceId?: string) => + account.data?.instances.find((i) => i.instance_id === instanceId)?.name; 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) => ( - - - - - + + + + + +

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

+ + support@hostxtra.co.uk + +
+ + } + > + {rows.length === 0 ? ( +

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

+ ) : ( +
+
PlanTermStatusRenews
{s.tier.replace("_", " ")}{s.term}{s.status} - {formatDate(s.current_period_end)} -
+ + + + + + + - ))} - -
InstancePlanTermStatusRenews
-
- )} - -

- 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. -

+ + + {rows.map((s) => ( + + + {nameFor(s.instance_id) ?? ( + Not linked yet + )} + + {s.tier.replace("_", " ")} + {s.term} + {s.status} + + {formatDate(s.current_period_end)} + + + ))} + + +
+ )} + ); } diff --git a/adminsite/app/(customer)/instances/[id]/page.tsx b/adminsite/app/(customer)/instances/[id]/page.tsx index 243263f..38d3cd6 100644 --- a/adminsite/app/(customer)/instances/[id]/page.tsx +++ b/adminsite/app/(customer)/instances/[id]/page.tsx @@ -9,6 +9,8 @@ import { LicenceDelivery } from "@/components/LicenceDelivery"; import { MembersPanel } from "@/components/MembersPanel"; import { RelinkPanel } from "@/components/RelinkPanel"; import { StatePill } from "@/components/StatePill"; +import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame"; +import { PageHeader } from "@/components/PageHeader"; import { formatDate, licenceState, limitLabel } from "@/lib/format"; export default function InstancePage() { @@ -47,68 +49,120 @@ export default function InstancePage() { const lic = licence.data; const state = licenceState(lic?.expires_at, Boolean(lic)); + const cloud = instance.deployment === "cloud"; return ( -
-
-
-

{instance.name}

- -
-

- {instance.instance_id} -

-
+
+ } + /> - {lic ? ( - <> -
- - - - -
+ + + {lic ? ( + + ) : ( +

+ No licence issued yet. +

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

No licence has been issued for this instance yet.

- )} + {lic && ( + + + + )} - {instance.deployment === "cloud" ? ( - - ) : ( -

- Users for this install are managed inside it, in Settings → Instance. We do not - have access to your own deployment. -

- )} -
- ); -} - -function Fact({ label, value }: { label: string; value: string }) { - return ( -
-
- {label} -
-
{value}
+ {!cloud && ( + + +

+ Moving a licence to a different install counts as one. +

+
+ )} + + } + > + {cloud ? ( + + ) : ( +

+ Users for this install are managed inside it, in Settings → Instance. We do + not have access to your own deployment. +

+ )} + + {lic && !cloud && ( + <> + + relink.mutate(newId)} + /> + + )} + + {!lic && ( +

+ No licence has been issued for this instance yet. +

+ )} +
); } diff --git a/adminsite/app/(customer)/instances/link/page.tsx b/adminsite/app/(customer)/instances/link/page.tsx index e213a86..561fed3 100644 --- a/adminsite/app/(customer)/instances/link/page.tsx +++ b/adminsite/app/(customer)/instances/link/page.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { LinkForm } from "./LinkForm"; +import { PageHeader } from "@/components/PageHeader"; export default function LinkPage() { const router = useRouter(); @@ -10,13 +11,11 @@ export default function LinkPage() { 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"] }); diff --git a/adminsite/app/(customer)/instances/new/page.tsx b/adminsite/app/(customer)/instances/new/page.tsx index 32da202..f1e05d2 100644 --- a/adminsite/app/(customer)/instances/new/page.tsx +++ b/adminsite/app/(customer)/instances/new/page.tsx @@ -1,19 +1,17 @@ import type { Metadata } from "next"; import { CreateForm } from "./CreateForm"; +import { PageHeader } from "@/components/PageHeader"; export const metadata: Metadata = { title: "New instance" }; export default function NewInstancePage() { return (
-
-

Create a free instance

-

- An instance owns its servers, keys, workflows, monitors and secrets. Nothing - inside it is visible to any other instance. Free covers three servers, and the - licence runs for a month at a time — we email you before it needs renewing. -

-
+
); diff --git a/adminsite/app/(customer)/layout.tsx b/adminsite/app/(customer)/layout.tsx index e7391b4..2bed622 100644 --- a/adminsite/app/(customer)/layout.tsx +++ b/adminsite/app/(customer)/layout.tsx @@ -1,31 +1,39 @@ "use client"; -import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; import { RequireKind } from "@/lib/session"; +import { AppBar, type NavLink } from "@/components/AppBar"; + +/* + * Three destinations, not five. Settings moved into the account menu — it is + * your password, not a place — and Instances went with it, because Overview + * already lists them and a second door to the same room is just a second thing + * to keep in sync. Linking an install is an action, so it is a button on + * Overview rather than a permanent nav entry. + */ +const LINKS: NavLink[] = [ + { href: "/", label: "Overview" }, + { href: "/users", label: "People" }, + { href: "/billing", label: "Billing" }, +]; + +function AccountName() { + // Shares the ["account"] key with Overview, so this costs no extra request. + const { data } = useQuery({ queryKey: ["account"], queryFn: api.account }); + if (!data) return null; + return ( + + {data.account.name} + + ); +} export default function CustomerLayout({ children }: { children: React.ReactNode }) { return ( - -
{children}
+ } /> +
{children}
); } diff --git a/adminsite/app/(customer)/page.tsx b/adminsite/app/(customer)/page.tsx index 6fcc4b5..4f3e2dd 100644 --- a/adminsite/app/(customer)/page.tsx +++ b/adminsite/app/(customer)/page.tsx @@ -4,10 +4,16 @@ 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"; +import { InstanceRecord } from "@/components/InstanceRecord"; +import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame"; +import { PageHeader } from "@/components/PageHeader"; +import { LinkButton } from "@/components/Button"; +import { StatePill } from "@/components/StatePill"; +import { daysRemaining, formatDate, licenceState } from "@/lib/format"; export default function OverviewPage() { const { data, error, isLoading } = useQuery({ queryKey: ["account"], queryFn: api.account }); + const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers }); const licences = useQueries({ queries: (data?.instances ?? []) @@ -26,35 +32,62 @@ export default function OverviewPage() { if (q.data) byInstance.set(q.data.instance_id, q.data); }); - const unlinked = data.instances.filter((i) => i.status === "awaiting_link"); + const live = data.instances.filter((i) => i.status !== "deleted"); + + // Work the customer has to do, gathered across every instance. This is the + // only account-level view of it — each record only knows about itself. + const attention = live.flatMap((i) => { + const lic = byInstance.get(i.instance_id); + const state = licenceState(lic?.expires_at, Boolean(lic)); + if (state === "none") + return [{ id: i.instance_id, text: `${i.name || "An instance"} is not linked`, note: "" }]; + if (state === "expired") + return [{ id: i.instance_id, text: `${i.name} has expired`, note: "now" }]; + if (state === "warn") + return [ + { + id: i.instance_id, + text: `${i.name} expires`, + note: `${daysRemaining(lic!.expires_at)}d`, + }, + ]; + return []; + }); + + const pending = (people.data ?? []).filter((p) => !p.verified_at).length; + const hasFree = live.some((i) => i.tier === "free" && i.status !== "cancelled"); + + const subtitle = + live.length === 0 + ? "Nothing here yet." + : `${live.length} ${live.length === 1 ? "instance" : "instances"}${ + attention.length ? ` · ${attention.length} needing attention` : " · all licensed" + }`; return ( -
-
-

{data.account.name}

-

{data.account.billing_email}

-
+
+ 0 ? ( + + Create a free instance + + ) : undefined + } + record={[ + { key: "Account", value: data.account.account_id, copy: true }, + { key: "Billing", value: data.account.billing_email }, + ]} + status={ + attention.length === 0 && live.length > 0 ? ( + + ) : undefined + } + /> - {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 ? ( + {live.length === 0 ? (

No instances yet

@@ -62,36 +95,127 @@ export default function OverviewPage() { automatically. Or buy a self-hosted licence, install Vantage on your own server, and link it here to get your licence file.

- - Create a free instance - +
+ Create a free instance + + Link an install + +
) : ( -
- {data.instances.map((i) => ( - - ))} -
- )} + + {attention.length > 0 && ( + +
    + {attention.map((a) => ( +
  • + {a.text} + {a.note && ( + + {a.note} + + )} +
  • + ))} +
+
+ )} - {data.instances.length > 0 && - !data.instances.some( - (i) => i.tier === "free" && i.status !== "cancelled" && i.status !== "deleted", - ) && ( - - Create a free instance - - )} + +
    + {(people.data ?? []).slice(0, 5).map((p) => ( +
  • + {p.email} + + {p.account_role} + +
  • + ))} +
+ {pending > 0 && ( +

+ {pending} {pending === 1 ? "invitation" : "invitations"} not + accepted yet +

+ )} + + Manage people + +
+ + {/* + * Account-level facts only. Tier, limits and renewal date + * belong to a licence, and a licence belongs to one + * instance — an account holding a Free cloud instance and + * a Professional self-hosted one has no single plan. + */} + + + + Billing history + + + + +

+ Link your own install to get its licence file. It keeps its own + users. +

+ + Link an install + +
+ + } + > + {live.map((i, n) => { + const lic = byInstance.get(i.instance_id); + const state = licenceState(lic?.expires_at, Boolean(lic)); + return ( + + ); + })} +
+ )}
); } diff --git a/adminsite/app/(customer)/settings/page.tsx b/adminsite/app/(customer)/settings/page.tsx index 9bc2886..4046230 100644 --- a/adminsite/app/(customer)/settings/page.tsx +++ b/adminsite/app/(customer)/settings/page.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { ApiError, api } from "@/lib/api"; import { Button } from "@/components/Button"; import { Field } from "@/components/Field"; +import { PageHeader } from "@/components/PageHeader"; export default function SettingsPage() { const [current, setCurrent] = useState(""); @@ -28,14 +29,12 @@ export default function SettingsPage() { }); return ( -
-
-

Settings

-

- Your password signs you in here and into every Vantage instance you belong to. - Changing it changes all of them. -

-
+
+
r !== "owner"); return ( -
+ + {canManage && ( + + { + e.preventDefault(); + setError(null); + if (email.trim()) invite.mutate(); + }} + > + setEmail(e.target.value)} + required + hint="They choose their own password from the emailed link. Nothing happens until they open it." + /> + + + + + )} + + +
+ {WHAT_ROLES_DO.map(([r, what]) => ( +
+
+ {r} +
+
{what}
+
+ ))} +
+

+ An account role is not access to an instance. Give someone that on the + instance itself. +

+
+ + } + > {error && (

{error}

)} - - - - - - - - - - {(users.data ?? []).map((u) => { - const isSelf = u.email === session?.email; - return ( - - - - - +
EmailAccount roleStatus -
- {u.email} - {isSelf && (you)} - - {canManage && !isSelf ? ( - - ) : ( - - {u.account_role} - - )} - - {u.verified_at ? "Active" : "Invitation pending"} - - {canManage && !isSelf && ( - - - )} - + )} +
+
+ ); } diff --git a/adminsite/app/(customer)/users/page.tsx b/adminsite/app/(customer)/users/page.tsx index e3dccb4..bfd9fd6 100644 --- a/adminsite/app/(customer)/users/page.tsx +++ b/adminsite/app/(customer)/users/page.tsx @@ -1,15 +1,15 @@ +"use client"; + import { InvitePanel } from "./InvitePanel"; +import { PageHeader } from "@/components/PageHeader"; export default function UsersPage() { return ( -
-
-

People

-

- Everyone on this account. Owners and admins can invite people and grant them - access to instances; billing stays with owners. -

-
+
+
); diff --git a/adminsite/app/(staff)/staff/accounts/[id]/page.tsx b/adminsite/app/(staff)/staff/accounts/[id]/page.tsx index ed96609..a00cd67 100644 --- a/adminsite/app/(staff)/staff/accounts/[id]/page.tsx +++ b/adminsite/app/(staff)/staff/accounts/[id]/page.tsx @@ -5,6 +5,7 @@ import { useParams } from "next/navigation"; import Link from "next/link"; import { api } from "@/lib/api"; import { formatDate } from "@/lib/format"; +import { PageHeader } from "@/components/PageHeader"; export default function AccountDetailPage() { const id = String(useParams().id); @@ -17,12 +18,18 @@ export default function AccountDetailPage() { return (
-
-

{data.account.name}

-

- {data.account.billing_email} · {data.account.account_id} -

-
+
    diff --git a/adminsite/app/(staff)/staff/accounts/page.tsx b/adminsite/app/(staff)/staff/accounts/page.tsx index b10e185..0745d7f 100644 --- a/adminsite/app/(staff)/staff/accounts/page.tsx +++ b/adminsite/app/(staff)/staff/accounts/page.tsx @@ -1,9 +1,13 @@ import { AccountSearch } from "./AccountSearch"; +import { PageHeader } from "@/components/PageHeader"; export default function AccountsPage() { return (
    -

    Accounts

    +
    ); diff --git a/adminsite/app/(staff)/staff/audit/page.tsx b/adminsite/app/(staff)/staff/audit/page.tsx index ab277ee..46bd27c 100644 --- a/adminsite/app/(staff)/staff/audit/page.tsx +++ b/adminsite/app/(staff)/staff/audit/page.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { api } from "@/lib/api"; import { formatDate, formatStamp } from "@/lib/format"; import { Field } from "@/components/Field"; +import { PageHeader } from "@/components/PageHeader"; export default function AuditPage() { const [filter, setFilter] = useState(""); @@ -18,7 +19,11 @@ export default function AuditPage() { return (
    -

    Audit

    + = { @@ -32,29 +33,40 @@ export default function StaffInstancePage() { return (
    -
    -

    {data.instance.name || data.instance.instance_id}

    -

    - {data.instance.instance_id} -

    -

    - - {data.account.name || data.account.account_id} - - - {" "} - · {data.instance.deployment} · {data.instance.status} - {data.instance.relink_count > 0 && - ` · ${data.instance.relink_count} relinks this term`} - -

    +
    + + + {data.account.name || data.account.account_id} + + + {" "} + · {data.instance.deployment} · {data.instance.status} + {data.instance.relink_count > 0 && + ` · ${data.instance.relink_count} relinks this term`} + + + } + record={[ + { key: "Instance", value: data.instance.instance_id, copy: true }, + ...(data.instance.slug + ? [{ key: "Slug", value: data.instance.slug }] + : []), + ]} + /> {data.injection.applicable && inj && (

    {inj.label}

    )} -
    +

    Licence history

    diff --git a/adminsite/app/(staff)/staff/layout.tsx b/adminsite/app/(staff)/staff/layout.tsx index 2e8c8ce..4b2763a 100644 --- a/adminsite/app/(staff)/staff/layout.tsx +++ b/adminsite/app/(staff)/staff/layout.tsx @@ -1,29 +1,29 @@ "use client"; -import Link from "next/link"; import { RequireKind } from "@/lib/session"; +import { AppBar, type NavLink } from "@/components/AppBar"; -const LINKS = [ - ["/staff", "Operations"], - ["/staff/accounts", "Accounts"], - ["/staff/licenses", "Licences"], - ["/staff/plans", "Plans"], - ["/staff/audit", "Audit"], -] as const; +const LINKS: NavLink[] = [ + { href: "/staff", label: "Operations" }, + { href: "/staff/accounts", label: "Accounts" }, + { href: "/staff/licenses", label: "Licences" }, + { href: "/staff/plans", label: "Plans" }, + { href: "/staff/audit", label: "Audit" }, +]; export default function StaffLayout({ children }: { children: React.ReactNode }) { return ( - -
    {children}
    + + Staff + + } + /> +
    {children}
    ); } diff --git a/adminsite/app/(staff)/staff/licenses/page.tsx b/adminsite/app/(staff)/staff/licenses/page.tsx index 809705b..3ad1bef 100644 --- a/adminsite/app/(staff)/staff/licenses/page.tsx +++ b/adminsite/app/(staff)/staff/licenses/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useState } from "react"; import { api, type Tier } from "@/lib/api"; import { formatDate } from "@/lib/format"; +import { PageHeader } from "@/components/PageHeader"; export default function LicensesPage() { const [tier, setTier] = useState<"" | Tier>(""); @@ -19,7 +20,11 @@ export default function LicensesPage() { return (
    -

    Licences

    +