api keys settings section

settings > integrations > personal > api keys. mirrors the dav app
password panel, which is the same problem: a secret that exists for one
response, so the new key stays on screen until dismissed rather than in
a toast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 10:13:24 +00:00
co-authored by Claude Opus 5
parent 00997f5ff1
commit ae54df7c30
2 changed files with 208 additions and 0 deletions
@@ -0,0 +1,200 @@
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 { useClient } from 'hooks/useClient';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
// 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 navigator.clipboard.writeText(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 [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 () => {
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 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 keys = data?.keys ?? [];
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. Give each app or device its own, 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>
)}
<div 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)}
onKeyDown={(ev) => ev.key === 'Enter' && create()}
placeholder="iPhone, Music app, laptop CLI…"
/>
</Label>
<Button onClick={create} disabled={!name.trim() || isCreating} className="h-11">
<Plus className="mr-1.5 h-4 w-4" />
Create
</Button>
</div>
{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.
</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 && (
<Button
variant="ghost"
size="sm"
onClick={() => revoke(entry.id)}
title="Revoke"
className="hover:text-red-500"
>
<Ban className="h-3.5 w-3.5" />
</Button>
)}
</div>
);
})}
</div>
)}
</div>
);
};
@@ -13,6 +13,7 @@ import { BrowserRelay } from './BrowserRelay';
import { ApifyConfig } from './ApifyConfig';
import { EmailAccounts } from './EmailAccounts';
import { DavAppPasswords } from './DavAppPasswords';
import { ApiKeys } from './ApiKeys';
const BASE_PATH = '/settings/integrations';
@@ -57,6 +58,13 @@ const personalSections: SettingsSection[] = [
description: 'Per-device passwords for CalDAV/CardDAV clients',
content: <DavAppPasswords />,
},
{
key: 'api-keys',
icon: KeyRound,
title: 'API keys',
description: 'Per-app keys that sign in as you, revocable one at a time',
content: <ApiKeys />,
},
{
key: 'browser-relay',
icon: Globe,