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)
This commit is contained in:
139
apps/web/src/app/dashboard/keys/page.tsx
Normal file
139
apps/web/src/app/dashboard/keys/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user