| import { useState } from "react"; |
| import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; |
| import { Key, Plus, Trash2, Copy, Check, Code2, Sparkles } from "lucide-react"; |
| import { Button } from "@/components/ui/button"; |
| import { Input } from "@/components/ui/input"; |
| import { Badge } from "@/components/ui/badge"; |
| import { useLang } from "@/contexts/LanguageContext"; |
| import { useAuth } from "@/contexts/AuthContext"; |
| import { AuthModal } from "@/components/AuthModal"; |
|
|
| const BASE = import.meta.env.BASE_URL.replace(/\/$/, ""); |
| const API_BASE = `${BASE}/api`; |
| const V1_BASE = window.location.origin + `${BASE}/api/v1`; |
|
|
| interface ApiKeyRecord { |
| id: number; |
| name: string; |
| keyPrefix: string; |
| createdAt: string; |
| lastUsedAt: string | null; |
| } |
|
|
| async function fetchKeys(): Promise<ApiKeyRecord[]> { |
| const res = await fetch(`${API_BASE}/apikeys`, { credentials: "include" }); |
| if (!res.ok) throw new Error("Failed to fetch keys"); |
| const data = await res.json(); |
| return data.keys; |
| } |
|
|
| async function createKey(name: string): Promise<{ key: string; id: number; name: string; keyPrefix: string; createdAt: string }> { |
| const res = await fetch(`${API_BASE}/apikeys`, { |
| method: "POST", |
| credentials: "include", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ name }), |
| }); |
| if (!res.ok) throw new Error("Failed to create key"); |
| return res.json(); |
| } |
|
|
| async function revokeKey(id: number): Promise<void> { |
| const res = await fetch(`${API_BASE}/apikeys/${id}`, { method: "DELETE", credentials: "include" }); |
| if (!res.ok) throw new Error("Failed to revoke key"); |
| } |
|
|
| function CopyButton({ text }: { text: string }) { |
| const [copied, setCopied] = useState(false); |
| const { t } = useLang(); |
| return ( |
| <button |
| onClick={async () => { |
| await navigator.clipboard.writeText(text).catch(() => {}); |
| setCopied(true); |
| setTimeout(() => setCopied(false), 2000); |
| }} |
| className="flex items-center gap-1 text-xs px-2 py-1 rounded border border-border/50 hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground" |
| > |
| {copied ? <Check className="w-3 h-3 text-green-400" /> : <Copy className="w-3 h-3" />} |
| {copied ? t.apiKeysCopied : t.apiKeysCopy} |
| </button> |
| ); |
| } |
|
|
| function KeyCard({ apiKey, onRevoke, isRevoking }: { apiKey: ApiKeyRecord; onRevoke: (id: number) => void; isRevoking: boolean }) { |
| const { t } = useLang(); |
| const created = new Date(apiKey.createdAt).toLocaleDateString(); |
| const lastUsed = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).toLocaleDateString() : t.apiKeysNeverUsed; |
|
|
| return ( |
| <div className="flex items-center justify-between p-4 rounded-xl border border-border/60 bg-card/50 hover:bg-card/80 transition-colors group"> |
| <div className="flex items-center gap-3 min-w-0"> |
| <div className="w-8 h-8 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center flex-shrink-0"> |
| <Key className="w-4 h-4 text-primary" /> |
| </div> |
| <div className="min-w-0"> |
| <div className="font-medium text-sm text-foreground">{apiKey.name}</div> |
| <div className="text-xs text-muted-foreground font-mono mt-0.5">{apiKey.keyPrefix}</div> |
| <div className="flex gap-3 mt-1 text-xs text-muted-foreground/70"> |
| <span>{t.apiKeysCreatedAt}: {created}</span> |
| <span>{t.apiKeysLastUsed}: {lastUsed}</span> |
| </div> |
| </div> |
| </div> |
| <button |
| onClick={() => onRevoke(apiKey.id)} |
| disabled={isRevoking} |
| className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-destructive/30 text-destructive/70 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/60 transition-colors opacity-0 group-hover:opacity-100 disabled:opacity-50" |
| > |
| <Trash2 className="w-3 h-3" /> |
| {isRevoking ? t.apiKeysRevoking : t.apiKeysRevoke} |
| </button> |
| </div> |
| ); |
| } |
|
|
| function NewKeyBanner({ rawKey }: { rawKey: string }) { |
| const { t } = useLang(); |
| return ( |
| <div className="rounded-xl border border-yellow-500/40 bg-yellow-500/5 p-4 space-y-2"> |
| <div className="flex items-center gap-2 text-yellow-400 text-sm font-medium"> |
| <Check className="w-4 h-4" /> |
| {t.apiKeysCopyHint} |
| </div> |
| <div className="flex items-center gap-2"> |
| <code className="flex-1 text-xs font-mono bg-black/40 rounded-lg px-3 py-2 text-green-300 overflow-x-auto whitespace-nowrap"> |
| {rawKey} |
| </code> |
| <CopyButton text={rawKey} /> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function KeysContent() { |
| const { t } = useLang(); |
| const qc = useQueryClient(); |
| const [name, setName] = useState(""); |
| const [newRawKey, setNewRawKey] = useState<string | null>(null); |
|
|
| const { data: keys = [], isLoading } = useQuery({ queryKey: ["apikeys"], queryFn: fetchKeys }); |
|
|
| const createMutation = useMutation({ |
| mutationFn: createKey, |
| onSuccess: (data) => { |
| setNewRawKey(data.key); |
| setName(""); |
| qc.invalidateQueries({ queryKey: ["apikeys"] }); |
| }, |
| }); |
|
|
| const revokeMutation = useMutation({ |
| mutationFn: revokeKey, |
| onSuccess: () => { |
| setNewRawKey(null); |
| qc.invalidateQueries({ queryKey: ["apikeys"] }); |
| }, |
| }); |
|
|
| const handleCreate = (e: React.FormEvent) => { |
| e.preventDefault(); |
| createMutation.mutate(name.trim() || "Default Key"); |
| }; |
|
|
| return ( |
| <div className="space-y-6"> |
| {newRawKey && <NewKeyBanner rawKey={newRawKey} />} |
| <form onSubmit={handleCreate} className="flex gap-2"> |
| <Input |
| value={name} |
| onChange={(e) => setName(e.target.value)} |
| placeholder={t.apiKeysCreateNamePlaceholder} |
| className="flex-1 bg-background/50 border-border/60" |
| maxLength={64} |
| /> |
| <Button type="submit" disabled={createMutation.isPending} className="gap-2"> |
| <Plus className="w-4 h-4" /> |
| {createMutation.isPending ? t.apiKeysCreating : t.apiKeysCreate} |
| </Button> |
| </form> |
| {isLoading ? ( |
| <div className="text-center py-8 text-muted-foreground text-sm">Loading...</div> |
| ) : keys.length === 0 ? ( |
| <div className="text-center py-12 space-y-2"> |
| <Key className="w-10 h-10 mx-auto text-muted-foreground/40" /> |
| <div className="text-muted-foreground">{t.apiKeysEmpty}</div> |
| <div className="text-xs text-muted-foreground/60">{t.apiKeysEmptyHint}</div> |
| </div> |
| ) : ( |
| <div className="space-y-2"> |
| {keys.map((k) => ( |
| <KeyCard key={k.id} apiKey={k} onRevoke={(id) => revokeMutation.mutate(id)} isRevoking={revokeMutation.isPending} /> |
| ))} |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|
| function DocsSection() { |
| const { t } = useLang(); |
| const exampleCode = `from openai import OpenAI |
| |
| client = OpenAI( |
| api_key="sk-sf-...", |
| base_url="${V1_BASE}", |
| ) |
| |
| response = client.images.generate( |
| model="grok", |
| prompt="a glowing neon cityscape at night", |
| n=1, |
| size="1024x1024", |
| ) |
| print(response.data[0].url)`; |
|
|
| return ( |
| <div className="space-y-4 rounded-xl border border-border/60 bg-card/30 p-6"> |
| <h2 className="text-base font-semibold text-foreground flex items-center gap-2"> |
| <Code2 className="w-4 h-4 text-primary" /> |
| {t.apiKeysDocsTitle} |
| </h2> |
| <div> |
| <div className="text-xs text-muted-foreground mb-1">{t.apiKeysBaseUrl}</div> |
| <code className="block text-xs font-mono bg-black/40 rounded-lg px-3 py-2 text-green-300">{V1_BASE}</code> |
| </div> |
| <div> |
| <div className="text-xs text-muted-foreground mb-2">{t.apiKeysEndpointsTitle}</div> |
| <div className="space-y-1.5"> |
| {[ |
| { method: "GET", path: "/models", desc: t.apiKeysModelsDesc }, |
| { method: "POST", path: "/images/generations", desc: t.apiKeysGenerateDesc }, |
| ].map(({ method, path, desc }) => ( |
| <div key={path} className="flex items-center gap-3 text-xs p-2 rounded-lg bg-black/20"> |
| <Badge variant="outline" className="font-mono text-[10px] px-1.5 py-0 border-primary/40 text-primary">{method}</Badge> |
| <code className="font-mono text-green-300">{path}</code> |
| <span className="text-muted-foreground ml-auto">{desc}</span> |
| </div> |
| ))} |
| </div> |
| </div> |
| <div> |
| <div className="text-xs text-muted-foreground mb-1">{t.apiKeysExampleTitle} (Python)</div> |
| <pre className="text-xs font-mono bg-black/40 rounded-lg px-4 py-3 text-emerald-300 overflow-x-auto leading-5 whitespace-pre">{exampleCode}</pre> |
| </div> |
| </div> |
| ); |
| } |
|
|
| export function ApiKeys() { |
| const { t } = useLang(); |
| const { isSignedIn, isLoaded } = useAuth(); |
| const [authOpen, setAuthOpen] = useState(false); |
|
|
| return ( |
| <div className="container mx-auto px-4 py-8 max-w-3xl space-y-8"> |
| <div> |
| <h1 className="text-2xl font-bold text-foreground flex items-center gap-3"> |
| <div className="w-9 h-9 rounded-xl bg-primary/15 border border-primary/30 flex items-center justify-center"> |
| <Sparkles className="w-5 h-5 text-primary" /> |
| </div> |
| {t.apiKeysTitle} |
| </h1> |
| <p className="text-muted-foreground text-sm mt-1">{t.apiKeysSubtitle}</p> |
| </div> |
| |
| {isLoaded && isSignedIn && <KeysContent />} |
| |
| {isLoaded && !isSignedIn && ( |
| <div className="text-center py-16 space-y-4"> |
| <Key className="w-12 h-12 mx-auto text-muted-foreground/30" /> |
| <p className="text-muted-foreground">{t.apiKeysSignInRequired}</p> |
| <Button onClick={() => setAuthOpen(true)}>{t.apiKeysSignIn}</Button> |
| </div> |
| )} |
| |
| <DocsSection /> |
| <AuthModal open={authOpen} onOpenChange={setAuthOpen} /> |
| </div> |
| ); |
| } |
|
|