diff --git a/CLAUDE.md b/CLAUDE.md index 50291e6..b2b8bd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -467,6 +467,16 @@ window, answering 429 with `Retry-After`; cookie sessions are untouched; it exists so a runaway script cannot take an instance down, not as the general API rate-limiting project some future ticket might build. +**The UI calls them API keys and lives at `/api-keys`, not on `/settings`.** +The page is reachable at **every** role, which is the whole reason it is a page: +`/settings` is owner|admin throughout, so a card there hid a capability every +member has. `settings.api_token_max_days` stays on `/settings` because it is +instance policy rather than one person's credentials, and that split is exactly +what lets the page be ungated. The label differs from the identifiers on +purpose — the collection is `api_tokens`, the prefix is `vt_`, the routes are +`/api/tokens`, and renaming a published endpoint to match a nav label would +break every script already written against it. + `server/internal/api/docs/openapi.json` is a **generated, committed** OpenAPI 3.1 document — `swag v2` reading `@…` annotations off the handlers — served at `GET /api/openapi.json` and rendered as a reference page by a vendored Scalar @@ -1012,9 +1022,20 @@ Customer nav is three destinations — Overview, People, Billing. Settings is in | `/steps` | Reusable step library | | `/monitors`, `/monitors/new`, `/monitors/[id][/edit]` | Checks, uptime, incidents | | `/secrets`, `/secrets/[group]` | Vault | +| `/api-keys` | Personal API keys — reachable at **every** role, unlike `/settings` | | `/audit` | Audit log | | `/settings`, `/settings/notifications`, `/settings/license` | Members, OIDC, alerts, retention, ESO token · channels · licence | +**The sidebar is grouped, and the groups are the nav's structure rather than +decoration.** `web/components/Sidebar.tsx` holds `navGroups` — Fleet, Access, +Automation, Instance — each rendered with a mono small-caps heading and a +hairline rule above it, the first group excepted. Grouping is by what the +operator is doing, not by which service answers: SSH keys, vault secrets and +API keys sit together under Access because all three are credentials. A group +whose every item is `adminOnly` disappears **whole**, heading and rule +included, for a member — a labelled section with nothing under it reads as +something that failed to load rather than something withheld. + **`/settings` is one page, not a section.** Members and single sign-on used to live at `/settings/instance` with their own sidebar entry; they are now the **Access** group at the top of `/settings`, above **Monitoring** and diff --git a/docsite/docs/reference/api-tokens.md b/docsite/docs/reference/api-tokens.md index d587def..6d4af65 100644 --- a/docsite/docs/reference/api-tokens.md +++ b/docsite/docs/reference/api-tokens.md @@ -9,8 +9,10 @@ needs something it can hold onto instead — an API token. ## Creating one -Settings → **Access** → API tokens. Give it a name, a role (owner, admin or -member) and one or more scopes, and optionally an expiry. The value is shown +**API Keys**, in the Access group of the sidebar. The page is reachable at +every role: any member may create and revoke their own keys, and owner and +admin additionally see every key in the instance. Give it a name, a role +(owner, admin or member) and one or more scopes, and optionally an expiry. The value is shown once, in full, immediately after creation: ``` @@ -54,7 +56,7 @@ independent of its owner. ## Expiry An expiry is optional on a token you create. An instance can set a -**maximum token lifetime** (Settings → Access) that caps how far out a new +**maximum key lifetime** (Settings → Integrations) that caps how far out a new token's expiry may be set; when that cap is in place, a token with no expiry at all is refused, so there is no way to route around the policy by leaving the field blank. diff --git a/web/app/(app)/api-keys/page.tsx b/web/app/(app)/api-keys/page.tsx new file mode 100644 index 0000000..9317672 --- /dev/null +++ b/web/app/(app)/api-keys/page.tsx @@ -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 ; +} diff --git a/web/app/(app)/settings/page.tsx b/web/app/(app)/settings/page.tsx index 4bbdc6f..f1688de 100644 --- a/web/app/(app)/settings/page.tsx +++ b/web/app/(app)/settings/page.tsx @@ -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() {
- { @@ -297,14 +295,29 @@ export default function SettingsPage() { setLogRetentionDays(Number(e.target.value))} className={numberInputClass} /> -
- - setApiTokenMaxDays(Number(e.target.value))} className={numberInputClass} /> - -
+ + + {/* 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. */} + } + > + + setApiTokenMaxDays(Number(e.target.value))} className={numberInputClass} /> + +

+ Keys themselves are managed on{" "} + + API Keys + + , which every member can reach. +

diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index 8216135..806cfab 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -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 ( @@ -145,18 +155,54 @@ function WorkloadIcon() { ); } -const navItems: NavItem[] = [ - { href: "/servers", label: "Servers", icon: }, - { href: "/monitors", label: "Monitors", icon: }, - { href: "/vulnerabilities", label: "Vulnerabilities", icon: }, - { href: "/workloads", label: "Workloads", icon: }, - { href: "/keys", label: "SSH Keys", icon: }, - { href: "/secrets", label: "Secrets", icon: }, - { href: "/workflows", label: "Workflows", icon: }, - { href: "/steps", label: "Steps", icon: }, - { href: "/audit", label: "Audit Log", icon: }, - { href: "/settings/license", label: "Licence", icon: , adminOnly: true }, - { href: "/settings", label: "Settings", icon: , adminOnly: true }, +function TokenIcon() { + return ( + + + + ); +} + +const navGroups: NavGroup[] = [ + { + label: "Fleet", + items: [ + { href: "/servers", label: "Servers", icon: }, + { href: "/workloads", label: "Workloads", icon: }, + { href: "/monitors", label: "Monitors", icon: }, + { href: "/vulnerabilities", label: "Vulnerabilities", icon: }, + ], + }, + { + label: "Access", + items: [ + { href: "/keys", label: "SSH Keys", icon: }, + { href: "/secrets", label: "Secrets", icon: }, + // 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: }, + ], + }, + { + label: "Automation", + items: [ + { href: "/workflows", label: "Workflows", icon: }, + { href: "/steps", label: "Steps", icon: }, + ], + }, + { + label: "Instance", + items: [ + { href: "/audit", label: "Audit Log", icon: }, + { href: "/settings/license", label: "Licence", icon: , adminOnly: true }, + { href: "/settings", label: "Settings", icon: , 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((best, item) => { const matches = pathname === item.href || pathname.startsWith(item.href + "/"); @@ -190,31 +243,41 @@ export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) { + {visibleGroups.map((group, groupIndex) => ( +
0 && "mt-4 border-t border-border pt-4")} + > +

{group.label}

+ +
+ ))}
diff --git a/web/components/settings/ApiTokensCard.tsx b/web/components/apikeys/ApiKeysPanel.tsx similarity index 87% rename from web/components/settings/ApiTokensCard.tsx rename to web/components/apikeys/ApiKeysPanel.tsx index b6e814c..6e35cfc 100644 --- a/web/components/settings/ApiTokensCard.tsx +++ b/web/components/apikeys/ApiKeysPanel.tsx @@ -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 ( - - - - ); -} - 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 ( - } - actions={ +
+
+
+

API Keys

+

+ Scoped, personal keys for scripts and CI to call the REST API without a browser session. A key never exceeds your own role. +

+
{isAdmin && ( )} -
- } - > +
+ + {isLoading ? (
) : error ? ( -

{friendlyMessage(error)}

+

{friendlyMessage(error)}

) : !tokens || tokens.length === 0 ? ( -

No API tokens yet.

+

+ No API keys yet. Create one to call the REST API from a script or a CI job. +

) : ( @@ -259,11 +260,12 @@ export function ApiTokensCard() {
)} + { @@ -279,10 +281,10 @@ export function ApiTokensCard() { } /> - + {result ? (
-

This is the only time this token will be shown. Store it now.

+

This is the only time this key will be shown. Store it now.

{result.token}
@@ -303,7 +305,7 @@ export function ApiTokensCard() { }} className="space-y-4" > - + setName(e.target.value)} className={inputClass} /> @@ -317,7 +319,7 @@ export function ApiTokensCard() { - +
{resources.map((r) => { const readScope = `${r}:read`; @@ -353,7 +355,7 @@ export function ApiTokensCard() { 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."} >