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() {
+
+
+ {/* 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 (