feat: user auth, API keys, Stripe billing, and dashboard scoping
- NextAuth v5 credentials auth with registration/login pages - API key CRUD (create, list, revoke) with secure hashing - Stripe checkout, webhooks, and customer portal integration - Rate limiting per subscription tier - All dashboard API endpoints scoped to authenticated user - Prisma schema: User, Account, Session, ApiKey, plus Stripe fields - Auth middleware protecting dashboard and API routes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
340
apps/web/src/app/dashboard/keys/page.tsx
Normal file
340
apps/web/src/app/dashboard/keys/page.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
"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) {
|
||||
const data = await res.json();
|
||||
setKeys(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch API keys:", error);
|
||||
} 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 {
|
||||
console.error("Failed to copy");
|
||||
}
|
||||
};
|
||||
|
||||
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 (error) {
|
||||
console.error("Failed to create API key:", error);
|
||||
} 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 (error) {
|
||||
console.error("Failed to revoke API key:", error);
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).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 SDK authentication
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateForm(true);
|
||||
setNewlyCreatedKey(null);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-400 text-neutral-950 rounded-lg text-sm font-semibold transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create New Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{newlyCreatedKey && (
|
||||
<div className="bg-emerald-500/5 border border-emerald-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-emerald-500/10 border border-emerald-500/20 flex items-center justify-center shrink-0">
|
||||
<Key className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-emerald-300">
|
||||
API Key Created
|
||||
</h3>
|
||||
<p className="text-xs text-emerald-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")
|
||||
}
|
||||
className={cn(
|
||||
"p-3 rounded-lg border transition-all shrink-0",
|
||||
copiedField === "new-key"
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-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 and store it
|
||||
securely.
|
||||
</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-emerald-400" />
|
||||
<h2 className="text-sm font-semibold">Create New API Key</h2>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-500 font-medium block mb-1.5">
|
||||
Key Name (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="e.g. Production, Staging, CI/CD"
|
||||
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-emerald-500/40 focus:ring-1 focus:ring-emerald-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-emerald-500 hover:bg-emerald-400 text-neutral-950 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-emerald-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 to authenticate your SDK
|
||||
</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"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Activity,
|
||||
GitBranch,
|
||||
Key,
|
||||
Settings,
|
||||
Menu,
|
||||
ChevronRight,
|
||||
@@ -22,6 +23,7 @@ interface NavItem {
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/dashboard", label: "Traces", icon: Activity },
|
||||
{ href: "/dashboard/decisions", label: "Decisions", icon: GitBranch },
|
||||
{ href: "/dashboard/keys", label: "API Keys", icon: Key },
|
||||
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
Database,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
CreditCard,
|
||||
Crown,
|
||||
Zap,
|
||||
ArrowUpRight,
|
||||
User,
|
||||
Calendar,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -21,12 +28,71 @@ interface Stats {
|
||||
totalEvents: number;
|
||||
}
|
||||
|
||||
interface AccountData {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
createdAt: string;
|
||||
subscription: {
|
||||
tier: "FREE" | "STARTER" | "PRO";
|
||||
status: "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID";
|
||||
sessionsUsed: number;
|
||||
sessionsLimit: number;
|
||||
currentPeriodStart: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
hasStripeSubscription: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const TIERS = [
|
||||
{
|
||||
key: "FREE" as const,
|
||||
name: "Free",
|
||||
price: 0,
|
||||
period: "day",
|
||||
sessions: 20,
|
||||
description: "For getting started",
|
||||
features: ["20 sessions per day", "Basic trace viewing", "Community support"],
|
||||
},
|
||||
{
|
||||
key: "STARTER" as const,
|
||||
name: "Starter",
|
||||
price: 5,
|
||||
period: "month",
|
||||
sessions: 1000,
|
||||
description: "For small teams",
|
||||
features: [
|
||||
"1,000 sessions per month",
|
||||
"Advanced analytics",
|
||||
"Priority support",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "PRO" as const,
|
||||
name: "Pro",
|
||||
price: 20,
|
||||
period: "month",
|
||||
sessions: 100000,
|
||||
description: "For production workloads",
|
||||
features: [
|
||||
"100,000 sessions per month",
|
||||
"Full analytics suite",
|
||||
"Dedicated support",
|
||||
"Custom retention",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [account, setAccount] = useState<AccountData | null>(null);
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
||||
const [isLoadingAccount, setIsLoadingAccount] = useState(true);
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
const [isPurging, setIsPurging] = useState(false);
|
||||
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
|
||||
const [upgradingTier, setUpgradingTier] = useState<string | null>(null);
|
||||
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
setIsLoadingStats(true);
|
||||
@@ -43,9 +109,25 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchAccount = useCallback(async () => {
|
||||
setIsLoadingAccount(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/account", { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAccount(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch account:", error);
|
||||
} finally {
|
||||
setIsLoadingAccount(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, [fetchStats]);
|
||||
fetchAccount();
|
||||
}, [fetchStats, fetchAccount]);
|
||||
|
||||
const copyToClipboard = async (text: string, field: string) => {
|
||||
try {
|
||||
@@ -72,6 +154,48 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpgrade = async (tierKey: string) => {
|
||||
setUpgradingTier(tierKey);
|
||||
try {
|
||||
const res = await fetch("/api/stripe/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tierKey }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkout:", error);
|
||||
} finally {
|
||||
setUpgradingTier(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageSubscription = async () => {
|
||||
setIsOpeningPortal(true);
|
||||
try {
|
||||
const res = await fetch("/api/stripe/portal", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open portal:", error);
|
||||
} finally {
|
||||
setIsOpeningPortal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentTier = account?.subscription?.tier ?? "FREE";
|
||||
const sessionsUsed = account?.subscription?.sessionsUsed ?? 0;
|
||||
const sessionsLimit = account?.subscription?.sessionsLimit ?? 20;
|
||||
const usagePercent =
|
||||
sessionsLimit > 0
|
||||
? Math.min((sessionsUsed / sessionsLimit) * 100, 100)
|
||||
: 0;
|
||||
|
||||
const endpointUrl =
|
||||
typeof window !== "undefined"
|
||||
? `${window.location.origin}/api/traces`
|
||||
@@ -82,10 +206,225 @@ export default function SettingsPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-100">Settings</h1>
|
||||
<p className="text-neutral-400 mt-1">
|
||||
Configuration and SDK connection details
|
||||
Account, billing, and configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Account */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-neutral-300">
|
||||
<User className="w-5 h-5 text-emerald-400" />
|
||||
<h2 className="text-lg font-semibold">Account</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
|
||||
{isLoadingAccount ? (
|
||||
<div className="flex items-center gap-3 text-neutral-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">Loading account...</span>
|
||||
</div>
|
||||
) : account ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 font-medium mb-1">
|
||||
Email
|
||||
</p>
|
||||
<p className="text-sm text-neutral-200 font-medium">
|
||||
{account.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 font-medium mb-1">
|
||||
Name
|
||||
</p>
|
||||
<p className="text-sm text-neutral-200 font-medium">
|
||||
{account.name ?? "\u2014"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 font-medium mb-1">
|
||||
Member since
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-sm text-neutral-200 font-medium">
|
||||
<Calendar className="w-3.5 h-3.5 text-neutral-500" />
|
||||
{new Date(account.createdAt).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">
|
||||
Unable to load account info
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Subscription & Billing */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-neutral-300">
|
||||
<CreditCard className="w-5 h-5 text-emerald-400" />
|
||||
<h2 className="text-lg font-semibold">Subscription & Billing</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-neutral-400">Current plan</span>
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-emerald-500/10 border border-emerald-500/20 text-emerald-400">
|
||||
{currentTier === "PRO" && <Crown className="w-3 h-3" />}
|
||||
{currentTier === "STARTER" && <Zap className="w-3 h-3" />}
|
||||
{currentTier}
|
||||
</span>
|
||||
</div>
|
||||
{currentTier !== "FREE" &&
|
||||
account?.subscription?.hasStripeSubscription && (
|
||||
<button
|
||||
onClick={handleManageSubscription}
|
||||
disabled={isOpeningPortal}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-neutral-400 bg-neutral-800 border border-neutral-700 rounded-lg hover:text-neutral-200 hover:border-neutral-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isOpeningPortal ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
)}
|
||||
Manage Subscription
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-neutral-400">
|
||||
{sessionsUsed.toLocaleString()} of{" "}
|
||||
{sessionsLimit.toLocaleString()} sessions used
|
||||
</span>
|
||||
<span className="text-neutral-500 text-xs">
|
||||
{currentTier === "FREE"
|
||||
? "20 sessions/day"
|
||||
: "This billing period"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-neutral-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-500",
|
||||
usagePercent > 90 ? "bg-amber-500" : "bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${usagePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{currentTier !== "FREE" &&
|
||||
account?.subscription?.currentPeriodStart &&
|
||||
account?.subscription?.currentPeriodEnd && (
|
||||
<p className="text-xs text-neutral-600">
|
||||
Period:{" "}
|
||||
{new Date(
|
||||
account.subscription.currentPeriodStart
|
||||
).toLocaleDateString()}{" "}
|
||||
\u2014{" "}
|
||||
{new Date(
|
||||
account.subscription.currentPeriodEnd
|
||||
).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{TIERS.map((tier) => {
|
||||
const isCurrent = currentTier === tier.key;
|
||||
const tierOrder = { FREE: 0, STARTER: 1, PRO: 2 };
|
||||
const isUpgrade = tierOrder[tier.key] > tierOrder[currentTier];
|
||||
const isDowngrade = tierOrder[tier.key] < tierOrder[currentTier];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tier.key}
|
||||
className={cn(
|
||||
"relative bg-neutral-900 border rounded-xl p-5 flex flex-col transition-colors",
|
||||
isCurrent
|
||||
? "border-emerald-500/40 shadow-[0_0_24px_-6px_rgba(16,185,129,0.12)]"
|
||||
: "border-neutral-800 hover:border-neutral-700"
|
||||
)}
|
||||
>
|
||||
{isCurrent && (
|
||||
<div className="absolute -top-2.5 left-4">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500 text-neutral-950">
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<h3 className="text-base font-semibold text-neutral-100">
|
||||
{tier.name}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-500 mt-0.5">
|
||||
{tier.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<span className="text-2xl font-bold text-neutral-100">
|
||||
${tier.price}
|
||||
</span>
|
||||
<span className="text-sm text-neutral-500">
|
||||
/{tier.period}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 mb-5 flex-1">
|
||||
{tier.features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-start gap-2 text-xs text-neutral-400"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5 text-emerald-500 mt-0.5 shrink-0" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{isCurrent ? (
|
||||
<div className="py-2 text-center text-xs font-medium text-emerald-400 bg-emerald-500/5 border border-emerald-500/10 rounded-lg">
|
||||
Active plan
|
||||
</div>
|
||||
) : isUpgrade ? (
|
||||
<button
|
||||
onClick={() => handleUpgrade(tier.key)}
|
||||
disabled={upgradingTier === tier.key}
|
||||
className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium bg-emerald-500 hover:bg-emerald-400 text-neutral-950 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{upgradingTier === tier.key ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Upgrade
|
||||
</button>
|
||||
) : isDowngrade ? (
|
||||
<button
|
||||
onClick={handleManageSubscription}
|
||||
disabled={isOpeningPortal}
|
||||
className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium bg-neutral-800 border border-neutral-700 text-neutral-300 rounded-lg hover:text-neutral-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isOpeningPortal && (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
)}
|
||||
Downgrade
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* SDK Connection */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-neutral-300">
|
||||
@@ -102,25 +441,17 @@ export default function SettingsPage() {
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
|
||||
<SettingField
|
||||
label="API Key"
|
||||
value="any-value-accepted"
|
||||
hint="Authentication is not enforced yet. Use any non-empty string as your Bearer token."
|
||||
copiedField={copiedField}
|
||||
fieldKey="apikey"
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
|
||||
<div className="pt-4 border-t border-neutral-800">
|
||||
<p className="text-xs text-neutral-500 mb-3">Quick start</p>
|
||||
<div className="bg-neutral-950 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
|
||||
<pre>{`from agentlens import init
|
||||
|
||||
init(
|
||||
api_key="your-api-key",
|
||||
endpoint="${endpointUrl.replace("/api/traces", "")}",
|
||||
)`}</pre>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mb-1.5">API Key</p>
|
||||
<p className="text-xs text-neutral-600">
|
||||
Manage your API keys from the{" "}
|
||||
<a
|
||||
href="/dashboard/keys"
|
||||
className="text-emerald-400 hover:text-emerald-300 transition-colors underline underline-offset-2"
|
||||
>
|
||||
API Keys page
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -275,9 +606,7 @@ function SettingField({
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{hint && (
|
||||
<p className="text-xs text-neutral-600 mt-1.5">{hint}</p>
|
||||
)}
|
||||
{hint && <p className="text-xs text-neutral-600 mt-1.5">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user