SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
8.0 KB · 203 lines tsx
Raw Blame History
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