/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Trust-focused BYOK API key entry modal. * * Replaces the inline password-style strip in ChatPanel. Renders one tab per * supported provider; each tab pairs the request-flow SVG with concrete, * DevTools-verifiable trust claims and an "Open Console → Create Key → * paste here" walkthrough. * * Clipboard handling: we deliberately do NOT do background `clipboard.readText()` * polling. Modern browsers gate that behind either transient user activation * or an explicit clipboard-read permission we can't request a prompt for — * and on macOS Chromium, every silent read triggers the native Paste affordance * even though we silently swallow the result. Instead, the input is autofocused * on open so the user's Cmd+V lands directly in the field, and a green inline * confirmation appears the moment the pasted value matches the provider shape. * * The web build ships this. Desktop also uses it (the /settings page is * desktop-only and not deployed on Vercel). */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Check, ChevronDown, ChevronUp, ExternalLink, Eye, EyeOff, Key, Trash2 } from 'lucide-react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { toast } from '@/components/ui/toast'; import { ByokTrustDiagram } from './ByokTrustDiagram'; import { getByokModelsForSource } from '@/lib/llm/models'; import { getApiKeys, updateApiKeys, subscribeApiKeys, type ApiKeyConfig, } from '@/services/api-keys'; import { looksLikeProviderKey, maskKey, type BYOKProvider, } from '@/lib/llm/clipboard-detect'; const REPO_BLOB = 'https://github.com/LTplus-AG/ifc-lite/blob/main'; const PROVIDER_META: Record = { anthropic: { label: 'Anthropic', apiHost: 'api.anthropic.com', keyPrefix: 'sk-ant-api03-', placeholder: 'sk-ant-api03-...', consoleUrl: 'https://console.anthropic.com/settings/keys', consoleLabel: 'console.anthropic.com', pricingHint: 'Pay-as-you-go on Anthropic billing. New accounts get $5 free credit.', }, openai: { label: 'OpenAI', apiHost: 'api.openai.com', keyPrefix: 'sk-', placeholder: 'sk-...', consoleUrl: 'https://platform.openai.com/api-keys', consoleLabel: 'platform.openai.com', pricingHint: 'OpenAI requires prepaid credits or a payment method on your OpenAI account.', }, }; interface ByokKeyModalProps { open: boolean; onOpenChange: (open: boolean) => void; initialProvider?: BYOKProvider; } export function ByokKeyModal({ open, onOpenChange, initialProvider = 'anthropic' }: ByokKeyModalProps) { const [provider, setProvider] = useState(initialProvider); const [apiKeys, setApiKeys] = useState(() => getApiKeys()); // Re-sync the controlled tab whenever the modal re-opens with a (possibly new) initial provider. useEffect(() => { if (open) setProvider(initialProvider); }, [open, initialProvider]); // Keep saved-state badges in sync across open/save/clear. useEffect(() => { setApiKeys(getApiKeys()); return subscribeApiKeys(() => setApiKeys(getApiKeys())); }, []); return ( Use your own API key Unlocks frontier models. Your key stays in this browser and goes straight to the provider — never through our servers. setProvider(v as BYOKProvider)}> Anthropic {apiKeys.anthropicKey && } OpenAI {apiKeys.openaiKey && } ); } // ── Per-provider tab body ────────────────────────────────────────────────── function ProviderTab({ provider, savedKey }: { provider: BYOKProvider; savedKey: string }) { const meta = PROVIDER_META[provider]; const [value, setValue] = useState(''); const [show, setShow] = useState(false); const [walkthroughOpen, setWalkthroughOpen] = useState(false); const inputRef = useRef(null); const unlockedModels = useMemo(() => getByokModelsForSource(provider), [provider]); // Autofocus the input so the user's Cmd+V lands directly in the field // without an extra click. Re-runs on tab switch. useEffect(() => { inputRef.current?.focus(); }, [provider]); const handleSave = useCallback((next: string) => { const trimmed = next.trim(); if (!trimmed) return; const field = provider === 'anthropic' ? 'anthropicKey' : 'openaiKey'; updateApiKeys({ [field]: trimmed }); setValue(''); toast.success(`${PROVIDER_META[provider].label} key saved`); }, [provider]); const handleClear = useCallback(() => { const field = provider === 'anthropic' ? 'anthropicKey' : 'openaiKey'; updateApiKeys({ [field]: '' }); toast.success(`${PROVIDER_META[provider].label} key removed`); }, [provider]); const handleOpenConsole = useCallback(() => { window.open(meta.consoleUrl, '_blank', 'noopener,noreferrer'); }, [meta.consoleUrl]); const trimmedValue = value.trim(); const inputIsValid = trimmedValue.length === 0 || looksLikeProviderKey(provider, value); const inputLooksGood = trimmedValue.length > 0 && looksLikeProviderKey(provider, value) && trimmedValue !== savedKey; return (
{/* Models unlocked */}
Unlocks: {unlockedModels.map((m) => ( {m.name} ))}
{/* The diagram — single most important trust element */}
{/* DevTools-verifiable trust claims */}
    Key stored only in this browser's localStorage.{' '} Inspect any time in DevTools. Every request goes to {meta.apiHost}. Verify in DevTools → Network → filter {meta.apiHost.split('.').slice(-2).join('.')}. The whole BYOK code path is ~60 lines.{' '} Read it on GitHub
{/* Paste-driven key entry. The input is autofocused on mount so Cmd+V lands here immediately after the user returns from the provider console — no extra click required. */}
setValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && inputIsValid) handleSave(value); }} placeholder={meta.placeholder} autoComplete="off" spellCheck={false} className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring pr-8" />
{inputLooksGood && (

Looks like a {meta.label} key ({maskKey(trimmedValue)}) — press Enter or Save.

)} {!inputIsValid && (

That doesn't look like a {meta.label} key (expected prefix{' '} {meta.keyPrefix}).

)}
{/* Currently configured key + remove */} {savedKey && (
Configured: {maskKey(savedKey)}
)} {/* Walkthrough */}
{walkthroughOpen && (
  1. Open the {meta.label} console — opens in a new tab.
  2. Click Create Key, name it ifc-lite.
  3. Set a spending limit (e.g. $10/month) so a leaked key can't burn you. The provider enforces it.
  4. Copy the key, come back here, paste it into the input above (the field is already focused — just press ⌘V).

{meta.pricingHint}

)}
); } function TrustBullet({ children }: { children: React.ReactNode }) { return (
  • {children}
  • ); }