Files
codeboard/apps/web/src/app/dashboard/keys/page.tsx
Vectry 64ce70daa4 feat: add subscription service — user auth, Stripe billing, API keys, dashboard
- NextAuth v5 with email+password credentials, JWT sessions
- Registration, login, email verification, password reset flows
- Stripe integration: Free (15/day), Starter ($5/1k/mo), Pro ($20/100k/mo)
- API key management (cb_ prefix) with hash-based validation
- Dashboard with generations history, settings, billing management
- Rate limiting: Redis daily counter (free), DB monthly (paid)
- Generate route auth: Bearer API key + session, anonymous allowed
- Worker userId propagation for generation history
- Pricing section on landing page, auth-aware navbar
- Middleware with route protection, CORS for codeboard.vectry.tech
- Docker env vars for auth, Stripe, email (smtp.migadu.com)
2026-02-10 20:08:13 +00:00

140 lines
9.8 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import { Key, Plus, Copy, Check, Trash2, AlertTriangle, RefreshCw, Shield } from "lucide-react";
import { cn } from "@/lib/utils";
interface ApiKey {
id: string;
name: string;
keyPrefix: string;
createdAt: string;
lastUsedAt: string | null;
}
interface NewKeyResponse extends ApiKey { key: string; }
export default function ApiKeysPage() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [newlyCreatedKey, setNewlyCreatedKey] = useState<NewKeyResponse | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [revokingId, setRevokingId] = useState<string | null>(null);
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
const fetchKeys = useCallback(async () => {
setIsLoading(true);
try { const res = await fetch("/api/keys", { cache: "no-store" }); if (res.ok) setKeys(await res.json()); } catch (e) { console.error("Failed to fetch:", e); } finally { setIsLoading(false); }
}, []);
useEffect(() => { fetchKeys(); }, [fetchKeys]);
const copyToClipboard = async (text: string, field: string) => {
try { await navigator.clipboard.writeText(text); setCopiedField(field); setTimeout(() => setCopiedField(null), 2000); } catch {}
};
const handleCreate = async () => {
setIsCreating(true);
try {
const res = await fetch("/api/keys", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newKeyName.trim() || undefined }) });
if (res.ok) { const data: NewKeyResponse = await res.json(); setNewlyCreatedKey(data); setShowCreateForm(false); setNewKeyName(""); fetchKeys(); }
} catch (e) { console.error("Failed to create:", e); } finally { setIsCreating(false); }
};
const handleRevoke = async (id: string) => {
setRevokingId(id);
try { const res = await fetch(`/api/keys/${id}`, { method: "DELETE" }); if (res.ok) { setConfirmRevokeId(null); fetchKeys(); } } catch (e) { console.error("Failed to revoke:", e); } finally { setRevokingId(null); }
};
const formatDate = (d: string) => new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
return (
<div className="space-y-8 max-w-3xl">
<div className="flex items-center justify-between">
<div><h1 className="text-2xl font-bold text-neutral-100">API Keys</h1><p className="text-neutral-400 mt-1">Manage API keys for programmatic access</p></div>
<button onClick={() => { setShowCreateForm(true); setNewlyCreatedKey(null); }} className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 hover:bg-blue-400 text-white rounded-lg text-sm font-semibold transition-colors"><Plus className="w-4 h-4" /> Create New Key</button>
</div>
{newlyCreatedKey && (
<div className="bg-blue-500/5 border border-blue-500/20 rounded-xl p-6 space-y-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center shrink-0"><Key className="w-5 h-5 text-blue-400" /></div>
<div className="flex-1 min-w-0"><h3 className="text-sm font-semibold text-blue-300">API Key Created</h3><p className="text-xs text-blue-400/60 mt-0.5">{newlyCreatedKey.name}</p></div>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 px-4 py-3 bg-neutral-950 border border-neutral-800 rounded-lg font-mono text-sm text-neutral-200 truncate select-all">{newlyCreatedKey.key}</div>
<button onClick={() => copyToClipboard(newlyCreatedKey.key, "new-key")} aria-label="Copy"
className={cn("p-3 rounded-lg border transition-all shrink-0", copiedField === "new-key" ? "bg-blue-500/10 border-blue-500/30 text-blue-400" : "bg-neutral-800 border-neutral-700 text-neutral-400 hover:text-neutral-200")}>
{copiedField === "new-key" ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="flex items-center gap-2 px-3 py-2.5 bg-amber-500/5 border border-amber-500/20 rounded-lg"><AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" /><p className="text-xs text-amber-300/80">This key won&apos;t be shown again. Copy it now.</p></div>
<button onClick={() => setNewlyCreatedKey(null)} className="text-xs text-neutral-500 hover:text-neutral-300 transition-colors">Dismiss</button>
</div>
)}
{showCreateForm && !newlyCreatedKey && (
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
<div className="flex items-center gap-2 text-neutral-300"><Plus className="w-5 h-5 text-blue-400" /><h2 className="text-sm font-semibold">Create New API Key</h2></div>
<div>
<label htmlFor="key-name" className="text-xs text-neutral-500 font-medium block mb-1.5">Key Name (optional)</label>
<input id="key-name" type="text" value={newKeyName} onChange={(e) => setNewKeyName(e.target.value)} placeholder="e.g. Production, Staging"
className="w-full px-4 py-2.5 bg-neutral-950 border border-neutral-800 rounded-lg text-sm text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-blue-500/40 focus:ring-1 focus:ring-blue-500/20 transition-colors"
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} autoFocus />
</div>
<div className="flex items-center gap-3">
<button onClick={handleCreate} disabled={isCreating} className="flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-400 text-white rounded-lg text-sm font-semibold disabled:opacity-50 transition-colors">
{isCreating ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Key className="w-4 h-4" />} Generate Key
</button>
<button onClick={() => { setShowCreateForm(false); setNewKeyName(""); }} className="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors">Cancel</button>
</div>
</div>
)}
<section className="space-y-4">
<div className="flex items-center gap-2 text-neutral-300"><Shield className="w-5 h-5 text-blue-400" /><h2 className="text-lg font-semibold">Active Keys</h2></div>
<div className="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-6 space-y-4">{Array.from({ length: 3 }).map((_, i) => (<div key={i} className="flex items-center gap-4 animate-pulse"><div className="w-8 h-8 bg-neutral-800 rounded-lg" /><div className="flex-1 space-y-2"><div className="h-4 w-32 bg-neutral-800 rounded" /><div className="h-3 w-48 bg-neutral-800 rounded" /></div><div className="h-8 w-20 bg-neutral-800 rounded" /></div>))}</div>
) : keys.length === 0 ? (
<div className="p-12 text-center"><div className="w-12 h-12 rounded-xl bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center mx-auto mb-4"><Key className="w-6 h-6 text-neutral-600" /></div><p className="text-sm text-neutral-400 font-medium">No API keys yet</p><p className="text-xs text-neutral-600 mt-1">Create one for programmatic access</p></div>
) : (
<div className="divide-y divide-neutral-800">
{keys.map((apiKey) => (
<div key={apiKey.id} className="flex items-center gap-4 px-6 py-4 group transition-colors">
<div className="w-9 h-9 rounded-lg bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center shrink-0"><Key className="w-4 h-4 text-neutral-500" /></div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-neutral-200 truncate">{apiKey.name}</p>
<div className="flex items-center gap-3 mt-0.5">
<code className="text-xs font-mono text-neutral-500">{apiKey.keyPrefix}</code>
<span className="text-xs text-neutral-600">Created {formatDate(apiKey.createdAt)}</span>
{apiKey.lastUsedAt && <span className="text-xs text-neutral-600">Last used {formatDate(apiKey.lastUsedAt)}</span>}
</div>
</div>
{confirmRevokeId === apiKey.id ? (
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => setConfirmRevokeId(null)} className="px-3 py-1.5 text-xs text-neutral-400 hover:text-neutral-200 transition-colors">Cancel</button>
<button onClick={() => handleRevoke(apiKey.id)} disabled={revokingId === apiKey.id}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/20 border border-red-500/30 text-red-400 rounded-lg text-xs font-medium hover:bg-red-500/30 disabled:opacity-50 transition-colors">
{revokingId === apiKey.id ? <RefreshCw className="w-3 h-3 animate-spin" /> : <AlertTriangle className="w-3 h-3" />} Confirm
</button>
</div>
) : (
<button onClick={() => setConfirmRevokeId(apiKey.id)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-neutral-800 border border-neutral-700 text-neutral-500 rounded-lg text-xs font-medium opacity-0 group-hover:opacity-100 hover:text-red-400 hover:border-red-500/30 transition-all shrink-0">
<Trash2 className="w-3 h-3" /> Revoke
</button>
)}
</div>
))}
</div>
)}
</div>
</section>
</div>
);
}