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..7fd880f --- /dev/null +++ b/frontend/src/pages/APIKeysPage.tsx @@ -0,0 +1,528 @@ +import { useState } from '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' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { toast } from 'sonner' +import { API_URL } from '@/config/api' +import { cn } from '@/lib/utils' + +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` + if (seconds < 2592000) return `${Math.floor(seconds / 86400)}d ago` + return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + +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' }, +} + +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 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('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 (display only, no copy -- preview is intentionally incomplete) */} +
+ + {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 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" + } + } + } +}`, + }, + 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" + } + } +}`, + }, + } + + const current = snippets[tab] + + return ( +
+
+ Connect to your tools +
+ {Object.entries(snippets).map(([key, { label }]) => ( + + ))} +
+
+
+
+          {current.config}
+        
+
+ +
+
+
+ ) +} + +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() { + const { session } = useAuth() + 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 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() || generating) 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('') + queryClient.invalidateQueries({ queryKey: ['api-keys', userId] }) + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to generate key') + } finally { + setGenerating(false) + } + } + + const handleRevoke = async (key: APIKey) => { + if (!token) return + setRevoking(key.id) + setRevokeConfirm(null) + try { + 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(`"${key.name}" revoked`) + queryClient.invalidateQueries({ queryKey: ['api-keys', userId] }) + } catch { + toast.error('Failed to revoke key') + } finally { + setRevoking(null) + } + } + + const closeGenerateDialog = () => { + setGenerateOpen(false) + setGeneratedKey(null) + setKeyName('') + } + + const activeKeys = keys.filter((k) => k.active) + + if (isLoading) { + return ( +
+
+
+
+ {[1, 2].map((i) => ( +
+ ))} +
+
+ ) + } + + 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 */} +
+
+

+ API Keys +

+

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

+
+ {keys.length > 0 && ( + + )} +
+ + {/* Key list or empty state */} + {keys.length === 0 ? ( + setGenerateOpen(true)} /> + ) : ( +
+ {keys.map((key) => ( + + ))} + + {/* Usage count */} +

+ {activeKeys.length} of 5 keys active +

+
+ )} + + {/* Connect guide */} + {activeKeys.length > 0 && } + + {/* Generate dialog */} + { if (!open && !generatedKey) closeGenerateDialog() }}> + + {generatedKey ? ( + <> + + Key created + + 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. +

+
+
+ + + + + ) : ( + <> + + Create API key + + Give your key a name to identify where it's used. + + +
+ setKeyName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && keyName.trim() && !generating && handleGenerate()} + className="h-10 bg-background/50" + autoFocus + /> +
+ + + + + + )} +
+
+ + {/* Revoke confirmation */} + setRevokeConfirm(null)}> + + + Revoke key + + {revokeConfirm?.name} will stop + working immediately. Any applications using it will lose access. + + + + + + + + +
+ ) +} 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) => ( +
+ ))}
+
) }