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:
@@ -15,14 +15,19 @@
|
|||||||
"@agentlens/database": "*",
|
"@agentlens/database": "*",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/dagre": "^0.7.53",
|
"@types/dagre": "^0.7.53",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Prisma } from "@agentlens/database";
|
import { Prisma } from "@agentlens/database";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const page = parseInt(searchParams.get("page") ?? "1", 10);
|
const page = parseInt(searchParams.get("page") ?? "1", 10);
|
||||||
const limit = parseInt(searchParams.get("limit") ?? "20", 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) {
|
if (type) {
|
||||||
where.type = type as Prisma.EnumDecisionTypeFilter["equals"];
|
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 { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
try {
|
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([
|
await prisma.$transaction([
|
||||||
prisma.event.deleteMany(),
|
prisma.event.deleteMany({ where: traceFilter }),
|
||||||
prisma.decisionPoint.deleteMany(),
|
prisma.decisionPoint.deleteMany({ where: traceFilter }),
|
||||||
prisma.span.deleteMany(),
|
prisma.span.deleteMany({ where: traceFilter }),
|
||||||
prisma.trace.deleteMany(),
|
prisma.trace.deleteMany({ where: { userId } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 });
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
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] =
|
const [totalTraces, totalSpans, totalDecisions, totalEvents] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
prisma.trace.count(),
|
prisma.trace.count({ where: traceFilter }),
|
||||||
prisma.span.count(),
|
prisma.span.count({ where: childFilter }),
|
||||||
prisma.decisionPoint.count(),
|
prisma.decisionPoint.count({ where: childFilter }),
|
||||||
prisma.event.count(),
|
prisma.event.count({ where: childFilter }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json(
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ id: string }> };
|
type RouteParams = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -23,14 +24,19 @@ export async function GET(
|
|||||||
{ params }: RouteParams
|
{ params }: RouteParams
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
if (!id || typeof id !== "string") {
|
if (!id || typeof id !== "string") {
|
||||||
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const trace = await prisma.trace.findUnique({
|
const trace = await prisma.trace.findFirst({
|
||||||
where: { id },
|
where: { id, userId: session.user.id },
|
||||||
include: {
|
include: {
|
||||||
decisionPoints: {
|
decisionPoints: {
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@@ -106,14 +112,19 @@ export async function DELETE(
|
|||||||
{ params }: RouteParams
|
{ params }: RouteParams
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
if (!id || typeof id !== "string") {
|
if (!id || typeof id !== "string") {
|
||||||
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid trace ID" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const trace = await prisma.trace.findUnique({
|
const trace = await prisma.trace.findFirst({
|
||||||
where: { id },
|
where: { id, userId: session.user.id },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Prisma } from "@agentlens/database";
|
import { Prisma } from "@agentlens/database";
|
||||||
|
import { validateApiKey } from "@/lib/api-key";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface DecisionPointPayload {
|
interface DecisionPointPayload {
|
||||||
@@ -90,11 +92,55 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 });
|
return NextResponse.json({ error: "Missing or invalid Authorization header" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = authHeader.slice(7);
|
const rawApiKey = authHeader.slice(7);
|
||||||
if (!apiKey) {
|
if (!rawApiKey) {
|
||||||
return NextResponse.json({ error: "Missing API key in Authorization header" }, { status: 401 });
|
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
|
// Parse and validate request body
|
||||||
const body: BatchTracesRequest = await request.json();
|
const body: BatchTracesRequest = await request.json();
|
||||||
if (!body.traces || !Array.isArray(body.traces)) {
|
if (!body.traces || !Array.isArray(body.traces)) {
|
||||||
@@ -190,8 +236,14 @@ export async function POST(request: NextRequest) {
|
|||||||
// final flushes both work seamlessly.
|
// final flushes both work seamlessly.
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
const upserted: string[] = [];
|
const upserted: string[] = [];
|
||||||
|
let newTraceCount = 0;
|
||||||
|
|
||||||
for (const trace of body.traces) {
|
for (const trace of body.traces) {
|
||||||
|
const existing = await tx.trace.findUnique({
|
||||||
|
where: { id: trace.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
const traceData = {
|
const traceData = {
|
||||||
name: trace.name,
|
name: trace.name,
|
||||||
sessionId: trace.sessionId,
|
sessionId: trace.sessionId,
|
||||||
@@ -205,13 +257,16 @@ export async function POST(request: NextRequest) {
|
|||||||
endedAt: trace.endedAt ? new Date(trace.endedAt) : null,
|
endedAt: trace.endedAt ? new Date(trace.endedAt) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Upsert the trace record
|
|
||||||
await tx.trace.upsert({
|
await tx.trace.upsert({
|
||||||
where: { id: trace.id },
|
where: { id: trace.id },
|
||||||
create: { id: trace.id, ...traceData },
|
create: { id: trace.id, userId, ...traceData },
|
||||||
update: traceData,
|
update: traceData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
newTraceCount++;
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Delete existing child records (order matters for FK constraints:
|
// 2. Delete existing child records (order matters for FK constraints:
|
||||||
// decision points reference spans, so delete decisions first)
|
// decision points reference spans, so delete decisions first)
|
||||||
await tx.decisionPoint.deleteMany({ where: { traceId: trace.id } });
|
await tx.decisionPoint.deleteMany({ where: { traceId: trace.id } });
|
||||||
@@ -283,6 +338,13 @@ export async function POST(request: NextRequest) {
|
|||||||
upserted.push(trace.id);
|
upserted.push(trace.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (newTraceCount > 0 && tier !== "FREE") {
|
||||||
|
await tx.subscription.update({
|
||||||
|
where: { id: subscription.id },
|
||||||
|
data: { sessionsUsed: { increment: newTraceCount } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return upserted;
|
return upserted;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -300,6 +362,11 @@ export async function POST(request: NextRequest) {
|
|||||||
// GET /api/traces — List traces with pagination
|
// GET /api/traces — List traces with pagination
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const page = parseInt(searchParams.get("page") ?? "1", 10);
|
const page = parseInt(searchParams.get("page") ?? "1", 10);
|
||||||
const limit = parseInt(searchParams.get("limit") ?? "20", 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 });
|
return NextResponse.json({ error: "Invalid dateTo parameter. Must be a valid ISO date string." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build where clause
|
const where: Record<string, unknown> = { userId: session.user.id };
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
if (status) {
|
if (status) {
|
||||||
where.status = 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 { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -22,6 +23,13 @@ interface TraceUpdateData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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();
|
const headers = new Headers();
|
||||||
headers.set("Content-Type", "text/event-stream");
|
headers.set("Content-Type", "text/event-stream");
|
||||||
headers.set("Cache-Control", "no-cache");
|
headers.set("Cache-Control", "no-cache");
|
||||||
@@ -43,6 +51,7 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const newTraces = await prisma.trace.findMany({
|
const newTraces = await prisma.trace.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
userId: currentUserId,
|
||||||
OR: [
|
OR: [
|
||||||
{ createdAt: { gt: lastCheck } },
|
{ createdAt: { gt: lastCheck } },
|
||||||
{ updatedAt: { 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 {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
Key,
|
||||||
Settings,
|
Settings,
|
||||||
Menu,
|
Menu,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -22,6 +23,7 @@ interface NavItem {
|
|||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ href: "/dashboard", label: "Traces", icon: Activity },
|
{ href: "/dashboard", label: "Traces", icon: Activity },
|
||||||
{ href: "/dashboard/decisions", label: "Decisions", icon: GitBranch },
|
{ href: "/dashboard/decisions", label: "Decisions", icon: GitBranch },
|
||||||
|
{ href: "/dashboard/keys", label: "API Keys", icon: Key },
|
||||||
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Trash2,
|
Trash2,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
CreditCard,
|
||||||
|
Crown,
|
||||||
|
Zap,
|
||||||
|
ArrowUpRight,
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -21,12 +28,71 @@ interface Stats {
|
|||||||
totalEvents: number;
|
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() {
|
export default function SettingsPage() {
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [account, setAccount] = useState<AccountData | null>(null);
|
||||||
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
||||||
|
const [isLoadingAccount, setIsLoadingAccount] = useState(true);
|
||||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||||
const [isPurging, setIsPurging] = useState(false);
|
const [isPurging, setIsPurging] = useState(false);
|
||||||
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
|
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
|
||||||
|
const [upgradingTier, setUpgradingTier] = useState<string | null>(null);
|
||||||
|
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
|
||||||
|
|
||||||
const fetchStats = useCallback(async () => {
|
const fetchStats = useCallback(async () => {
|
||||||
setIsLoadingStats(true);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchStats();
|
fetchStats();
|
||||||
}, [fetchStats]);
|
fetchAccount();
|
||||||
|
}, [fetchStats, fetchAccount]);
|
||||||
|
|
||||||
const copyToClipboard = async (text: string, field: string) => {
|
const copyToClipboard = async (text: string, field: string) => {
|
||||||
try {
|
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 =
|
const endpointUrl =
|
||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
? `${window.location.origin}/api/traces`
|
? `${window.location.origin}/api/traces`
|
||||||
@@ -82,10 +206,225 @@ export default function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-100">Settings</h1>
|
<h1 className="text-2xl font-bold text-neutral-100">Settings</h1>
|
||||||
<p className="text-neutral-400 mt-1">
|
<p className="text-neutral-400 mt-1">
|
||||||
Configuration and SDK connection details
|
Account, billing, and configuration
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* SDK Connection */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center gap-2 text-neutral-300">
|
<div className="flex items-center gap-2 text-neutral-300">
|
||||||
@@ -102,25 +441,17 @@ export default function SettingsPage() {
|
|||||||
onCopy={copyToClipboard}
|
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">
|
<div className="pt-4 border-t border-neutral-800">
|
||||||
<p className="text-xs text-neutral-500 mb-3">Quick start</p>
|
<p className="text-xs text-neutral-500 mb-1.5">API Key</p>
|
||||||
<div className="bg-neutral-950 border border-neutral-800 rounded-lg p-4 font-mono text-sm text-neutral-300 overflow-x-auto">
|
<p className="text-xs text-neutral-600">
|
||||||
<pre>{`from agentlens import init
|
Manage your API keys from the{" "}
|
||||||
|
<a
|
||||||
init(
|
href="/dashboard/keys"
|
||||||
api_key="your-api-key",
|
className="text-emerald-400 hover:text-emerald-300 transition-colors underline underline-offset-2"
|
||||||
endpoint="${endpointUrl.replace("/api/traces", "")}",
|
>
|
||||||
)`}</pre>
|
API Keys page
|
||||||
</div>
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -275,9 +606,7 @@ function SettingField({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{hint && (
|
{hint && <p className="text-xs text-neutral-600 mt-1.5">{hint}</p>}
|
||||||
<p className="text-xs text-neutral-600 mt-1.5">{hint}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,26 @@ export default function GettingStartedPage() {
|
|||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -62,6 +81,23 @@ export default function GettingStartedPage() {
|
|||||||
<h2 className="text-2xl font-semibold mb-4">
|
<h2 className="text-2xl font-semibold mb-4">
|
||||||
Step 2: Initialize AgentLens
|
Step 2: Initialize AgentLens
|
||||||
</h2>
|
</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>
|
<h3 className="text-lg font-medium text-neutral-200 mb-2">Python</h3>
|
||||||
<CodeBlock title="main.py" language="python">{`import agentlens
|
<CodeBlock title="main.py" language="python">{`import agentlens
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function PythonSdkPage() {
|
|||||||
<ApiSection
|
<ApiSection
|
||||||
name="init()"
|
name="init()"
|
||||||
signature="agentlens.init(api_key, endpoint, *, flush_interval=5.0, max_batch_size=100, enabled=True)"
|
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">
|
<h4 className="text-sm font-medium text-neutral-300 mb-2">
|
||||||
Parameters
|
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 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">str</td>
|
||||||
<td className="py-2 pr-4 text-neutral-500">required</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>
|
||||||
<tr className="border-b border-neutral-800/50">
|
<tr className="border-b border-neutral-800/50">
|
||||||
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">endpoint</td>
|
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">endpoint</td>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function TypeScriptSdkPage() {
|
|||||||
<ApiSection
|
<ApiSection
|
||||||
name="init()"
|
name="init()"
|
||||||
signature='init({ apiKey, endpoint, flushInterval?, maxBatchSize?, enabled? })'
|
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">
|
<h4 className="text-sm font-medium text-neutral-300 mb-2">
|
||||||
Options
|
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 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">string</td>
|
||||||
<td className="py-2 pr-4 text-neutral-500">required</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>
|
||||||
<tr className="border-b border-neutral-800/50">
|
<tr className="border-b border-neutral-800/50">
|
||||||
<td className="py-2 pr-4 font-mono text-emerald-400 text-xs">endpoint</td>
|
<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 { Inter } from "next/font/google";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
@@ -72,7 +73,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark">
|
||||||
<body className={`${inter.className} bg-neutral-950 text-neutral-100 antialiased`}>
|
<body className={`${inter.className} bg-neutral-950 text-neutral-100 antialiased`}>
|
||||||
{children}
|
<SessionProvider>{children}</SessionProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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).*)"],
|
||||||
|
};
|
||||||
@@ -9,6 +9,12 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
- DATABASE_URL=postgresql://agentlens:agentlens@postgres:5432/agentlens
|
- DATABASE_URL=postgresql://agentlens:agentlens@postgres:5432/agentlens
|
||||||
|
- AUTH_SECRET=Ge0Gh6bObko0Gdrzv+l0qKHgvut3M7Av8mDFQG9fYzs=
|
||||||
|
- AUTH_TRUST_HOST=true
|
||||||
|
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
||||||
|
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
||||||
|
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
|
||||||
|
- STRIPE_PRO_PRICE_ID=${STRIPE_PRO_PRICE_ID:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
@@ -63,7 +69,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: builder
|
target: builder
|
||||||
command: npx prisma migrate deploy --schema=packages/database/prisma/schema.prisma
|
command: npx prisma db push --schema=packages/database/prisma/schema.prisma --skip-generate
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://agentlens:agentlens@postgres:5432/agentlens
|
- DATABASE_URL=postgresql://agentlens:agentlens@postgres:5432/agentlens
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
159
package-lock.json
generated
159
package-lock.json
generated
@@ -24,14 +24,19 @@
|
|||||||
"@agentlens/database": "*",
|
"@agentlens/database": "*",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
"next-auth": "^5.0.0-beta.30",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/dagre": "^0.7.53",
|
"@types/dagre": "^0.7.53",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
@@ -41,6 +46,15 @@
|
|||||||
"typescript": "^5.7"
|
"typescript": "^5.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"apps/web/node_modules/zod": {
|
||||||
|
"version": "4.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@agentlens/database": {
|
"node_modules/@agentlens/database": {
|
||||||
"resolved": "packages/database",
|
"resolved": "packages/database",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -62,6 +76,35 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@auth/core": {
|
||||||
|
"version": "0.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz",
|
||||||
|
"integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@panva/hkdf": "^1.2.1",
|
||||||
|
"jose": "^6.0.6",
|
||||||
|
"oauth4webapi": "^3.3.0",
|
||||||
|
"preact": "10.24.3",
|
||||||
|
"preact-render-to-string": "6.5.11"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.2",
|
||||||
|
"nodemailer": "^6.8.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@simplewebauthn/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@simplewebauthn/server": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@dagrejs/dagre": {
|
"node_modules/@dagrejs/dagre": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz",
|
||||||
@@ -1197,6 +1240,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@panva/hkdf": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.2",
|
"version": "6.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
|
||||||
@@ -1986,6 +2038,13 @@
|
|||||||
"tailwindcss": "4.1.18"
|
"tailwindcss": "4.1.18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/bcryptjs": {
|
||||||
|
"version": "2.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||||
|
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-color": {
|
"node_modules/@types/d3-color": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
@@ -2071,7 +2130,7 @@
|
|||||||
"version": "22.19.10",
|
"version": "22.19.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz",
|
||||||
"integrity": "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==",
|
"integrity": "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -2165,6 +2224,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bundle-require": {
|
"node_modules/bundle-require": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
|
||||||
@@ -2777,6 +2845,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
|
||||||
|
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/joycon": {
|
"node_modules/joycon": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||||
@@ -3328,6 +3405,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-auth": {
|
||||||
|
"version": "5.0.0-beta.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz",
|
||||||
|
"integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/core": "0.41.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.2",
|
||||||
|
"next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
|
||||||
|
"nodemailer": "^7.0.7",
|
||||||
|
"react": "^18.2.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@simplewebauthn/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@simplewebauthn/server": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -3388,6 +3492,15 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth4webapi": {
|
||||||
|
"version": "3.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.4.tgz",
|
||||||
|
"integrity": "sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -3553,6 +3666,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.24.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||||
|
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/preact-render-to-string": {
|
||||||
|
"version": "6.5.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
|
||||||
|
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prisma": {
|
"node_modules/prisma": {
|
||||||
"version": "6.19.2",
|
"version": "6.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
|
||||||
@@ -3854,6 +3986,23 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stripe": {
|
||||||
|
"version": "20.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz",
|
||||||
|
"integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/styled-jsx": {
|
"node_modules/styled-jsx": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||||
@@ -4131,7 +4280,7 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unist-util-is": {
|
"node_modules/unist-util-is": {
|
||||||
@@ -4300,7 +4449,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode-plugin": {
|
"packages/opencode-plugin": {
|
||||||
"name": "opencode-agentlens",
|
"name": "opencode-agentlens",
|
||||||
"version": "0.1.0",
|
"version": "0.1.6",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"agentlens-sdk": "*"
|
"agentlens-sdk": "*"
|
||||||
@@ -4376,7 +4525,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk-ts": {
|
"packages/sdk-ts": {
|
||||||
"name": "agentlens-sdk",
|
"name": "agentlens-sdk",
|
||||||
"version": "0.1.0",
|
"version": "0.1.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsup": "^8.3.0",
|
"tsup": "^8.3.0",
|
||||||
|
|||||||
@@ -7,6 +7,82 @@ generator client {
|
|||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Auth & Billing ────────────────────────────────────────────
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
name String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
subscription Subscription?
|
||||||
|
apiKeys ApiKey[]
|
||||||
|
traces Trace[]
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
}
|
||||||
|
|
||||||
|
model ApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
name String @default("Default")
|
||||||
|
keyHash String @unique // SHA-256 hash of the actual key
|
||||||
|
keyPrefix String // First 8 chars for display: "al_xxxx..."
|
||||||
|
lastUsedAt DateTime?
|
||||||
|
|
||||||
|
revoked Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([keyHash])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Subscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
tier SubscriptionTier @default(FREE)
|
||||||
|
stripeCustomerId String? @unique
|
||||||
|
stripeSubscriptionId String? @unique
|
||||||
|
stripePriceId String?
|
||||||
|
|
||||||
|
currentPeriodStart DateTime?
|
||||||
|
currentPeriodEnd DateTime?
|
||||||
|
|
||||||
|
// Usage tracking for the current billing period
|
||||||
|
sessionsUsed Int @default(0)
|
||||||
|
sessionsLimit Int @default(20) // Free tier: 20/day, paid: per month
|
||||||
|
|
||||||
|
status SubscriptionStatus @default(ACTIVE)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([stripeCustomerId])
|
||||||
|
@@index([stripeSubscriptionId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionTier {
|
||||||
|
FREE // 20 sessions/day
|
||||||
|
STARTER // $5/mo — 1,000 sessions/mo
|
||||||
|
PRO // $20/mo — 100,000 sessions/mo
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubscriptionStatus {
|
||||||
|
ACTIVE
|
||||||
|
PAST_DUE
|
||||||
|
CANCELED
|
||||||
|
UNPAID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Observability ─────────────────────────────────────────────
|
||||||
|
|
||||||
model Trace {
|
model Trace {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sessionId String?
|
sessionId String?
|
||||||
@@ -15,6 +91,10 @@ model Trace {
|
|||||||
tags String[] @default([])
|
tags String[] @default([])
|
||||||
metadata Json?
|
metadata Json?
|
||||||
|
|
||||||
|
// Owner — nullable for backward compat with existing unowned traces
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
totalCost Float?
|
totalCost Float?
|
||||||
totalTokens Int?
|
totalTokens Int?
|
||||||
totalDuration Int?
|
totalDuration Int?
|
||||||
@@ -32,6 +112,7 @@ model Trace {
|
|||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([name])
|
@@index([name])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model DecisionPoint {
|
model DecisionPoint {
|
||||||
|
|||||||
Reference in New Issue
Block a user