Skip to content

Commit 289e0f1

Browse files
committed
feat: API Keys dashboard page -- generate, list, revoke (OPE-167)
New page at /dashboard/api-keys for managing MCP API keys: - Generate modal: name input, creates key, shows raw key ONCE with copy-to-clipboard and 'will not be shown again' warning - Key list: name, preview (ci_...suffix), tier badge, active/revoked status, last used timestamp, revoke button - Empty state with helpful context about what keys are for - Quick setup hint pointing to Claude Desktop config path - Sidebar: added 'API Keys' nav item with KeyRound icon - Command palette: added API Keys navigation entry - Max 5 active keys enforced (button disabled at limit) Uses existing patterns: useAuth(), API_URL, Bearer token, shadcn/ui Card/Dialog/Badge/Button, toast from sonner. Backend endpoints from PR #288: - POST /api/v1/keys/generate - GET /api/v1/keys - DELETE /api/v1/keys/{key_id} TypeScript clean, build passes.
1 parent 67115a9 commit 289e0f1

4 files changed

Lines changed: 355 additions & 0 deletions

File tree

frontend/src/components/Dashboard.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { DashboardLayout } from './dashboard/DashboardLayout'
33
import { DashboardHome } from './dashboard/DashboardHome'
44
import { SettingsPage } from '../pages/SettingsPage'
55
import { UsagePage } from '../pages/UsagePage'
6+
import { APIKeysPage } from '../pages/APIKeysPage'
67

78
export function Dashboard() {
89
return (
910
<DashboardLayout>
1011
<Routes>
1112
<Route index element={<DashboardHome />} />
1213
<Route path="usage" element={<UsagePage />} />
14+
<Route path="api-keys" element={<APIKeysPage />} />
1315
<Route path="settings" element={<SettingsPage />} />
1416
<Route path="*" element={<Navigate to="/dashboard" replace />} />
1517
</Routes>

frontend/src/components/dashboard/CommandPalette.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) {
102102
items.push({ id: 'action-add-repo', type: 'action', title: 'Add Repository', subtitle: 'Clone and index a new repository', icon: '➕', action: () => { window.dispatchEvent(new CustomEvent('openAddRepo')); navigate('/dashboard') } })
103103
items.push({ id: 'action-refresh', type: 'action', title: 'Refresh Repositories', subtitle: 'Reload the repository list', icon: '🔄', action: () => window.location.reload() })
104104
items.push({ id: 'nav-dashboard', type: 'navigation', title: 'Go to Dashboard', subtitle: 'View all repositories', icon: '🏠', action: () => navigate('/dashboard') })
105+
items.push({ id: 'nav-api-keys', type: 'navigation', title: 'API Keys', subtitle: 'Manage MCP and API access keys', icon: '🔑', action: () => navigate('/dashboard/api-keys') })
105106
items.push({ id: 'nav-settings', type: 'navigation', title: 'Settings', subtitle: 'Account and preferences', icon: '⚙️', action: () => navigate('/dashboard/settings') })
106107
items.push({ id: 'nav-docs', type: 'navigation', title: 'Documentation', subtitle: 'Learn how to use OpenCodeIntel', icon: '📚', action: () => window.open('/docs', '_blank') })
107108
items.push({ id: 'action-signout', type: 'action', title: 'Sign Out', subtitle: 'Log out of your account', icon: '🚪', action: () => signOut() })

frontend/src/components/dashboard/Sidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Link, useLocation } from 'react-router-dom'
22
import {
33
FolderGit2,
44
BarChart3,
5+
KeyRound,
56
BookOpen,
67
ChevronLeft,
78
ChevronRight,
@@ -26,6 +27,7 @@ interface NavItem {
2627
const mainNavItems: NavItem[] = [
2728
{ name: 'Repositories', href: '/dashboard', icon: <FolderGit2 className="w-5 h-5" /> },
2829
{ name: 'Usage', href: '/dashboard/usage', icon: <BarChart3 className="w-5 h-5" /> },
30+
{ name: 'API Keys', href: '/dashboard/api-keys', icon: <KeyRound className="w-5 h-5" /> },
2931
]
3032

3133
const bottomNavItems: NavItem[] = [

frontend/src/pages/APIKeysPage.tsx

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
import { useEffect, useState, useCallback } from 'react'
2+
import { KeyRound, Plus, Copy, Check, Loader2, Trash2, Clock } from 'lucide-react'
3+
import { useAuth } from '@/contexts/AuthContext'
4+
import { Button } from '@/components/ui/button'
5+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
6+
import { Badge } from '@/components/ui/badge'
7+
import { Input } from '@/components/ui/input'
8+
import { Label } from '@/components/ui/label'
9+
import {
10+
Dialog,
11+
DialogContent,
12+
DialogDescription,
13+
DialogFooter,
14+
DialogHeader,
15+
DialogTitle,
16+
} from '@/components/ui/dialog'
17+
import { toast } from 'sonner'
18+
import { API_URL } from '@/config/api'
19+
20+
interface APIKey {
21+
id: string
22+
name: string
23+
tier: string
24+
active: boolean
25+
created_at: string
26+
last_used_at: string | null
27+
key_preview: string
28+
}
29+
30+
function timeAgo(dateStr: string | null): string {
31+
if (!dateStr) return 'Never'
32+
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000)
33+
if (seconds < 60) return 'Just now'
34+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
35+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
36+
return `${Math.floor(seconds / 86400)}d ago`
37+
}
38+
39+
function TierBadge({ tier }: { tier: string }) {
40+
const variants: Record<string, string> = {
41+
enterprise: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
42+
pro: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
43+
free: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20',
44+
}
45+
return (
46+
<Badge variant="outline" className={variants[tier] || variants.free}>
47+
{tier}
48+
</Badge>
49+
)
50+
}
51+
52+
function StatusDot({ active }: { active: boolean }) {
53+
return (
54+
<span className="flex items-center gap-1.5">
55+
<span className={`w-2 h-2 rounded-full ${active ? 'bg-emerald-400' : 'bg-zinc-600'}`} />
56+
<span className={`text-xs ${active ? 'text-emerald-400' : 'text-zinc-500'}`}>
57+
{active ? 'Active' : 'Revoked'}
58+
</span>
59+
</span>
60+
)
61+
}
62+
63+
function CopyButton({ text }: { text: string }) {
64+
const [copied, setCopied] = useState(false)
65+
66+
const handleCopy = async () => {
67+
await navigator.clipboard.writeText(text)
68+
setCopied(true)
69+
setTimeout(() => setCopied(false), 2000)
70+
}
71+
72+
return (
73+
<Button variant="ghost" size="icon" onClick={handleCopy} className="h-8 w-8">
74+
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
75+
</Button>
76+
)
77+
}
78+
79+
export function APIKeysPage() {
80+
const { session } = useAuth()
81+
const [keys, setKeys] = useState<APIKey[]>([])
82+
const [loading, setLoading] = useState(true)
83+
const [generateOpen, setGenerateOpen] = useState(false)
84+
const [keyName, setKeyName] = useState('')
85+
const [generating, setGenerating] = useState(false)
86+
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
87+
const [revoking, setRevoking] = useState<string | null>(null)
88+
89+
const token = session?.access_token
90+
91+
const fetchKeys = useCallback(async () => {
92+
if (!token) return
93+
try {
94+
const res = await fetch(`${API_URL}/keys`, {
95+
headers: { Authorization: `Bearer ${token}` },
96+
})
97+
if (!res.ok) throw new Error('Failed to load keys')
98+
const data = await res.json()
99+
setKeys(data.keys || [])
100+
} catch {
101+
toast.error('Failed to load API keys')
102+
} finally {
103+
setLoading(false)
104+
}
105+
}, [token])
106+
107+
useEffect(() => {
108+
fetchKeys()
109+
}, [fetchKeys])
110+
111+
const handleGenerate = async () => {
112+
if (!token || !keyName.trim()) return
113+
setGenerating(true)
114+
try {
115+
const res = await fetch(`${API_URL}/keys/generate`, {
116+
method: 'POST',
117+
headers: {
118+
Authorization: `Bearer ${token}`,
119+
'Content-Type': 'application/json',
120+
},
121+
body: JSON.stringify({ name: keyName.trim() }),
122+
})
123+
if (!res.ok) {
124+
const err = await res.json().catch(() => ({}))
125+
throw new Error(err.detail || 'Failed to generate key')
126+
}
127+
const data = await res.json()
128+
setGeneratedKey(data.api_key)
129+
setKeyName('')
130+
fetchKeys()
131+
} catch (e) {
132+
toast.error(e instanceof Error ? e.message : 'Failed to generate key')
133+
} finally {
134+
setGenerating(false)
135+
}
136+
}
137+
138+
const handleRevoke = async (keyId: string) => {
139+
if (!token) return
140+
setRevoking(keyId)
141+
try {
142+
const res = await fetch(`${API_URL}/keys/${keyId}`, {
143+
method: 'DELETE',
144+
headers: { Authorization: `Bearer ${token}` },
145+
})
146+
if (!res.ok) throw new Error('Failed to revoke key')
147+
toast.success('API key revoked')
148+
fetchKeys()
149+
} catch {
150+
toast.error('Failed to revoke key')
151+
} finally {
152+
setRevoking(null)
153+
}
154+
}
155+
156+
const closeGenerateDialog = () => {
157+
setGenerateOpen(false)
158+
setGeneratedKey(null)
159+
setKeyName('')
160+
}
161+
162+
const activeKeys = keys.filter((k) => k.active)
163+
const revokedKeys = keys.filter((k) => !k.active)
164+
165+
if (loading) {
166+
return (
167+
<div className="flex items-center justify-center min-h-[300px] text-muted-foreground">
168+
<Loader2 className="w-5 h-5 animate-spin mr-2" />
169+
Loading API keys...
170+
</div>
171+
)
172+
}
173+
174+
return (
175+
<div className="space-y-6 max-w-4xl">
176+
{/* Header */}
177+
<div className="flex items-center justify-between">
178+
<div className="flex items-center gap-3">
179+
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center">
180+
<KeyRound className="w-5 h-5 text-primary" />
181+
</div>
182+
<div>
183+
<h1 className="text-2xl font-bold text-foreground">API Keys</h1>
184+
<p className="text-sm text-muted-foreground">
185+
Manage keys for MCP, Claude Desktop, and API access
186+
</p>
187+
</div>
188+
</div>
189+
<Button onClick={() => setGenerateOpen(true)} disabled={activeKeys.length >= 5}>
190+
<Plus className="w-4 h-4 mr-2" />
191+
Create Key
192+
</Button>
193+
</div>
194+
195+
{/* Key list */}
196+
{keys.length === 0 ? (
197+
<Card className="border-dashed">
198+
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
199+
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center mb-4">
200+
<KeyRound className="w-6 h-6 text-muted-foreground" />
201+
</div>
202+
<h3 className="font-semibold text-foreground mb-1">No API keys yet</h3>
203+
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
204+
Generate a key to connect Claude Desktop, Claude Code, Cursor, or any MCP client to
205+
your indexed repositories.
206+
</p>
207+
<Button onClick={() => setGenerateOpen(true)}>
208+
<Plus className="w-4 h-4 mr-2" />
209+
Create Your First Key
210+
</Button>
211+
</CardContent>
212+
</Card>
213+
) : (
214+
<Card>
215+
<CardHeader className="pb-3">
216+
<CardTitle className="text-base">
217+
{activeKeys.length} active key{activeKeys.length !== 1 ? 's' : ''}
218+
{activeKeys.length >= 5 && (
219+
<span className="text-xs font-normal text-muted-foreground ml-2">(limit reached)</span>
220+
)}
221+
</CardTitle>
222+
</CardHeader>
223+
<CardContent className="p-0">
224+
<div className="divide-y divide-border">
225+
{keys.map((key) => (
226+
<div
227+
key={key.id}
228+
className={`flex items-center justify-between px-6 py-4 ${
229+
!key.active ? 'opacity-50' : ''
230+
}`}
231+
>
232+
<div className="flex items-center gap-4 min-w-0">
233+
<div className="min-w-0">
234+
<div className="flex items-center gap-2 mb-0.5">
235+
<span className="font-medium text-foreground truncate">{key.name}</span>
236+
<TierBadge tier={key.tier} />
237+
<StatusDot active={key.active} />
238+
</div>
239+
<div className="flex items-center gap-3 text-xs text-muted-foreground">
240+
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">
241+
{key.key_preview}
242+
</code>
243+
<span className="flex items-center gap-1">
244+
<Clock className="w-3 h-3" />
245+
{key.last_used_at ? `Used ${timeAgo(key.last_used_at)}` : 'Never used'}
246+
</span>
247+
<span>Created {timeAgo(key.created_at)}</span>
248+
</div>
249+
</div>
250+
</div>
251+
{key.active && (
252+
<Button
253+
variant="ghost"
254+
size="sm"
255+
onClick={() => handleRevoke(key.id)}
256+
disabled={revoking === key.id}
257+
className="text-destructive hover:text-destructive hover:bg-destructive/10"
258+
>
259+
{revoking === key.id ? (
260+
<Loader2 className="w-4 h-4 animate-spin" />
261+
) : (
262+
<Trash2 className="w-4 h-4" />
263+
)}
264+
</Button>
265+
)}
266+
</div>
267+
))}
268+
</div>
269+
</CardContent>
270+
</Card>
271+
)}
272+
273+
{/* Quick setup hint */}
274+
{activeKeys.length > 0 && (
275+
<Card className="bg-muted/30 border-muted">
276+
<CardContent className="py-4">
277+
<p className="text-sm text-muted-foreground">
278+
<span className="font-medium text-foreground">Quick setup:</span>{' '}
279+
Copy your key and add it to your Claude Desktop config at{' '}
280+
<code className="text-xs bg-muted px-1 py-0.5 rounded">
281+
~/Library/Application Support/Claude/claude_desktop_config.json
282+
</code>
283+
</p>
284+
</CardContent>
285+
</Card>
286+
)}
287+
288+
{/* Generate dialog */}
289+
<Dialog open={generateOpen} onOpenChange={closeGenerateDialog}>
290+
<DialogContent className="sm:max-w-md">
291+
{generatedKey ? (
292+
<>
293+
<DialogHeader>
294+
<DialogTitle>Key Created</DialogTitle>
295+
<DialogDescription>
296+
Copy this key now. It will not be shown again.
297+
</DialogDescription>
298+
</DialogHeader>
299+
<div className="space-y-3">
300+
<div className="flex items-center gap-2 p-3 bg-muted rounded-lg border">
301+
<code className="flex-1 text-sm font-mono break-all text-foreground select-all">
302+
{generatedKey}
303+
</code>
304+
<CopyButton text={generatedKey} />
305+
</div>
306+
<p className="text-xs text-amber-400">
307+
Store this key securely. You will not be able to see it again.
308+
</p>
309+
</div>
310+
<DialogFooter>
311+
<Button onClick={closeGenerateDialog}>Done</Button>
312+
</DialogFooter>
313+
</>
314+
) : (
315+
<>
316+
<DialogHeader>
317+
<DialogTitle>Create API Key</DialogTitle>
318+
<DialogDescription>
319+
Name your key so you can identify it later.
320+
</DialogDescription>
321+
</DialogHeader>
322+
<div className="space-y-3">
323+
<div>
324+
<Label htmlFor="key-name">Key name</Label>
325+
<Input
326+
id="key-name"
327+
placeholder="e.g. Claude Desktop, CI/CD, Development"
328+
value={keyName}
329+
onChange={(e) => setKeyName(e.target.value)}
330+
onKeyDown={(e) => e.key === 'Enter' && handleGenerate()}
331+
autoFocus
332+
/>
333+
</div>
334+
</div>
335+
<DialogFooter>
336+
<Button variant="outline" onClick={closeGenerateDialog}>
337+
Cancel
338+
</Button>
339+
<Button onClick={handleGenerate} disabled={!keyName.trim() || generating}>
340+
{generating ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
341+
Generate
342+
</Button>
343+
</DialogFooter>
344+
</>
345+
)}
346+
</DialogContent>
347+
</Dialog>
348+
</div>
349+
)
350+
}

0 commit comments

Comments
 (0)