api keys live in profile, not integrations

Reported by the owner, who went looking for them in user management and then in profile, and found
them in neither. They were under Integrations.

A key is not an integration with anything. It is a second way to present the authority the account
already has, and the permission registry already says exactly that: `/api-keys` is `core`, sitting
beside `/user` and `/dock`, with the comment "a key is not new authority". Profile, next to the
password it stands in for, is where somebody looks.

Moved rather than duplicated, and the route moves with it — /settings/profile/api-keys. Nothing
linked to the old path, so there is no redirect to keep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 23:15:36 +00:00
co-authored by Claude Opus 5
parent 10feb76880
commit c4042886fc
3 changed files with 13 additions and 9 deletions
@@ -0,0 +1,202 @@
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 { 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 [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 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>
)}
<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.
</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>
);
};