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:
Vectry
2026-02-10 15:37:49 +00:00
parent 07cf717c15
commit 61268f870f
33 changed files with 2247 additions and 57 deletions

View File

@@ -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>
);
}