From f7e19a5faae6676c9cb23b914f41a64404cc3883 Mon Sep 17 00:00:00 2001 From: Devanshu Rajesh Chicholikar Date: Sun, 8 Mar 2026 13:26:40 -0400 Subject: [PATCH] feat: premium API keys page redesign Visual overhaul to Stripe/Vercel quality. All functionality preserved. - Table layout with column headers (Name | Key | Tier | Last used | Status) - Skeleton loading state replacing spinner - Hover-reveal revoke button (reduces visual noise) - One-time key reveal modal with amber security warning and usage hint - Inline copy button with 2s checkmark state - Color-coded tier badges (free/pro/enterprise) - Security callout with Shield icon - Footer summary showing active count and remaining slots - AlertDialog for revoke confirmation (was plain Dialog) - Matches UsagePage header pattern exactly --- frontend/src/pages/APIKeysPage.tsx | 525 +++++++++++++++++++++++++++++ 1 file changed, 525 insertions(+) create mode 100644 frontend/src/pages/APIKeysPage.tsx diff --git a/frontend/src/pages/APIKeysPage.tsx b/frontend/src/pages/APIKeysPage.tsx new file mode 100644 index 0000000..fa00815 --- /dev/null +++ b/frontend/src/pages/APIKeysPage.tsx @@ -0,0 +1,525 @@ +import { useState } from 'react' +import { Key, Plus, Copy, Check, Trash2, Clock, Shield, Terminal, AlertTriangle } from 'lucide-react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useAuth } from '@/contexts/AuthContext' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Skeleton } from '@/components/ui/skeleton' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-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', year: 'numeric' }) +} + +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 || [] +} + + +// -- Small sub-components -- + +function TierBadge({ tier }: { tier: string }) { + const styles: Record = { + enterprise: 'bg-violet-500/10 text-violet-400 border-violet-500/20', + pro: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + free: 'bg-zinc-800 text-zinc-400 border-zinc-700', + } + return ( + + {tier} + + ) +} + +function CopyButton({ value, className }: { value: string; className?: string }) { + const [copied, setCopied] = useState(false) + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(value) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Clipboard API unavailable -- silently ignore, user can select manually + } + } + return ( + + ) +} + +function APIKeysSkeleton() { + return ( +
+
+
+ +
+ + +
+
+ +
+ +
+
+ {[...Array(3)].map((_, i) => ( +
+
+ + +
+ + + + +
+ ))} +
+
+ ) +} + + +function EmptyState({ onGenerate }: { onGenerate: () => void }) { + return ( +
+
+ +
+

No API keys yet

+

+ Generate a key to authenticate API requests or connect your MCP server to Claude Desktop, Cursor, or any MCP client. +

+ +
+ ) +} + +// Key row -- table-style, hover reveals revoke button +function KeyRow({ apiKey, onRevoke, revoking }: { apiKey: APIKey; onRevoke: () => void; revoking: boolean }) { + const isRevoked = !apiKey.active + return ( +
+ {/* Name + created date */} +
+

{apiKey.name}

+

+ + Created {timeAgo(apiKey.created_at)} +

+
+ + {/* Key preview + inline copy */} +
+ + {apiKey.key_preview} + + {!isRevoked && } +
+ + {/* Tier */} +
+ + {/* Last used */} + + {apiKey.last_used_at + ? timeAgo(apiKey.last_used_at) + : Never + } + + + {/* Status dot + hover-reveal revoke */} +
+ + + + {apiKey.active ? 'Active' : 'Revoked'} + + + {!isRevoked && ( + + )} +
+
+ ) +} + + +// New key one-time reveal modal +function NewKeyRevealModal({ apiKey, onClose }: { apiKey: string | null; onClose: () => void }) { + if (!apiKey) return null + return ( + + + + +
+ +
+ API key generated +
+ Copy this now. It will not be shown again. +
+ +
+ {/* Key display with copy footer */} +
+
+ + {apiKey} + +
+
+ +
+
+ + {/* Security warning */} +
+ +

+ This is the only time this key will be shown. Store it in a password manager or as + an environment secret. Losing it means generating a new one. +

+
+ + {/* Usage hint */} +
+

+ + Usage +

+ + Authorization: Bearer {''} + +
+
+ + + + +
+
+ ) +} + +// Generate key name modal +function GenerateKeyModal({ + open, + onClose, + onGenerate, + generating, +}: { + open: boolean + onClose: () => void + onGenerate: (name: string) => void + generating: boolean +}) { + const [name, setName] = useState('') + const handleSubmit = () => { + if (!name.trim()) return + onGenerate(name.trim()) + setName('') + } + return ( + + + + New API key + + Give it a name so you know where it is used. + + +
+ + setName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} + autoFocus + className="h-9 text-sm" + /> +
+ + + + +
+
+ ) +} + + +// -- Main page component -- + +export function APIKeysPage() { + const { session } = useAuth() + const queryClient = useQueryClient() + const token = session?.access_token || '' + + const [generateOpen, setGenerateOpen] = useState(false) + const [generating, setGenerating] = useState(false) + const [generatedKey, setGeneratedKey] = useState(null) + const [revoking, setRevoking] = useState(null) + const [revokeTarget, setRevokeTarget] = useState(null) + + const { data: keys = [], isLoading, isError } = useQuery({ + queryKey: ['api-keys'], + queryFn: () => fetchKeys(token), + enabled: !!token, + }) + + const handleGenerate = async (name: string) => { + if (!token) 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 }), + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.detail || 'Failed to generate key') + } + const data = await res.json() + setGenerateOpen(false) + setGeneratedKey(data.api_key) + queryClient.invalidateQueries({ queryKey: ['api-keys'] }) + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Failed to generate key') + } finally { + setGenerating(false) + } + } + + const handleRevoke = async () => { + if (!token || !revokeTarget) return + setRevoking(revokeTarget.id) + setRevokeTarget(null) + try { + const res = await fetch(`${API_URL}/keys/${revokeTarget.id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }) + if (!res.ok) throw new Error('Failed to revoke key') + toast.success('API key revoked') + queryClient.invalidateQueries({ queryKey: ['api-keys'] }) + } catch { + toast.error('Failed to revoke key') + } finally { + setRevoking(null) + } + } + + const activeKeys = keys.filter((k) => k.active) + + if (isLoading) return + + if (isError) { + return ( +
+ Failed to load API keys. +
+ ) + } + + return ( +
+ + {/* Header -- matches UsagePage pattern exactly */} +
+
+
+ +
+
+

API Keys

+

+ Authenticate MCP, Claude Desktop, and direct API requests +

+
+
+ +
+ + {/* Security callout */} +
+ +
+

Keep your keys secure

+

+ API keys grant full account access. Never commit them to source control. + Use environment variables or a secrets manager. +

+
+
+ + + {/* Keys table or empty state */} + {keys.length === 0 ? ( +
+ setGenerateOpen(true)} /> +
+ ) : ( +
+ {/* Column headers */} +
+ Name + Key + Tier + Last used + Status +
+ + {/* Rows */} + {keys.map((k) => ( + setRevokeTarget(k)} + revoking={revoking === k.id} + /> + ))} + + {/* Footer summary */} +
+ + {activeKeys.length} active + {activeKeys.length !== keys.length && ` / ${keys.length} total`} + + + {5 - activeKeys.length} key{5 - activeKeys.length !== 1 ? 's' : ''} remaining + +
+
+ )} + + {/* Modals */} + setGenerateOpen(false)} + onGenerate={handleGenerate} + generating={generating} + /> + + setGeneratedKey(null)} + /> + + !open && setRevokeTarget(null)}> + + + Revoke key + + Revoke {revokeTarget?.name}? + Any services using this key will lose access immediately. This cannot be undone. + + + + Cancel + + Revoke key + + + + + +
+ ) +}