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
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { ApiKeysPanel } from "@/components/apikeys/ApiKeysPanel";
/**
* Reachable at every role, unlike /settings. Any member may mint and revoke
* their own API keys — the API has never required owner or admin for that —
* and owner and admin additionally see every key in the instance.
*/
export default function ApiKeysPage() {
return <ApiKeysPanel />;
}
+23 -10
View File
@@ -10,7 +10,6 @@ import { Field } from "@/components/settings/Field";
import { Group } from "@/components/settings/Group";
import { SectionCard } from "@/components/settings/SectionCard";
import { MembersCard } from "@/components/settings/MembersCard";
import { ApiTokensCard } from "@/components/settings/ApiTokensCard";
import { AuthProvidersCard } from "@/components/settings/AuthProvidersCard";
const numberInputClass =
@@ -224,7 +223,6 @@ export default function SettingsPage() {
<div className="space-y-10">
<Group label="Access">
<MembersCard />
<ApiTokensCard />
<AuthProvidersCard
localLoginEnabled={settings?.local_login_enabled ?? true}
onLocalLoginChange={(v) => {
@@ -297,14 +295,29 @@ export default function SettingsPage() {
<input type="number" min={0} value={logRetentionDays} onChange={(e) => setLogRetentionDays(Number(e.target.value))} className={numberInputClass} />
</Field>
<div className="mt-6">
<Field
label="Maximum API token lifetime (days)"
hint="0 means no cap, and tokens may be created with no expiry. Changing this affects new tokens only."
>
<input type="number" min={0} value={apiTokenMaxDays} onChange={(e) => setApiTokenMaxDays(Number(e.target.value))} className={numberInputClass} />
</Field>
</div>
</SectionCard>
{/* The cap lives here rather than on /api-keys because it is
instance policy, not one person's credentials — which is
also what lets that page be reachable at every role. */}
<SectionCard
title="API keys"
description="Issuance policy for the keys people create to call the REST API."
icon={<KeyIcon />}
>
<Field
label="Maximum API key lifetime (days)"
hint="0 means no cap, and keys may be created with no expiry. Changing this affects new keys only — existing keys keep working and are flagged for rotation."
>
<input type="number" min={0} value={apiTokenMaxDays} onChange={(e) => setApiTokenMaxDays(Number(e.target.value))} className={numberInputClass} />
</Field>
<p className="mt-4 text-sm text-text-secondary">
Keys themselves are managed on{" "}
<Link href="/api-keys" className="text-accent hover:underline">
API Keys
</Link>
, which every member can reach.
</p>
</SectionCard>
</div>
+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">
@@ -4,9 +4,8 @@ import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type ApiToken, type Role } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Badge, Button, ConfirmDialog, Modal, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
import { Field, inputClass } from "./Field";
import { SectionCard } from "./SectionCard";
import { Badge, Button, Card, ConfirmDialog, Modal, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
import { Field, inputClass } from "@/components/settings/Field";
const ROLES: Role[] = ["owner", "admin", "member"];
@@ -24,18 +23,6 @@ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
* confirmation message name a token rather than a token_id. */
type PendingRevoke = { id: string; name: string };
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>
);
}
function roleVariant(role: Role) {
if (role === "owner") return "accent" as const;
if (role === "admin") return "warning" as const;
@@ -76,7 +63,15 @@ function ExpiryCell({ token, capDays }: { token: ApiToken; capDays: number }) {
);
}
export function ApiTokensCard() {
/**
* The whole API Keys page body, header included.
*
* It is a page rather than a card on /settings because any member may mint and
* revoke their own keys the API has never required owner or admin for that
* while /settings is owner|admin throughout. The instance-wide lifetime cap
* stays on /settings, being policy rather than one person's credentials.
*/
export function ApiKeysPanel() {
const queryClient = useQueryClient();
const { user, isAdmin } = useAuth();
const toast = useToast();
@@ -174,11 +169,14 @@ export function ApiTokensCard() {
const assignableRoles = user ? rolesAtOrBelow(user.role) : ROLES;
return (
<SectionCard
title="API tokens"
description="Scoped, personal tokens for scripts and CI to call the REST API without a browser session."
icon={<TokenIcon />}
actions={
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">API Keys</h1>
<p className="mt-1 text-sm text-text-secondary">
Scoped, personal keys for scripts and CI to call the REST API without a browser session. A key never exceeds your own role.
</p>
</div>
<div className="flex items-center gap-3">
{isAdmin && (
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
@@ -188,23 +186,26 @@ export function ApiTokensCard() {
onChange={(e) => setShowAll(e.target.checked)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
All tokens
All keys
</label>
)}
<Button variant="primary" size="sm" onClick={() => setCreateOpen(true)}>
Create token
<Button variant="primary" onClick={() => setCreateOpen(true)}>
New key
</Button>
</div>
}
>
</div>
<Card className="p-0">
{isLoading ? (
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<p className="py-6 text-sm text-danger">{friendlyMessage(error)}</p>
<p className="p-6 text-sm text-danger">{friendlyMessage(error)}</p>
) : !tokens || tokens.length === 0 ? (
<p className="py-6 text-sm text-text-secondary">No API tokens yet.</p>
<p className="p-6 text-sm text-text-secondary">
No API keys yet. Create one to call the REST API from a script or a CI job.
</p>
) : (
<Table>
<Thead>
@@ -259,11 +260,12 @@ export function ApiTokensCard() {
</Tbody>
</Table>
)}
</Card>
<ConfirmDialog
open={revoking !== null}
title="Revoke token"
confirmLabel="Revoke token"
title="Revoke key"
confirmLabel="Revoke key"
loading={isRevoking}
error={revokeError ? friendlyMessage(revokeError) : null}
onClose={() => {
@@ -279,10 +281,10 @@ export function ApiTokensCard() {
}
/>
<Modal open={createOpen} title={result ? "Token created" : "Create API token"} onClose={closeCreate}>
<Modal open={createOpen} title={result ? "Key created" : "New API key"} onClose={closeCreate}>
{result ? (
<div className="space-y-4">
<p className="text-sm text-text-secondary">This is the only time this token will be shown. Store it now.</p>
<p className="text-sm text-text-secondary">This is the only time this key will be shown. Store it now.</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-well p-3 font-mono text-sm break-all text-text-primary">{result.token}</code>
</div>
@@ -303,7 +305,7 @@ export function ApiTokensCard() {
}}
className="space-y-4"
>
<Field label="Name" hint="A short label identifying what will use this token, e.g. the CI pipeline or the script.">
<Field label="Name" hint="A short label identifying what will use this key, e.g. the CI pipeline or the script.">
<input required value={name} onChange={(e) => setName(e.target.value)} className={inputClass} />
</Field>
@@ -317,7 +319,7 @@ export function ApiTokensCard() {
</select>
</Field>
<Field label="Scopes" hint="What this token may call. Grant only what the caller actually needs.">
<Field label="Scopes" hint="What this key may call. Grant only what the caller actually needs.">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{resources.map((r) => {
const readScope = `${r}:read`;
@@ -353,7 +355,7 @@ export function ApiTokensCard() {
<Field
label="Expires"
hint={capDays > 0 ? `This instance caps new tokens at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the token has no expiry."}
hint={capDays > 0 ? `This instance caps new keys at ${capDays} days. Options beyond that, and Never, are disabled.` : "Never means the key has no expiry."}
>
<select
value={expiryDays === null ? "never" : String(expiryDays)}
@@ -378,12 +380,12 @@ export function ApiTokensCard() {
Cancel
</Button>
<Button type="submit" variant="primary" loading={creating}>
Create token
Create key
</Button>
</div>
</form>
)}
</Modal>
</SectionCard>
</div>
);
}