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:
+101
-38
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user