TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { Eye, EyeOff, ExternalLink, ShieldCheck, ClipboardPaste } from "lucide-react";4import { ResponsiveDialog } from "@/components/ui/sheet";5import { Button } from "@/components/ui/button";6import { Input, Field } from "@/components/ui/input";7import { toast } from "@/components/ui/toast";8import { ProviderIcon } from "@/components/brand/provider-icon";9import { PROVIDERS } from "@/lib/client/providers";10import { api } from "@/lib/client/api";11import { useApp } from "@/components/app/store";12import { errorMessage } from "@/lib/client/humanize";13import { formatMs } from "@/lib/utils";14import type { ProviderId, PublicConnection } from "@/lib/client/types";1516interface PutResponse {17 ok: boolean;18 error?: string;19 modelsAvailable?: number;20 latencyMs?: number;21 connections: PublicConnection[];22}2324export interface AddKeyDialogProps {25 provider: ProviderId | null;26 open: boolean;27 onOpenChange: (open: boolean) => void;28 /** True when a key already exists (copy switches to "Replace"). */29 replacing?: boolean;30 onSaved?: (result: PutResponse) => void;31}3233/**34 * Add / replace an API key for one provider. Bottom sheet on phones, dialog on desktop.35 * The key is sent once over TLS, validated by the server against the provider, then encrypted at36 * rest (AES-256-GCM). It is never echoed back — only a hint such as `sk-••••9A2K`.37 */38export function AddKeyDialog({ provider, open, onOpenChange, replacing, onSaved }: AddKeyDialogProps) {39 const { refreshConnections } = useApp();40 const [key, setKey] = React.useState("");41 const [show, setShow] = React.useState(false);42 const [busy, setBusy] = React.useState(false);43 const [error, setError] = React.useState<string | null>(null);44 const [canPaste, setCanPaste] = React.useState(false);45 const inputRef = React.useRef<HTMLInputElement>(null);46 const meta = provider ? PROVIDERS[provider] : null;4748 React.useEffect(() => {49 // Clipboard read needs a user gesture + permission; only offer the button where the API exists.50 // eslint-disable-next-line react-hooks/set-state-in-effect51 setCanPaste(typeof navigator !== "undefined" && Boolean(navigator.clipboard?.readText));52 }, []);5354 const reset = () => {55 setKey("");56 setShow(false);57 setError(null);58 };5960 const paste = async () => {61 try {62 const text = (await navigator.clipboard.readText()).trim();63 if (!text) {64 toast.info("Clipboard is empty");65 return;66 }67 setKey(text);68 setError(null);69 inputRef.current?.focus();70 } catch {71 toast.error("Could not read the clipboard", "Paste into the field instead.");72 }73 };7475 const submit = async (e?: React.FormEvent) => {76 e?.preventDefault();77 const trimmed = key.trim();78 if (!provider || trimmed.length < 8) {79 setError("Paste a complete API key.");80 return;81 }82 if (/\s/.test(trimmed)) {83 setError("Keys never contain spaces or line breaks — check the paste.");84 return;85 }86 setBusy(true);87 setError(null);88 try {89 const res = await api<PutResponse>("/api/providers", { method: "PUT", json: { provider, apiKey: trimmed } });90 if (!res.ok) {91 setError(res.error ?? "The provider rejected this key.");92 return;93 }94 await refreshConnections();95 toast.success(`${meta?.name ?? "Provider"} connected`, [res.modelsAvailable ? `${res.modelsAvailable} models available` : null, res.latencyMs ? formatMs(res.latencyMs) : null].filter(Boolean).join(" · ") || undefined);96 onSaved?.(res);97 reset();98 onOpenChange(false);99 } catch (err) {100 setError(errorMessage(err, "Could not save the key."));101 } finally {102 setBusy(false);103 }104 };105106 const valid = key.trim().length >= 8;107108 return (109 <ResponsiveDialog110 open={open}111 onOpenChange={(v) => {112 if (!v) reset();113 onOpenChange(v);114 }}115 size="sm"116 title={117 <span className="flex items-center gap-2.5">118 <span className="flex size-9 items-center justify-center rounded-lg border border-border bg-bg-subtle">119 <ProviderIcon provider={provider} size={18} />120 </span>121 <span>122 {replacing ? "Replace" : "Connect"} {meta?.name ?? "provider"}123 </span>124 </span>125 }126 description={meta?.description}127 footer={128 <div className="flex gap-2">129 <Button type="button" variant="ghost" className="flex-1 md:flex-none" onClick={() => onOpenChange(false)} disabled={busy}>130 Cancel131 </Button>132 <Button type="submit" form="add-key-form" variant="accent" className="flex-1 md:flex-none" loading={busy} disabled={!valid}>133 {busy ? "Validating…" : replacing ? "Replace key" : "Connect"}134 </Button>135 </div>136 }137 >138 <form id="add-key-form" onSubmit={submit} className="space-y-4 pt-1">139 <Field label="API key" hint={meta?.keyHelp} error={error} htmlFor="provider-api-key">140 <div className="flex gap-2">141 <div className="relative min-w-0 flex-1">142 <Input143 ref={inputRef}144 id="provider-api-key"145 type={show ? "text" : "password"}146 value={key}147 onChange={(e) => {148 setKey(e.target.value);149 if (error) setError(null);150 }}151 placeholder={meta?.keyPrefixHint || "Paste your key"}152 autoComplete="off"153 autoCorrect="off"154 autoCapitalize="off"155 spellCheck={false}156 enterKeyHint="go"157 className="h-11 pr-11 font-mono text-[16px] md:h-9 md:text-sm"158 aria-invalid={Boolean(error)}159 />160 <button type="button" onClick={() => setShow((s) => !s)} className="tap absolute right-1.5 top-1/2 -translate-y-1/2 rounded-sm p-1.5 text-fg-subtle hover:bg-bg-muted hover:text-fg" aria-label={show ? "Hide key" : "Show key"} aria-pressed={show}>161 {show ? <EyeOff className="size-4" /> : <Eye className="size-4" />}162 </button>163 </div>164 {canPaste ? (165 <Button type="button" variant="outline" className="h-11 shrink-0 px-3 md:h-9" onClick={paste} aria-label="Paste from clipboard">166 <ClipboardPaste />167 <span className="hidden sm:inline">Paste</span>168 </Button>169 ) : null}170 </div>171 </Field>172 <div className="flex items-start gap-2 rounded-lg bg-bg-subtle px-3 py-2.5 text-xs leading-5 text-fg-muted">173 <ShieldCheck className="mt-0.5 size-3.5 shrink-0 text-success" />174 <p>175 We only ever ask for an <span className="font-medium text-fg">API key</span>, never your provider password. It is validated once, encrypted at rest, and only a hint like <span className="font-mono">sk-••••9A2K</span> is shown afterwards.176 </p>177 </div>178 {meta ? (179 <a href={meta.keyDocsUrl} target="_blank" rel="noreferrer noopener" className="tap inline-flex items-center gap-1 text-xs font-medium text-accent hover:underline">180 How to create a {meta.shortName} key181 <ExternalLink className="size-3" />182 </a>183 ) : null}184 </form>185 </ResponsiveDialog>186 );187}188189/** Small hook to drive the dialog from lists: `const kd = useAddKeyDialog(); kd.open("openai", true); <AddKeyDialog {...kd.props} />`. */190export function useAddKeyDialog(onSaved?: (r: PutResponse) => void) {191 const [state, setState] = React.useState<{ provider: ProviderId | null; replacing: boolean; open: boolean }>({ provider: null, replacing: false, open: false });192 return {193 open: (provider: ProviderId, replacing = false) => setState({ provider, replacing, open: true }),194 props: {195 provider: state.provider,196 replacing: state.replacing,197 open: state.open,198 onOpenChange: (open: boolean) => setState((s) => ({ ...s, open })),199 onSaved,200 } satisfies AddKeyDialogProps,201 };202}203