"use client"; import * as React from "react"; import { Eye, EyeOff, ExternalLink, ShieldCheck, ClipboardPaste } from "lucide-react"; import { ResponsiveDialog } from "@/components/ui/sheet"; import { Button } from "@/components/ui/button"; import { Input, Field } from "@/components/ui/input"; import { toast } from "@/components/ui/toast"; import { ProviderIcon } from "@/components/brand/provider-icon"; import { PROVIDERS } from "@/lib/client/providers"; import { api } from "@/lib/client/api"; import { useApp } from "@/components/app/store"; import { errorMessage } from "@/lib/client/humanize"; import { formatMs } from "@/lib/utils"; import type { ProviderId, PublicConnection } from "@/lib/client/types"; interface PutResponse { ok: boolean; error?: string; modelsAvailable?: number; latencyMs?: number; connections: PublicConnection[]; } export interface AddKeyDialogProps { provider: ProviderId | null; open: boolean; onOpenChange: (open: boolean) => void; /** True when a key already exists (copy switches to "Replace"). */ replacing?: boolean; onSaved?: (result: PutResponse) => void; } /** * Add / replace an API key for one provider. Bottom sheet on phones, dialog on desktop. * The key is sent once over TLS, validated by the server against the provider, then encrypted at * rest (AES-256-GCM). It is never echoed back — only a hint such as `sk-••••9A2K`. */ export function AddKeyDialog({ provider, open, onOpenChange, replacing, onSaved }: AddKeyDialogProps) { const { refreshConnections } = useApp(); const [key, setKey] = React.useState(""); const [show, setShow] = React.useState(false); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(null); const [canPaste, setCanPaste] = React.useState(false); const inputRef = React.useRef(null); const meta = provider ? PROVIDERS[provider] : null; React.useEffect(() => { // Clipboard read needs a user gesture + permission; only offer the button where the API exists. // eslint-disable-next-line react-hooks/set-state-in-effect setCanPaste(typeof navigator !== "undefined" && Boolean(navigator.clipboard?.readText)); }, []); const reset = () => { setKey(""); setShow(false); setError(null); }; const paste = async () => { try { const text = (await navigator.clipboard.readText()).trim(); if (!text) { toast.info("Clipboard is empty"); return; } setKey(text); setError(null); inputRef.current?.focus(); } catch { toast.error("Could not read the clipboard", "Paste into the field instead."); } }; const submit = async (e?: React.FormEvent) => { e?.preventDefault(); const trimmed = key.trim(); if (!provider || trimmed.length < 8) { setError("Paste a complete API key."); return; } if (/\s/.test(trimmed)) { setError("Keys never contain spaces or line breaks — check the paste."); return; } setBusy(true); setError(null); try { const res = await api("/api/providers", { method: "PUT", json: { provider, apiKey: trimmed } }); if (!res.ok) { setError(res.error ?? "The provider rejected this key."); return; } await refreshConnections(); toast.success(`${meta?.name ?? "Provider"} connected`, [res.modelsAvailable ? `${res.modelsAvailable} models available` : null, res.latencyMs ? formatMs(res.latencyMs) : null].filter(Boolean).join(" · ") || undefined); onSaved?.(res); reset(); onOpenChange(false); } catch (err) { setError(errorMessage(err, "Could not save the key.")); } finally { setBusy(false); } }; const valid = key.trim().length >= 8; return ( { if (!v) reset(); onOpenChange(v); }} size="sm" title={ {replacing ? "Replace" : "Connect"} {meta?.name ?? "provider"} } description={meta?.description} footer={
} >
{ setKey(e.target.value); if (error) setError(null); }} placeholder={meta?.keyPrefixHint || "Paste your key"} autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false} enterKeyHint="go" className="h-11 pr-11 font-mono text-[16px] md:h-9 md:text-sm" aria-invalid={Boolean(error)} />
{canPaste ? ( ) : null}

We only ever ask for an API key, never your provider password. It is validated once, encrypted at rest, and only a hint like sk-••••9A2K is shown afterwards.

{meta ? ( How to create a {meta.shortName} key ) : null}
); } /** Small hook to drive the dialog from lists: `const kd = useAddKeyDialog(); kd.open("openai", true); `. */ export function useAddKeyDialog(onSaved?: (r: PutResponse) => void) { const [state, setState] = React.useState<{ provider: ProviderId | null; replacing: boolean; open: boolean }>({ provider: null, replacing: false, open: false }); return { open: (provider: ProviderId, replacing = false) => setState({ provider, replacing, open: true }), props: { provider: state.provider, replacing: state.replacing, open: state.open, onOpenChange: (open: boolean) => setState((s) => ({ ...s, open })), onSaved, } satisfies AddKeyDialogProps, }; }