profileShare

rasmusjy / roundtable

Read-only snapshot

No repository description.

main default branch 181 files Expires Sep 13, 2026, 9:06 AM
api-key-dialog.tsx 6,934 bytes
1 'use client';
2
3 import { KeyRound, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
4 import { useCallback, useEffect, useState } from 'react';
5 import { Badge } from '@/components/ui/badge';
6 import { Button } from '@/components/ui/button';
7 import {
8 Dialog,
9 DialogContent,
10 DialogDescription,
11 DialogHeader,
12 DialogTitle,
13 } from '@/components/ui/dialog';
14 import { Input } from '@/components/ui/input';
15 import { Label } from '@/components/ui/label';
16 import { toast } from '@/components/ui/sonner';
17 import { formatUsd } from '@/lib/utils';
18
19 interface KeyStatus {
20 mockMode: boolean;
21 hasKey: boolean;
22 source: 'mock' | 'saved' | 'session' | null;
23 saved: { keyMask: string; label: string | null } | null;
24 authenticated: boolean;
25 }
26
27 export function ApiKeyDialog({
28 open,
29 onOpenChange,
30 onChanged,
31 }: {
32 open: boolean;
33 onOpenChange: (v: boolean) => void;
34 onChanged?: () => void;
35 }) {
36 const [status, setStatus] = useState<KeyStatus | null>(null);
37 const [apiKey, setApiKey] = useState('');
38 const [mode, setMode] = useState<'session' | 'save'>('session');
39 const [busy, setBusy] = useState(false);
40
41 const refresh = useCallback(async () => {
42 try {
43 const res = await fetch('/api/keys');
44 if (!res.ok) return false;
45 const s = (await res.json()) as KeyStatus;
46 setStatus(s);
47 setMode(s.authenticated ? 'save' : 'session');
48 return true;
49 } catch {
50 return false;
51 }
52 }, []);
53
54 useEffect(() => {
55 if (open) void refresh();
56 }, [open, refresh]);
57
58 const submit = async () => {
59 if (!apiKey.trim()) return;
60 setBusy(true);
61 try {
62 const res = await fetch('/api/keys', {
63 method: 'POST',
64 headers: { 'Content-Type': 'application/json' },
65 body: JSON.stringify({ apiKey: apiKey.trim(), mode }),
66 });
67 const data = (await res.json()) as {
68 error?: string;
69 credits?: { remaining: number | null; limit: number | null };
70 };
71 if (!res.ok) {
72 toast.error(data.error ?? 'Key rejected');
73 return;
74 }
75 const remaining = data.credits?.remaining;
76 toast.success(
77 remaining != null
78 ? `Key verified - ${formatUsd(remaining)} credits remaining`
79 : 'Key verified and stored',
80 );
81 setApiKey('');
82 await refresh();
83 onChanged?.();
84 } catch {
85 toast.error('Could not connect models');
86 } finally {
87 setBusy(false);
88 }
89 };
90
91 const remove = async () => {
92 setBusy(true);
93 try {
94 const res = await fetch('/api/keys', { method: 'DELETE' });
95 if (!res.ok) throw new Error('Key removal failed');
96 toast.success('Key removed');
97 await refresh();
98 onChanged?.();
99 } catch {
100 toast.error('Could not remove key');
101 } finally {
102 setBusy(false);
103 }
104 };
105
106 return (
107 <Dialog open={open} onOpenChange={onOpenChange}>
108 <DialogContent>
109 <DialogHeader>
110 <DialogTitle className="flex items-center gap-2">
111 <KeyRound className="h-5 w-5 text-primary" /> Connect AI models
112 </DialogTitle>
113 <DialogDescription>
114 Paste an OpenRouter key so Roundtable can ask several AI models for you. Model usage is
115 billed to your OpenRouter account.
116 </DialogDescription>
117 </DialogHeader>
118
119 {status?.mockMode ? (
120 <div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
121 <Badge variant="warning" className="mb-2">
122 Demo mode
123 </Badge>
124 <p>
125 This deployment uses offline demo models. You can ask a question without connecting an
126 account.
127 </p>
128 </div>
129 ) : (
130 <div className="space-y-4">
131 {status?.hasKey && status.source !== 'mock' && (
132 <div className="flex items-center justify-between rounded-lg border bg-secondary/40 px-3 py-2 text-sm">
133 <span className="flex items-center gap-2">
134 <ShieldCheck className="h-4 w-4 text-emerald-500" />
135 {status.source === 'saved' && status.saved ? (
136 <>
137 Saved key <code className="font-mono text-xs">{status.saved.keyMask}</code>
138 </>
139 ) : (
140 'Session key connected'
141 )}
142 </span>
143 <Button size="sm" variant="ghost" onClick={remove} disabled={busy}>
144 <Trash2 className="h-3.5 w-3.5" /> Remove
145 </Button>
146 </div>
147 )}
148
149 <div className="space-y-2">
150 <Label htmlFor="rt-key">OpenRouter key</Label>
151 <Input
152 id="rt-key"
153 type="password"
154 placeholder="sk-or-v1-..."
155 value={apiKey}
156 onChange={(e) => setApiKey(e.target.value)}
157 autoComplete="off"
158 />
159 <p className="text-xs text-muted-foreground">
160 Roundtable checks the key with OpenRouter before using it.
161 </p>
162 </div>
163
164 <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
165 <StorageOption
166 active={mode === 'session'}
167 title="This browser session"
168 desc="Encrypted for 12 hours and not saved to your account."
169 onClick={() => setMode('session')}
170 />
171 <StorageOption
172 active={mode === 'save'}
173 disabled={!status?.authenticated}
174 title="Save to my account"
175 desc={
176 status?.authenticated
177 ? 'Encrypted and available next time.'
178 : 'Sign in to save a key.'
179 }
180 onClick={() => status?.authenticated && setMode('save')}
181 />
182 </div>
183
184 <Button onClick={submit} disabled={busy || !apiKey.trim()} className="w-full">
185 {busy ? (
186 <Loader2 className="h-4 w-4 animate-spin" />
187 ) : (
188 <ShieldCheck className="h-4 w-4" />
189 )}
190 Connect models
191 </Button>
192 </div>
193 )}
194 </DialogContent>
195 </Dialog>
196 );
197 }
198
199 function StorageOption({
200 active,
201 disabled,
202 title,
203 desc,
204 onClick,
205 }: {
206 active: boolean;
207 disabled?: boolean;
208 title: string;
209 desc: string;
210 onClick: () => void;
211 }) {
212 return (
213 <button
214 type="button"
215 onClick={onClick}
216 disabled={disabled}
217 className={`rounded-lg border p-3 text-left text-sm transition-colors disabled:opacity-50 ${
218 active ? 'border-primary bg-accent/50' : 'hover:border-primary/40'
219 }`}
220 >
221 <div className="font-medium">{title}</div>
222 <div className="mt-0.5 text-xs text-muted-foreground">{desc}</div>
223 </button>
224 );
225 }
226