Files
vantage/adminsite/components/AccountMenu.tsx
T
mrhid6andClaude Opus 5 34a0373eca
Server Deploy / deploy (push) Successful in 53s
feat(adminsite): one masthead, a shared page frame, collapsible instance records
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>
2026-07-26 17:45:53 +01:00

150 lines
6.2 KiB
TypeScript

"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>
);
}