api keys: revoked ones stop filling the screen, and can be forgotten

Five revoked keys, no live ones, and no way to remove any of them — which is what the screen looks
like after a few rounds of testing. `listApiKeys` returns every row and nothing filtered them, so a
list whose job is "which of my keys still work" answered with a wall of dead ones.

Nobody decided that. Revoke was implemented and the list was never taught the difference.

Dead keys — revoked or past expiry — are now hidden behind a "Show 5 revoked or expired keys"
toggle rather than merely dimmed, because on any account in use for a while they outnumber the live
ones. The empty state distinguishes "No keys yet" from "No keys in use": the first is false on an
account whose keys are all revoked, and it is exactly the sentence that sends somebody hunting for a
bug in the list.

DELETE /:id/record removes the row for good, and only after it is revoked. The `revokedAt is not
null` clause is in the statement rather than checked before it, so there is no ordering in which a
live key is deleted by a request that raced a revoke. Nothing is weakened by allowing it: the key
cannot be honoured again either way, since authentication matches a hash and a deleted row has none.
What goes is the history — `lastUsedAt`, the only evidence of whether a leaked key was used before it
was killed — which is why it stays a second, deliberate step rather than what revoke does.

Also corrected the copy, which claimed keys are per-app. They are not scoped to anything: one key
works everywhere, and per-device is a revocation convenience, not a requirement. The owner had to ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 23:22:29 +00:00
co-authored by Claude Opus 5
parent c4042886fc
commit 43aeffd9d3
5 changed files with 108 additions and 10 deletions
@@ -2,7 +2,7 @@ import type { FormEvent } from 'react';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Copy, Ban, Plus, KeyRound, Loader2 } from 'lucide-react';
import { Copy, Ban, Plus, KeyRound, Loader2, Trash2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -53,6 +53,7 @@ export const ApiKeys = () => {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [showRevoked, setShowRevoked] = useState(false);
const [minted, setMinted] = useState<Minted | null>(null);
const { data, isLoading } = useQuery<{ keys: ApiKey[] }>({
@@ -79,6 +80,15 @@ export const ApiKeys = () => {
}
};
const forget = async (id: number) => {
try {
await client.delete(`/api-keys/${id}/record`);
await refresh();
} catch {
toast.error('Could not remove it');
}
};
const revoke = async (id: number) => {
try {
await client.delete(`/api-keys/${id}`);
@@ -89,7 +99,19 @@ export const ApiKeys = () => {
}
};
const keys = data?.keys ?? [];
const allKeys = data?.keys ?? [];
// Dead means revoked OR past its expiry — the same test the row rendering uses, lifted here so the
// count in the toggle and the rows it reveals can never disagree.
const isDead = (entry: ApiKey) =>
!!entry.revokedAt || (!!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now());
// Dead keys are HIDDEN by default rather than merely dimmed. Revoking is the normal end of a key's
// life, so on any account that has been used for a while the dead ones outnumber the live ones and the
// screen stops answering the question it exists for — which of my keys still work. The rows are kept
// and reachable; they are just not the first thing shown.
const deadKeys = allKeys.filter(isDead);
const keys = showRevoked ? allKeys : allKeys.filter((entry) => !isDead(entry));
if (isLoading) {
return (
@@ -102,8 +124,8 @@ export const ApiKeys = () => {
return (
<div className="grid gap-5">
<p className="text-sm text-duck-dark/70 dark:text-foreground/70">
An API key signs in as you, without a password and without expiring. Give each app or device its own, so you can
cut one off without touching the rest. Send it as{' '}
An API key signs in as you, without a password and without expiring. One key works everywhere — a key is not
tied to an app. Giving each device its own is only so you can cut one off without touching the rest. Send it as{' '}
<code className="font-mono text-xs">Authorization: Bearer …</code>.
</p>
@@ -150,7 +172,9 @@ export const ApiKeys = () => {
{keys.length === 0 ? (
<div className="rounded-lg border border-dashed border-duck-dark/15 dark:border-foreground/15 p-6 text-center text-sm text-duck-dark/40 dark:text-foreground/40">
No keys yet.
{/* "No keys yet" would be false on an account whose keys are all revoked, and it is the sentence
that would send somebody looking for a bug in the list. */}
{allKeys.length === 0 ? 'No keys yet.' : 'No keys in use.'}
</div>
) : (
<div className="grid gap-2">
@@ -181,11 +205,23 @@ export const ApiKeys = () => {
{lastUsed ? `last used ${lastUsed}` : 'never used'}
</div>
</div>
{!dead && (
{dead ? (
// Only offered once the key is already dead — the server refuses it otherwise, and the
// button matching that rule is what keeps the two from drifting.
<Button
variant="ghost"
size="sm"
onClick={() => revoke(entry.id)}
onClick={() => void forget(entry.id)}
title="Remove this record"
className="hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => void revoke(entry.id)}
title="Revoke"
className="hover:text-red-500"
>
@@ -197,6 +233,18 @@ export const ApiKeys = () => {
})}
</div>
)}
{deadKeys.length > 0 && (
<button
type="button"
onClick={() => setShowRevoked((v) => !v)}
className="mt-3 cursor-pointer text-xs text-duck-dark/40 underline underline-offset-2 transition-colors hover:text-duck-dark/70 dark:text-foreground/40 dark:hover:text-foreground/70"
>
{showRevoked
? 'Hide revoked and expired'
: `Show ${deadKeys.length} revoked or expired key${deadKeys.length === 1 ? '' : 's'}`}
</button>
)}
</div>
);
};
@@ -30,7 +30,7 @@ const sections: SettingsSection[] = [
key: 'api-keys',
icon: KeyRound,
title: 'API keys',
description: 'Per-app keys that sign in as you, revocable one at a time',
description: 'Keys that sign in as you, revocable one at a time',
content: <ApiKeys />,
},
{