feat: Restyle the API keys page onto the shared list patterns
The page was hand-rolling its loading spinner, error line and empty
paragraph while the rest of the console routes these through
AsyncBoundary with a TableSkeleton and an EmptyState — the same drift
Async.tsx was written to end. It also passed className="p-0" where Card
takes padding={false}.
The admin-only scope switch becomes the vulnerabilities page's pill
filter rather than a loose checkbox: seeing everyone's keys is a filter
over the list, not a preference, and someone who has learned one control
has now learned both.
Scopes collapse to one chip per resource with an r/rw qualifier. Sixteen
badges made the row taller than everything around it and still had to be
read one at a time.
In the create dialog Copy is now the primary action and Done the quiet
one, because the value is unrecoverable once the dialog closes, and the
result panel repeats the role, scopes and expiry that were just granted.
This commit is contained in:
@@ -4,7 +4,24 @@ 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, Card, ConfirmDialog, Modal, Table, Tbody, Td, Th, Thead, Tr, friendlyMessage, useToast } from "@/components/ui";
|
||||
import {
|
||||
AsyncBoundary,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
Modal,
|
||||
Table,
|
||||
TableSkeleton,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
friendlyMessage,
|
||||
useToast,
|
||||
} from "@/components/ui";
|
||||
import { Field, inputClass } from "@/components/settings/Field";
|
||||
|
||||
const ROLES: Role[] = ["owner", "admin", "member"];
|
||||
@@ -34,6 +51,43 @@ function rolesAtOrBelow(role: Role): Role[] {
|
||||
return idx === -1 ? ROLES : ROLES.slice(idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses ["servers:read","servers:write","keys:read"] into one chip per
|
||||
* resource carrying its access. Sixteen scopes rendered as sixteen badges make
|
||||
* the row taller than everything around it and still have to be read one at a
|
||||
* time; the resource is what a person scans for, and r/w is the qualifier.
|
||||
*/
|
||||
function summariseScopes(scopes: string[]): { resource: string; access: string }[] {
|
||||
const byResource = new Map<string, { read: boolean; write: boolean }>();
|
||||
for (const scope of scopes) {
|
||||
const [resource, action] = scope.split(":");
|
||||
const entry = byResource.get(resource) ?? { read: false, write: false };
|
||||
if (action === "read") entry.read = true;
|
||||
if (action === "write") entry.write = true;
|
||||
byResource.set(resource, entry);
|
||||
}
|
||||
return Array.from(byResource, ([resource, { read, write }]) => ({
|
||||
resource,
|
||||
// write implies read on the server, so a token holding only :write is
|
||||
// still shown as rw rather than pretending it cannot read.
|
||||
access: write ? "rw" : read ? "r" : "",
|
||||
}));
|
||||
}
|
||||
|
||||
function ScopeChips({ scopes }: { scopes: string[] }) {
|
||||
if (scopes.length === 0) return <span className="text-text-secondary">—</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{summariseScopes(scopes).map(({ resource, access }) => (
|
||||
<Badge key={resource} variant="neutral">
|
||||
{resource}
|
||||
<span className="ml-1 font-mono text-[0.65rem] uppercase tracking-[0.08em] opacity-70">{access}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a token's expiry, plus a policy note when the cap has tightened
|
||||
* since the token was issued. The policy is not applied retroactively, so an
|
||||
* outside-policy token is a prompt to rotate, not a failure of any kind. */
|
||||
@@ -168,98 +222,124 @@ export function ApiKeysPanel() {
|
||||
|
||||
const assignableRoles = user ? rolesAtOrBelow(user.role) : ROLES;
|
||||
|
||||
const count = tokens?.length ?? 0;
|
||||
|
||||
return (
|
||||
<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.
|
||||
{count} key{count !== 1 ? "s" : ""} · {showAll ? "instance-wide" : "yours"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isAdmin && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAll}
|
||||
onChange={(e) => setShowAll(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
|
||||
/>
|
||||
All keys
|
||||
</label>
|
||||
)}
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
||||
New key
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setCreateOpen(true)}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
New key
|
||||
</Button>
|
||||
</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" />
|
||||
{/* Owner and admin can see everyone's keys, so the scope of the list
|
||||
is a filter rather than a preference — the same pill treatment
|
||||
the vulnerabilities page uses for its state filter, so a person
|
||||
who has learned one has learned both. */}
|
||||
{isAdmin && (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ label: "My keys", all: false },
|
||||
{ label: "All keys", all: true },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
type="button"
|
||||
onClick={() => setShowAll(option.all)}
|
||||
aria-pressed={showAll === option.all}
|
||||
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
|
||||
showAll === option.all ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="p-6 text-sm text-danger">{friendlyMessage(error)}</p>
|
||||
) : !tokens || tokens.length === 0 ? (
|
||||
<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>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
{showAll && <Th>Owner</Th>}
|
||||
<Th>Role</Th>
|
||||
<Th>Scopes</Th>
|
||||
<Th>Last used</Th>
|
||||
<Th>Expires</Th>
|
||||
<Th className="text-right">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{tokens.map((t) => (
|
||||
<Tr key={t.token_id}>
|
||||
<Td label="Name">
|
||||
<span className="font-medium text-text-primary">{t.name}</span>
|
||||
<div className="font-mono text-xs text-text-secondary">{t.hint}…</div>
|
||||
</Td>
|
||||
{showAll && <Td label="Owner" className="text-text-secondary">{t.user_email ?? t.user_id}</Td>}
|
||||
<Td label="Role">
|
||||
<Badge variant={roleVariant(t.role)}>{t.role}</Badge>
|
||||
</Td>
|
||||
<Td label="Scopes">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{t.scopes.map((s) => (
|
||||
<Badge key={s} variant="neutral">
|
||||
{s}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Td>
|
||||
<Td label="Last used" className="text-text-secondary">
|
||||
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : "Never"}
|
||||
</Td>
|
||||
<Td label="Expires">
|
||||
<ExpiryCell token={t} capDays={capDays} />
|
||||
</Td>
|
||||
<Td label="Actions" className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-danger hover:text-danger"
|
||||
onClick={() => setRevoking({ id: t.token_id, name: t.name })}
|
||||
>
|
||||
Revoke<span className="sr-only"> {t.name}</span>
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Card padding={false}>
|
||||
<AsyncBoundary
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
skeleton={<TableSkeleton columns={showAll ? 7 : 6} />}
|
||||
isEmpty={count === 0}
|
||||
empty={
|
||||
<EmptyState
|
||||
title={showAll ? "No API keys in this instance." : "You have no API keys."}
|
||||
description={
|
||||
showAll
|
||||
? "Nobody has created a key yet. Keys let scripts and CI call the REST API without a browser session."
|
||||
: "Create one to call the REST API from a script or a CI job. It is scoped to what you grant it and never exceeds your own role."
|
||||
}
|
||||
icon={
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
|
||||
<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>
|
||||
}
|
||||
action={{ label: "Create your first key", onClick: () => setCreateOpen(true) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
{showAll && <Th>Owner</Th>}
|
||||
<Th>Role</Th>
|
||||
<Th>Scopes</Th>
|
||||
<Th>Last used</Th>
|
||||
<Th>Expires</Th>
|
||||
<Th className="text-right">Actions</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{tokens?.map((t) => (
|
||||
<Tr key={t.token_id}>
|
||||
<Td label="Name">
|
||||
<span className="font-medium text-text-primary">{t.name}</span>
|
||||
<div className="font-mono text-xs text-text-secondary">{t.hint}…</div>
|
||||
</Td>
|
||||
{showAll && <Td label="Owner" className="text-text-secondary">{t.user_email ?? t.user_id}</Td>}
|
||||
<Td label="Role">
|
||||
<Badge variant={roleVariant(t.role)}>{t.role}</Badge>
|
||||
</Td>
|
||||
<Td label="Scopes">
|
||||
<ScopeChips scopes={t.scopes} />
|
||||
</Td>
|
||||
<Td label="Last used" className="text-text-secondary">
|
||||
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : <span className="text-text-secondary/70">Never used</span>}
|
||||
</Td>
|
||||
<Td label="Expires">
|
||||
<ExpiryCell token={t} capDays={capDays} />
|
||||
</Td>
|
||||
<Td label="Actions" className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-danger hover:text-danger"
|
||||
onClick={() => setRevoking({ id: t.token_id, name: t.name })}
|
||||
>
|
||||
Revoke<span className="sr-only"> {t.name}</span>
|
||||
</Button>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</AsyncBoundary>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -284,17 +364,33 @@ export function ApiKeysPanel() {
|
||||
<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 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 className="rounded border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
This is the only time <span className="font-semibold">{result.record.name}</span> is shown. Copy it now — Vantage stores only a
|
||||
hash and cannot show it again.
|
||||
</div>
|
||||
<code className="block overflow-x-auto rounded bg-well p-3 font-mono text-sm break-all text-text-primary">{result.token}</code>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-text-secondary">Role</dt>
|
||||
<dd className="text-text-primary">{result.record.role}</dd>
|
||||
<dt className="text-text-secondary">Scopes</dt>
|
||||
<dd>
|
||||
<ScopeChips scopes={result.record.scopes} />
|
||||
</dd>
|
||||
<dt className="text-text-secondary">Expires</dt>
|
||||
<dd className="text-text-primary">
|
||||
{result.record.expires_at ? new Date(result.record.expires_at).toLocaleDateString() : "Never"}
|
||||
</dd>
|
||||
</dl>
|
||||
{/* Copy is the primary action, not Done: the value is
|
||||
unrecoverable once this closes, so the button that
|
||||
saves it should be the one under the pointer. */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={copyToken}>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={closeCreate}>
|
||||
<Button type="button" variant="ghost" onClick={closeCreate}>
|
||||
Done
|
||||
</Button>
|
||||
<Button type="button" variant="primary" onClick={copyToken}>
|
||||
{copied ? "Copied" : "Copy key"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user