diff --git a/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx b/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx index 4b56de6..8ad90fc 100644 --- a/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx +++ b/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { Check, Copy, Loader2, KeyRound, AlertTriangle } from 'lucide-react'; +import { Check, Copy, Loader2, KeyRound, AlertTriangle, Terminal } from 'lucide-react'; import { toast } from 'sonner'; import { ApiError } from '@/api/client'; import { Alert, AlertDescription, AlertTitle } from '@/ui/alert'; @@ -41,6 +41,21 @@ function legacyCopy(text: string): boolean { return ok; } +// Derive a cix CLI server alias from the browser host. The CLI stores each +// server as one entry under `server.` and parses that config key by +// splitting on dots, so the alias must be dot-free (and whitespace-free, and +// non-empty) — see validateServerName / parseServerKey in the CLI. We fold the +// host (including any non-default port, so distinct ports get distinct aliases) +// to [a-z0-9-]: "cix.example.com" -> "cix-example-com", +// "localhost:21847" -> "localhost-21847". +function cixServerAlias(host: string): string { + const alias = host + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return alias || 'default'; +} + // Two-stage dialog: collect a name, then reveal the freshly minted key once. // Once revealed, the dialog refuses outside-click and Escape — accidental // dismissal would lose the unrecoverable secret. Only the explicit "I've @@ -49,7 +64,8 @@ export function CreateApiKeyDialog() { const [open, setOpen] = useState(false); const [name, setName] = useState(''); const [revealed, setRevealed] = useState(null); - const [copied, setCopied] = useState(false); + const [copiedKey, setCopiedKey] = useState(false); + const [copiedCmd, setCopiedCmd] = useState(false); const inputRef = useRef(null); const create = useCreateApiKey(); @@ -65,7 +81,8 @@ export function CreateApiKeyDialog() { function reset() { setName(''); setRevealed(null); - setCopied(false); + setCopiedKey(false); + setCopiedCmd(false); create.reset(); } @@ -81,23 +98,50 @@ export function CreateApiKeyDialog() { } } - async function copyToClipboard() { - if (!revealed) return; - // navigator.clipboard requires a secure context (HTTPS or localhost). On - // bare-IP / HTTP deploys it throws — fall back to document.execCommand - // through a transient textarea so users still get one-click copy. + // copyText copies one string to the clipboard, surfacing a toast on failure. + // navigator.clipboard requires a secure context (HTTPS or localhost). On + // bare-IP / HTTP deploys it throws — fall back to document.execCommand + // through a transient textarea so users still get one-click copy. + async function copyText(text: string): Promise { try { if (window.isSecureContext && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(revealed); + await navigator.clipboard.writeText(text); } else { - if (!legacyCopy(revealed)) throw new Error('legacy copy failed'); + if (!legacyCopy(text)) throw new Error('legacy copy failed'); } - setCopied(true); - window.setTimeout(() => setCopied(false), 2000); + return true; } catch { toast.error('Could not copy automatically.', { - description: 'Click the field, ⌘A / Ctrl-A to select, then copy.', + description: 'Select the text, ⌘A / Ctrl-A, then copy.', }); + return false; + } + } + + // One-paste command that registers THIS server (URL + the freshly minted key, + // as a single `server.` entry) in the cix CLI config and makes it the + // default. The dashboard is served same-origin from cix-server, so + // window.location.origin is exactly the base URL the CLI must talk to. Values + // are shell-safe (a URL, a `cix_` key, an [a-z0-9-] alias), so no quoting + // is needed — matching the CLI README's unquoted examples. + const alias = cixServerAlias(window.location.host); + const connectCmd = + `cix config set server.${alias}.url ${window.location.origin} && ` + + `cix config set server.${alias}.key ${revealed ?? ''} && ` + + `cix config set default_server ${alias}`; + + async function copyKey() { + if (!revealed) return; + if (await copyText(revealed)) { + setCopiedKey(true); + window.setTimeout(() => setCopiedKey(false), 2000); + } + } + + async function copyConnectCmd() { + if (await copyText(connectCmd)) { + setCopiedCmd(true); + window.setTimeout(() => setCopiedCmd(false), 2000); } } @@ -105,11 +149,14 @@ export function CreateApiKeyDialog() { { - // Once a key is revealed, only the explicit "I've saved it" button - // (or close-X) may dismiss the dialog. Outside-click and Escape are - // blocked at the DialogContent layer below; we still gate state-resets - // here so a sibling dismiss path can't wipe the key silently. - if (!next && revealed) return; + // Accidental dismissal of the reveal screen (outside-click, Escape) is + // blocked at the DialogContent layer below via preventDefault, so it + // never reaches here. The only close paths that DO reach onOpenChange + // while a key is revealed are explicit user intent — the top-right X + // button (the "I've saved it" button sets state directly) — so honor + // every close and always reset. (An earlier guard returned here when a + // key was revealed, which also swallowed the X click: the dialog could + // then only be dismissed by reloading the page.) setOpen(next); if (!next) reset(); }} @@ -165,8 +212,8 @@ export function CreateApiKeyDialog() { onFocus={(e) => e.currentTarget.select()} onClick={(e) => e.currentTarget.select()} /> - + +

+ Paste this in a terminal with the cix CLI installed. It saves the + server to ~/.cix/config.yaml as the default, then{' '} + cix search "…" works. +

+