Skip to content

Commit bc47fdd

Browse files
committed
feat(ui): Register API-key client modal (Clients tab)
Adds a modal to mint a pre-approved API-key client from the Clients tab: enter a name, generate a key shown once (mcpk_ + 256 bits), copy it, and an explainer of how the client authenticates with `Authorization: Bearer <key>`. The "Lock to a Space" selector is deferred to P2 (Strategy Y) along with the rest of the locked_space wiring; P1 registers an unconfined API-key client. Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent 7496e8f commit bc47fdd

2 files changed

Lines changed: 240 additions & 5 deletions

File tree

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

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
Check,
2424
Globe,
2525
ShieldOff,
26+
KeyRound,
2627
} from 'lucide-react';
2728
import { ConnectIDEs } from '@/components/ConnectIDEs';
2829
import type { GatewayStatus, OAuthClient } from '@/lib/api/gateway';
@@ -55,6 +56,7 @@ import {
5556
usePendingClientId,
5657
useSetPendingClientId,
5758
} from '@/stores';
59+
import { RegisterApiKeyClientModal } from './RegisterApiKeyClientModal';
5860

5961
// Bundled icons for well-known AI clients.
6062
const CLIENT_ICON_ASSETS: Record<string, string> = {
@@ -124,6 +126,7 @@ export default function ClientsPage() {
124126
const [error, setError] = useState<string | null>(null);
125127
const [searchQuery, setSearchQuery] = useState('');
126128
const [selected, setSelected] = useState<OAuthClient | null>(null);
129+
const [showRegister, setShowRegister] = useState(false);
127130
const [editAlias, setEditAlias] = useState('');
128131
const [isSaving, setIsSaving] = useState(false);
129132
const [gatewayStatus, setGatewayStatus] = useState<GatewayStatus>({
@@ -283,10 +286,21 @@ export default function ClientsPage() {
283286
</>
284287
}
285288
actions={
286-
<Button variant="ghost" size="md" onClick={refreshClients} disabled={isRefreshing}>
287-
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
288-
Refresh
289-
</Button>
289+
<div className="flex items-center gap-2">
290+
<Button variant="ghost" size="md" onClick={refreshClients} disabled={isRefreshing}>
291+
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
292+
Refresh
293+
</Button>
294+
<Button
295+
variant="primary"
296+
size="md"
297+
onClick={() => setShowRegister(true)}
298+
data-testid="register-api-key-client-btn"
299+
>
300+
<KeyRound className="mr-2 h-4 w-4" />
301+
Register client
302+
</Button>
303+
</div>
290304
}
291305
/>
292306

@@ -409,6 +423,16 @@ export default function ClientsPage() {
409423
</>
410424
)}
411425

426+
{showRegister && (
427+
<RegisterApiKeyClientModal
428+
onClose={() => setShowRegister(false)}
429+
onRegistered={(client) => {
430+
success(`Registered "${client.clientName}" with an API key.`);
431+
void refreshClients();
432+
}}
433+
/>
434+
)}
435+
412436
<ToastContainer toasts={toasts} onClose={dismiss} />
413437
{ConfirmDialogElement}
414438
</div>
@@ -582,7 +606,9 @@ function SidePanel({
582606
When this client reports a folder as an MCP root, mcpmux uses the matching Workspace
583607
binding to pick the Space and FeatureSet. If it doesn&apos;t report the folder
584608
reliably (e.g. Cursor), open the folder in Workspaces and{' '}
585-
<span className="font-medium text-[rgb(var(--foreground))]">Connect apps to this folder</span>{' '}
609+
<span className="font-medium text-[rgb(var(--foreground))]">
610+
Connect apps to this folder
611+
</span>{' '}
586612
to auto-write its config with a workspace header.
587613
</p>
588614
<button
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/**
2+
* Register API-key client modal.
3+
*
4+
* Creates a pre-approved inbound client authenticated by a long-lived API key —
5+
* no browser/OAuth consent. This is the secure way to connect a headless,
6+
* remote, or CI client (or any client reaching the gateway over the network,
7+
* where the `mcpmux://` consent deep link can't complete).
8+
*
9+
* The generated key is shown ONCE. McpMux stores only its SHA-256 hash and can
10+
* never display it again — if lost, revoke it and issue a new one.
11+
*/
12+
13+
import { useState } from 'react';
14+
import { AlertTriangle, Check, Copy, KeyRound, Loader2, ShieldCheck, X } from 'lucide-react';
15+
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@mcpmux/ui';
16+
import { registerApiKeyClient, type RegisteredApiKeyClient } from '@/lib/api/gateway';
17+
18+
interface RegisterApiKeyClientModalProps {
19+
onClose: () => void;
20+
/** Called once the client + key are created, so the page can refresh. */
21+
onRegistered: (client: RegisteredApiKeyClient) => void;
22+
}
23+
24+
export function RegisterApiKeyClientModal({
25+
onClose,
26+
onRegistered,
27+
}: RegisterApiKeyClientModalProps) {
28+
const [name, setName] = useState('');
29+
const [isSubmitting, setIsSubmitting] = useState(false);
30+
const [error, setError] = useState<string | null>(null);
31+
const [result, setResult] = useState<RegisteredApiKeyClient | null>(null);
32+
const [copied, setCopied] = useState(false);
33+
34+
const handleGenerate = async () => {
35+
const trimmed = name.trim();
36+
if (!trimmed) {
37+
setError('Give the client a name so you can recognise it later.');
38+
return;
39+
}
40+
setIsSubmitting(true);
41+
setError(null);
42+
try {
43+
const client = await registerApiKeyClient(trimmed);
44+
setResult(client);
45+
} catch (e) {
46+
setError(e instanceof Error ? e.message : String(e));
47+
} finally {
48+
setIsSubmitting(false);
49+
}
50+
};
51+
52+
const handleCopy = async () => {
53+
if (!result) return;
54+
try {
55+
await navigator.clipboard.writeText(result.apiKey);
56+
setCopied(true);
57+
setTimeout(() => setCopied(false), 2000);
58+
} catch {
59+
// Clipboard can be unavailable; the field is selectable as a fallback.
60+
}
61+
};
62+
63+
const handleDone = () => {
64+
if (result) onRegistered(result);
65+
onClose();
66+
};
67+
68+
return (
69+
<div
70+
className="animate-in fade-in fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4 backdrop-blur-[2px] duration-200"
71+
onClick={result ? undefined : onClose}
72+
>
73+
<Card className="w-full max-w-lg shadow-2xl" onClick={(e) => e.stopPropagation()}>
74+
<CardHeader className="relative">
75+
<button
76+
onClick={result ? handleDone : onClose}
77+
className="absolute right-4 top-4 rounded-lg p-1.5 text-[rgb(var(--muted))] transition-colors hover:bg-[rgb(var(--surface))] hover:text-[rgb(var(--text))]"
78+
aria-label="Close"
79+
>
80+
<X className="h-5 w-5" />
81+
</button>
82+
<div className="mb-2 flex h-11 w-11 items-center justify-center rounded-xl bg-[rgb(var(--accent))]/10">
83+
<KeyRound className="h-5 w-5 text-[rgb(var(--accent))]" />
84+
</div>
85+
<CardTitle data-testid="register-api-key-title">
86+
{result ? 'API key created' : 'Register client (API key)'}
87+
</CardTitle>
88+
<CardDescription>
89+
{result
90+
? 'Copy the key now — this is the only time it will be shown.'
91+
: 'A pre-authorised client that connects with an API key instead of browser approval. Use this for headless, CI, or remote clients reaching the gateway over the network.'}
92+
</CardDescription>
93+
</CardHeader>
94+
95+
<CardContent className="space-y-5">
96+
{result ? (
97+
<>
98+
<div>
99+
<label className="mb-1.5 block text-sm font-medium">API key</label>
100+
<div className="flex items-stretch gap-2">
101+
<code
102+
data-testid="register-api-key-value"
103+
className="flex-1 select-all break-all rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-3 py-2.5 font-mono text-sm"
104+
>
105+
{result.apiKey}
106+
</code>
107+
<Button variant="secondary" size="md" onClick={handleCopy}>
108+
{copied ? (
109+
<Check className="h-4 w-4 text-emerald-500" />
110+
) : (
111+
<Copy className="h-4 w-4" />
112+
)}
113+
</Button>
114+
</div>
115+
</div>
116+
117+
<div className="flex items-start gap-3 rounded-xl border border-amber-300 bg-amber-50 p-3.5 dark:border-amber-700/60 dark:bg-amber-900/20">
118+
<AlertTriangle className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
119+
<p className="text-sm text-amber-800 dark:text-amber-200">
120+
Store this key in your client now. McpMux keeps only a hash and{' '}
121+
<strong>cannot show it again</strong>. If you lose it, revoke the key and create a
122+
new one.
123+
</p>
124+
</div>
125+
126+
<div className="rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] p-3.5">
127+
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-[rgb(var(--muted))]">
128+
How the client authenticates
129+
</p>
130+
<code className="block break-all font-mono text-xs text-[rgb(var(--text))]">
131+
Authorization: Bearer {result.keyPrefix}
132+
</code>
133+
</div>
134+
135+
<div className="flex justify-end">
136+
<Button variant="primary" size="md" onClick={handleDone}>
137+
Done
138+
</Button>
139+
</div>
140+
</>
141+
) : (
142+
<>
143+
<div>
144+
<label htmlFor="api-key-client-name" className="mb-1.5 block text-sm font-medium">
145+
Client name
146+
</label>
147+
<input
148+
id="api-key-client-name"
149+
data-testid="register-api-key-name"
150+
type="text"
151+
autoFocus
152+
value={name}
153+
onChange={(e) => setName(e.target.value)}
154+
onKeyDown={(e) => {
155+
if (e.key === 'Enter' && !isSubmitting) void handleGenerate();
156+
}}
157+
placeholder="e.g. CI runner, my-laptop, prod-bot"
158+
className="w-full rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-3.5 py-2.5 text-sm transition-all focus:border-[rgb(var(--accent))] focus:outline-none focus:ring-2 focus:ring-[rgb(var(--accent))]/40"
159+
/>
160+
</div>
161+
162+
<div className="flex items-start gap-3 rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] p-3.5">
163+
<ShieldCheck className="mt-0.5 h-5 w-5 flex-shrink-0 text-[rgb(var(--accent))]" />
164+
<p className="text-xs text-[rgb(var(--muted))]">
165+
The key is generated on this machine, shown once, and stored only as a SHA-256
166+
hash. The client then sends it as a Bearer token — no approval prompt needed.
167+
</p>
168+
</div>
169+
170+
{error && (
171+
<p
172+
className="text-sm text-red-600 dark:text-red-400"
173+
data-testid="register-api-key-error"
174+
>
175+
{error}
176+
</p>
177+
)}
178+
179+
<div className="flex justify-end gap-2">
180+
<Button variant="ghost" size="md" onClick={onClose} disabled={isSubmitting}>
181+
Cancel
182+
</Button>
183+
<Button
184+
variant="primary"
185+
size="md"
186+
onClick={handleGenerate}
187+
disabled={isSubmitting}
188+
data-testid="register-api-key-generate"
189+
>
190+
{isSubmitting ? (
191+
<>
192+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
193+
Generating…
194+
</>
195+
) : (
196+
<>
197+
<KeyRound className="mr-2 h-4 w-4" />
198+
Generate key
199+
</>
200+
)}
201+
</Button>
202+
</div>
203+
</>
204+
)}
205+
</CardContent>
206+
</Card>
207+
</div>
208+
);
209+
}

0 commit comments

Comments
 (0)