From 289e0f17706e88bc683cec0eea23d021431f6a2a Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 7 Mar 2026 21:43:01 -0500 Subject: [PATCH 1/6] 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. --- frontend/src/components/Dashboard.tsx | 2 + .../components/dashboard/CommandPalette.tsx | 1 + frontend/src/components/dashboard/Sidebar.tsx | 2 + frontend/src/pages/APIKeysPage.tsx | 350 ++++++++++++++++++ 4 files changed, 355 insertions(+) create mode 100644 frontend/src/pages/APIKeysPage.tsx diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 4a07ce6..2c49c25 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -3,6 +3,7 @@ import { DashboardLayout } from './dashboard/DashboardLayout' import { DashboardHome } from './dashboard/DashboardHome' import { SettingsPage } from '../pages/SettingsPage' import { UsagePage } from '../pages/UsagePage' +import { APIKeysPage } from '../pages/APIKeysPage' export function Dashboard() { return ( @@ -10,6 +11,7 @@ export function Dashboard() { } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/dashboard/CommandPalette.tsx b/frontend/src/components/dashboard/CommandPalette.tsx index 9758f3e..7152541 100644 --- a/frontend/src/components/dashboard/CommandPalette.tsx +++ b/frontend/src/components/dashboard/CommandPalette.tsx @@ -102,6 +102,7 @@ export function CommandPalette({ isOpen, onClose }: CommandPaletteProps) { 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') } }) items.push({ id: 'action-refresh', type: 'action', title: 'Refresh Repositories', subtitle: 'Reload the repository list', icon: '🔄', action: () => window.location.reload() }) items.push({ id: 'nav-dashboard', type: 'navigation', title: 'Go to Dashboard', subtitle: 'View all repositories', icon: '🏠', action: () => navigate('/dashboard') }) + items.push({ id: 'nav-api-keys', type: 'navigation', title: 'API Keys', subtitle: 'Manage MCP and API access keys', icon: '🔑', action: () => navigate('/dashboard/api-keys') }) items.push({ id: 'nav-settings', type: 'navigation', title: 'Settings', subtitle: 'Account and preferences', icon: '⚙️', action: () => navigate('/dashboard/settings') }) items.push({ id: 'nav-docs', type: 'navigation', title: 'Documentation', subtitle: 'Learn how to use OpenCodeIntel', icon: '📚', action: () => window.open('/docs', '_blank') }) items.push({ id: 'action-signout', type: 'action', title: 'Sign Out', subtitle: 'Log out of your account', icon: '🚪', action: () => signOut() }) diff --git a/frontend/src/components/dashboard/Sidebar.tsx b/frontend/src/components/dashboard/Sidebar.tsx index 7788f6d..8bdb748 100644 --- a/frontend/src/components/dashboard/Sidebar.tsx +++ b/frontend/src/components/dashboard/Sidebar.tsx @@ -2,6 +2,7 @@ import { Link, useLocation } from 'react-router-dom' import { FolderGit2, BarChart3, + KeyRound, BookOpen, ChevronLeft, ChevronRight, @@ -26,6 +27,7 @@ interface NavItem { const mainNavItems: NavItem[] = [ { name: 'Repositories', href: '/dashboard', icon: }, { name: 'Usage', href: '/dashboard/usage', icon: }, + { name: 'API Keys', href: '/dashboard/api-keys', icon: }, ] const bottomNavItems: NavItem[] = [ diff --git a/frontend/src/pages/APIKeysPage.tsx b/frontend/src/pages/APIKeysPage.tsx new file mode 100644 index 0000000..73e3a7a --- /dev/null +++ b/frontend/src/pages/APIKeysPage.tsx @@ -0,0 +1,350 @@ +import { useEffect, useState, useCallback } from 'react' +import { KeyRound, Plus, Copy, Check, Loader2, Trash2, Clock } from 'lucide-react' +import { useAuth } from '@/contexts/AuthContext' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { toast } from 'sonner' +import { API_URL } from '@/config/api' + +interface APIKey { + id: string + name: string + tier: string + active: boolean + created_at: string + last_used_at: string | null + key_preview: string +} + +function timeAgo(dateStr: string | null): string { + if (!dateStr) return 'Never' + const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000) + if (seconds < 60) return 'Just now' + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago` + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago` + return `${Math.floor(seconds / 86400)}d ago` +} + +function TierBadge({ tier }: { tier: string }) { + const variants: Record = { + enterprise: 'bg-purple-500/10 text-purple-400 border-purple-500/20', + pro: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + free: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20', + } + return ( + + {tier} + + ) +} + +function StatusDot({ active }: { active: boolean }) { + return ( + + + + {active ? 'Active' : 'Revoked'} + + + ) +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + ) +} + +export function APIKeysPage() { + const { session } = useAuth() + const [keys, setKeys] = useState([]) + const [loading, setLoading] = useState(true) + const [generateOpen, setGenerateOpen] = useState(false) + const [keyName, setKeyName] = useState('') + const [generating, setGenerating] = useState(false) + const [generatedKey, setGeneratedKey] = useState(null) + const [revoking, setRevoking] = useState(null) + + const token = session?.access_token + + const fetchKeys = useCallback(async () => { + if (!token) return + try { + const res = await fetch(`${API_URL}/keys`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Failed to load keys') + const data = await res.json() + setKeys(data.keys || []) + } catch { + toast.error('Failed to load API keys') + } finally { + setLoading(false) + } + }, [token]) + + useEffect(() => { + fetchKeys() + }, [fetchKeys]) + + const handleGenerate = async () => { + if (!token || !keyName.trim()) return + setGenerating(true) + try { + const res = await fetch(`${API_URL}/keys/generate`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: keyName.trim() }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.detail || 'Failed to generate key') + } + const data = await res.json() + setGeneratedKey(data.api_key) + setKeyName('') + fetchKeys() + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to generate key') + } finally { + setGenerating(false) + } + } + + const handleRevoke = async (keyId: string) => { + if (!token) return + setRevoking(keyId) + try { + const res = await fetch(`${API_URL}/keys/${keyId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Failed to revoke key') + toast.success('API key revoked') + fetchKeys() + } catch { + toast.error('Failed to revoke key') + } finally { + setRevoking(null) + } + } + + const closeGenerateDialog = () => { + setGenerateOpen(false) + setGeneratedKey(null) + setKeyName('') + } + + const activeKeys = keys.filter((k) => k.active) + const revokedKeys = keys.filter((k) => !k.active) + + if (loading) { + return ( +
+ + Loading API keys... +
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

API Keys

+

+ Manage keys for MCP, Claude Desktop, and API access +

+
+
+ +
+ + {/* Key list */} + {keys.length === 0 ? ( + + +
+ +
+

No API keys yet

+

+ Generate a key to connect Claude Desktop, Claude Code, Cursor, or any MCP client to + your indexed repositories. +

+ +
+
+ ) : ( + + + + {activeKeys.length} active key{activeKeys.length !== 1 ? 's' : ''} + {activeKeys.length >= 5 && ( + (limit reached) + )} + + + +
+ {keys.map((key) => ( +
+
+
+
+ {key.name} + + +
+
+ + {key.key_preview} + + + + {key.last_used_at ? `Used ${timeAgo(key.last_used_at)}` : 'Never used'} + + Created {timeAgo(key.created_at)} +
+
+
+ {key.active && ( + + )} +
+ ))} +
+
+
+ )} + + {/* Quick setup hint */} + {activeKeys.length > 0 && ( + + +

+ Quick setup:{' '} + Copy your key and add it to your Claude Desktop config at{' '} + + ~/Library/Application Support/Claude/claude_desktop_config.json + +

+
+
+ )} + + {/* Generate dialog */} + + + {generatedKey ? ( + <> + + Key Created + + Copy this key now. It will not be shown again. + + +
+
+ + {generatedKey} + + +
+

+ Store this key securely. You will not be able to see it again. +

+
+ + + + + ) : ( + <> + + Create API Key + + Name your key so you can identify it later. + + +
+
+ + setKeyName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleGenerate()} + autoFocus + /> +
+
+ + + + + + )} +
+
+
+ ) +} From 0ab722572ae2c10af42d53dd5fe42e833a53bad9 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sat, 7 Mar 2026 22:02:06 -0500 Subject: [PATCH 2/6] fix: review findings -- clipboard error handling, React Query, revoke confirm, platform paths 1. CopyButton: wrapped clipboard.writeText in try/catch, shows toast.error on failure (matches DocsCodeBlock.tsx pattern) 2. React Query: replaced manual fetchKeys/useEffect/useState with useQuery + queryClient.invalidateQueries. Matches the established useCachedQuery.ts pattern used everywhere else in the dashboard. Generate and revoke both invalidate ['api-keys'] query. 3. Revoke confirmation: added a Dialog that shows key name and warns 'applications using this key will stop working immediately'. Matches SettingsPage delete confirmation pattern. 4. Platform-aware config paths: detects OS from userAgent, shows macOS/Windows/Linux paths accordingly instead of hardcoded macOS. Build passes, TS clean. --- frontend/src/pages/APIKeysPage.tsx | 118 ++++++++++++++++++++--------- 1 file changed, 81 insertions(+), 37 deletions(-) diff --git a/frontend/src/pages/APIKeysPage.tsx b/frontend/src/pages/APIKeysPage.tsx index 73e3a7a..6b73fe6 100644 --- a/frontend/src/pages/APIKeysPage.tsx +++ b/frontend/src/pages/APIKeysPage.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState, useCallback } from 'react' +import { useState } from 'react' import { KeyRound, Plus, Copy, Check, Loader2, Trash2, Clock } from 'lucide-react' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useAuth } from '@/contexts/AuthContext' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -64,9 +65,13 @@ function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false) const handleCopy = async () => { - await navigator.clipboard.writeText(text) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + toast.error('Failed to copy. Try selecting the text manually.') + } } return ( @@ -76,37 +81,49 @@ function CopyButton({ text }: { text: string }) { ) } +function getConfigPaths(): { label: string; path: string }[] { + const ua = navigator.userAgent.toLowerCase() + if (ua.includes('win')) { + return [ + { label: 'Windows', path: '%APPDATA%\\Claude\\claude_desktop_config.json' }, + ] + } + if (ua.includes('linux')) { + return [ + { label: 'Linux', path: '~/.config/Claude/claude_desktop_config.json' }, + ] + } + return [ + { label: 'macOS', path: '~/Library/Application Support/Claude/claude_desktop_config.json' }, + ] +} + +async function fetchKeys(token: string): Promise { + const res = await fetch(`${API_URL}/keys`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Failed to load keys') + const data = await res.json() + return data.keys || [] +} + export function APIKeysPage() { const { session } = useAuth() - const [keys, setKeys] = useState([]) - const [loading, setLoading] = useState(true) + const queryClient = useQueryClient() const [generateOpen, setGenerateOpen] = useState(false) const [keyName, setKeyName] = useState('') const [generating, setGenerating] = useState(false) const [generatedKey, setGeneratedKey] = useState(null) const [revoking, setRevoking] = useState(null) + const [revokeConfirm, setRevokeConfirm] = useState(null) - const token = session?.access_token - - const fetchKeys = useCallback(async () => { - if (!token) return - try { - const res = await fetch(`${API_URL}/keys`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Failed to load keys') - const data = await res.json() - setKeys(data.keys || []) - } catch { - toast.error('Failed to load API keys') - } finally { - setLoading(false) - } - }, [token]) + const token = session?.access_token || '' - useEffect(() => { - fetchKeys() - }, [fetchKeys]) + const { data: keys = [], isLoading } = useQuery({ + queryKey: ['api-keys'], + queryFn: () => fetchKeys(token), + enabled: !!token, + }) const handleGenerate = async () => { if (!token || !keyName.trim()) return @@ -127,7 +144,7 @@ export function APIKeysPage() { const data = await res.json() setGeneratedKey(data.api_key) setKeyName('') - fetchKeys() + queryClient.invalidateQueries({ queryKey: ['api-keys'] }) } catch (e) { toast.error(e instanceof Error ? e.message : 'Failed to generate key') } finally { @@ -135,17 +152,18 @@ export function APIKeysPage() { } } - const handleRevoke = async (keyId: string) => { + const handleRevoke = async (key: APIKey) => { if (!token) return - setRevoking(keyId) + setRevoking(key.id) + setRevokeConfirm(null) try { - const res = await fetch(`${API_URL}/keys/${keyId}`, { + const res = await fetch(`${API_URL}/keys/${key.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to revoke key') toast.success('API key revoked') - fetchKeys() + queryClient.invalidateQueries({ queryKey: ['api-keys'] }) } catch { toast.error('Failed to revoke key') } finally { @@ -160,9 +178,9 @@ export function APIKeysPage() { } const activeKeys = keys.filter((k) => k.active) - const revokedKeys = keys.filter((k) => !k.active) + const configPaths = getConfigPaths() - if (loading) { + if (isLoading) { return (
@@ -252,7 +270,7 @@ export function APIKeysPage() { + + + +
) } From e44193b63b48d43d37745ba760cee04b13a3ddb6 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 8 Mar 2026 03:13:36 -0400 Subject: [PATCH 3/6] fix: remove max-w-4xl constraint so API Keys page fills full width Matches DashboardHome which uses full-width layout. The constrained width left half the screen empty and looked odd. --- frontend/src/pages/APIKeysPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/APIKeysPage.tsx b/frontend/src/pages/APIKeysPage.tsx index 6b73fe6..a083f0e 100644 --- a/frontend/src/pages/APIKeysPage.tsx +++ b/frontend/src/pages/APIKeysPage.tsx @@ -190,7 +190,7 @@ export function APIKeysPage() { } return ( -
+
{/* Header */}
From 5bec84fc99e8e58dd6170f991fbb29652345262a Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 8 Mar 2026 14:35:56 -0400 Subject: [PATCH 4/6] feat: premium API Keys page redesign (OPE-167) Complete visual overhaul: - Key cards with tier-colored left accent stripe (amber/indigo/zinc) - Revoke on hover only, connect guide with tabbed config snippets - JetBrains Mono key preview, full-width layout - Empty state with feature hints, emerald key in generate dialog - No functional changes, same API calls and React Query patterns --- frontend/src/pages/APIKeysPage.tsx | 515 ++++++++++++++++++----------- 1 file changed, 315 insertions(+), 200 deletions(-) diff --git a/frontend/src/pages/APIKeysPage.tsx b/frontend/src/pages/APIKeysPage.tsx index a083f0e..7335fc4 100644 --- a/frontend/src/pages/APIKeysPage.tsx +++ b/frontend/src/pages/APIKeysPage.tsx @@ -1,12 +1,10 @@ import { useState } from 'react' -import { KeyRound, Plus, Copy, Check, Loader2, Trash2, Clock } from 'lucide-react' +import { Plus, Copy, Check, Loader2, X, Clock, Shield, Terminal, Zap } from 'lucide-react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useAuth } from '@/contexts/AuthContext' import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' import { Dialog, DialogContent, @@ -17,6 +15,7 @@ import { } from '@/components/ui/dialog' import { toast } from 'sonner' import { API_URL } from '@/config/api' +import { cn } from '@/lib/utils' interface APIKey { id: string @@ -34,77 +33,252 @@ function timeAgo(dateStr: string | null): string { if (seconds < 60) return 'Just now' if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago` if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago` - return `${Math.floor(seconds / 86400)}d ago` + if (seconds < 2592000) return `${Math.floor(seconds / 86400)}d ago` + return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) } -function TierBadge({ tier }: { tier: string }) { - const variants: Record = { - enterprise: 'bg-purple-500/10 text-purple-400 border-purple-500/20', - pro: 'bg-blue-500/10 text-blue-400 border-blue-500/20', - free: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20', - } - return ( - - {tier} - - ) +const TIER_STYLES: Record = { + enterprise: { bg: 'bg-amber-500/8 border-amber-500/15', text: 'text-amber-400', dot: 'bg-amber-400' }, + pro: { bg: 'bg-indigo-500/8 border-indigo-500/15', text: 'text-indigo-400', dot: 'bg-indigo-400' }, + free: { bg: 'bg-zinc-500/8 border-zinc-500/15', text: 'text-zinc-400', dot: 'bg-zinc-500' }, } -function StatusDot({ active }: { active: boolean }) { - return ( - - - - {active ? 'Active' : 'Revoked'} - - - ) +async function fetchKeys(token: string): Promise { + const res = await fetch(`${API_URL}/keys`, { + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Failed to load keys') + const data = await res.json() + return data.keys || [] } -function CopyButton({ text }: { text: string }) { +function CopyInline({ text, label }: { text: string; label?: string }) { const [copied, setCopied] = useState(false) - const handleCopy = async () => { try { await navigator.clipboard.writeText(text) setCopied(true) + toast.success(label ? `${label} copied` : 'Copied') setTimeout(() => setCopied(false), 2000) } catch { - toast.error('Failed to copy. Try selecting the text manually.') + toast.error('Copy failed') } } + return ( + + ) +} + +function KeyCard({ + apiKey, + onRevoke, + revoking, +}: { + apiKey: APIKey + onRevoke: (key: APIKey) => void + revoking: boolean +}) { + const tier = TIER_STYLES[apiKey.tier] || TIER_STYLES.free + const isRevoked = !apiKey.active return ( - +
+
+ {/* Top row: name + tier + status */} +
+
+ + {apiKey.name} + + + {apiKey.tier} + + {isRevoked && ( + + Revoked + + )} +
+ + {apiKey.active && ( + + )} +
+ + {/* Key preview */} +
+ + {apiKey.key_preview} + + +
+ + {/* Bottom metadata */} +
+ + + Created {timeAgo(apiKey.created_at)} + + {apiKey.last_used_at && ( + + + Last used {timeAgo(apiKey.last_used_at)} + + )} + {!apiKey.last_used_at && apiKey.active && ( + Not yet used + )} +
+
+ + {/* Active indicator line */} + {apiKey.active && ( +
+ )} +
) } -function getConfigPaths(): { label: string; path: string }[] { - const ua = navigator.userAgent.toLowerCase() - if (ua.includes('win')) { - return [ - { label: 'Windows', path: '%APPDATA%\\Claude\\claude_desktop_config.json' }, - ] +function ConnectGuide() { + const [tab, setTab] = useState<'desktop' | 'code' | 'cursor'>('desktop') + + const snippets: Record = { + desktop: { + label: 'Claude Desktop', + config: `{ + "mcpServers": { + "codeintel": { + "command": "npx", + "args": ["-y", "mcp-remote", "https://mcp.opencodeintel.com/mcp"], + "env": { + "API_KEY": "ci_your-key-here" + } + } } - if (ua.includes('linux')) { - return [ - { label: 'Linux', path: '~/.config/Claude/claude_desktop_config.json' }, - ] +}`, + }, + code: { + label: 'Claude Code', + config: `claude mcp add codeintel \\ + --transport http \\ + https://mcp.opencodeintel.com/mcp`, + }, + cursor: { + label: 'Cursor', + config: `{ + "mcpServers": { + "codeintel": { + "url": "https://mcp.opencodeintel.com/mcp" + } } - return [ - { label: 'macOS', path: '~/Library/Application Support/Claude/claude_desktop_config.json' }, - ] +}`, + }, + } + + const current = snippets[tab] + + return ( +
+
+ Connect to your tools +
+ {Object.entries(snippets).map(([key, { label }]) => ( + + ))} +
+
+
+
+          {current.config}
+        
+
+ +
+
+
+ ) } -async function fetchKeys(token: string): Promise { - const res = await fetch(`${API_URL}/keys`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (!res.ok) throw new Error('Failed to load keys') - const data = await res.json() - return data.keys || [] +function EmptyState({ onCreate }: { onCreate: () => void }) { + return ( +
+ {/* Visual element */} +
+
+ +
+
+ +
+
+ +

+ Connect your AI tools +

+

+ Generate an API key to connect Claude Desktop, Claude Code, Cursor, + or any MCP-compatible client to your indexed repositories. +

+ + + + {/* Feature hints */} +
+ +
+ Semantic search + + +
+ Codebase DNA + + +
+ Impact analysis + +
+
+ ) } export function APIKeysPage() { @@ -162,7 +336,7 @@ export function APIKeysPage() { headers: { Authorization: `Bearer ${token}` }, }) if (!res.ok) throw new Error('Failed to revoke key') - toast.success('API key revoked') + toast.success(`"${key.name}" revoked`) queryClient.invalidateQueries({ queryKey: ['api-keys'] }) } catch { toast.error('Failed to revoke key') @@ -178,187 +352,128 @@ export function APIKeysPage() { } const activeKeys = keys.filter((k) => k.active) - const configPaths = getConfigPaths() if (isLoading) { return ( -
- - Loading API keys... +
+
+
+
+ {[1, 2].map((i) => ( +
+ ))} +
) } return ( -
- {/* Header */} -
-
-
- -
-
-

API Keys

-

- Manage keys for MCP, Claude Desktop, and API access -

-
+
+ {/* Page header */} +
+
+

+ API Keys +

+

+ Authenticate MCP clients, Claude Desktop, and programmatic access. +

- + {keys.length > 0 && ( + + )}
- {/* Key list */} + {/* Key list or empty state */} {keys.length === 0 ? ( - - -
- -
-

No API keys yet

-

- Generate a key to connect Claude Desktop, Claude Code, Cursor, or any MCP client to - your indexed repositories. -

- -
-
+ setGenerateOpen(true)} /> ) : ( - - - - {activeKeys.length} active key{activeKeys.length !== 1 ? 's' : ''} - {activeKeys.length >= 5 && ( - (limit reached) - )} - - - -
- {keys.map((key) => ( -
-
-
-
- {key.name} - - -
-
- - {key.key_preview} - - - - {key.last_used_at ? `Used ${timeAgo(key.last_used_at)}` : 'Never used'} - - Created {timeAgo(key.created_at)} -
-
-
- {key.active && ( - - )} -
- ))} -
-
-
- )} +
+ {keys.map((key) => ( + + ))} - {/* Quick setup hint */} - {activeKeys.length > 0 && ( - - -

- Quick setup:{' '} - Copy your key and add it to your Claude Desktop config at{' '} - {configPaths.map((cp) => ( - - {cp.path} - - ))} -

-
-
+ {/* Usage count */} +

+ {activeKeys.length} of 5 keys active +

+
)} + {/* Connect guide */} + {activeKeys.length > 0 && } + {/* Generate dialog */} - + {generatedKey ? ( <> - Key Created + Key created - Copy this key now. It will not be shown again. + This is the only time this key will be shown. Copy it now. -
-
- +
+
+ {generatedKey} - +
+ +
+
+
+ +

+ Store this key securely. It cannot be retrieved after you close this dialog. +

-

- Store this key securely. You will not be able to see it again. -

- + ) : ( <> - Create API Key + Create API key - Name your key so you can identify it later. + Give your key a name to identify where it's used. -
-
- - setKeyName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleGenerate()} - autoFocus - /> -
+
+ setKeyName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && keyName.trim() && handleGenerate()} + className="h-10 bg-background/50" + autoFocus + />
- - @@ -366,25 +481,25 @@ export function APIKeysPage() {
- {/* Revoke confirmation dialog */} + {/* Revoke confirmation */} setRevokeConfirm(null)}> - + - Revoke API Key - - Are you sure you want to revoke {revokeConfirm?.name}? - Any applications using this key will stop working immediately. + Revoke key + + {revokeConfirm?.name} will stop + working immediately. Any applications using it will lose access. - - From a746b0f81b2f7c3e437c585eb4a24006183995d2 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 8 Mar 2026 14:59:32 -0400 Subject: [PATCH 5/6] feat: Usage page redesign -- matching API Keys premium aesthetic Redesigned UsagePage to match the new API Keys design language: - Tier badge: same amber/indigo/zinc style as API Keys cards - Resource cards: 3-column grid with large numbers, progress bar on repos - Features: row-based layout with icon boxes, descriptions, and emerald checkmarks for enabled / indigo Pro badge for locked - Full-width layout (removed max-w-4xl) - Section labels: uppercase tracking-wider muted text - Upgrade CTA: button in header + subtle banner, not heavy card - Loading skeleton: matches 3-col + feature list layout - Cost tracking: dashed border placeholder, dimmed Same data, same hooks, same API. Pure visual upgrade. --- frontend/src/pages/UsagePage.tsx | 321 ++++++++++++++++++------------- 1 file changed, 189 insertions(+), 132 deletions(-) diff --git a/frontend/src/pages/UsagePage.tsx b/frontend/src/pages/UsagePage.tsx index aa9c36b..05ce6b6 100644 --- a/frontend/src/pages/UsagePage.tsx +++ b/frontend/src/pages/UsagePage.tsx @@ -2,25 +2,38 @@ * UsagePage -- plan info, resource usage, and feature availability. * * Fetches from GET /users/usage. Shows tier, repo usage, - * limits, and features in a compact two-column layout. + * limits, and features. Premium design matching API Keys page. */ import { useAuth } from '@/contexts/AuthContext' import { useUserUsage } from '@/hooks/useCachedQuery' import { - BarChart3, Package, FunctionSquare, Files, Zap, Search, - Server, Sparkles, Lock, ArrowRight, TrendingUp, + Package, FunctionSquare, Files, Zap, Search, + Server, Sparkles, Lock, ArrowRight, TrendingUp, Check, } from 'lucide-react' -import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Skeleton } from '@/components/ui/Skeleton' import { cn } from '@/lib/utils' -const TIER_COLORS: Record = { - free: 'bg-muted text-muted-foreground', - pro: 'bg-primary/10 text-primary border-primary/20', - enterprise: 'bg-amber-500/10 text-amber-500 border-amber-500/20', +const TIER_STYLES: Record = { + enterprise: { + bg: 'bg-amber-500/8 border-amber-500/15', + text: 'text-amber-400', + accent: 'bg-amber-400', + label: 'Enterprise', + }, + pro: { + bg: 'bg-indigo-500/8 border-indigo-500/15', + text: 'text-indigo-400', + accent: 'bg-indigo-400', + label: 'Pro', + }, + free: { + bg: 'bg-zinc-500/8 border-zinc-500/15', + text: 'text-zinc-400', + accent: 'bg-zinc-500', + label: 'Free', + }, } export function UsagePage() { @@ -37,138 +50,166 @@ export function UsagePage() { } const tier = usage.tier || 'free' + const tierStyle = TIER_STYLES[tier] || TIER_STYLES.free const repos = usage.repositories || { current: 0, limit: 3 } const limits = usage.limits || { max_files_per_repo: 500, max_functions_per_repo: 2000 } const features = usage.features || { priority_indexing: false, mcp_access: true } const isFree = tier === 'free' + const repoPct = repos.limit ? (repos.current / repos.limit) * 100 : 0 return ( -
- {/* Header */} -
-
- -
+
+ {/* Page header */} +
-
-

Usage

- - {tier} +
+

+ Usage +

+ + {tierStyle.label}
-

Plan details and resource limits

+

+ Plan details, resource limits, and feature availability. +

-
- - {/* Upgrade CTA for free users */} - {isFree && ( -
-
-

Unlock higher limits and priority indexing

-

- Pro: 5 repos, 100K functions/repo, Cohere reranking -

-
+ {isFree && ( + )} +
+ + {/* Upgrade banner for free tier */} + {isFree && ( +
+

+ Unlock higher limits and priority indexing +

+

+ Pro gives you 5 repos, 100K functions per repo, Cohere reranking, and priority indexing. +

)} - {/* Two-column: Usage + Features */} -
- {/* Left: Resource Usage */} - - -

Resource Limits

- } - label="Repositories" - value={`${repos.current} / ${repos.limit ?? 'unlimited'}`} - pct={repos.limit ? (repos.current / repos.limit) * 100 : 0} - showBar + {/* Resource usage cards */} +
+

+ Resources +

+
+ {/* Repositories */} +
+
+
+ + Repositories +
+ + {repos.current} / {repos.limit ?? '---'} + +
+
+ {repos.current} +
+ {repos.limit && ( +
+
90 ? 'bg-red-500' : repoPct > 70 ? 'bg-amber-500' : 'bg-emerald-500' + )} + style={{ width: `${repoPct > 0 ? Math.max(repoPct, 4) : 0}%` }} + /> +
+ )} +
+ + {/* Files per repo */} +
+
+ + Files per repo +
+
+ {limits.max_files_per_repo.toLocaleString()} +
+

maximum

+
+ + {/* Functions per repo */} +
+
+ + Functions per repo +
+
+ {limits.max_functions_per_repo.toLocaleString()} +
+

maximum

+
+
+
+ + {/* Features */} +
+

+ Features +

+
+
+ } + label="Semantic Code Search" + description="Find code by meaning, not just keywords" + enabled /> - } - label="Files / repo" - value={`up to ${limits.max_files_per_repo.toLocaleString()}`} + } + label="Codebase DNA" + description="Extract architectural patterns and conventions" + enabled /> - } - label="Functions / repo" - value={`up to ${limits.max_functions_per_repo.toLocaleString()}`} + } + label="MCP Server Access" + description="Connect Claude Desktop, Claude Code, and Cursor" + enabled={features.mcp_access} /> - - - - {/* Right: Features */} - - -

Features

- } label="Semantic Code Search" enabled /> - } label="Codebase DNA" enabled /> - } label="MCP Server Access" enabled={features.mcp_access} /> - } label="Priority Indexing" enabled={features.priority_indexing} /> -
-
+ } + label="Priority Indexing" + description="Skip the queue on indexing and re-indexing" + enabled={features.priority_indexing} + /> +
+
{/* Cost tracking placeholder */} - - -
- -
-
-

API Cost Tracking

-

- Token usage, cost breakdown by model, and monthly spend tracking -- coming soon. -

-
-
-
-
- ) -} - - -function UsageRow({ - icon, - label, - value, - pct, - showBar, -}: { - icon: React.ReactNode - label: string - value: string - pct?: number - showBar?: boolean -}) { - const barColor = (pct ?? 0) > 90 ? 'bg-destructive' : (pct ?? 0) > 70 ? 'bg-amber-500' : 'bg-emerald-500' - - return ( -
-
-
- {icon} - {label} +
+
+
- {value} -
- {showBar && pct !== undefined && ( -
-
0 ? Math.max(pct, 2) : 0}%` }} - /> +
+

API Cost Tracking

+

+ Token usage, cost breakdown by model, and monthly spend tracking -- coming soon. +

- )} +
) } @@ -177,23 +218,40 @@ function UsageRow({ function FeatureRow({ icon, label, + description, enabled, }: { icon: React.ReactNode label: string + description: string enabled: boolean }) { return (
- +
{enabled ? icon : } - - {label} - {!enabled && ( - Pro +
+
+

{label}

+

{description}

+
+ {enabled ? ( + + ) : ( + + Pro + )}
) @@ -202,18 +260,17 @@ function FeatureRow({ function UsageSkeleton() { return ( -
-
- -
- - -
+
+
+
+
-
- - +
+ {[1, 2, 3].map((i) => ( +
+ ))}
+
) } From 1e94e8fc67ef7a00de74d0011eb2a072ca0e2a6b Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 8 Mar 2026 15:32:50 -0400 Subject: [PATCH 6/6] fix: API Keys review findings -- security, a11y, error handling Security: - handleGenerate guards against double-submit (if generating, return) - Enter key also checks !generating before triggering - Dialog prevents backdrop/Escape close when generatedKey is displayed (only explicit 'Done' button clears the one-time key) - Removed CopyInline from key_preview (preview is intentionally incomplete, copying it gives users a broken value) Accessibility: - CopyInline button has dynamic aria-label ('Copy/Copied' + label) - Revoke button has aria-label='Revoke API key {name}' - Removed unused X import Error handling: - useQuery destructures error alongside data/isLoading - Error state renders destructive-styled message instead of empty list - Query key scoped to user: ['api-keys', userId] prevents cross-user cache leakage between sessions - invalidateQueries calls updated to match scoped key Skipped findings (verified not applicable): - Radix alert-dialog removal: not in package.json - Skeleton import: not imported in this file - data.api_key -> data.key: backend returns api_key (line 94) --- frontend/src/pages/APIKeysPage.tsx | 41 ++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/APIKeysPage.tsx b/frontend/src/pages/APIKeysPage.tsx index 7335fc4..7fd880f 100644 --- a/frontend/src/pages/APIKeysPage.tsx +++ b/frontend/src/pages/APIKeysPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Plus, Copy, Check, Loader2, X, Clock, Shield, Terminal, Zap } from 'lucide-react' +import { Plus, Copy, Check, Loader2, Clock, Shield, Terminal, Zap } from 'lucide-react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useAuth } from '@/contexts/AuthContext' import { Button } from '@/components/ui/button' @@ -67,6 +67,7 @@ function CopyInline({ text, label }: { text: string; label?: string }) { return (
- {/* Key preview */} + {/* Key preview (display only, no copy -- preview is intentionally incomplete) */}
- + {apiKey.key_preview} -
{/* Bottom metadata */} @@ -293,14 +294,15 @@ export function APIKeysPage() { const token = session?.access_token || '' - const { data: keys = [], isLoading } = useQuery({ - queryKey: ['api-keys'], + const userId = session?.user?.id || '' + const { data: keys = [], isLoading, error } = useQuery({ + queryKey: ['api-keys', userId], queryFn: () => fetchKeys(token), enabled: !!token, }) const handleGenerate = async () => { - if (!token || !keyName.trim()) return + if (!token || !keyName.trim() || generating) return setGenerating(true) try { const res = await fetch(`${API_URL}/keys/generate`, { @@ -318,7 +320,7 @@ export function APIKeysPage() { const data = await res.json() setGeneratedKey(data.api_key) setKeyName('') - queryClient.invalidateQueries({ queryKey: ['api-keys'] }) + queryClient.invalidateQueries({ queryKey: ['api-keys', userId] }) } catch (e) { toast.error(e instanceof Error ? e.message : 'Failed to generate key') } finally { @@ -337,7 +339,7 @@ export function APIKeysPage() { }) if (!res.ok) throw new Error('Failed to revoke key') toast.success(`"${key.name}" revoked`) - queryClient.invalidateQueries({ queryKey: ['api-keys'] }) + queryClient.invalidateQueries({ queryKey: ['api-keys', userId] }) } catch { toast.error('Failed to revoke key') } finally { @@ -367,6 +369,23 @@ export function APIKeysPage() { ) } + if (error) { + return ( +
+
+

API Keys

+

Authenticate MCP clients, Claude Desktop, and programmatic access.

+
+
+

Failed to load API keys

+

+ {error instanceof Error ? error.message : 'An unexpected error occurred.'} +

+
+
+ ) + } + return (
{/* Page header */} @@ -419,7 +438,7 @@ export function APIKeysPage() { {activeKeys.length > 0 && } {/* Generate dialog */} - + { if (!open && !generatedKey) closeGenerateDialog() }}> {generatedKey ? ( <> @@ -462,7 +481,7 @@ export function APIKeysPage() { placeholder="e.g. Claude Desktop, Development, CI/CD" value={keyName} onChange={(e) => setKeyName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && keyName.trim() && handleGenerate()} + onKeyDown={(e) => e.key === 'Enter' && keyName.trim() && !generating && handleGenerate()} className="h-10 bg-background/50" autoFocus />