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>
251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
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, Trash2 } from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Button } from '@/components/ui/button';
|
|
import { copyToClipboard } from 'helpers/clipboard';
|
|
|
|
// Your own API keys: one per app or device, so a phone holds a credential you can revoke on its own
|
|
// instead of a session everything shares.
|
|
//
|
|
// The key is returned by POST and never again — the column holds a SHA-256, so there is nothing to read
|
|
// back. That drives the whole screen: the new key sits in a panel that stays put until dismissed, because
|
|
// the moment it disappears the only remedy is to revoke and mint another. Same reasoning as the DAV app
|
|
// passwords beside this.
|
|
//
|
|
// A key carries your full account authority. It is not more than you already had — it is what your
|
|
// password could already do — but it does mean a leaked key is a leaked account, so revoke is one click
|
|
// and "last used" is on every row: a key that has never been used is the tell that something was set up
|
|
// wrong, and one used from somewhere you did not expect is the tell that matters more.
|
|
|
|
type ApiKey = {
|
|
id: number;
|
|
name: string;
|
|
prefix: string;
|
|
lastUsedAt: string | null;
|
|
expiresAt: string | null;
|
|
revokedAt: string | null;
|
|
createdAt: string;
|
|
};
|
|
|
|
type Minted = { key: string; name: string };
|
|
|
|
const API_KEYS_QUERY_KEY = ['API_KEYS'];
|
|
|
|
const formatDate = (value: string | null) =>
|
|
value ? new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null;
|
|
|
|
const copy = async (text: string) => {
|
|
try {
|
|
await copyToClipboard(text);
|
|
toast.success('Key copied');
|
|
} catch {
|
|
toast.error('Could not copy — select and copy manually');
|
|
}
|
|
};
|
|
|
|
export const ApiKeys = () => {
|
|
const client = useClient();
|
|
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[] }>({
|
|
queryKey: API_KEYS_QUERY_KEY,
|
|
queryFn: () => client.get<{ keys: ApiKey[] }>('/api-keys'),
|
|
});
|
|
|
|
const refresh = () => queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
|
|
|
const create = async (ev?: FormEvent) => {
|
|
ev?.preventDefault();
|
|
const trimmed = name.trim();
|
|
if (!trimmed || isCreating) return;
|
|
setIsCreating(true);
|
|
try {
|
|
const res = await client.post<{ key: string }>('/api-keys', { name: trimmed });
|
|
setMinted({ key: res.key, name: trimmed });
|
|
setName('');
|
|
await refresh();
|
|
} catch (ex) {
|
|
toast.error(ex instanceof Error ? ex.message : 'Could not create the key');
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
};
|
|
|
|
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}`);
|
|
toast.success('Revoked — anything using that key is signed out');
|
|
await refresh();
|
|
} catch {
|
|
toast.error('Could not revoke');
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading keys…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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. 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>
|
|
|
|
{minted && (
|
|
<div className="grid gap-3 rounded-lg border-2 border-duck-teal/40 bg-duck-teal/5 p-4">
|
|
<div className="text-sm font-medium text-duck-dark dark:text-foreground">
|
|
Key for “{minted.name}” — shown once
|
|
</div>
|
|
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">
|
|
This is the only time it is displayed. It's stored hashed, so it can't be shown again — if you lose it,
|
|
revoke this entry and make another.
|
|
</p>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<code className="min-w-0 flex-1 truncate rounded bg-background/70 px-2 py-1.5 font-mono text-xs">
|
|
{minted.key}
|
|
</code>
|
|
<Button variant="ghost" size="sm" onClick={() => copy(minted.key)} title="Copy key">
|
|
<Copy className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
|
|
<Button variant="outline" size="sm" className="justify-self-start" onClick={() => setMinted(null)}>
|
|
I've saved it
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={create} className="flex items-end gap-2">
|
|
<Label className="grid flex-1 gap-2">
|
|
<span className="text-duck-dark/70 dark:text-foreground/70">New key</span>
|
|
<Input
|
|
className="h-11 bg-background/60 border-duck-dark/20"
|
|
value={name}
|
|
onChange={(ev) => setName(ev.target.value)}
|
|
placeholder="iPhone, Music app, laptop CLI…"
|
|
/>
|
|
</Label>
|
|
<Button type="submit" disabled={!name.trim() || isCreating} className="h-11">
|
|
<Plus className="mr-1.5 h-4 w-4" />
|
|
Create
|
|
</Button>
|
|
</form>
|
|
|
|
{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" 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">
|
|
{keys.map((entry) => {
|
|
const expired = !!entry.expiresAt && new Date(entry.expiresAt).getTime() <= Date.now();
|
|
const dead = !!entry.revokedAt || expired;
|
|
const lastUsed = formatDate(entry.lastUsedAt);
|
|
return (
|
|
<div
|
|
key={entry.id}
|
|
className={`flex items-center gap-3 rounded-lg border p-3 ${
|
|
dead
|
|
? 'border-duck-dark/10 dark:border-foreground/10 opacity-50'
|
|
: 'border-duck-dark/15 dark:border-foreground/15'
|
|
}`}
|
|
>
|
|
<KeyRound className="h-4 w-4 shrink-0 text-duck-teal/70" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm text-duck-dark dark:text-foreground">
|
|
{entry.name}
|
|
{entry.revokedAt && <span className="ml-2 text-xs text-red-500">revoked</span>}
|
|
{!entry.revokedAt && expired && <span className="ml-2 text-xs text-red-500">expired</span>}
|
|
</div>
|
|
<div className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
|
<code className="font-mono">{entry.prefix}…</code>
|
|
{' · '}
|
|
{/* "never used" is the tell that an app was configured wrong, so it earns its own wording. */}
|
|
{lastUsed ? `last used ${lastUsed}` : 'never used'}
|
|
</div>
|
|
</div>
|
|
{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={() => 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"
|
|
>
|
|
<Ban className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</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>
|
|
);
|
|
};
|