feat(adminsite): one masthead, a shared page frame, collapsible instance records
Server Deploy / deploy (push) Successful in 53s
Server Deploy / deploy (push) Successful in 53s
The console had components but no shell: a brand bar and a nav strip stacked into 100px carrying eight words, no sign-out, no account identity, and an Overview link hardcoded to text-accent so it read as the current page on every screen. Nine pages each hand-rolled their own header. AppBar replaces both bars and derives its active state from usePathname. Settings moves into AccountMenu — it is your password, not a destination — taking appearance with it, which finally sets the data-theme attribute the token blocks have supported in both directions since they were written. That leaves three customer destinations: Overview, People, Billing. PageFrame adds a support rail so a page has a floor, and InstanceRecord replaces InstanceCard with one component that opens and closes: an account with a single instance used to render a third of a row of summary with its substance a click away. It defaults open when the instance is the only one or needs attention. No plan card in the rail: tier, limits and expiry belong to a licence and a licence belongs to one instance, so an account holding a Free cloud instance and a Professional self-hosted one has no single plan. The rail carries only what is account-wide. Tokens and globals.css are untouched — they stay verbatim shared with site/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <NotConnectedPanel url={API_BASE} />;
|
||||
if (isLoading) return <p className="text-ink-3">Loading…</p>;
|
||||
if (subs.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
if (subs.isLoading) return <p className="text-ink-3">Loading…</p>;
|
||||
|
||||
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 (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Billing</h1>
|
||||
<PageHeader
|
||||
title="Billing"
|
||||
subtitle="One subscription per instance — each carries its own tier and term."
|
||||
record={
|
||||
account.data
|
||||
? [{ key: "Billing", value: account.data.account.billing_email }]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{!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>
|
||||
<PageFrame
|
||||
aside={
|
||||
<>
|
||||
<RailCard title="Account">
|
||||
<RailFacts
|
||||
rows={[
|
||||
{
|
||||
label: "Billing contact",
|
||||
value: account.data?.account.billing_email ?? "—",
|
||||
},
|
||||
{ label: "Status", value: account.data?.account.status ?? "—" },
|
||||
{ label: "Subscriptions", value: rows.length },
|
||||
]}
|
||||
/>
|
||||
</RailCard>
|
||||
<RailCard title="Need a change?">
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
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>
|
||||
<a
|
||||
href="mailto:support@hostxtra.co.uk"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
support@hostxtra.co.uk
|
||||
</a>
|
||||
</RailCard>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{rows.length === 0 ? (
|
||||
<p className="rounded border border-rule bg-panel p-5 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.68rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
<th className="px-4 py-2.5 font-normal">Instance</th>
|
||||
<th className="px-4 py-2.5 font-normal">Plan</th>
|
||||
<th className="px-4 py-2.5 font-normal">Term</th>
|
||||
<th className="px-4 py-2.5 font-normal">Status</th>
|
||||
<th className="px-4 py-2.5 font-normal">Renews</th>
|
||||
</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((s) => (
|
||||
<tr
|
||||
key={s.subscription_id}
|
||||
className="border-b border-rule-soft last:border-0"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
{nameFor(s.instance_id) ?? (
|
||||
<span className="text-ink-3">Not linked yet</span>
|
||||
)}
|
||||
</td>
|
||||
<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>
|
||||
)}
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-3xl">{instance.name}</h1>
|
||||
<StatePill state={state} />
|
||||
</div>
|
||||
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">
|
||||
{instance.instance_id}
|
||||
</p>
|
||||
</header>
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title={instance.name || "Unnamed instance"}
|
||||
subtitle={`${cloud ? "Cloud" : "Self-hosted"}${
|
||||
instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""
|
||||
} · created ${formatDate(instance.created_at)}`}
|
||||
record={[
|
||||
{ key: "Instance", value: instance.instance_id, copy: true },
|
||||
...(lic ? [{ key: "Licence", value: lic.license_id, copy: true }] : []),
|
||||
]}
|
||||
status={<StatePill state={state} />}
|
||||
/>
|
||||
|
||||
{lic ? (
|
||||
<>
|
||||
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Fact label="Tier" value={lic.tier.replace("_", " ")} />
|
||||
<Fact label="Expires" value={formatDate(lic.expires_at)} />
|
||||
<Fact label="Servers" value={limitLabel(lic.limits.max_servers)} />
|
||||
<Fact label="Features" value={lic.features.join(", ") || "none"} />
|
||||
</dl>
|
||||
<PageFrame
|
||||
aside={
|
||||
<>
|
||||
<RailCard title="Licence">
|
||||
{lic ? (
|
||||
<RailFacts
|
||||
rows={[
|
||||
{ label: "Tier", value: lic.tier.replace("_", " ") },
|
||||
{ label: "Issued", value: formatDate(lic.issued_at) },
|
||||
{ label: "Expires", value: formatDate(lic.expires_at) },
|
||||
{ label: "Reason", value: lic.reason },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
No licence issued yet.
|
||||
</p>
|
||||
)}
|
||||
</RailCard>
|
||||
|
||||
{instance.deployment === "self_hosted" && (
|
||||
<>
|
||||
<LicenceDelivery
|
||||
instanceId={instance.instance_id}
|
||||
blob={lic.blob ?? ""}
|
||||
downloadUrl={api.licenseBlobUrl(instance.instance_id)}
|
||||
/>
|
||||
<RelinkPanel
|
||||
instanceId={instance.instance_id}
|
||||
used={instance.relink_count}
|
||||
max={account.data?.max_relinks ?? 3}
|
||||
error={relinkError}
|
||||
onRelink={(newId) => relink.mutate(newId)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-ink-2">No licence has been issued for this instance yet.</p>
|
||||
)}
|
||||
{lic && (
|
||||
<RailCard title="Included">
|
||||
<RailFacts
|
||||
rows={[
|
||||
{
|
||||
label: "Servers",
|
||||
value: limitLabel(lic.limits.max_servers),
|
||||
},
|
||||
{
|
||||
label: "Secret groups",
|
||||
value: limitLabel(lic.limits.max_secret_groups),
|
||||
},
|
||||
{
|
||||
label: "Channels",
|
||||
value: limitLabel(lic.limits.max_channels),
|
||||
},
|
||||
{
|
||||
label: "Features",
|
||||
value: lic.features.join(", ") || "none",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</RailCard>
|
||||
)}
|
||||
|
||||
{instance.deployment === "cloud" ? (
|
||||
<MembersPanel instanceId={instance.instance_id} />
|
||||
) : (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
Users for this install are managed inside it, in Settings → Instance. We do not
|
||||
have access to your own deployment.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
<dt className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="font-mono tabular-nums">{value}</dd>
|
||||
{!cloud && (
|
||||
<RailCard title="Moves">
|
||||
<RailFacts
|
||||
rows={[
|
||||
{
|
||||
label: "Relinks used",
|
||||
value: `${instance.relink_count} of ${
|
||||
account.data?.max_relinks ?? 3
|
||||
}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Moving a licence to a different install counts as one.
|
||||
</p>
|
||||
</RailCard>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{cloud ? (
|
||||
<MembersPanel instanceId={instance.instance_id} />
|
||||
) : (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
Users for this install are managed inside it, in Settings → Instance. We do
|
||||
not have access to your own deployment.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{lic && !cloud && (
|
||||
<>
|
||||
<LicenceDelivery
|
||||
instanceId={instance.instance_id}
|
||||
blob={lic.blob ?? ""}
|
||||
downloadUrl={api.licenseBlobUrl(instance.instance_id)}
|
||||
/>
|
||||
<RelinkPanel
|
||||
instanceId={instance.instance_id}
|
||||
used={instance.relink_count}
|
||||
max={account.data?.max_relinks ?? 3}
|
||||
error={relinkError}
|
||||
onRelink={(newId) => relink.mutate(newId)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!lic && (
|
||||
<p className="rounded border border-rule bg-panel p-5 text-ink-2">
|
||||
No licence has been issued for this instance yet.
|
||||
</p>
|
||||
)}
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
<PageHeader
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title="Link an install"
|
||||
subtitle="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."
|
||||
/>
|
||||
<LinkForm
|
||||
onLinked={(instanceId) => {
|
||||
qc.invalidateQueries({ queryKey: ["account"] });
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-6">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="text-3xl">Create a free instance</h1>
|
||||
<p className="max-w-prose text-ink-2">
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
<PageHeader
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title="Create a free instance"
|
||||
subtitle="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."
|
||||
/>
|
||||
<CreateForm />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<span className="block truncate text-[0.92rem] font-bold tracking-[-0.01em]">
|
||||
{data.account.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomerLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<RequireKind kind="customer">
|
||||
<nav className="border-b border-rule-soft bg-panel-2">
|
||||
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-5 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em]">
|
||||
<Link href="/" className="text-accent">
|
||||
Overview
|
||||
</Link>
|
||||
<Link href="/instances/link" className="text-ink-3">
|
||||
Link an install
|
||||
</Link>
|
||||
<Link href="/billing" className="text-ink-3">
|
||||
Billing
|
||||
</Link>
|
||||
<Link href="/users" className="text-ink-3">
|
||||
People
|
||||
</Link>
|
||||
<Link href="/settings" className="text-ink-3">
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
|
||||
<AppBar links={LINKS} context={<AccountName />} />
|
||||
<main className="mx-auto max-w-rail px-5 py-7">{children}</main>
|
||||
</RequireKind>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="text-3xl">{data.account.name}</h1>
|
||||
<p className="text-ink-2">{data.account.billing_email}</p>
|
||||
</header>
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Overview"
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
!hasFree && live.length > 0 ? (
|
||||
<LinkButton variant="line" href="/instances/new">
|
||||
Create a free instance
|
||||
</LinkButton>
|
||||
) : 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 ? (
|
||||
<StatePill state="valid" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{unlinked.length > 0 && (
|
||||
<div className="rounded border border-accent bg-accent-wash p-4">
|
||||
<h2 className="text-xl">Finish setting up your licence</h2>
|
||||
<p className="mt-1 text-[0.82rem] text-ink-2">
|
||||
{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"}.
|
||||
</p>
|
||||
<Link
|
||||
href="/instances/link"
|
||||
className="mt-2 inline-block text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Link an install
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.instances.length === 0 ? (
|
||||
{live.length === 0 ? (
|
||||
<div className="grid max-w-xl gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">No instances yet</h2>
|
||||
<p className="text-ink-2">
|
||||
@@ -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.
|
||||
</p>
|
||||
<Link
|
||||
href="/instances/new"
|
||||
className="justify-self-start rounded bg-accent px-4 py-2 text-[0.9rem] font-semibold text-accent-ink"
|
||||
>
|
||||
Create a free instance
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<LinkButton href="/instances/new">Create a free instance</LinkButton>
|
||||
<LinkButton variant="line" href="/instances/link">
|
||||
Link an install
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.instances.map((i) => (
|
||||
<InstanceCard
|
||||
key={i.instance_id}
|
||||
instance={i}
|
||||
license={byInstance.get(i.instance_id)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
<PageFrame
|
||||
aside={
|
||||
<>
|
||||
{attention.length > 0 && (
|
||||
<RailCard title="Needs you" count={attention.length}>
|
||||
<ul className="grid gap-2">
|
||||
{attention.map((a) => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<span>{a.text}</span>
|
||||
{a.note && (
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.08em] text-warn">
|
||||
{a.note}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</RailCard>
|
||||
)}
|
||||
|
||||
{data.instances.length > 0 &&
|
||||
!data.instances.some(
|
||||
(i) => i.tier === "free" && i.status !== "cancelled" && i.status !== "deleted",
|
||||
) && (
|
||||
<Link
|
||||
href="/instances/new"
|
||||
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Create a free instance
|
||||
</Link>
|
||||
)}
|
||||
<RailCard title="Your team" count={people.data?.length}>
|
||||
<ul className="grid gap-2">
|
||||
{(people.data ?? []).slice(0, 5).map((p) => (
|
||||
<li
|
||||
key={p.user_id}
|
||||
className="flex items-center justify-between gap-2.5 text-[0.82rem] text-ink-2"
|
||||
>
|
||||
<span className="truncate">{p.email}</span>
|
||||
<span className="shrink-0 font-mono text-[0.64rem] uppercase tracking-[0.08em] text-ink-3">
|
||||
{p.account_role}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{pending > 0 && (
|
||||
<p className="border-t border-rule-soft pt-2 text-[0.78rem] text-warn">
|
||||
{pending} {pending === 1 ? "invitation" : "invitations"} not
|
||||
accepted yet
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
href="/users"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Manage people
|
||||
</Link>
|
||||
</RailCard>
|
||||
|
||||
{/*
|
||||
* 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.
|
||||
*/}
|
||||
<RailCard title="Account">
|
||||
<RailFacts
|
||||
rows={[
|
||||
{ label: "Billing contact", value: data.account.billing_email },
|
||||
{ label: "Status", value: data.account.status },
|
||||
{
|
||||
label: "Customer since",
|
||||
value: formatDate(data.account.created_at),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
href="/billing"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Billing history
|
||||
</Link>
|
||||
</RailCard>
|
||||
|
||||
<RailCard title="Running Vantage yourself?">
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Link your own install to get its licence file. It keeps its own
|
||||
users.
|
||||
</p>
|
||||
<Link
|
||||
href="/instances/link"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Link an install
|
||||
</Link>
|
||||
</RailCard>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{live.map((i, n) => {
|
||||
const lic = byInstance.get(i.instance_id);
|
||||
const state = licenceState(lic?.expires_at, Boolean(lic));
|
||||
return (
|
||||
<InstanceRecord
|
||||
key={i.instance_id}
|
||||
instance={i}
|
||||
license={lic}
|
||||
// Open when it is the only one, or when it is the
|
||||
// first thing that needs a decision. A saved toggle
|
||||
// beats this from then on.
|
||||
defaultOpen={
|
||||
live.length === 1 ||
|
||||
(state !== "valid" &&
|
||||
attention[0]?.id === i.instance_id) ||
|
||||
(attention.length === 0 && n === 0)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</PageFrame>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="text-3xl">Settings</h1>
|
||||
<p className="text-ink-2">
|
||||
Your password signs you in here and into every Vantage instance you belong to.
|
||||
Changing it changes all of them.
|
||||
</p>
|
||||
</header>
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title="Settings"
|
||||
subtitle="Your password signs you in here and into every Vantage instance you belong to. Changing it changes all of them."
|
||||
/>
|
||||
|
||||
<form
|
||||
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
|
||||
|
||||
@@ -7,9 +7,17 @@ import { useSession } from "@/lib/session";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Button } from "@/components/Button";
|
||||
import { Field } from "@/components/Field";
|
||||
import { PageFrame, RailCard } from "@/components/PageFrame";
|
||||
import { formatDate } from "@/lib/format";
|
||||
|
||||
const ROLES: AccountRole[] = ["owner", "admin", "member"];
|
||||
|
||||
const WHAT_ROLES_DO: [AccountRole, string][] = [
|
||||
["owner", "Everything, including billing."],
|
||||
["admin", "Invite people, create instances, grant access. No billing."],
|
||||
["member", "Sign in to the instances they are given."],
|
||||
];
|
||||
|
||||
export function InvitePanel() {
|
||||
const qc = useQueryClient();
|
||||
const { session } = useSession();
|
||||
@@ -49,125 +57,154 @@ export function InvitePanel() {
|
||||
const assignable = myRole === "owner" ? ROLES : ROLES.filter((r) => r !== "owner");
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<PageFrame
|
||||
aside={
|
||||
<>
|
||||
{canManage && (
|
||||
<RailCard title="Invite someone">
|
||||
<form
|
||||
className="grid gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (email.trim()) invite.mutate();
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
hint="They choose their own password from the emailed link. Nothing happens until they open it."
|
||||
/>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Account role
|
||||
</span>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as AccountRole)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
{assignable.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" disabled={invite.isPending || !email.trim()}>
|
||||
{invite.isPending ? "Sending…" : "Send invitation"}
|
||||
</Button>
|
||||
</form>
|
||||
</RailCard>
|
||||
)}
|
||||
|
||||
<RailCard title="What the roles do">
|
||||
<dl className="grid gap-2">
|
||||
{WHAT_ROLES_DO.map(([r, what]) => (
|
||||
<div key={r} className="grid gap-0.5">
|
||||
<dt className="font-mono text-[0.68rem] uppercase tracking-[0.08em] text-ink">
|
||||
{r}
|
||||
</dt>
|
||||
<dd className="m-0 text-[0.8rem] text-ink-2">{what}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="border-t border-rule-soft pt-2 text-[0.8rem] text-ink-2">
|
||||
An account role is not access to an instance. Give someone that on the
|
||||
instance itself.
|
||||
</p>
|
||||
</RailCard>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<p className="rounded border border-expired bg-panel p-3 text-[0.9rem] text-expired">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<table className="w-full border-collapse text-left text-[0.9rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-rule font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
<th className="py-2">Email</th>
|
||||
<th className="py-2">Account role</th>
|
||||
<th className="py-2">Status</th>
|
||||
<th className="py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(users.data ?? []).map((u) => {
|
||||
const isSelf = u.email === session?.email;
|
||||
return (
|
||||
<tr key={u.user_id} className="border-b border-rule-soft">
|
||||
<td className="py-2.5">
|
||||
{u.email}
|
||||
{isSelf && <span className="ml-2 text-ink-3">(you)</span>}
|
||||
</td>
|
||||
<td className="py-2.5">
|
||||
{canManage && !isSelf ? (
|
||||
<select
|
||||
value={u.account_role}
|
||||
onChange={(e) =>
|
||||
setRoleFor.mutate({
|
||||
id: u.user_id,
|
||||
role: e.target.value as AccountRole,
|
||||
})
|
||||
}
|
||||
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
|
||||
>
|
||||
{assignable.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="font-mono text-[0.82rem]">
|
||||
{u.account_role}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 text-ink-2">
|
||||
{u.verified_at ? "Active" : "Invitation pending"}
|
||||
</td>
|
||||
<td className="py-2.5 text-right">
|
||||
{canManage && !isSelf && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Remove ${u.email}? They lose access to every instance on this account.`,
|
||||
<div className="overflow-x-auto rounded border border-rule bg-panel">
|
||||
<table className="w-full border-collapse text-left text-[0.9rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-rule bg-panel-2 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
<th className="px-4 py-2.5 font-normal">Email</th>
|
||||
<th className="px-4 py-2.5 font-normal">Account role</th>
|
||||
<th className="px-4 py-2.5 font-normal">Status</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(users.data ?? []).map((u) => {
|
||||
const isSelf = u.email === session?.email;
|
||||
return (
|
||||
<tr key={u.user_id} className="border-b border-rule-soft last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
{u.email}
|
||||
{isSelf && <span className="ml-2 text-ink-3">(you)</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{canManage && !isSelf ? (
|
||||
<select
|
||||
value={u.account_role}
|
||||
onChange={(e) =>
|
||||
setRoleFor.mutate({
|
||||
id: u.user_id,
|
||||
role: e.target.value as AccountRole,
|
||||
})
|
||||
}
|
||||
className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
|
||||
>
|
||||
{assignable.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span className="font-mono text-[0.82rem]">
|
||||
{u.account_role}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-ink-2">
|
||||
{u.verified_at
|
||||
? `Active since ${formatDate(u.verified_at)}`
|
||||
: "Invitation pending"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{canManage && !isSelf && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.82rem] font-semibold text-expired underline"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Remove ${u.email}? They lose access to every instance on this account.`,
|
||||
)
|
||||
)
|
||||
)
|
||||
remove.mutate(u.user_id);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
remove.mutate(u.user_id);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{users.data?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-6 text-ink-3">
|
||||
Nobody yet.
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{canManage && (
|
||||
<form
|
||||
className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (email.trim()) invite.mutate();
|
||||
}}
|
||||
>
|
||||
<h2 className="text-xl">Invite someone</h2>
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
hint="They choose their own password from the emailed link. Nothing happens until they open it."
|
||||
/>
|
||||
<label className="grid max-w-md gap-1.5">
|
||||
<span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
Account role
|
||||
</span>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as AccountRole)}
|
||||
className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink"
|
||||
>
|
||||
{assignable.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
An account role is not access to an instance. Give them that on the
|
||||
instance itself.
|
||||
</p>
|
||||
<Button type="submit" disabled={invite.isPending || !email.trim()}>
|
||||
{invite.isPending ? "Sending…" : "Send invitation"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { InvitePanel } from "./InvitePanel";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
export default function UsersPage() {
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="text-3xl">People</h1>
|
||||
<p className="text-ink-2">
|
||||
Everyone on this account. Owners and admins can invite people and grant them
|
||||
access to instances; billing stays with owners.
|
||||
</p>
|
||||
</header>
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="People"
|
||||
subtitle="Everyone on this account. Owners and admins can invite people and grant them access to instances; billing stays with owners."
|
||||
/>
|
||||
<InvitePanel />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-1">
|
||||
<h1 className="text-3xl">{data.account.name}</h1>
|
||||
<p className="font-mono text-[0.82rem] text-ink-3">
|
||||
{data.account.billing_email} · {data.account.account_id}
|
||||
</p>
|
||||
</header>
|
||||
<PageHeader
|
||||
back={{ href: "/staff/accounts", label: "Accounts" }}
|
||||
title={data.account.name}
|
||||
subtitle={data.account.billing_email}
|
||||
record={[
|
||||
{ key: "Account", value: data.account.account_id, copy: true },
|
||||
{ key: "Status", value: data.account.status },
|
||||
...(data.account.paddle_customer_id
|
||||
? [{ key: "Paddle", value: data.account.paddle_customer_id, copy: true }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<Panel title="Instances">
|
||||
<ul className="grid gap-2">
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { AccountSearch } from "./AccountSearch";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
export default function AccountsPage() {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Accounts</h1>
|
||||
<PageHeader
|
||||
title="Accounts"
|
||||
subtitle="Search by name, email, Paddle ID or instance UUID."
|
||||
/>
|
||||
<AccountSearch />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Audit</h1>
|
||||
<PageHeader
|
||||
title="Audit"
|
||||
subtitle="Every mutating action across every account, newest first."
|
||||
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
|
||||
/>
|
||||
<Field
|
||||
label="Filter"
|
||||
value={filter}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
import { api, type InjectionState } from "@/lib/api";
|
||||
import { Ledger } from "@/components/Ledger";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { IssuePanel } from "./IssuePanel";
|
||||
|
||||
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
|
||||
@@ -32,29 +33,40 @@ export default function StaffInstancePage() {
|
||||
|
||||
return (
|
||||
<div className="grid gap-8">
|
||||
<header className="grid gap-2">
|
||||
<h1 className="text-3xl">{data.instance.name || data.instance.instance_id}</h1>
|
||||
<p className="font-mono text-[0.82rem] tabular-nums text-ink-3">
|
||||
{data.instance.instance_id}
|
||||
</p>
|
||||
<p className="text-[0.82rem]">
|
||||
<Link
|
||||
href={`/staff/accounts/${data.account.account_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
{data.account.name || data.account.account_id}
|
||||
</Link>
|
||||
<span className="text-ink-3">
|
||||
{" "}
|
||||
· {data.instance.deployment} · {data.instance.status}
|
||||
{data.instance.relink_count > 0 &&
|
||||
` · ${data.instance.relink_count} relinks this term`}
|
||||
</span>
|
||||
</p>
|
||||
<div className="grid gap-3">
|
||||
<PageHeader
|
||||
back={{
|
||||
href: `/staff/accounts/${data.account.account_id}`,
|
||||
label: data.account.name || "Account",
|
||||
}}
|
||||
title={data.instance.name || data.instance.instance_id}
|
||||
subtitle={
|
||||
<>
|
||||
<Link
|
||||
href={`/staff/accounts/${data.account.account_id}`}
|
||||
className="text-accent underline"
|
||||
>
|
||||
{data.account.name || data.account.account_id}
|
||||
</Link>
|
||||
<span className="text-ink-3">
|
||||
{" "}
|
||||
· {data.instance.deployment} · {data.instance.status}
|
||||
{data.instance.relink_count > 0 &&
|
||||
` · ${data.instance.relink_count} relinks this term`}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
record={[
|
||||
{ key: "Instance", value: data.instance.instance_id, copy: true },
|
||||
...(data.instance.slug
|
||||
? [{ key: "Slug", value: data.instance.slug }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
{data.injection.applicable && inj && (
|
||||
<p className={clsx("font-mono text-[0.72rem]", inj.tone)}>{inj.label}</p>
|
||||
)}
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">Licence history</h2>
|
||||
|
||||
@@ -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 (
|
||||
<RequireKind kind="staff">
|
||||
<nav className="border-b border-rule-soft bg-panel-2">
|
||||
<div className="mx-auto flex max-w-rail flex-wrap gap-5 px-5 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.06em]">
|
||||
{LINKS.map(([href, label]) => (
|
||||
<Link key={href} href={href} className="text-ink-3 hover:text-accent">
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<main className="mx-auto max-w-rail px-5 py-8">{children}</main>
|
||||
<AppBar
|
||||
links={LINKS}
|
||||
staff
|
||||
context={
|
||||
<span className="rounded-sm border border-accent px-1.5 py-0.5 font-mono text-[0.64rem] uppercase tracking-[0.12em] text-accent">
|
||||
Staff
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<main className="mx-auto max-w-rail px-5 py-7">{children}</main>
|
||||
</RequireKind>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Licences</h1>
|
||||
<PageHeader
|
||||
title="Licences"
|
||||
subtitle="Append-only. A renewal writes a new row and supersedes the old one."
|
||||
record={[{ key: "Showing", value: `${rows.length} of ${(data ?? []).length}` }]}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select
|
||||
value={tier}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { API_BASE, NotConnected, api } from "@/lib/api";
|
||||
import { NotConnectedPanel } from "@/components/NotConnected";
|
||||
import { Queue } from "@/components/Queue";
|
||||
import { daysRemaining } from "@/lib/format";
|
||||
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { StatePill } from "@/components/StatePill";
|
||||
import { LinkButton } from "@/components/Button";
|
||||
import { daysRemaining, formatStamp } from "@/lib/format";
|
||||
|
||||
const HOURS_48 = 48 * 3600_000;
|
||||
|
||||
@@ -22,6 +27,11 @@ export default function StaffDashboard() {
|
||||
queryKey: ["instances", "awaiting_link"],
|
||||
queryFn: () => api.staff.instances({ status: "awaiting_link" }),
|
||||
});
|
||||
const audit = useQuery({ queryKey: ["staff-audit"], queryFn: () => api.staff.audit() });
|
||||
const allInstances = useQuery({
|
||||
queryKey: ["instances", "all"],
|
||||
queryFn: () => api.staff.instances(),
|
||||
});
|
||||
|
||||
if (injection.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;
|
||||
|
||||
@@ -29,14 +39,32 @@ export default function StaffDashboard() {
|
||||
(i) => Date.now() - new Date(i.created_at).getTime() > HOURS_48,
|
||||
);
|
||||
|
||||
const failed = injection.data?.count ?? 0;
|
||||
const instances = allInstances.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Operations</h1>
|
||||
<PageHeader
|
||||
title="Operations"
|
||||
subtitle={
|
||||
failed > 0
|
||||
? "Injection failures come first — those instances are paying for a licence they have not received."
|
||||
: "Nothing failing. Queues below are routine chasing."
|
||||
}
|
||||
actions={
|
||||
<LinkButton variant="line" href="/staff/accounts">
|
||||
Find an account
|
||||
</LinkButton>
|
||||
}
|
||||
record={[{ key: "Checked", value: formatStamp(new Date().toISOString()) }]}
|
||||
status={failed > 0 ? <StatePill state="expired" /> : <StatePill state="valid" />}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Queue
|
||||
title="Failed injections"
|
||||
tone="expired"
|
||||
count={injection.data?.count ?? 0}
|
||||
count={failed}
|
||||
items={(injection.data?.failed ?? []).slice(0, 4).map((i) => ({
|
||||
label: i.name || i.instance_id,
|
||||
href: `/staff/instances/${i.instance_id}`,
|
||||
@@ -76,6 +104,85 @@ export default function StaffDashboard() {
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PageFrame
|
||||
aside={
|
||||
<RailCard title="Fleet">
|
||||
<RailFacts
|
||||
rows={[
|
||||
{ label: "Instances", value: instances.length },
|
||||
{
|
||||
label: "Cloud",
|
||||
value: instances.filter((i) => i.deployment === "cloud").length,
|
||||
},
|
||||
{
|
||||
label: "Self-hosted",
|
||||
value: instances.filter((i) => i.deployment === "self_hosted")
|
||||
.length,
|
||||
},
|
||||
{
|
||||
label: "Awaiting link",
|
||||
value: (unlinked.data ?? []).length,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
href="/staff/licenses"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
All licences
|
||||
</Link>
|
||||
</RailCard>
|
||||
}
|
||||
>
|
||||
<section className="overflow-hidden rounded border border-rule bg-panel">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft bg-panel-2 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-[0.95rem]">Recent activity</h2>
|
||||
<p className="text-[0.8rem] text-ink-2">
|
||||
Every licence issued, relinked or reaped, newest first.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/staff/audit"
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Full audit
|
||||
</Link>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-[0.86rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-rule-soft font-mono text-[0.64rem] uppercase tracking-[0.11em] text-ink-3">
|
||||
<th className="px-4 py-2 font-normal">Time</th>
|
||||
<th className="px-4 py-2 font-normal">Action</th>
|
||||
<th className="px-4 py-2 font-normal">Target</th>
|
||||
<th className="px-4 py-2 font-normal">Actor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(audit.data ?? []).slice(0, 12).map((e, n) => (
|
||||
<tr key={n} className="border-b border-rule-soft last:border-0">
|
||||
<td className="px-4 py-2.5 font-mono tabular-nums text-ink-2">
|
||||
{new Date(e.created_at).toISOString().slice(11, 16)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">{e.action}</td>
|
||||
<td className="px-4 py-2.5 text-ink-2">{e.target ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-ink-3">{e.actor}</td>
|
||||
</tr>
|
||||
))}
|
||||
{audit.data?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-6 text-ink-3">
|
||||
Nothing yet today.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api, type Plan } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
|
||||
import { limitLabel } from "@/lib/format";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
export default function PlansPage() {
|
||||
const qc = useQueryClient();
|
||||
@@ -36,7 +37,10 @@ export default function PlansPage() {
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<h1 className="text-3xl">Plans</h1>
|
||||
<PageHeader
|
||||
title="Plans"
|
||||
subtitle="The authoritative tier table. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
|
||||
/>
|
||||
|
||||
{draft && original && (
|
||||
<ConfirmPlanChange
|
||||
|
||||
+11
-13
@@ -1,28 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import { EnvBadge } from "@/components/EnvBadge";
|
||||
import { THEME_BOOT_SCRIPT } from "@/lib/theme";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Vantage Licensing",
|
||||
title: "Vantage HQ",
|
||||
description: "Licences, instances and billing for Vantage.",
|
||||
};
|
||||
|
||||
/*
|
||||
* The masthead deliberately does NOT live here. It belongs to the authenticated
|
||||
* layouts, so /login, /signup, /verify and /accept-invite stop rendering a bar
|
||||
* whose navigation and account menu they cannot use.
|
||||
*/
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
{/* Runs before first paint, so a dark-preferring viewer never sees white. */}
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }} />
|
||||
</head>
|
||||
<body>
|
||||
<header className="border-b border-rule bg-panel">
|
||||
<div className="mx-auto flex max-w-rail flex-wrap items-center justify-between gap-4 px-5 py-4">
|
||||
<span className="flex items-baseline gap-2 text-[1.16rem] font-extrabold tracking-[-0.02em]">
|
||||
Vantage
|
||||
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">
|
||||
Licensing
|
||||
</span>
|
||||
</span>
|
||||
<EnvBadge />
|
||||
</div>
|
||||
</header>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { useTheme, type ThemePref } from "@/lib/theme";
|
||||
|
||||
const APPEARANCE: { value: ThemePref; label: string }[] = [
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "system", label: "System" },
|
||||
];
|
||||
|
||||
/*
|
||||
* Everything here is about YOU rather than about the account: your settings,
|
||||
* your password, how you want the app to look, and leaving. None of it is a
|
||||
* destination worth a slot in the primary nav, which is why Settings moved off
|
||||
* the bar and into this menu.
|
||||
*/
|
||||
export function AccountMenu({ staff = false }: { staff?: boolean }) {
|
||||
const { session } = useSession();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pref, setPref] = useTheme();
|
||||
const wrap = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (wrap.current && !wrap.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const signOut = useMutation({
|
||||
mutationFn: api.logout,
|
||||
// Clear the cache before leaving: a cached account response outliving
|
||||
// the session would show the next person who signs in on this browser
|
||||
// the previous account's name for a beat.
|
||||
onSettled: () => {
|
||||
qc.clear();
|
||||
router.replace("/login");
|
||||
},
|
||||
});
|
||||
|
||||
const email = session?.email ?? "";
|
||||
const initials =
|
||||
email
|
||||
.split("@")[0]
|
||||
.split(/[.\-_]/)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? "")
|
||||
.join("") || "?";
|
||||
|
||||
return (
|
||||
<div className="relative" ref={wrap}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
className={`flex items-center gap-2 rounded-sm border px-2 py-1 text-[0.8rem] ${
|
||||
open ? "border-accent text-ink" : "border-rule text-ink-2"
|
||||
} bg-panel hover:border-ink-3`}
|
||||
>
|
||||
<span className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full bg-accent font-mono text-[0.56rem] font-bold text-accent-ink">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="hidden max-w-[16ch] truncate sm:inline">{email}</span>
|
||||
<span aria-hidden className="text-[0.6rem] text-ink-3">
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
// --shadow rather than a literal: globals.css defines it per
|
||||
// theme, and a hardcoded rgba would be a colour value living
|
||||
// in a component, which this app's tokens rule forbids.
|
||||
className="absolute right-0 top-[calc(100%+8px)] z-50 grid w-64 overflow-hidden rounded border border-rule bg-panel shadow-[var(--shadow)]"
|
||||
>
|
||||
<div className="grid gap-0.5 border-b border-rule-soft px-3 py-2.5">
|
||||
<strong className="truncate text-[0.86rem]">{email}</strong>
|
||||
<span className="font-mono text-[0.66rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
{staff ? "Vantage staff" : (session?.account_role ?? "member")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!staff && (
|
||||
<Link
|
||||
href="/settings"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
className="px-3 py-2 text-[0.86rem] text-ink hover:bg-accent-wash"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5 border-y border-rule-soft px-3 py-2.5">
|
||||
<span className="font-mono text-[0.66rem] uppercase tracking-[0.12em] text-ink-3">
|
||||
Appearance
|
||||
</span>
|
||||
<div className="flex overflow-hidden rounded-sm border border-rule">
|
||||
{APPEARANCE.map((a) => (
|
||||
<button
|
||||
key={a.value}
|
||||
type="button"
|
||||
onClick={() => setPref(a.value)}
|
||||
aria-pressed={pref === a.value}
|
||||
className={`flex-1 px-0 py-1 font-mono text-[0.62rem] uppercase tracking-[0.08em] ${
|
||||
pref === a.value
|
||||
? "bg-accent text-accent-ink"
|
||||
: "bg-panel text-ink-3 hover:text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => signOut.mutate()}
|
||||
disabled={signOut.isPending}
|
||||
className="px-3 py-2 text-left text-[0.86rem] text-expired hover:bg-accent-wash"
|
||||
>
|
||||
{signOut.isPending ? "Signing out…" : "Sign out"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AccountMenu } from "@/components/AccountMenu";
|
||||
import { EnvBadge } from "@/components/EnvBadge";
|
||||
|
||||
export type NavLink = { href: string; label: string };
|
||||
|
||||
/*
|
||||
* One masthead in three zones: who you are acting as, where you can go, and
|
||||
* which environment you are in.
|
||||
*
|
||||
* It replaces a brand bar and a separate nav strip. The nav's active state is
|
||||
* derived from the pathname rather than hardcoded — the previous customer nav
|
||||
* marked Overview as current on every page, including the ones that weren't it.
|
||||
*
|
||||
* Staff sit on --panel-2 with a chip where the account name goes. web/ is locked
|
||||
* to dark so this app defaults to light for the same reason CLAUDE.md gives:
|
||||
* telling two consoles apart before you click Reissue. Staff and customer need
|
||||
* that distinction from each other too, and one shade plus one chip buys it
|
||||
* without a second palette.
|
||||
*/
|
||||
export function AppBar({
|
||||
links,
|
||||
context,
|
||||
staff = false,
|
||||
}: {
|
||||
links: NavLink[];
|
||||
context?: React.ReactNode;
|
||||
staff?: boolean;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isCurrent = (href: string) =>
|
||||
// The section root matches only exactly; deeper routes match by prefix,
|
||||
// so /staff/accounts/:id still lights Accounts while /staff/accounts
|
||||
// does not light Operations.
|
||||
href === "/" || href === "/staff"
|
||||
? pathname === href
|
||||
: pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
<header className={`border-b border-rule ${staff ? "bg-panel-2" : "bg-panel"}`}>
|
||||
<div className="mx-auto grid max-w-rail grid-cols-[auto_1fr_auto] items-center gap-4 px-5 md:gap-7">
|
||||
<div className="col-start-1 row-start-1 flex min-w-0 items-center gap-3 py-2.5">
|
||||
<span className="flex items-baseline gap-2 text-[1.16rem] font-extrabold tracking-[-0.02em]">
|
||||
Vantage
|
||||
<span className="font-mono text-[0.72rem] font-normal uppercase tracking-[0.14em] text-ink-3">
|
||||
HQ
|
||||
</span>
|
||||
</span>
|
||||
{context && (
|
||||
<>
|
||||
<span aria-hidden className="hidden h-[22px] w-px bg-rule sm:block" />
|
||||
<span className="hidden min-w-0 sm:block">{context}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav
|
||||
aria-label={staff ? "Staff" : "Account"}
|
||||
className="col-span-3 col-start-1 row-start-2 flex items-stretch gap-1 overflow-x-auto border-t border-rule-soft md:col-span-1 md:col-start-2 md:row-start-1 md:border-t-0"
|
||||
>
|
||||
{links.map((l) => {
|
||||
const on = isCurrent(l.href);
|
||||
return (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
aria-current={on ? "page" : undefined}
|
||||
className={`relative inline-flex shrink-0 items-center px-3 py-2.5 font-mono text-[0.72rem] uppercase tracking-[0.08em] md:py-0 ${
|
||||
on
|
||||
? "font-bold text-accent after:absolute after:inset-x-3 after:bottom-0 after:h-0.5 after:bg-accent after:content-['']"
|
||||
: "text-ink-3 hover:text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="col-start-3 row-start-1 flex items-center justify-end gap-2.5 py-2.5">
|
||||
<span className="hidden sm:block">
|
||||
<EnvBadge />
|
||||
</span>
|
||||
<AccountMenu staff={staff} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,57 @@
|
||||
import clsx from "clsx";
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "solid" | "line" };
|
||||
type Variant = "solid" | "line";
|
||||
|
||||
/*
|
||||
* Matches site/'s .btn--solid and .btn--line exactly, including the neutral
|
||||
* border on the secondary variant. site/ does not have an accent-outlined
|
||||
* button and this app should not invent one.
|
||||
*/
|
||||
export function Button({ variant = "solid", className, ...rest }: Props) {
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
|
||||
"transition-[filter,border-color] duration-150 hover:brightness-110",
|
||||
variant === "solid"
|
||||
? "border-accent bg-accent text-accent-ink"
|
||||
: "border-rule bg-panel text-ink hover:border-ink-3",
|
||||
rest.disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
export function buttonClass(variant: Variant = "solid", disabled = false, className?: string) {
|
||||
return clsx(
|
||||
"inline-flex items-center gap-2 rounded border px-4 py-2.5 text-[0.94rem] font-semibold",
|
||||
"transition-[filter,border-color] duration-150 hover:brightness-110",
|
||||
variant === "solid"
|
||||
? "border-accent bg-accent text-accent-ink"
|
||||
: "border-rule bg-panel text-ink hover:border-ink-3",
|
||||
disabled && "cursor-not-allowed border-rule bg-panel text-ink-3 hover:brightness-100",
|
||||
className,
|
||||
);
|
||||
}
|
||||
|
||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant };
|
||||
|
||||
export function Button({ variant = "solid", className, ...rest }: Props) {
|
||||
return <button {...rest} className={buttonClass(variant, rest.disabled, className)} />;
|
||||
}
|
||||
|
||||
/*
|
||||
* A link that looks like a button. It exists so a navigation action never has to
|
||||
* be an <a> wrapped around a <button> — invalid markup, and it gives screen
|
||||
* readers two nested controls where the page means one.
|
||||
*/
|
||||
export function LinkButton({
|
||||
href,
|
||||
variant = "solid",
|
||||
external,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
variant?: Variant;
|
||||
external?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cls = buttonClass(variant, false, className);
|
||||
return external ? (
|
||||
<a href={href} className={cls}>
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<Link href={href} className={cls}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, type Instance, type 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,
|
||||
reapAfterDays,
|
||||
}: {
|
||||
instance: Instance;
|
||||
license?: License;
|
||||
reapAfterDays?: number;
|
||||
}) {
|
||||
const state = licenceState(license?.expires_at, Boolean(license));
|
||||
const days = license ? daysRemaining(license.expires_at) : 0;
|
||||
const cloud = instance.deployment === "cloud";
|
||||
const termDays = instance.tier === "free" ? 30 : 365;
|
||||
const deleteInDays =
|
||||
license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
|
||||
|
||||
const qc = useQueryClient();
|
||||
const renew = useMutation({
|
||||
mutationFn: () => api.renewInstance(instance.instance_id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
|
||||
});
|
||||
const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={clsx(
|
||||
"relative grid gap-3 rounded border border-rule bg-panel p-4 pl-5",
|
||||
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']",
|
||||
STRIPE[state],
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg">{instance.name || "Unnamed instance"}</h3>
|
||||
<p className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
{cloud ? "Cloud" : "Self-hosted"}
|
||||
{instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<StatePill state={state} />
|
||||
</div>
|
||||
|
||||
{state === "expired" && (
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Servers and monitors are still running, and your agents keep their keys. Changes
|
||||
are disabled until you renew.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state === "expired" && deleteInDays !== null && (
|
||||
<p className="text-[0.82rem] font-semibold text-expired">
|
||||
{deleteInDays <= 0
|
||||
? "Scheduled for deletion."
|
||||
: `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state === "none" && (
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
You have paid for this but it is not attached to an install yet, so no licence
|
||||
has been issued. Linking takes a minute.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{license && state !== "expired" && (
|
||||
<div className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
|
||||
<span>{days} days remaining</span>
|
||||
<div className="h-[3px] overflow-hidden rounded-sm bg-rule-soft">
|
||||
<div
|
||||
className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
|
||||
style={{ width: `${Math.max(2, Math.min(100, (days / termDays) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>Renews {formatDate(license.expires_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{state === "none" ? (
|
||||
<Link
|
||||
href="/instances/link"
|
||||
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Link an install
|
||||
</Link>
|
||||
) : cloud && instance.slug ? (
|
||||
<a
|
||||
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
|
||||
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Open {instance.slug}.vantage.hostxtra.co.uk
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={`/instances/${instance.instance_id}`}
|
||||
className="justify-self-start text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
{state === "expired" ? "Renew and download" : "Licence and download"}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{canRenew && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => renew.mutate()}
|
||||
disabled={renew.isPending}
|
||||
className="justify-self-start rounded bg-accent px-3 py-1.5 text-[0.82rem] font-semibold text-accent-ink"
|
||||
>
|
||||
{renew.isPending ? "Renewing…" : "Renew"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Instance, type License } from "@/lib/api";
|
||||
import { daysRemaining, formatDate, licenceState, limitLabel } from "@/lib/format";
|
||||
import { StatePill } from "./StatePill";
|
||||
import { Button, LinkButton } from "./Button";
|
||||
|
||||
const STRIPE = {
|
||||
valid: "before:bg-valid",
|
||||
warn: "before:bg-warn",
|
||||
expired: "before:bg-expired",
|
||||
none: "before:bg-accent",
|
||||
} as const;
|
||||
|
||||
const KEY = (id: string) => `vantage-hq-record-open:${id}`;
|
||||
|
||||
/*
|
||||
* One instance, open or closed.
|
||||
*
|
||||
* Closed it is a row — name, tier, host, term bar, state. Open it adds what the
|
||||
* licence includes, who can sign in, and the actions. Deliberately ONE component
|
||||
* rather than a card and a detail panel: two components meant a single-instance
|
||||
* account got a third of a row of summary with its substance a click away, and
|
||||
* a six-instance account got a grid of summaries with no way to look closer.
|
||||
*
|
||||
* It defaults open when it is the only instance or when it needs attention,
|
||||
* because the thing that needs you is the thing that should be open. A manual
|
||||
* toggle is remembered per instance and beats the default from then on.
|
||||
*/
|
||||
export function InstanceRecord({
|
||||
instance,
|
||||
license,
|
||||
reapAfterDays,
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
instance: Instance;
|
||||
license?: License;
|
||||
reapAfterDays?: number;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const state = licenceState(license?.expires_at, Boolean(license));
|
||||
const days = license ? daysRemaining(license.expires_at) : 0;
|
||||
const cloud = instance.deployment === "cloud";
|
||||
const termDays = instance.tier === "free" ? 30 : 365;
|
||||
const deleteInDays =
|
||||
license && reapAfterDays ? daysRemaining(license.expires_at) + reapAfterDays : null;
|
||||
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(KEY(instance.instance_id));
|
||||
if (saved !== null) setOpen(saved === "1");
|
||||
}, [instance.instance_id]);
|
||||
|
||||
const toggle = () => {
|
||||
setOpen((v) => {
|
||||
localStorage.setItem(KEY(instance.instance_id), v ? "0" : "1");
|
||||
return !v;
|
||||
});
|
||||
};
|
||||
|
||||
const qc = useQueryClient();
|
||||
const renew = useMutation({
|
||||
mutationFn: () => api.renewInstance(instance.instance_id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["account"] }),
|
||||
});
|
||||
const canRenew = instance.tier === "free" && license !== undefined && days <= 7;
|
||||
|
||||
// Only fetched once the record is open, and only for cloud: a self-hosted
|
||||
// install manages its own users and the endpoint refuses it.
|
||||
const members = useQuery({
|
||||
queryKey: ["members", instance.instance_id],
|
||||
queryFn: () => api.members(instance.instance_id),
|
||||
enabled: open && cloud,
|
||||
});
|
||||
|
||||
const panelId = `record-${instance.instance_id}`;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={clsx(
|
||||
"relative grid gap-3.5 rounded border border-rule bg-panel p-4 pl-5",
|
||||
"before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-['']",
|
||||
STRIPE[state],
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-[1.22rem]">{instance.name || "Unnamed instance"}</h2>
|
||||
<p className="mt-1 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
{cloud ? "Cloud" : "Self-hosted"}
|
||||
{instance.tier ? ` · ${instance.tier.replace("_", " ")}` : ""}
|
||||
{` · created ${formatDate(instance.created_at)}`}
|
||||
</p>
|
||||
{cloud && instance.slug && (
|
||||
<a
|
||||
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
|
||||
className="mt-1.5 inline-block font-mono text-[0.78rem] text-accent underline"
|
||||
>
|
||||
{instance.slug}.vantage.hostxtra.co.uk →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2.5">
|
||||
<StatePill state={state} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
aria-label={open ? "Hide details" : "Show details"}
|
||||
className="grid h-[26px] w-[26px] place-items-center rounded-sm border border-rule bg-panel text-[0.6rem] text-ink-3 hover:border-accent hover:text-accent"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={clsx("block transition-transform", open && "rotate-180")}
|
||||
>
|
||||
▲
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{license && state !== "expired" && (
|
||||
<div className="grid max-w-md gap-1.5">
|
||||
<div className="flex justify-between font-mono text-[0.78rem] tabular-nums text-ink-2">
|
||||
<span>{days} days remaining</span>
|
||||
<span>Renews {formatDate(license.expires_at)}</span>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-sm bg-rule-soft">
|
||||
<div
|
||||
className={clsx("h-full", state === "warn" ? "bg-warn" : "bg-valid")}
|
||||
style={{
|
||||
width: `${Math.max(2, Math.min(100, (days / termDays) * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === "expired" && (
|
||||
<div className="grid gap-1">
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
Servers and monitors are still running, and your agents keep their keys.
|
||||
Changes are disabled until you renew.
|
||||
</p>
|
||||
{deleteInDays !== null && (
|
||||
<p className="text-[0.82rem] font-semibold text-expired">
|
||||
{deleteInDays <= 0
|
||||
? "Scheduled for deletion."
|
||||
: `Deleted in ${deleteInDays} ${deleteInDays === 1 ? "day" : "days"} unless renewed.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === "none" && (
|
||||
<p className="text-[0.82rem] text-ink-2">
|
||||
You have paid for this but it is not attached to an install yet, so no licence
|
||||
has been issued. Linking takes a minute.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div id={panelId} hidden={!open} className="grid gap-3.5">
|
||||
{license && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">
|
||||
Included in {instance.tier?.replace("_", " ") ?? "this licence"}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-7 gap-y-2.5">
|
||||
<Stat n={limitLabel(license.limits.max_servers)} label="Servers" />
|
||||
<Stat
|
||||
n={limitLabel(license.limits.max_secret_groups)}
|
||||
label="Secret groups"
|
||||
/>
|
||||
<Stat n={limitLabel(license.limits.max_channels)} label="Channels" />
|
||||
<Stat
|
||||
n={license.features.length ? license.features.join(" · ") : "None"}
|
||||
label="Features"
|
||||
quiet={license.features.length === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cloud && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="font-mono text-[0.68rem] uppercase tracking-[0.12em] text-ink-3">
|
||||
Who can sign in
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(members.data ?? []).map((m) => (
|
||||
<span
|
||||
key={m.member_id}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-rule-soft py-0.5 pl-0.5 pr-2.5 text-[0.78rem] text-ink-2"
|
||||
>
|
||||
<span className="grid h-[18px] w-[18px] place-items-center rounded-full bg-accent font-mono text-[0.56rem] font-bold text-accent-ink">
|
||||
{m.email.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
{m.email}
|
||||
</span>
|
||||
))}
|
||||
{members.isLoading && <span className="text-[0.82rem] text-ink-3">Loading…</span>}
|
||||
{members.data?.length === 0 && (
|
||||
<span className="text-[0.82rem] text-ink-3">Nobody yet.</span>
|
||||
)}
|
||||
<Link
|
||||
href={`/instances/${instance.instance_id}`}
|
||||
className="text-[0.82rem] font-semibold text-accent underline"
|
||||
>
|
||||
Manage access
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{state === "none" ? (
|
||||
<LinkButton href="/instances/link">Link an install</LinkButton>
|
||||
) : cloud && instance.slug ? (
|
||||
<>
|
||||
<LinkButton
|
||||
external
|
||||
href={`https://${instance.slug}.vantage.hostxtra.co.uk`}
|
||||
>
|
||||
Open instance
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
variant="line"
|
||||
href={`/instances/${instance.instance_id}`}
|
||||
>
|
||||
Instance settings
|
||||
</LinkButton>
|
||||
</>
|
||||
) : (
|
||||
<LinkButton href={`/instances/${instance.instance_id}`}>
|
||||
{state === "expired" ? "Renew and download" : "Licence and download"}
|
||||
</LinkButton>
|
||||
)}
|
||||
|
||||
{canRenew && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
onClick={() => renew.mutate()}
|
||||
disabled={renew.isPending}
|
||||
>
|
||||
{renew.isPending ? "Renewing…" : "Renew"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ n, label, quiet }: { n: string; label: string; quiet?: boolean }) {
|
||||
return (
|
||||
<div className="grid gap-px">
|
||||
<b
|
||||
className={clsx(
|
||||
"tabular-nums tracking-[-0.02em]",
|
||||
quiet
|
||||
? "text-[0.95rem] font-semibold text-ink-3"
|
||||
: "text-[1.18rem] font-extrabold",
|
||||
)}
|
||||
>
|
||||
{n}
|
||||
</b>
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.1em] text-ink-3">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Main column plus a fixed support rail.
|
||||
*
|
||||
* The rail is what stops a page being empty and the main column is what stops
|
||||
* it being thin: an account with one instance used to render a third of a row
|
||||
* of summary and nothing else. The rail carries what is true regardless of how
|
||||
* many instances exist, so the page has a floor.
|
||||
*
|
||||
* It collapses below lg in source order, which puts the main column first on a
|
||||
* phone. Nothing is hidden at any width — if content only fits on a desktop it
|
||||
* does not belong in the rail.
|
||||
*/
|
||||
export function PageFrame({
|
||||
children,
|
||||
aside,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
aside?: React.ReactNode;
|
||||
}) {
|
||||
if (!aside) return <div className="grid gap-5">{children}</div>;
|
||||
|
||||
return (
|
||||
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="grid min-w-0 gap-4">{children}</div>
|
||||
<aside className="grid gap-3.5">{aside}</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One card in the rail. Title is a label, not a heading you read for pleasure. */
|
||||
export function RailCard({
|
||||
title,
|
||||
count,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number | string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="grid gap-2.5 rounded border border-rule bg-panel p-3.5">
|
||||
<header className="flex items-baseline justify-between gap-2.5">
|
||||
<h2 className="font-mono text-[0.66rem] font-normal uppercase tracking-[0.12em] text-ink-3">
|
||||
{title}
|
||||
</h2>
|
||||
{count !== undefined && (
|
||||
<b className="text-[0.95rem] font-extrabold tabular-nums">{count}</b>
|
||||
)}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Key/value rows for the rail. Values are mono so numbers line up. */
|
||||
export function RailFacts({ rows }: { rows: { label: string; value: React.ReactNode }[] }) {
|
||||
return (
|
||||
<dl className="grid gap-1.5">
|
||||
{rows.map((r) => (
|
||||
<div key={r.label} className="flex justify-between gap-2.5 text-[0.82rem]">
|
||||
<dt className="text-ink-3">{r.label}</dt>
|
||||
<dd className="m-0 truncate font-mono text-[0.78rem] tabular-nums text-ink">
|
||||
{r.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
/*
|
||||
* One record-line entry. `copy` marks the value as worth lifting to the
|
||||
* clipboard — an instance UUID or a licence ID, the strings people paste into
|
||||
* support tickets.
|
||||
*/
|
||||
export type RecordField = { key: string; value: string; copy?: boolean };
|
||||
|
||||
function CopyButton({ value }: { value: string }) {
|
||||
const [done, setDone] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setDone(true);
|
||||
setTimeout(() => setDone(false), 1200);
|
||||
} catch {
|
||||
// Clipboard is refused without a secure context or a user
|
||||
// gesture the browser trusts. The value is on screen and
|
||||
// selectable either way, so this needs no error state.
|
||||
}
|
||||
}}
|
||||
className="rounded-sm border border-rule px-1.5 py-px font-mono text-[0.62rem] uppercase tracking-[0.1em] text-ink-3 hover:border-accent hover:text-accent"
|
||||
>
|
||||
{done ? "Copied" : "Copy"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* The page frame every screen starts with, replacing nine hand-rolled header
|
||||
* blocks that each picked their own gaps and their own place for actions.
|
||||
*
|
||||
* The record line is the one new idea: Vantage HQ is a registry, so every screen
|
||||
* is a record and records have reference numbers. Giving the reference a fixed
|
||||
* slot, in mono, above the fold, means "where is the ID" stops being a per-page
|
||||
* question. It costs one hairline rule.
|
||||
*/
|
||||
export function PageHeader({
|
||||
back,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
record,
|
||||
status,
|
||||
}: {
|
||||
back?: { href: string; label: string };
|
||||
title: string;
|
||||
subtitle?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
record?: RecordField[];
|
||||
status?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="grid gap-3">
|
||||
{back && (
|
||||
<Link
|
||||
href={back.href}
|
||||
className="justify-self-start font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3 hover:text-accent"
|
||||
>
|
||||
← {back.label}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[1.9rem]">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-[0.92rem] text-ink-2">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
|
||||
{(record?.length || status) && (
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 border-t border-rule pt-2.5">
|
||||
{record?.map((f) => (
|
||||
<span key={f.key} className="flex items-center gap-2">
|
||||
<span className="font-mono text-[0.64rem] uppercase tracking-[0.14em] text-ink-3">
|
||||
{f.key}
|
||||
</span>
|
||||
<span className="font-mono text-[0.78rem] tabular-nums text-ink-2">
|
||||
{f.value}
|
||||
</span>
|
||||
{f.copy && <CopyButton value={f.value} />}
|
||||
</span>
|
||||
))}
|
||||
{status && <span className="ml-auto">{status}</span>}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type ThemePref = "light" | "dark" | "system";
|
||||
|
||||
const KEY = "vantage-hq-theme";
|
||||
|
||||
/*
|
||||
* globals.css has always defined the dark tokens under BOTH
|
||||
* :root[data-theme="dark"] and :root[data-theme="light"], specifically so an
|
||||
* in-page control can win over the OS preference in either direction. Nothing
|
||||
* ever set the attribute. This is that missing half.
|
||||
*
|
||||
* "system" removes the attribute rather than writing a value, which hands the
|
||||
* decision back to the prefers-color-scheme block.
|
||||
*/
|
||||
export function applyTheme(pref: ThemePref) {
|
||||
const root = document.documentElement;
|
||||
if (pref === "system") root.removeAttribute("data-theme");
|
||||
else root.setAttribute("data-theme", pref);
|
||||
}
|
||||
|
||||
export function readTheme(): ThemePref {
|
||||
if (typeof localStorage === "undefined") return "system";
|
||||
const v = localStorage.getItem(KEY);
|
||||
return v === "light" || v === "dark" ? v : "system";
|
||||
}
|
||||
|
||||
export function useTheme(): [ThemePref, (p: ThemePref) => void] {
|
||||
// Starts at "system" on both server and first client render so hydration
|
||||
// matches; the real value lands in the effect below. The inline script in
|
||||
// app/layout.tsx has already painted the correct colours by then, so there
|
||||
// is no flash — only this control's own highlight settles a tick late.
|
||||
const [pref, setPref] = useState<ThemePref>("system");
|
||||
|
||||
useEffect(() => setPref(readTheme()), []);
|
||||
|
||||
return [
|
||||
pref,
|
||||
(next: ThemePref) => {
|
||||
setPref(next);
|
||||
if (next === "system") localStorage.removeItem(KEY);
|
||||
else localStorage.setItem(KEY, next);
|
||||
applyTheme(next);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
* Runs before first paint, so a dark-preferring user never sees a white flash.
|
||||
* Inlined as a string because it has to execute ahead of React.
|
||||
*/
|
||||
export const THEME_BOOT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(
|
||||
KEY,
|
||||
)});if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;
|
||||
@@ -93,7 +93,7 @@ vantage/
|
||||
├── adminsite/ # staff + customer console (vantage-hq)
|
||||
│ ├── app/(customer)/ # overview, instance, link, billing
|
||||
│ ├── app/(staff)/staff/ # operations, accounts, licences, plans, audit
|
||||
│ ├── components/ # InstanceCard, Ledger, Queue, EnvBadge
|
||||
│ ├── components/ # AppBar, PageHeader, PageFrame, InstanceRecord
|
||||
│ └── lib/ # api client, session guards, formatters
|
||||
├── shared/ # imported by server, sitesvc and admin
|
||||
│ ├── license/ # payload, sign, verify, trusted keys, plans
|
||||
@@ -501,7 +501,13 @@ There are **three separate visual identities**, and the split is deliberate:
|
||||
|
||||
`adminsite/app/globals.css` holds `site/app/globals.css`'s token blocks **copied verbatim** — same names, same values. **Change them in both files in the same commit; nothing enforces the match automatically**, the same shape of hazard as sitesvc's mirrored slug rules. Tailwind in `adminsite/` maps `var(--…)` references only, so no component may carry a hex value. `site/` names the semantic three `--up`/`--pend`/`--down` for monitor state; `adminsite/` aliases them to `valid`/`warn`/`expired` for licence state — same colours.
|
||||
|
||||
`adminsite/` defaults to **light** on purpose: `web/` is locked to dark, and a staff member with both open should never mistake one for the other before clicking Reissue. In dark mode the shared accent lifts to `#5b9be8`, closer to web/'s indigo, so that distinction rests on the ground — do not make dark the default. Licence state never reads by colour alone: every pill carries a distinct shape and a text label.
|
||||
`adminsite/` defaults to **light** on purpose: `web/` is locked to dark, and a staff member with both open should never mistake one for the other before clicking Reissue. In dark mode the shared accent lifts to `#5b9be8`, closer to web/'s indigo, so that distinction rests on the ground — do not make dark the default. Licence state never reads by colour alone: every pill carries a distinct shape and a text label. The same argument applies one level in: the **staff** masthead sits on `--panel-2` with a `STAFF` chip, so staff and customer screens are not identical either.
|
||||
|
||||
**The `adminsite/` shell.** `AppBar` is the single masthead — identity, nav, environment, account menu — and it belongs to the two authenticated layouts, never to `app/layout.tsx`, so `/login` and `/accept-invite` do not render navigation they cannot use. Nav active state is derived from `usePathname`; do not hardcode it. `PageHeader` gives every screen the same back link, title, actions and **record line** (the reference number in mono, click-to-copy) — the reference is what people paste into support tickets, so it has a fixed slot rather than a per-page treatment. `PageFrame` is the main-plus-320px-rail split; the rail carries only what is true account-wide, which is why there is no plan card in it — **tier, limits and expiry belong to a licence, and a licence belongs to one instance**, so an account holding a Free cloud instance and a Professional self-hosted one has no single plan.
|
||||
|
||||
Customer nav is three destinations — Overview, People, Billing. Settings is in the account menu because it is your password, not a place, and appearance lives there too: `AccountMenu` is the only thing that sets `data-theme`, which the token blocks have always supported in both directions.
|
||||
|
||||
`InstanceRecord` is one component open or closed, and it **replaced** `InstanceCard`. Closed it is a row; open it adds licence contents, members and actions. It defaults open when the instance is the only one or needs attention, and a manual toggle is remembered per instance in `localStorage`. Do not reintroduce a second summary component — the split is what left a one-instance account showing a third of a row and nothing else.
|
||||
|
||||
| Route | Purpose |
|
||||
| --------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
|
||||
Reference in New Issue
Block a user