feat: Give API keys their own page and group the sidebar

The token management card sat on /settings, which is owner|admin
throughout, so it hid a capability every member already had: the API has
never required a role to mint or revoke your own key. It is now the
/api-keys page, reachable at every role, with the instance-wide lifetime
cap left behind on /settings because that is policy rather than one
person's credentials — and that split is what lets the page be ungated.

The sidebar gains groups: Fleet, Access, Automation, Instance, each with a
small-caps heading and a rule above it. Grouping is by what the operator
is doing rather than by which service answers, so SSH keys, secrets and
API keys sit together as credentials. A group whose every item is
admin-only disappears whole for a member; a labelled section with nothing
under it reads as a failure rather than a restriction.

The UI says keys while the collection, prefix and routes still say tokens.
Renaming a published endpoint to match a nav label would break every
script already written against it.
This commit is contained in:
2026-08-13 08:54:42 +00:00
parent 95527b3956
commit 689d0e1d5b
6 changed files with 201 additions and 88 deletions
+101 -38
View File
@@ -16,6 +16,16 @@ interface NavItem {
adminOnly?: boolean;
}
/**
* A labelled run of nav items. Grouping is by what the operator is doing, not
* by which API serves the page: credentials sit together under Access whether
* they are SSH keys, vault secrets or API keys.
*/
interface NavGroup {
label: string;
items: NavItem[];
}
function ServerIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -145,18 +155,54 @@ function WorkloadIcon() {
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
{ href: "/workloads", label: "Workloads", icon: <WorkloadIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings/license", label: "Licence", icon: <LicenceIcon />, adminOnly: true },
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
function TokenIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
/>
</svg>
);
}
const navGroups: NavGroup[] = [
{
label: "Fleet",
items: [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/workloads", label: "Workloads", icon: <WorkloadIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
],
},
{
label: "Access",
items: [
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
// Not adminOnly: the API lets any member mint and revoke their own
// keys, capped at their own role, so gating the page would hide a
// capability they have.
{ href: "/api-keys", label: "API Keys", icon: <TokenIcon /> },
],
},
{
label: "Automation",
items: [
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
],
},
{
label: "Instance",
items: [
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings/license", label: "Licence", icon: <LicenceIcon />, adminOnly: true },
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
],
},
];
/** Shared by the permanent aside and the offcanvas drawer one copy of the nav. */
@@ -164,7 +210,14 @@ export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
const { user, instance, isAdmin } = useAuth();
const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin);
// A group whose every item is admin-only disappears entirely for a member,
// heading and rule included — an empty labelled section reads as something
// that failed to load.
const visibleGroups = navGroups
.map((group) => ({ ...group, items: group.items.filter((item) => !item.adminOnly || isAdmin) }))
.filter((group) => group.items.length > 0);
const visibleItems = visibleGroups.flatMap((group) => group.items);
const activeHref = visibleItems.reduce<string | null>((best, item) => {
const matches = pathname === item.href || pathname.startsWith(item.href + "/");
@@ -190,31 +243,41 @@ export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="space-y-1">
{visibleItems.map((item) => {
const isActive = activeHref === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
onClick={onNavigate}
// The active marker is an accent bar, the same device
// site/ uses to mark the chosen plan. A filled pill
// reads as a button you can press again.
className={clsx(
"relative flex items-center gap-3 rounded px-3 py-2.5 text-sm transition-colors",
isActive
? "bg-surface-2 font-semibold text-text-primary before:absolute before:inset-y-1 before:left-0 before:w-[2px] before:rounded-full before:bg-accent before:content-['']"
: "font-medium text-text-secondary hover:bg-surface-2 hover:text-text-primary",
)}
>
{item.icon}
{item.label}
</Link>
</li>
);
})}
</ul>
{visibleGroups.map((group, groupIndex) => (
<div
key={group.label}
// The heading terminates the group above it, so the rule
// goes on top and the first group needs none.
className={clsx(groupIndex > 0 && "mt-4 border-t border-border pt-4")}
>
<p className="px-3 pb-1.5 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{group.label}</p>
<ul className="space-y-1">
{group.items.map((item) => {
const isActive = activeHref === item.href;
return (
<li key={item.href}>
<Link
href={item.href}
onClick={onNavigate}
// The active marker is an accent bar, the same device
// site/ uses to mark the chosen plan. A filled pill
// reads as a button you can press again.
className={clsx(
"relative flex items-center gap-3 rounded px-3 py-2.5 text-sm transition-colors",
isActive
? "bg-surface-2 font-semibold text-text-primary before:absolute before:inset-y-1 before:left-0 before:w-[2px] before:rounded-full before:bg-accent before:content-['']"
: "font-medium text-text-secondary hover:bg-surface-2 hover:text-text-primary",
)}
>
{item.icon}
{item.label}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
<div className="shrink-0 border-t border-border px-4 py-3">