|
| 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