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:
@@ -1,202 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,6 @@ import { GoogleOAuthConfig } from './GoogleOAuthConfig';
|
||||
import { BrowserRelay } from './BrowserRelay';
|
||||
import { ApifyConfig } from './ApifyConfig';
|
||||
import { DavAppPasswords } from './DavAppPasswords';
|
||||
import { ApiKeys } from './ApiKeys';
|
||||
|
||||
const BASE_PATH = '/settings/integrations';
|
||||
|
||||
@@ -51,13 +50,6 @@ 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,
|
||||
|
||||
Reference in New Issue
Block a user