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>