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:
11
apps/web/src/app/(auth)/layout.tsx
Normal file
11
apps/web/src/app/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
apps/web/src/app/(auth)/login/page.tsx
Normal file
158
apps/web/src/app/(auth)/login/page.tsx
Normal 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't have an account?{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-emerald-400 hover:text-emerald-300 font-medium transition-colors"
|
||||
>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
apps/web/src/app/(auth)/register/page.tsx
Normal file
202
apps/web/src/app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
3
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
67
apps/web/src/app/api/auth/register/route.ts
Normal file
67
apps/web/src/app/api/auth/register/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
38
apps/web/src/app/api/keys/[id]/route.ts
Normal file
38
apps/web/src/app/api/keys/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
77
apps/web/src/app/api/keys/route.ts
Normal file
77
apps/web/src/app/api/keys/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
59
apps/web/src/app/api/settings/account/route.ts
Normal file
59
apps/web/src/app/api/settings/account/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(
|
||||
|
||||
95
apps/web/src/app/api/stripe/checkout/route.ts
Normal file
95
apps/web/src/app/api/stripe/checkout/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
apps/web/src/app/api/stripe/portal/route.ts
Normal file
41
apps/web/src/app/api/stripe/portal/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
179
apps/web/src/app/api/stripe/webhook/route.ts
Normal file
179
apps/web/src/app/api/stripe/webhook/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 → 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 → 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
|
||||
|
||||
@@ -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 → 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>
|
||||
|
||||
@@ -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 → 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
9
apps/web/src/auth.config.ts
Normal file
9
apps/web/src/auth.config.ts
Normal 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
72
apps/web/src/auth.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
33
apps/web/src/lib/api-key.ts
Normal file
33
apps/web/src/lib/api-key.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
35
apps/web/src/lib/stripe.ts
Normal file
35
apps/web/src/lib/stripe.ts
Normal 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;
|
||||
50
apps/web/src/middleware.ts
Normal file
50
apps/web/src/middleware.ts
Normal 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).*)"],
|
||||
};
|
||||
Reference in New Issue
Block a user