feat(adminsite): one masthead, a shared page frame, collapsible instance records
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:
mrhid6
2026-07-26 17:45:53 +01:00
co-authored by Claude Opus 5
parent 6bf288f83e
commit 34a0373eca
27 changed files with 1591 additions and 531 deletions
+94 -45
View File
@@ -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>
);
}
+111 -57
View File
@@ -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>
);
+29 -21
View File
@@ -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>
);
}
+178 -54
View File
@@ -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>
);
}
+7 -8
View File
@@ -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"
+148 -111
View File
@@ -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>
);
}
+8 -8
View File
@@ -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>
);