Skip to content

Commit 557653e

Browse files
committed
feat(ui): manage a client's API keys in the side panel
Phase 1. For preregistered (API-key) clients, the Apps side panel now lists the client's keys (prefix + last-used, never the secret) with per-key revoke and a "New key" rotation button that reveals the freshly-minted key once. Wired to list_client_api_keys / create_client_api_key / revoke_client_api_key. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent bc47fdd commit 557653e

2 files changed

Lines changed: 189 additions & 0 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* API keys for a preregistered (API-key) client — rendered in the client side
3+
* panel. Lists the client's keys (prefix + metadata, never the secret), and
4+
* lets the user revoke a key or mint a new one (rotation). A freshly-minted key
5+
* is shown ONCE inline.
6+
*/
7+
8+
import { useEffect, useState } from 'react';
9+
import { AlertTriangle, Check, Copy, Loader2, Plus, Trash2 } from 'lucide-react';
10+
import { Button } from '@mcpmux/ui';
11+
import {
12+
createClientApiKey,
13+
listClientApiKeys,
14+
revokeClientApiKey,
15+
type ApiKeyInfo,
16+
type RegisteredApiKeyClient,
17+
} from '@/lib/api/gateway';
18+
19+
interface ClientApiKeysSectionProps {
20+
clientId: string;
21+
onError: (title: string, body?: string) => void;
22+
onSuccess: (title: string, body?: string) => void;
23+
}
24+
25+
export function ClientApiKeysSection({ clientId, onError, onSuccess }: ClientApiKeysSectionProps) {
26+
const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
27+
const [isLoading, setIsLoading] = useState(true);
28+
const [isCreating, setIsCreating] = useState(false);
29+
const [revokingId, setRevokingId] = useState<string | null>(null);
30+
const [newKey, setNewKey] = useState<RegisteredApiKeyClient | null>(null);
31+
const [copied, setCopied] = useState(false);
32+
33+
const load = async () => {
34+
setIsLoading(true);
35+
try {
36+
setKeys(await listClientApiKeys(clientId));
37+
} catch (e) {
38+
onError('Failed to load API keys', e instanceof Error ? e.message : String(e));
39+
} finally {
40+
setIsLoading(false);
41+
}
42+
};
43+
44+
useEffect(() => {
45+
void load();
46+
// eslint-disable-next-line react-hooks/exhaustive-deps
47+
}, [clientId]);
48+
49+
const handleCreate = async () => {
50+
setIsCreating(true);
51+
try {
52+
const issued = await createClientApiKey(clientId);
53+
setNewKey(issued);
54+
setCopied(false);
55+
await load();
56+
} catch (e) {
57+
onError('Failed to create key', e instanceof Error ? e.message : String(e));
58+
} finally {
59+
setIsCreating(false);
60+
}
61+
};
62+
63+
const handleCopy = async () => {
64+
if (!newKey) return;
65+
try {
66+
await navigator.clipboard.writeText(newKey.apiKey);
67+
setCopied(true);
68+
setTimeout(() => setCopied(false), 2000);
69+
} catch {
70+
// Clipboard can be unavailable; the field is selectable as a fallback.
71+
}
72+
};
73+
74+
const handleRevoke = async (keyId: string) => {
75+
setRevokingId(keyId);
76+
try {
77+
await revokeClientApiKey(keyId);
78+
onSuccess('Key revoked', 'It can no longer authenticate.');
79+
await load();
80+
} catch (e) {
81+
onError('Failed to revoke key', e instanceof Error ? e.message : String(e));
82+
} finally {
83+
setRevokingId(null);
84+
}
85+
};
86+
87+
const liveKeys = keys.filter((k) => !k.revoked);
88+
89+
return (
90+
<section>
91+
<div className="mb-2 flex items-center justify-between">
92+
<h3 className="text-xs font-semibold uppercase tracking-wide text-[rgb(var(--muted))]">
93+
API keys
94+
</h3>
95+
<Button
96+
size="sm"
97+
variant="ghost"
98+
onClick={handleCreate}
99+
disabled={isCreating}
100+
data-testid="client-new-api-key"
101+
>
102+
{isCreating ? (
103+
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
104+
) : (
105+
<Plus className="mr-1.5 h-3.5 w-3.5" />
106+
)}
107+
New key
108+
</Button>
109+
</div>
110+
111+
{newKey && (
112+
<div className="mb-3 rounded-xl border border-amber-300 bg-amber-50 p-3 dark:border-amber-700/60 dark:bg-amber-900/20">
113+
<div className="mb-2 flex items-start gap-2">
114+
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
115+
<p className="text-xs text-amber-800 dark:text-amber-200">
116+
Copy this key now — it won&apos;t be shown again.
117+
</p>
118+
</div>
119+
<div className="flex items-stretch gap-2">
120+
<code className="flex-1 select-all break-all rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-2.5 py-2 font-mono text-xs">
121+
{newKey.apiKey}
122+
</code>
123+
<Button size="sm" variant="secondary" onClick={handleCopy}>
124+
{copied ? (
125+
<Check className="h-3.5 w-3.5 text-emerald-500" />
126+
) : (
127+
<Copy className="h-3.5 w-3.5" />
128+
)}
129+
</Button>
130+
</div>
131+
</div>
132+
)}
133+
134+
{isLoading ? (
135+
<div className="flex justify-center py-4">
136+
<Loader2 className="h-5 w-5 animate-spin text-[rgb(var(--muted))]" />
137+
</div>
138+
) : liveKeys.length === 0 ? (
139+
<p className="rounded-lg border border-dashed border-[rgb(var(--border))] px-3 py-3 text-center text-xs text-[rgb(var(--muted))]">
140+
No active keys. Create one so this client can authenticate.
141+
</p>
142+
) : (
143+
<ul className="space-y-2">
144+
{liveKeys.map((k) => (
145+
<li
146+
key={k.keyId}
147+
className="flex items-center justify-between gap-2 rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-3 py-2"
148+
>
149+
<div className="min-w-0 flex-1">
150+
<code className="font-mono text-xs">{k.keyPrefix}</code>
151+
<p className="mt-0.5 text-[11px] text-[rgb(var(--muted))]">
152+
{k.lastUsedAt
153+
? `Last used ${new Date(k.lastUsedAt).toLocaleDateString()}`
154+
: 'Never used'}
155+
</p>
156+
</div>
157+
<button
158+
onClick={() => handleRevoke(k.keyId)}
159+
disabled={revokingId === k.keyId}
160+
className="flex-shrink-0 rounded-md p-1.5 text-[rgb(var(--muted))] transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
161+
aria-label="Revoke key"
162+
>
163+
{revokingId === k.keyId ? (
164+
<Loader2 className="h-4 w-4 animate-spin" />
165+
) : (
166+
<Trash2 className="h-4 w-4" />
167+
)}
168+
</button>
169+
</li>
170+
))}
171+
</ul>
172+
)}
173+
174+
<p className="mt-2 text-xs text-[rgb(var(--muted))]">
175+
This client authenticates with an API key as a Bearer token. Keys are stored hashed — revoke
176+
a leaked one and mint a new key.
177+
</p>
178+
</section>
179+
);
180+
}

apps/desktop/src/features/clients/ClientsPage.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
useSetPendingClientId,
5858
} from '@/stores';
5959
import { RegisterApiKeyClientModal } from './RegisterApiKeyClientModal';
60+
import { ClientApiKeysSection } from './ClientApiKeysSection';
6061

6162
// Bundled icons for well-known AI clients.
6263
const CLIENT_ICON_ASSETS: Record<string, string> = {
@@ -595,6 +596,14 @@ function SidePanel({
595596
</p>
596597
</section>
597598

599+
{client.registration_type === 'preregistered' && (
600+
<ClientApiKeysSection
601+
clientId={client.client_id}
602+
onError={onToastError}
603+
onSuccess={onToastSuccess}
604+
/>
605+
)}
606+
598607
<section className="rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--background))] p-4">
599608
<div className="flex items-start gap-3">
600609
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-[rgb(var(--accent))]/10">

0 commit comments

Comments
 (0)