/settings/api-keysAPI key settings
Every key in the workspace, live and revoked, with a create dialog that shows the whole key once and a confirmation behind every revoke.
The page is a server component inside AppShell: the table is db.apiKeys, the maker of each key is resolved through db.members, and every date is formatted against REFERENCE_DATE on the server rather than by a cell that reads the clock. Creating validates the name against the live keys and the scopes against the vocabulary the existing keys already use, then writes the row with db.apiKeys.create — the row keeps only the prefix, and the whole key goes back to the caller once, into an ApiKeyField that is unmasked and carries its own copy button, under a heading that takes focus because the form it replaced is gone. The submit is a plain client handler around the server action rather than useActionState: the table has to be told about the new row, and telling a parent during render is a React error — here the call sits after the await. The revealed secret is local dialog state that closing clears, so reopening is an empty form and a second key gets its own prefix. Closing during a create — Cancel, Escape, a click outside — retires that submission: the continuation checks the token it started with and neither reveals the key nor tells the table, so the row simply turns up the next time the page is loaded. Revoking is an update, never a delete: the row stays, because a revoked key is what a reader checks when a call starts failing. The section list beside the page is the nav's own: the leaves of the `/settings` entry in this block's `nav.ts`, in nav order, resolved through the same NAV_ICONS table the sidebar reads. Nothing lists the sections twice, so editing that one file — or letting a template replace it — moves the sidebar and the sub-nav together, and the sub-nav can never offer a section the product has no page for. Composes AppShell, PageHeader, SettingsLayout, SectionHeader, Card, SimpleTable, TagList, StatusBadge, Dialog, FormRow, Input, Checkbox, ApiKeyField, Callout and ConfirmDialog.
Preview
import { formatDate, formatRelative } from "@/lib/format"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { SettingsLayout } from "@/components/ui/settings-layout"
import { signOut } from "./actions"
import { ApiKeysPanel } from "./components/api-keys-panel"
import { SETTINGS_SECTIONS } from "./components/settings-sections"
import { apiKeys, currentUser, keyCounts, scopeVocabulary, shellNotifications } from "./data"
import { NAV, ROUTE } from "./nav"
/**
* API keys. The page is a server component inside the shell: it reads the
* rows through `db.apiKeys`, resolves who made each one through `db.members`,
* and formats every date against `REFERENCE_DATE` here rather than letting a
* cell read the clock.
*/
export default function SettingsApiKeysPage() {
const counts = keyCounts()
const rows = apiKeys().map((key) => ({
id: key.id,
name: key.name,
prefix: key.prefix,
scopes: key.scopes,
status: key.status,
createdBy: key.createdByName,
created: formatDate(key.createdAt, "medium", { timeZone: "UTC" }),
lastUsed: key.lastUsedAt ? formatRelative(key.lastUsedAt, REFERENCE_DATE) : "Never",
}))
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="API keys"
description={`${counts.active} live keys and ${counts.revoked} revoked ones. A key is shown in full once, and never again.`}
/>
<SettingsLayout nav={SETTINGS_SECTIONS} activeHref={ROUTE}>
<ApiKeysPanel
rows={rows}
scopes={scopeVocabulary()}
createdToday={formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}
/>
</SettingsLayout>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/settings-api-keysNeeds the @vibra registry in your components.json — set it up once.
Source
import { formatDate, formatRelative } from "@/lib/format"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { SettingsLayout } from "@/components/ui/settings-layout"
import { signOut } from "./actions"
import { ApiKeysPanel } from "./components/api-keys-panel"
import { SETTINGS_SECTIONS } from "./components/settings-sections"
import { apiKeys, currentUser, keyCounts, scopeVocabulary, shellNotifications } from "./data"
import { NAV, ROUTE } from "./nav"
/**
* API keys. The page is a server component inside the shell: it reads the
* rows through `db.apiKeys`, resolves who made each one through `db.members`,
* and formats every date against `REFERENCE_DATE` here rather than letting a
* cell read the clock.
*/
export default function SettingsApiKeysPage() {
const counts = keyCounts()
const rows = apiKeys().map((key) => ({
id: key.id,
name: key.name,
prefix: key.prefix,
scopes: key.scopes,
status: key.status,
createdBy: key.createdByName,
created: formatDate(key.createdAt, "medium", { timeZone: "UTC" }),
lastUsed: key.lastUsedAt ? formatRelative(key.lastUsedAt, REFERENCE_DATE) : "Never",
}))
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="API keys"
description={`${counts.active} live keys and ${counts.revoked} revoked ones. A key is shown in full once, and never again.`}
/>
<SettingsLayout nav={SETTINGS_SECTIONS} activeHref={ROUTE}>
<ApiKeysPanel
rows={rows}
scopes={scopeVocabulary()}
createdToday={formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}
/>
</SettingsLayout>
</AppShell>
)
}import { type NavConfig } from "@/lib/nav-config"
/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/settings/api-keys"
/**
* This product's navigation, as plain data. AppShell resolves the icon names
* and works out which item is current from the route, so nothing here is a
* component and nothing here says "I am the page you are on".
*
* Settings is a disclosure with one leaf per settings page, so this page's own
* route is a leaf: a parent with `items` renders as a button and has no anchor
* for `aria-current="page"` to land on.
*/
export const NAV: NavConfig = {
brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
groups: [
{
label: "Workspace",
items: [
{ title: "Overview", href: "/saas", icon: "layout-dashboard" },
{ title: "Customers", href: "/ecommerce/customers", icon: "users" },
{ title: "Revenue", href: "/saas/revenue", icon: "credit-card" },
{ title: "Monitoring", href: "/engineering/monitoring", icon: "activity" },
],
},
{
label: "Account",
items: [
{
title: "Settings",
href: "/settings",
icon: "settings",
items: [
// The parent route is a real page (the workspace settings block),
// so it gets a leaf of its own — a parent with `items` renders as
// a disclosure button and is never a link.
{ title: "General", href: "/settings", icon: "settings" },
{ title: "Profile", href: "/settings/profile", icon: "user-round" },
{ title: "Security", href: "/settings/security", icon: "shield" },
{ title: "Notifications", href: "/settings/notifications", icon: "bell" },
{ title: "API keys", href: "/settings/api-keys", icon: "key-round" },
{ title: "Integrations", href: "/settings/integrations", icon: "plug" },
],
},
],
},
],
// Pinned under the groups, where the old secondary links sat.
footer: [{ title: "Support", href: "/support", icon: "life-buoy" }],
}/**
* What this page reads. Every key is a row in `db.apiKeys`, and the person who
* made it is resolved through `db.members`, so the table is the repository
* rather than a copy of it. The scopes a new key may take are the union of the
* scopes the existing keys hold — the vocabulary the create action validates
* against, which is why it is derived here and not written out in the dialog.
* A key's secret is shown once and never stored; the generator behind it is
* seeded, so a demo mints the same series every time. "Now" is
* `REFERENCE_DATE`.
*/
import { getInitials } from "@/lib/format"
import { db, seeded, type ApiKey, type Member } from "@/lib/sample-data"
export type ApiKeyRow = ApiKey & {
/** The member who created it, or their id when the row has outlived them. */
createdByName: string
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The member id every key created from this page is attributed to. */
export function ownerId(): string {
return ownerRow().id
}
/**
* Every key, live ones first, each group most recently used first. A revoked
* key stays on the page: it is what a reader checks when a call starts failing.
*/
export function apiKeys(): ApiKeyRow[] {
const names = new Map(db.members.all().map((member) => [member.id, member.name]))
return db.apiKeys
.all()
.sort((a, b) => {
if (a.status !== b.status) return a.status === "active" ? -1 : 1
return (b.lastUsedAt?.getTime() ?? 0) - (a.lastUsedAt?.getTime() ?? 0)
})
.map((key) => ({ ...key, createdByName: names.get(key.createdBy) ?? key.createdBy }))
}
/** How many are live, and how many have been turned off. */
export function keyCounts(): { active: number; revoked: number } {
const rows = db.apiKeys.all()
return {
active: rows.filter((key) => key.status === "active").length,
revoked: rows.filter((key) => key.status === "revoked").length,
}
}
/** Every scope any key in the workspace holds — what a new key may be given. */
export function scopeVocabulary(): string[] {
return [...new Set(db.apiKeys.all().flatMap((key) => key.scopes))].sort()
}
/** The environment prefix every key in this workspace carries. */
export const KEY_PREFIX = "sk_live_"
const TOKEN_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"
// One generator for the process, walked on every create, so two keys minted in
// a session differ and a fresh process mints the same series again.
const rand = seeded("settings-api-keys")
/**
* A whole key, of which only the first two segments are ever shown again.
* Returns the secret and the prefix the row keeps.
*/
export function mintKey(): { secret: string; prefix: string } {
const token = (length: number) =>
Array.from({ length }, () => TOKEN_CHARS[Math.floor(rand() * TOKEN_CHARS.length)]).join("")
const prefix = `${KEY_PREFIX}${token(6)}`
return { secret: `${prefix}${token(24)}`, prefix }
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, invalidInput, isForm, REFERENCE_DATE, type Result } from "@/lib/sample-data"
import { mintKey, ownerId, scopeVocabulary } from "./data"
export type CreatedKey = {
id: string
name: string
prefix: string
/** The whole key. Handed back once, on the response to the create, and never stored. */
secret: string
scopes: string[]
}
/** A key's name is a label in a table: long enough to say what calls with it. */
const MAX_NAME = 60
/**
* Mints a key and stores the row that survives it. The secret goes back to the
* caller once and is never written down — `db.apiKeys` keeps the prefix, which
* is the half that identifies a key without authenticating anything.
*
* Called from an awaited client handler rather than through `useActionState`,
* so the dialog can tell the table about the new row from inside that handler
* instead of during a render.
*/
export async function createApiKey(formData: FormData): Promise<Result<CreatedKey>> {
if (!isForm(formData)) return invalidInput("Send the key as the form sends it.")
const raw = formData.get("name")
const name = (typeof raw === "string" ? raw : "").trim()
const scopes = [...new Set(formData.getAll("scopes").filter((value): value is string => typeof value === "string"))]
if (!name) {
return { ok: false, error: { code: "invalid_input", field: "name", message: "Name this key." } }
}
if (name.length > MAX_NAME) {
return { ok: false, error: { code: "invalid_input", field: "name", message: `Keep the name under ${MAX_NAME} characters.` } }
}
const clash = db.apiKeys
.all()
.some((key) => key.name.toLowerCase() === name.toLowerCase() && key.status === "active")
if (clash) {
return {
ok: false,
error: {
code: "name_taken",
field: "name",
message: `A live key is already called "${name}".`,
},
}
}
// Every unknown scope, not the first one `find` returns: an empty one is
// falsy, and `find` handed it through.
const vocabulary = scopeVocabulary()
const unknown = scopes.filter((scope) => !vocabulary.includes(scope))
if (unknown.length > 0) {
return {
ok: false,
error: { code: "invalid_input", field: "scopes", message: unknown[0] ? `No such scope: ${unknown[0].slice(0, 60)}.` : "A scope cannot be blank." },
}
}
if (scopes.length === 0) {
return {
ok: false,
error: { code: "invalid_input", field: "scopes", message: "Give the key at least one scope." },
}
}
const { secret, prefix } = mintKey()
const created = await db.apiKeys.create({
name,
prefix,
createdAt: REFERENCE_DATE,
createdBy: ownerId(),
scopes,
status: "active",
})
if (!created.ok) return created
return {
ok: true,
data: { id: created.data.id, name, prefix, secret, scopes },
}
}
/**
* Turns a key off. The row stays — a revoked key is what a reader checks when
* a call starts failing — so this is an update, never a delete.
*/
export async function revokeApiKey(id: string): Promise<Result<{ id: string; name: string }>> {
const key = db.apiKeys.all().find((row) => row.id === id)
if (!key) {
return { ok: false, error: { code: "not_found", message: `No key with id "${id}".` } }
}
if (key.status === "revoked") {
return {
ok: false,
error: { code: "invalid_input", message: `${key.name} was already revoked.` },
}
}
const updated = await db.apiKeys.update(id, { status: "revoked" })
if (!updated.ok) return updated
return { ok: true, data: { id, name: updated.data.name } }
}
/**
* The one thing the shell calls. A server action so the page can stay a server
* component and still hand the shell something to call, and a `Result` so the
* caller reads the same success-or-error shape every mutation returns.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { SectionHeader } from "@/components/ui/section-header"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { TagList } from "@/components/ui/tag-list"
import { revokeApiKey, type CreatedKey } from "../actions"
import { CreateKeyDialog } from "./create-key-dialog"
export type KeyRow = {
id: string
name: string
prefix: string
scopes: string[]
status: "active" | "revoked"
createdBy: string
created: string
lastUsed: string
}
/** A revoked key is off, not gone; StatusBadge has no word for it of its own. */
const STATUS_MAP = { revoked: "neutral" } as const
export type ApiKeysPanelProps = {
rows: KeyRow[]
scopes: string[]
/** How the new key's created and last-used columns should read. */
createdToday: string
}
/** The key table, the create dialog above it, and the revoke behind each row. */
export function ApiKeysPanel({ rows, scopes, createdToday }: ApiKeysPanelProps) {
const [keys, setKeys] = React.useState(rows)
const [error, setError] = React.useState<string>()
const [note, setNote] = React.useState<string>()
function added(created: CreatedKey, owner: string) {
setKeys((current) => [
{
id: created.id,
name: created.name,
prefix: created.prefix,
scopes: created.scopes,
status: "active" as const,
createdBy: owner,
created: createdToday,
lastUsed: "Never",
},
...current,
])
setError(undefined)
setNote(`${created.name} is live.`)
}
async function revoke(row: KeyRow) {
const result = await revokeApiKey(row.id)
if (!result.ok) {
setNote(undefined)
setError(result.error.message)
return
}
setError(undefined)
setNote(`${result.data.name} was revoked.`)
setKeys((current) =>
current.map((key) => (key.id === row.id ? { ...key, status: "revoked" as const } : key))
)
}
const columns: SimpleTableColumn<KeyRow>[] = [
{
key: "name",
header: "Name",
cell: (row) => (
<div className="flex flex-col gap-0.5">
<span className="font-medium">{row.name}</span>
<span className="text-xs text-muted-foreground">Created by {row.createdBy}</span>
</div>
),
},
{
key: "prefix",
header: "Key",
// font-mono earns its place: this is the half of a secret that is safe to
// show, and it is read character by character.
cell: (row) => <span className="font-mono text-xs">{row.prefix}…</span>,
},
{
key: "scopes",
header: "Scopes",
cell: (row) => (
<TagList
size="sm"
max={2}
tags={row.scopes.map((scope) => ({ value: scope, label: scope }))}
/>
),
},
{ key: "created", header: "Created", align: "right" },
{ key: "lastUsed", header: "Last used", align: "right" },
{
key: "status",
header: "Status",
cell: (row) => <StatusBadge status={row.status} map={STATUS_MAP} size="sm" />,
},
{
key: "actions",
header: <span className="sr-only">Actions</span>,
align: "right",
width: "6rem",
cell: (row) =>
row.status === "revoked" ? null : (
<ConfirmDialog
variant="destructive"
title={`Revoke ${row.name}?`}
description="Every call using this key starts failing straight away. The row stays, so you can see what was turned off and when."
confirmText="Revoke it"
onConfirm={() => revoke(row)}
trigger={
<Button variant="ghost" size="sm" aria-label={`Revoke ${row.name}`}>
Revoke
</Button>
}
/>
),
},
]
return (
<Card>
<CardHeader>
<SectionHeader
as="h2"
title="Every key in this workspace"
description="Keys authenticate every request to the events API. A key is shown in full once, when it is made."
actions={
<CreateKeyDialog
scopes={scopes}
onCreated={(created) => added(created, "you")}
/>
}
/>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<SimpleTable
columns={columns}
rows={keys}
rowKey="id"
emptyMessage="No keys yet."
className="overflow-x-auto"
/>
{error ? (
<p role="alert" className="text-sm text-danger">
{error}
</p>
) : null}
<p role="status" className="text-sm text-muted-foreground">
{note ?? null}
</p>
</CardContent>
</Card>
)
}"use client"
import * as React from "react"
import { PlusIcon } from "lucide-react"
import { ApiKeyField } from "@/components/ui/api-key-field"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { FormRow } from "@/components/ui/form-section"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { createApiKey, type CreatedKey } from "../actions"
/** What a refusal from the action looks like once it is on screen. */
type ActionError = { message: string; field?: string }
/** The one moment the whole key exists on screen. */
function Reveal({ created }: { created: CreatedKey }) {
const headingRef = React.useRef<HTMLHeadingElement>(null)
// The form the reader was in is gone, so the heading that replaced it takes
// focus — otherwise focus is left on a button that no longer exists.
React.useEffect(() => {
headingRef.current?.focus()
}, [])
return (
<div className="flex flex-col gap-3">
<h3 ref={headingRef} tabIndex={-1} className="text-sm font-medium outline-none">
Copy it now — this is the only time it is shown
</h3>
<ApiKeyField value={created.secret} masked={false} label={created.name} />
<Callout variant="warning" title="Nothing here can show it again">
Only the prefix <span className="font-mono">{created.prefix}</span> is stored. Lose the key
and the way back is a new one.
</Callout>
</div>
)
}
export type CreateKeyDialogProps = {
scopes: string[]
onCreated: (key: CreatedKey) => void
}
/**
* Name it, scope it, and read it once.
*
* The submit is a plain client handler around the server action rather than
* `useActionState`: the table has to be told about the new row, and telling a
* parent during render is a React error. Here the call sits after the await,
* where an event handler belongs. It also keeps the revealed secret in local
* state that `close()` clears, so "once" means once — reopening the dialog
* shows an empty form, never the key from last time.
*/
export function CreateKeyDialog({ scopes, onCreated }: CreateKeyDialogProps) {
const [open, setOpen] = React.useState(false)
const [name, setName] = React.useState("")
const [chosen, setChosen] = React.useState<string[]>([])
const [created, setCreated] = React.useState<CreatedKey>()
const [error, setError] = React.useState<ActionError>()
const [pending, setPending] = React.useState(false)
// Bumped by every close. A create still in the air when the reader walks
// away belongs to a dialog that no longer exists, so its continuation checks
// the token it started with and does nothing.
const submission = React.useRef(0)
const errorFor = (field: string) => (error?.field === field ? error.message : undefined)
// A refusal with no field is nobody's input in particular, so it goes above
// the buttons as a form-level alert rather than under an arbitrary control.
const formError = error && !error.field ? error.message : undefined
function close() {
submission.current += 1
setOpen(false)
setName("")
setChosen([])
setCreated(undefined)
setError(undefined)
setPending(false)
}
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const token = submission.current
setPending(true)
const result = await createApiKey(formData)
// Cancel, Escape or a click outside during the wait: the key is dropped
// rather than revealed to a dialog the reader has left, and the table is
// not told about it either. The row does exist on the server — the action
// had already run — so it appears the next time the page is loaded, which
// is the honest outcome of asking for a key and walking away.
if (token !== submission.current) return
setPending(false)
if (!result.ok) {
setError(result.error)
return
}
setError(undefined)
setCreated(result.data)
onCreated(result.data)
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (next) setOpen(true)
else close()
}}
>
<DialogTrigger
render={
<Button size="sm">
<PlusIcon />
New key
</Button>
}
/>
<DialogContent className="sm:max-w-lg">
{created ? (
<>
<DialogHeader>
<DialogTitle>API key created</DialogTitle>
</DialogHeader>
<Reveal created={created} />
<DialogFooter>
<DialogClose render={<Button>Done</Button>} />
</DialogFooter>
</>
) : (
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>New API key</DialogTitle>
<DialogDescription>
A key authenticates every call to the events API. Give it only what it needs.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-5 py-4">
{chosen.map((scope) => (
<input key={scope} type="hidden" name="scopes" value={scope} />
))}
<FormRow label="Name" htmlFor="key-name" required error={errorFor("name")}>
<Input
id="key-name"
name="name"
placeholder="Reporting worker"
autoComplete="off"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</FormRow>
<FormRow
label="Scopes"
htmlFor="key-scopes"
description="What this key is allowed to do."
error={errorFor("scopes")}
>
{/* A group rather than a bare div: the boxes are one control
between them, so the refusal has something to attach to.
`aria-invalid` is not a group attribute, so the invalid
state stays on FormRow's own data-invalid and the message
reaches a reader through aria-describedby. */}
{(field) => (
<div
id={field.id}
role="group"
aria-label="Scopes"
aria-describedby={field["aria-describedby"]}
className="grid gap-2 sm:grid-cols-2"
>
{scopes.map((scope) => (
<Label
key={scope}
className="flex items-center gap-2 font-mono text-xs font-normal"
>
<Checkbox
checked={chosen.includes(scope)}
onCheckedChange={(next) =>
setChosen((current) =>
next ? [...current, scope] : current.filter((entry) => entry !== scope)
)
}
/>
{scope}
</Label>
))}
</div>
)}
</FormRow>
{formError ? (
<p role="alert" className="text-sm text-danger">
{formError}
</p>
) : null}
</div>
<DialogFooter>
<DialogClose render={<Button variant="ghost" type="button">Cancel</Button>} />
<Button type="submit" disabled={pending} aria-busy={pending || undefined}>
{pending ? "Creating…" : "Create key"}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
)
}import { flattenNav, type NavConfig } from "@/lib/nav-config"
import { navIcon } from "@/components/ui/app-shell/icons"
import { type SettingsNavItem } from "@/components/ui/settings-layout"
import { NAV } from "../nav"
// Not `SETTINGS_HREF`: this is the entry to look up, not a link. The template
// generator reads any *href constant holding an absolute path as a route a
// page links to, and would stub "/settings" for a product that has no page
// there — the same reason a page nav's own ROUTE is not called a href.
/** The route the settings area is rooted at. */
const SETTINGS_ROOT = "/settings"
/**
* The settings area's own sections, as SettingsLayout wants them: the leaves of
* the nav's `/settings` entry, in nav order. Read from the nav rather than
* written out a second time, because a template replaces `nav.ts` with its own
* — a hand-listed set would then offer sections that product has no page for,
* and the sub-nav beside the page would disagree with the sidebar above it.
*
* `findNavItem` is not the lookup: these navs give `/settings` a leaf of its
* own (General, the area's front page), and that leaf ties with its parent on
* href length, so the entry that owns the list has to be asked for directly.
* An entry with no leaves is its own only section.
*
* Each section is a real route, so these stay plain anchors — no `onNavigate`,
* no client state — and SettingsLayout marks the one matching `activeHref` as
* the current page.
*/
export function settingsSections(nav: NavConfig = NAV): SettingsNavItem[] {
const entries = flattenNav(nav).filter((item) => item.href === SETTINGS_ROOT)
// The parent wins over its own General leaf, which shares its href: the one
// that carries the list is the one being asked for. `findNavItem` would hand
// back the leaf instead, since equal href lengths go to the later item.
const settings = entries.find((item) => item.items?.length) ?? entries[0]
if (!settings) return []
const leaves = settings.items?.length ? settings.items : [settings]
return leaves.map((leaf) => {
// A NavConfig names its icons rather than holding them; this is the table
// the sidebar resolves them through, so the two cannot drift.
const Icon = navIcon(leaf.icon)
return { title: leaf.title, href: leaf.href, icon: Icon ? <Icon /> : undefined }
})
}
/** This block's own sections: the nav it ships with, resolved once. */
export const SETTINGS_SECTIONS: SettingsNavItem[] = settingsSections()