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

@@ -15,14 +15,19 @@
"@agentlens/database": "*",
"@dagrejs/dagre": "^2.0.4",
"@xyflow/react": "^12.10.0",
"bcryptjs": "^3.0.3",
"lucide-react": "^0.469.0",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.30",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"shiki": "^3.22.0"
"shiki": "^3.22.0",
"stripe": "^20.3.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/dagre": "^0.7.53",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",

View File

@@ -0,0 +1,11 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-neutral-950 flex items-center justify-center px-4">
<div className="w-full max-w-md">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Activity, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const passwordValid = password.length >= 8;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!emailValid) {
setError("Please enter a valid email address");
return;
}
if (!passwordValid) {
setError("Password must be at least 8 characters");
return;
}
setLoading(true);
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
setError("Invalid email or password");
setLoading(false);
return;
}
router.push("/dashboard");
router.refresh();
}
return (
<div className="space-y-8">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<Activity className="w-6 h-6 text-white" />
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-neutral-100">Welcome back</h1>
<p className="mt-1 text-sm text-neutral-400">
Sign in to your AgentLens account
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
<div className="space-y-2">
<label
htmlFor="email"
className="block text-sm font-medium text-neutral-300"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={cn(
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
email && !emailValid
? "border-red-500/50 focus:border-red-500"
: "border-neutral-800 focus:border-emerald-500"
)}
/>
{email && !emailValid && (
<p className="text-xs text-red-400">
Please enter a valid email address
</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="password"
className="block text-sm font-medium text-neutral-300"
>
Password
</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className={cn(
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
password && !passwordValid
? "border-red-500/50 focus:border-red-500"
: "border-neutral-800 focus:border-emerald-500"
)}
/>
{password && !passwordValid && (
<p className="text-xs text-red-400">
Password must be at least 8 characters
</p>
)}
</div>
</div>
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className={cn(
"w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors",
loading
? "bg-emerald-500/50 text-neutral-950/50 cursor-not-allowed"
: "bg-emerald-500 hover:bg-emerald-400 text-neutral-950"
)}
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
<p className="text-center text-sm text-neutral-400">
Don&apos;t have an account?{" "}
<Link
href="/register"
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
>
Create one
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Activity, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export default function RegisterPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const passwordValid = password.length >= 8;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!emailValid) {
setError("Please enter a valid email address");
return;
}
if (!passwordValid) {
setError("Password must be at least 8 characters");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
password,
...(name.trim() ? { name: name.trim() } : {}),
}),
});
if (!res.ok) {
const data: { error?: string } = await res.json();
setError(data.error ?? "Registration failed");
setLoading(false);
return;
}
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
setError("Account created but sign-in failed. Please log in manually.");
setLoading(false);
return;
}
router.push("/dashboard");
router.refresh();
} catch {
setError("Something went wrong. Please try again.");
setLoading(false);
}
}
return (
<div className="space-y-8">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/20">
<Activity className="w-6 h-6 text-white" />
</div>
<div className="text-center">
<h1 className="text-2xl font-bold text-neutral-100">
Create your account
</h1>
<p className="mt-1 text-sm text-neutral-400">
Start monitoring your AI agents with AgentLens
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
<div className="space-y-2">
<label
htmlFor="name"
className="block text-sm font-medium text-neutral-300"
>
Name{" "}
<span className="text-neutral-500 font-normal">(optional)</span>
</label>
<input
id="name"
type="text"
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Jane Doe"
className="w-full px-3 py-2.5 bg-neutral-950 border border-neutral-800 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors focus:border-emerald-500"
/>
</div>
<div className="space-y-2">
<label
htmlFor="email"
className="block text-sm font-medium text-neutral-300"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={cn(
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
email && !emailValid
? "border-red-500/50 focus:border-red-500"
: "border-neutral-800 focus:border-emerald-500"
)}
/>
{email && !emailValid && (
<p className="text-xs text-red-400">
Please enter a valid email address
</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="password"
className="block text-sm font-medium text-neutral-300"
>
Password
</label>
<input
id="password"
type="password"
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className={cn(
"w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors",
password && !passwordValid
? "border-red-500/50 focus:border-red-500"
: "border-neutral-800 focus:border-emerald-500"
)}
/>
{password && !passwordValid && (
<p className="text-xs text-red-400">
Password must be at least 8 characters
</p>
)}
</div>
</div>
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={loading}
className={cn(
"w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors",
loading
? "bg-emerald-500/50 text-neutral-950/50 cursor-not-allowed"
: "bg-emerald-500 hover:bg-emerald-400 text-neutral-950"
)}
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Creating account…" : "Create account"}
</button>
</form>
<p className="text-center text-sm text-neutral-400">
Already have an account?{" "}
<Link
href="/login"
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
>
Sign in
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { hash } from "bcryptjs";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
const registerSchema = z.object({
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
name: z.string().min(1).optional(),
});
export async function POST(request: Request) {
try {
const body: unknown = await request.json();
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
);
}
const { email, password, name } = parsed.data;
const normalizedEmail = email.toLowerCase();
const existing = await prisma.user.findUnique({
where: { email: normalizedEmail },
});
if (existing) {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 409 }
);
}
const passwordHash = await hash(password, 12);
const user = await prisma.user.create({
data: {
email: normalizedEmail,
passwordHash,
name: name ?? null,
subscription: {
create: {
tier: "FREE",
sessionsLimit: 20,
},
},
},
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
return NextResponse.json(user, { status: 201 });
} catch {
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -1,9 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@agentlens/database";
import { auth } from "@/auth";
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") ?? "1", 10);
const limit = parseInt(searchParams.get("limit") ?? "20", 10);
@@ -51,8 +57,9 @@ export async function GET(request: NextRequest) {
);
}
// Build where clause
const where: Prisma.DecisionPointWhereInput = {};
const where: Prisma.DecisionPointWhereInput = {
trace: { userId: session.user.id },
};
if (type) {
where.type = type as Prisma.EnumDecisionTypeFilter["equals"];
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const apiKey = await prisma.apiKey.findFirst({
where: { id, userId: session.user.id, revoked: false },
select: { id: true },
});
if (!apiKey) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
await prisma.apiKey.update({
where: { id: apiKey.id },
data: { revoked: true },
});
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Error revoking API key:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { randomBytes, createHash } from "crypto";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const keys = await prisma.apiKey.findMany({
where: { userId: session.user.id, revoked: false },
select: {
id: true,
name: true,
keyPrefix: true,
createdAt: true,
lastUsedAt: true,
},
orderBy: { createdAt: "desc" },
});
return NextResponse.json(keys, { status: 200 });
} catch (error) {
console.error("Error listing API keys:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json().catch(() => ({}));
const name =
typeof body.name === "string" && body.name.trim()
? body.name.trim()
: "Default";
const rawHex = randomBytes(24).toString("hex");
const fullKey = `al_${rawHex}`;
const keyPrefix = fullKey.slice(0, 10);
const keyHash = createHash("sha256").update(fullKey).digest("hex");
const apiKey = await prisma.apiKey.create({
data: {
userId: session.user.id,
name,
keyHash,
keyPrefix,
},
select: {
id: true,
name: true,
keyPrefix: true,
createdAt: true,
},
});
return NextResponse.json(
{ ...apiKey, key: fullKey },
{ status: 201 }
);
} catch (error) {
console.error("Error creating API key:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
id: true,
email: true,
name: true,
createdAt: true,
subscription: {
select: {
tier: true,
status: true,
sessionsUsed: true,
sessionsLimit: true,
currentPeriodStart: true,
currentPeriodEnd: true,
stripeCustomerId: true,
},
},
},
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Don't expose raw Stripe customer ID to the client
const { subscription, ...rest } = user;
const safeSubscription = subscription
? {
tier: subscription.tier,
status: subscription.status,
sessionsUsed: subscription.sessionsUsed,
sessionsLimit: subscription.sessionsLimit,
currentPeriodStart: subscription.currentPeriodStart,
currentPeriodEnd: subscription.currentPeriodEnd,
hasStripeSubscription: !!subscription.stripeCustomerId,
}
: null;
return NextResponse.json({ ...rest, subscription: safeSubscription }, { status: 200 });
} catch (error) {
console.error("Error fetching account:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -1,13 +1,22 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
export async function POST() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = session.user.id;
const traceFilter = { trace: { userId } };
await prisma.$transaction([
prisma.event.deleteMany(),
prisma.decisionPoint.deleteMany(),
prisma.span.deleteMany(),
prisma.trace.deleteMany(),
prisma.event.deleteMany({ where: traceFilter }),
prisma.decisionPoint.deleteMany({ where: traceFilter }),
prisma.span.deleteMany({ where: traceFilter }),
prisma.trace.deleteMany({ where: { userId } }),
]);
return NextResponse.json({ success: true }, { status: 200 });

View File

@@ -1,14 +1,24 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
export async function GET() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = session.user.id;
const traceFilter = { userId };
const childFilter = { trace: { userId } };
const [totalTraces, totalSpans, totalDecisions, totalEvents] =
await Promise.all([
prisma.trace.count(),
prisma.span.count(),
prisma.decisionPoint.count(),
prisma.event.count(),
prisma.trace.count({ where: traceFilter }),
prisma.span.count({ where: childFilter }),
prisma.decisionPoint.count({ where: childFilter }),
prisma.event.count({ where: childFilter }),
]);
return NextResponse.json(

View File

@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { priceId, tierKey } = body as {
priceId?: string;
tierKey?: string;
};
let resolvedPriceId = priceId;
if (!resolvedPriceId && tierKey) {
const tierConfig =
TIER_CONFIG[tierKey as keyof typeof TIER_CONFIG];
if (tierConfig && "priceId" in tierConfig) {
resolvedPriceId = tierConfig.priceId;
}
}
if (!resolvedPriceId) {
return NextResponse.json(
{ error: "priceId or tierKey is required" },
{ status: 400 }
);
}
const validPriceIds = [TIER_CONFIG.STARTER.priceId, TIER_CONFIG.PRO.priceId];
if (!validPriceIds.includes(resolvedPriceId)) {
return NextResponse.json(
{ error: "Invalid priceId" },
{ status: 400 }
);
}
const userId = session.user.id;
let subscription = await prisma.subscription.findUnique({
where: { userId },
});
let stripeCustomerId = subscription?.stripeCustomerId;
if (!stripeCustomerId) {
const customer = await getStripe().customers.create({
email: session.user.email,
name: session.user.name ?? undefined,
metadata: { userId },
});
stripeCustomerId = customer.id;
if (subscription) {
await prisma.subscription.update({
where: { userId },
data: { stripeCustomerId },
});
} else {
subscription = await prisma.subscription.create({
data: {
userId,
stripeCustomerId,
},
});
}
}
const origin =
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
const checkoutSession = await getStripe().checkout.sessions.create({
customer: stripeCustomerId,
mode: "subscription",
line_items: [{ price: resolvedPriceId, quantity: 1 }],
success_url: `${origin}/dashboard/settings?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/dashboard/settings`,
metadata: { userId },
});
return NextResponse.json({ url: checkoutSession.url }, { status: 200 });
} catch (error) {
console.error("Error creating checkout session:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { getStripe } from "@/lib/stripe";
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const subscription = await prisma.subscription.findUnique({
where: { userId: session.user.id },
select: { stripeCustomerId: true },
});
if (!subscription?.stripeCustomerId) {
return NextResponse.json(
{ error: "No active subscription to manage" },
{ status: 400 }
);
}
const origin =
request.headers.get("origin") ?? "https://agentlens.vectry.tech";
const portalSession = await getStripe().billingPortal.sessions.create({
customer: subscription.stripeCustomerId,
return_url: `${origin}/dashboard/settings`,
});
return NextResponse.json({ url: portalSession.url }, { status: 200 });
} catch (error) {
console.error("Error creating portal session:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,179 @@
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
export const runtime = "nodejs";
function tierFromPriceId(priceId: string | null): "FREE" | "STARTER" | "PRO" {
if (priceId === TIER_CONFIG.STARTER.priceId) return "STARTER";
if (priceId === TIER_CONFIG.PRO.priceId) return "PRO";
return "FREE";
}
function sessionsLimitForTier(tier: "FREE" | "STARTER" | "PRO"): number {
return TIER_CONFIG[tier].sessionsLimit;
}
async function handleCheckoutCompleted(
checkoutSession: Stripe.Checkout.Session
) {
const userId = checkoutSession.metadata?.userId;
if (!userId) return;
const subscriptionId = checkoutSession.subscription as string;
const customerId = checkoutSession.customer as string;
const sub = await getStripe().subscriptions.retrieve(subscriptionId);
const firstItem = sub.items.data[0];
const priceId = firstItem?.price?.id ?? null;
const tier = tierFromPriceId(priceId);
const periodStart = firstItem?.current_period_start
? new Date(firstItem.current_period_start * 1000)
: new Date();
const periodEnd = firstItem?.current_period_end
? new Date(firstItem.current_period_end * 1000)
: new Date();
await prisma.subscription.upsert({
where: { userId },
update: {
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
stripePriceId: priceId,
tier,
sessionsLimit: sessionsLimitForTier(tier),
sessionsUsed: 0,
status: "ACTIVE",
currentPeriodStart: periodStart,
currentPeriodEnd: periodEnd,
},
create: {
userId,
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
stripePriceId: priceId,
tier,
sessionsLimit: sessionsLimitForTier(tier),
sessionsUsed: 0,
status: "ACTIVE",
currentPeriodStart: periodStart,
currentPeriodEnd: periodEnd,
},
});
}
async function handleSubscriptionUpdated(sub: Stripe.Subscription) {
const firstItem = sub.items.data[0];
const priceId = firstItem?.price?.id ?? null;
const tier = tierFromPriceId(priceId);
const statusMap: Record<string, "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID"> = {
active: "ACTIVE",
past_due: "PAST_DUE",
canceled: "CANCELED",
unpaid: "UNPAID",
};
const dbStatus = statusMap[sub.status] ?? "ACTIVE";
const periodStart = firstItem?.current_period_start
? new Date(firstItem.current_period_start * 1000)
: undefined;
const periodEnd = firstItem?.current_period_end
? new Date(firstItem.current_period_end * 1000)
: undefined;
await prisma.subscription.updateMany({
where: { stripeSubscriptionId: sub.id },
data: {
tier,
stripePriceId: priceId,
sessionsLimit: sessionsLimitForTier(tier),
status: dbStatus,
...(periodStart && { currentPeriodStart: periodStart }),
...(periodEnd && { currentPeriodEnd: periodEnd }),
},
});
}
async function handleSubscriptionDeleted(sub: Stripe.Subscription) {
await prisma.subscription.updateMany({
where: { stripeSubscriptionId: sub.id },
data: {
status: "CANCELED",
tier: "FREE",
sessionsLimit: TIER_CONFIG.FREE.sessionsLimit,
},
});
}
async function handleInvoicePaid(invoice: Stripe.Invoice) {
const subDetail = invoice.parent?.subscription_details?.subscription;
const subscriptionId =
typeof subDetail === "string" ? subDetail : subDetail?.id;
if (!subscriptionId) return;
await prisma.subscription.updateMany({
where: { stripeSubscriptionId: subscriptionId },
data: { sessionsUsed: 0 },
});
}
export async function POST(request: Request) {
const body = await request.text();
const sig = request.headers.get("stripe-signature");
if (!sig) {
return NextResponse.json(
{ error: "Missing stripe-signature header" },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = getStripe().webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed");
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
);
}
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(
event.data.object as Stripe.Subscription
);
break;
case "invoice.paid":
await handleInvoicePaid(event.data.object as Stripe.Invoice);
break;
}
} catch (error) {
console.error(`Error handling ${event.type}:`, error);
return NextResponse.json(
{ error: "Webhook handler failed" },
{ status: 500 }
);
}
return NextResponse.json({ received: true }, { status: 200 });
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
type RouteParams = { params: Promise<{ id: string }> };
@@ -23,14 +24,19 @@ export async function GET(
{ params }: RouteParams
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
if (!id || typeof id !== "string") {
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
}
const trace = await prisma.trace.findUnique({
where: { id },
const trace = await prisma.trace.findFirst({
where: { id, userId: session.user.id },
include: {
decisionPoints: {
orderBy: {
@@ -106,14 +112,19 @@ export async function DELETE(
{ params }: RouteParams
) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
if (!id || typeof id !== "string") {
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
}
const trace = await prisma.trace.findUnique({
where: { id },
const trace = await prisma.trace.findFirst({
where: { id, userId: session.user.id },
select: { id: true },
});

View File

@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@agentlens/database";
import { validateApiKey } from "@/lib/api-key";
import { auth } from "@/auth";
// Types
interface DecisionPointPayload {
@@ -90,11 +92,55 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 });
}
const apiKey = authHeader.slice(7);
if (!apiKey) {
const rawApiKey = authHeader.slice(7);
if (!rawApiKey) {
return NextResponse.json({ error: "Missing API key in Authorization header" }, { status: 401 });
}
const keyValidation = await validateApiKey(rawApiKey);
if (!keyValidation) {
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
}
const { userId, subscription } = keyValidation;
if (!subscription) {
return NextResponse.json({ error: "No subscription found for this user" }, { status: 403 });
}
const tier = subscription.tier;
const sessionsLimit = subscription.sessionsLimit;
if (tier === "FREE") {
const startOfToday = new Date();
startOfToday.setUTCHours(0, 0, 0, 0);
const dailyCount = await prisma.trace.count({
where: {
userId,
createdAt: { gte: startOfToday },
},
});
if (dailyCount >= sessionsLimit) {
return NextResponse.json(
{
error: `Rate limit exceeded. Current plan: ${tier}. Limit: ${sessionsLimit}/day. Upgrade at /settings/billing`,
},
{ status: 429 }
);
}
} else {
if (subscription.sessionsUsed >= sessionsLimit) {
return NextResponse.json(
{
error: `Rate limit exceeded. Current plan: ${tier}. Limit: ${sessionsLimit}/month. Upgrade at /settings/billing`,
},
{ status: 429 }
);
}
}
// Parse and validate request body
const body: BatchTracesRequest = await request.json();
if (!body.traces || !Array.isArray(body.traces)) {
@@ -190,8 +236,14 @@ export async function POST(request: NextRequest) {
// final flushes both work seamlessly.
const result = await prisma.$transaction(async (tx) => {
const upserted: string[] = [];
let newTraceCount = 0;
for (const trace of body.traces) {
const existing = await tx.trace.findUnique({
where: { id: trace.id },
select: { id: true },
});
const traceData = {
name: trace.name,
sessionId: trace.sessionId,
@@ -205,13 +257,16 @@ export async function POST(request: NextRequest) {
endedAt: trace.endedAt ? new Date(trace.endedAt) : null,
};
// 1. Upsert the trace record
await tx.trace.upsert({
where: { id: trace.id },
create: { id: trace.id, ...traceData },
create: { id: trace.id, userId, ...traceData },
update: traceData,
});
if (!existing) {
newTraceCount++;
}
// 2. Delete existing child records (order matters for FK constraints:
// decision points reference spans, so delete decisions first)
await tx.decisionPoint.deleteMany({ where: { traceId: trace.id } });
@@ -283,6 +338,13 @@ export async function POST(request: NextRequest) {
upserted.push(trace.id);
}
if (newTraceCount > 0 && tier !== "FREE") {
await tx.subscription.update({
where: { id: subscription.id },
data: { sessionsUsed: { increment: newTraceCount } },
});
}
return upserted;
});
@@ -300,6 +362,11 @@ export async function POST(request: NextRequest) {
// GET /api/traces — List traces with pagination
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") ?? "1", 10);
const limit = parseInt(searchParams.get("limit") ?? "20", 10);
@@ -339,8 +406,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Invalid dateTo parameter. Must be a valid ISO date string." }, { status: 400 });
}
// Build where clause
const where: Record<string, unknown> = {};
const where: Record<string, unknown> = { userId: session.user.id };
if (status) {
where.status = status;
}

View File

@@ -1,5 +1,6 @@
import { NextRequest } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
export const dynamic = "force-dynamic";
@@ -22,6 +23,13 @@ interface TraceUpdateData {
}
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const currentUserId = session.user.id;
const headers = new Headers();
headers.set("Content-Type", "text/event-stream");
headers.set("Cache-Control", "no-cache");
@@ -43,6 +51,7 @@ export async function GET(request: NextRequest) {
try {
const newTraces = await prisma.trace.findMany({
where: {
userId: currentUserId,
OR: [
{ createdAt: { gt: lastCheck } },
{ updatedAt: { gt: lastCheck } },

View 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&apos;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>
);
}

View File

@@ -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 },
];

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

View File

@@ -40,7 +40,26 @@ export default function GettingStartedPage() {
</a>
)
</li>
<li>An API key for authentication</li>
<li>
An AgentLens account {" "}
<a
href="/register"
className="text-emerald-400 hover:underline"
>
sign up here
</a>{" "}
if you haven{"'"}t already
</li>
<li>
An API key (create one in{" "}
<a
href="/dashboard/keys"
className="text-emerald-400 hover:underline"
>
Dashboard &rarr; API Keys
</a>
)
</li>
</ul>
</section>
@@ -62,6 +81,23 @@ export default function GettingStartedPage() {
<h2 className="text-2xl font-semibold mb-4">
Step 2: Initialize AgentLens
</h2>
<p className="text-neutral-400 leading-relaxed mb-4">
Sign up at{" "}
<a
href="https://agentlens.vectry.tech/register"
className="text-emerald-400 hover:underline"
>
agentlens.vectry.tech
</a>
, then go to{" "}
<a
href="/dashboard/keys"
className="text-emerald-400 hover:underline"
>
Dashboard &rarr; API Keys
</a>{" "}
to create your key. Pass it to the SDK during initialization:
</p>
<h3 className="text-lg font-medium text-neutral-200 mb-2">Python</h3>
<CodeBlock title="main.py" language="python">{`import agentlens

View File

@@ -50,7 +50,7 @@ export default function PythonSdkPage() {
<ApiSection
name="init()"
signature="agentlens.init(api_key, endpoint, *, flush_interval=5.0, max_batch_size=100, enabled=True)"
description="Initialize the AgentLens SDK. Must be called before any tracing functions. Typically called once at application startup."
description="Initialize the AgentLens SDK. Must be called before any tracing functions. Typically called once at application startup. Your API key can be created after registering at agentlens.vectry.tech — go to Dashboard > API Keys to generate one."
>
<h4 className="text-sm font-medium text-neutral-300 mb-2">
Parameters
@@ -70,7 +70,7 @@ export default function PythonSdkPage() {
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">api_key</td>
<td className="py-2 pr-4 text-neutral-500">str</td>
<td className="py-2 pr-4 text-neutral-500">required</td>
<td className="py-2">Your AgentLens API key</td>
<td className="py-2">Your AgentLens API key (from <a href="/dashboard/keys" className="text-emerald-400 hover:underline">Dashboard &rarr; API Keys</a>)</td>
</tr>
<tr className="border-b border-neutral-800/50">
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">endpoint</td>

View File

@@ -52,7 +52,7 @@ export default function TypeScriptSdkPage() {
<ApiSection
name="init()"
signature='init({ apiKey, endpoint, flushInterval?, maxBatchSize?, enabled? })'
description="Initialize the SDK. Must be called once before creating any traces."
description="Initialize the SDK. Must be called once before creating any traces. Your API key can be created after registering at agentlens.vectry.tech — go to Dashboard > API Keys to generate one."
>
<h4 className="text-sm font-medium text-neutral-300 mb-2">
Options
@@ -72,7 +72,7 @@ export default function TypeScriptSdkPage() {
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">apiKey</td>
<td className="py-2 pr-4 text-neutral-500">string</td>
<td className="py-2 pr-4 text-neutral-500">required</td>
<td className="py-2">Your AgentLens API key</td>
<td className="py-2">Your AgentLens API key (from <a href="/dashboard/keys" className="text-emerald-400 hover:underline">Dashboard &rarr; API Keys</a>)</td>
</tr>
<tr className="border-b border-neutral-800/50">
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">endpoint</td>

View File

@@ -1,5 +1,6 @@
import { Inter } from "next/font/google";
import type { Metadata } from "next";
import { SessionProvider } from "next-auth/react";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
@@ -72,7 +73,7 @@ export default function RootLayout({
return (
<html lang="en" className="dark">
<body className={`${inter.className} bg-neutral-950 text-neutral-100 antialiased`}>
{children}
<SessionProvider>{children}</SessionProvider>
</body>
</html>
);

View File

@@ -0,0 +1,9 @@
import type { NextAuthConfig } from "next-auth";
export default {
providers: [],
session: { strategy: "jwt" },
pages: {
signIn: "/login",
},
} satisfies NextAuthConfig;

72
apps/web/src/auth.ts Normal file
View File

@@ -0,0 +1,72 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import authConfig from "./auth.config";
declare module "next-auth" {
interface Session {
user: {
id: string;
email: string;
name?: string | null;
image?: string | null;
};
}
}
declare module "@auth/core/jwt" {
interface JWT {
id: string;
}
}
const loginSchema = z.object({
email: z.email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) return null;
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (!user) return null;
const isValid = await compare(password, user.passwordHash);
if (!isValid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id as string;
}
return token;
},
session({ session, token }) {
session.user.id = token.id;
return session;
},
},
});

View File

@@ -0,0 +1,33 @@
import { createHash } from "crypto";
import { prisma } from "@/lib/prisma";
export async function validateApiKey(bearerToken: string) {
const keyHash = createHash("sha256").update(bearerToken).digest("hex");
const apiKey = await prisma.apiKey.findFirst({
where: { keyHash, revoked: false },
include: {
user: {
include: {
subscription: true,
},
},
},
});
if (!apiKey) return null;
prisma.apiKey
.update({
where: { id: apiKey.id },
data: { lastUsedAt: new Date() },
})
.catch(() => {});
return {
userId: apiKey.userId,
user: apiKey.user,
subscription: apiKey.user.subscription,
apiKey,
};
}

View File

@@ -0,0 +1,35 @@
import Stripe from "stripe";
let _stripe: Stripe | null = null;
export function getStripe(): Stripe {
if (!_stripe) {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
_stripe = new Stripe(key, { apiVersion: "2026-01-28.clover" });
}
return _stripe;
}
export const TIER_CONFIG = {
FREE: {
name: "Free",
sessionsLimit: 20,
period: "day",
price: 0,
},
STARTER: {
name: "Starter",
priceId: process.env.STRIPE_STARTER_PRICE_ID!,
sessionsLimit: 1000,
period: "month",
price: 5,
},
PRO: {
name: "Pro",
priceId: process.env.STRIPE_PRO_PRICE_ID!,
sessionsLimit: 100000,
period: "month",
price: 20,
},
} as const;

View File

@@ -0,0 +1,50 @@
import NextAuth from "next-auth";
import { NextResponse } from "next/server";
import authConfig from "./auth.config";
const { auth } = NextAuth(authConfig);
const publicPaths = [
"/",
"/docs",
"/api/auth",
"/api/traces",
"/api/health",
];
function isPublicPath(pathname: string): boolean {
return publicPaths.some(
(p) => pathname === p || pathname.startsWith(`${p}/`)
);
}
export default auth((req) => {
const { pathname } = req.nextUrl;
const isLoggedIn = !!req.auth;
if (isPublicPath(pathname)) {
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
}
return NextResponse.next();
}
if (pathname === "/login" || pathname === "/register") {
if (isLoggedIn) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
}
return NextResponse.next();
}
if (pathname.startsWith("/dashboard") && !isLoggedIn) {
const loginUrl = new URL("/login", req.nextUrl.origin);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|og-image.png).*)"],
};