feat: add subscription service — user auth, Stripe billing, API keys, dashboard

- NextAuth v5 with email+password credentials, JWT sessions
- Registration, login, email verification, password reset flows
- Stripe integration: Free (15/day), Starter ($5/1k/mo), Pro ($20/100k/mo)
- API key management (cb_ prefix) with hash-based validation
- Dashboard with generations history, settings, billing management
- Rate limiting: Redis daily counter (free), DB monthly (paid)
- Generate route auth: Bearer API key + session, anonymous allowed
- Worker userId propagation for generation history
- Pricing section on landing page, auth-aware navbar
- Middleware with route protection, CORS for codeboard.vectry.tech
- Docker env vars for auth, Stripe, email (smtp.migadu.com)
This commit is contained in:
Vectry
2026-02-10 20:08:13 +00:00
parent 7ff493a89a
commit 64ce70daa4
45 changed files with 3073 additions and 34 deletions

View File

@@ -14,19 +14,28 @@
"@codeboard/database": "*",
"@codeboard/shared": "*",
"@tailwindcss/typography": "^0.5.19",
"bcryptjs": "^3.0.3",
"bullmq": "^5.34.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"ioredis": "^5.4.0",
"lucide-react": "^0.563.0",
"mermaid": "^11.4.0",
"next": "^14.2.0",
"next-auth": "^5.0.0-beta.30",
"nodemailer": "^7.0.7",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-markdown": "^9.0.0"
"react-markdown": "^9.0.0",
"stripe": "^20.3.1",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.0.0",
"@types/nodemailer": "^6.4.17",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"postcss": "^8.5.0",

View File

@@ -0,0 +1,70 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Code2, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState("");
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!emailValid) { setError("Please enter a valid email address"); return; }
setLoading(true);
try {
const res = await fetch("/api/auth/forgot-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }) });
if (!res.ok) { const data: { error?: string } = await res.json(); setError(data.error ?? "Something went wrong"); setLoading(false); return; }
setSubmitted(true);
} catch { setError("Something went wrong."); setLoading(false); }
}
if (submitted) {
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-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
<div className="text-center">
<h1 className="text-2xl font-bold text-neutral-100">Check your email</h1>
<p className="mt-1 text-sm text-neutral-400">If an account exists for that email, we sent a password reset link. It expires in 1 hour.</p>
</div>
</div>
<p className="text-center text-sm text-neutral-400"><Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Back to sign in</Link></p>
</div>
);
}
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-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
<div className="text-center">
<h1 className="text-2xl font-bold text-neutral-100">Reset your password</h1>
<p className="mt-1 text-sm text-neutral-400">Enter your email and we&apos;ll send you a reset link</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-blue-500")} />
{email && !emailValid && <p className="text-xs text-red-400">Please enter a valid email address</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-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Sending..." : "Send reset link"}
</button>
</form>
<p className="text-center text-sm text-neutral-400">Remember your password?{" "}<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in</Link></p>
</div>
);
}

View File

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

View File

@@ -0,0 +1,89 @@
"use client";
import { Suspense, useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Code2, CheckCircle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
);
}
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const verified = searchParams.get("verified") === "true";
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-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
<Code2 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 CodeBoard account</p>
</div>
</div>
{verified && (
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 px-4 py-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-blue-400 shrink-0" />
<p className="text-sm text-blue-400">Email verified! You can now sign in.</p>
</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-blue-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-blue-500")} />
{password && !passwordValid && <p className="text-xs text-red-400">Password must be at least 8 characters</p>}
</div>
</div>
<div className="flex justify-end">
<Link href="/forgot-password" className="text-sm text-neutral-500 hover:text-blue-400 transition-colors">Forgot password?</Link>
</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-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Signing in…" : "Sign in"}
</button>
</form>
<p className="text-center text-sm text-neutral-400">Don&apos;t have an account?{" "}<Link href="/register" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Create one</Link></p>
</div>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Code2, 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.status === 429) { const data: { error?: string } = await res.json(); setError(data.error ?? "Too many attempts."); setLoading(false); return; }
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) { router.push("/login"); return; }
router.push("/dashboard");
router.refresh();
} catch { setError("Something went wrong."); 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-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
<Code2 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 generating architecture diagrams with CodeBoard</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-blue-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-blue-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-blue-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-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
{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-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in</Link></p>
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Code2, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
export default function ResetPasswordPage() {
return (<Suspense><ResetPasswordForm /></Suspense>);
}
function ResetPasswordForm() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const passwordValid = password.length >= 8;
const passwordsMatch = password === confirmPassword;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!passwordValid) { setError("Password must be at least 8 characters"); return; }
if (!passwordsMatch) { setError("Passwords do not match"); return; }
if (!token) { setError("Invalid reset link"); return; }
setLoading(true);
try {
const res = await fetch("/api/auth/reset-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, password }) });
if (!res.ok) { const data: { error?: string } = await res.json(); setError(data.error ?? "Something went wrong"); setLoading(false); return; }
setSuccess(true);
} catch { setError("Something went wrong."); setLoading(false); }
}
if (!token) {
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-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
<div className="text-center"><h1 className="text-2xl font-bold text-neutral-100">Invalid reset link</h1><p className="mt-1 text-sm text-neutral-400">This password reset link is invalid or has expired.</p></div>
</div>
<p className="text-center text-sm text-neutral-400"><Link href="/forgot-password" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Request a new reset link</Link></p>
</div>
);
}
if (success) {
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-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
<div className="text-center"><h1 className="text-2xl font-bold text-neutral-100">Password reset</h1><p className="mt-1 text-sm text-neutral-400">Your password has been successfully reset.</p></div>
</div>
<p className="text-center text-sm text-neutral-400"><Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in with your new password</Link></p>
</div>
);
}
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-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
<div className="text-center"><h1 className="text-2xl font-bold text-neutral-100">Set new password</h1><p className="mt-1 text-sm text-neutral-400">Enter your new password below</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="password" className="block text-sm font-medium text-neutral-300">New 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-blue-500")} />
{password && !passwordValid && <p className="text-xs text-red-400">Password must be at least 8 characters</p>}
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-300">Confirm password</label>
<input id="confirmPassword" type="password" autoComplete="new-password" required value={confirmPassword} onChange={(e) => setConfirmPassword(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", confirmPassword && !passwordsMatch ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
{confirmPassword && !passwordsMatch && <p className="text-xs text-red-400">Passwords do not match</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-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Resetting..." : "Reset password"}
</button>
</form>
<p className="text-center text-sm text-neutral-400">Remember your password?{" "}<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in</Link></p>
</div>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Code2, Loader2, Mail } from "lucide-react";
import { cn } from "@/lib/utils";
export default function VerifyEmailPage() {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
async function handleResend() {
setLoading(true); setMessage(""); setError("");
try {
const res = await fetch("/api/auth/resend-verification", { method: "POST" });
if (!res.ok) { const data: { error?: string } = await res.json(); setError(data.error ?? "Failed to resend email"); setLoading(false); return; }
setMessage("Verification email sent! Check your inbox.");
} catch { setError("Something went wrong."); } finally { 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-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
<div className="text-center"><h1 className="text-2xl font-bold text-neutral-100">Check your email</h1><p className="mt-1 text-sm text-neutral-400">We sent a verification link to your inbox</p></div>
</div>
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
<div className="flex items-center justify-center">
<div className="w-16 h-16 rounded-full bg-blue-500/10 border border-blue-500/20 flex items-center justify-center"><Mail className="w-8 h-8 text-blue-400" /></div>
</div>
<p className="text-sm text-neutral-400 text-center leading-relaxed">Click the link in the email to verify your account. The link expires in 24 hours.</p>
</div>
{message && (<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 px-4 py-3"><p className="text-sm text-blue-400">{message}</p></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 onClick={handleResend} 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-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? "Sending..." : "Resend verification email"}
</button>
<p className="text-center text-sm text-neutral-400">Already verified?{" "}<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in</Link></p>
</div>
);
}

View File

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

View File

@@ -0,0 +1,92 @@
import { NextResponse } from "next/server";
import { randomBytes, createHash } from "crypto";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email";
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
const forgotPasswordSchema = z.object({
email: z.string().email("Invalid email address"),
});
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export async function POST(request: Request) {
try {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`forgot:${ip}`, AUTH_RATE_LIMITS.forgotPassword);
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many requests. Please try again later." },
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
);
}
const body: unknown = await request.json();
const parsed = forgotPasswordSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
);
}
const { email } = parsed.data;
const normalizedEmail = email.toLowerCase();
const user = await prisma.user.findUnique({
where: { email: normalizedEmail },
});
if (!user) {
return NextResponse.json({ success: true });
}
await prisma.passwordResetToken.updateMany({
where: { userId: user.id, used: false },
data: { used: true },
});
const rawToken = randomBytes(32).toString("hex");
const tokenHash = hashToken(rawToken);
await prisma.passwordResetToken.create({
data: {
userId: user.id,
token: tokenHash,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
const resetUrl = `https://codeboard.vectry.tech/reset-password?token=${rawToken}`;
await sendEmail({
to: normalizedEmail,
subject: "Reset your CodeBoard password",
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h2 style="color: #f5f5f5; font-size: 20px; margin-bottom: 16px;">Reset your password</h2>
<p style="color: #a3a3a3; font-size: 14px; line-height: 1.6; margin-bottom: 24px;">
You requested a password reset for your CodeBoard account. Click the button below to set a new password.
</p>
<a href="${resetUrl}" style="display: inline-block; background-color: #3b82f6; color: #fff; font-weight: 600; font-size: 14px; padding: 12px 24px; border-radius: 8px; text-decoration: none;">
Reset password
</a>
<p style="color: #737373; font-size: 12px; line-height: 1.5; margin-top: 32px;">
This link expires in 1 hour. If you did not request this, you can safely ignore this email.
</p>
</div>
`,
});
return NextResponse.json({ success: true });
} catch {
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,117 @@
import { NextResponse } from "next/server";
import { hash } from "bcryptjs";
import crypto from "crypto";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email";
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
const registerSchema = z.object({
email: z.string().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 ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`register:${ip}`, AUTH_RATE_LIMITS.register);
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many registration attempts. Please try again later." },
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
);
}
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(
{ message: "If this email is available, a confirmation email will be sent." },
{ status: 200 }
);
}
const passwordHash = await hash(password, 12);
const user = await prisma.user.create({
data: {
email: normalizedEmail,
passwordHash,
name: name ?? null,
subscription: {
create: {
tier: "FREE",
generationsLimit: 15,
},
},
},
select: {
id: true,
email: true,
name: true,
createdAt: true,
},
});
try {
const rawToken = crypto.randomBytes(32).toString("hex");
const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex");
await prisma.emailVerificationToken.create({
data: {
userId: user.id,
token: tokenHash,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
const verifyUrl = `https://codeboard.vectry.tech/verify-email?token=${rawToken}`;
await sendEmail({
to: user.email,
subject: "Verify your CodeBoard email",
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h2 style="color: #e5e5e5; margin-bottom: 16px;">Verify your email</h2>
<p style="color: #a3a3a3; line-height: 1.6;">
Thanks for signing up for CodeBoard. Click the link below to verify your email address.
</p>
<a href="${verifyUrl}" style="display: inline-block; margin-top: 24px; padding: 12px 24px; background-color: #3b82f6; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600;">
Verify Email
</a>
<p style="color: #737373; font-size: 13px; margin-top: 32px;">
This link expires in 24 hours. If you didn't create an account, you can safely ignore this email.
</p>
</div>
`,
});
} catch (emailError) {
console.error("[register] Failed to send verification email:", emailError);
}
return NextResponse.json(
{ message: "If this email is available, a confirmation email will be sent." },
{ status: 200 }
);
} catch {
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { sendEmail } from "@/lib/email";
export async function POST() {
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 },
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
if (user.emailVerified) {
return NextResponse.json({ error: "Email already verified" }, { status: 400 });
}
const latestToken = await prisma.emailVerificationToken.findFirst({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
});
if (latestToken && Date.now() - latestToken.createdAt.getTime() < 60_000) {
return NextResponse.json(
{ error: "Please wait 60 seconds before requesting another email" },
{ status: 429 }
);
}
await prisma.emailVerificationToken.updateMany({
where: { userId: user.id, used: false },
data: { used: true },
});
const rawToken = crypto.randomBytes(32).toString("hex");
const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex");
await prisma.emailVerificationToken.create({
data: {
userId: user.id,
token: tokenHash,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
const verifyUrl = `https://codeboard.vectry.tech/verify-email?token=${rawToken}`;
await sendEmail({
to: user.email,
subject: "Verify your CodeBoard email",
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h2 style="color: #e5e5e5; margin-bottom: 16px;">Verify your email</h2>
<p style="color: #a3a3a3; line-height: 1.6;">
Click the link below to verify your email address for CodeBoard.
</p>
<a href="${verifyUrl}" style="display: inline-block; margin-top: 24px; padding: 12px 24px; background-color: #3b82f6; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600;">
Verify Email
</a>
<p style="color: #737373; font-size: 13px; margin-top: 32px;">
This link expires in 24 hours.
</p>
</div>
`,
});
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { createHash } from "crypto";
import { hash } from "bcryptjs";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
const resetPasswordSchema = z.object({
token: z.string().min(1, "Token is required"),
password: z.string().min(8, "Password must be at least 8 characters"),
});
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export async function POST(request: Request) {
try {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const rl = await checkRateLimit(`reset:${ip}`, AUTH_RATE_LIMITS.resetPassword);
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many attempts. Please try again later." },
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
);
}
const body: unknown = await request.json();
const parsed = resetPasswordSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 }
);
}
const { token, password } = parsed.data;
const tokenHash = hashToken(token);
const resetToken = await prisma.passwordResetToken.findUnique({
where: { token: tokenHash },
include: { user: true },
});
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
return NextResponse.json(
{ error: "Invalid or expired reset link" },
{ status: 400 }
);
}
const passwordHash = await hash(password, 12);
await prisma.$transaction([
prisma.user.update({
where: { id: resetToken.userId },
data: { passwordHash },
}),
prisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { used: true },
}),
]);
return NextResponse.json({ success: true });
} catch {
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest) {
const rawToken = request.nextUrl.searchParams.get("token");
if (!rawToken) {
return NextResponse.redirect(new URL("/login?error=missing-token", request.url));
}
const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex");
const verificationToken = await prisma.emailVerificationToken.findUnique({
where: { token: tokenHash },
include: { user: true },
});
if (!verificationToken) {
return NextResponse.redirect(new URL("/login?error=invalid-token", request.url));
}
if (verificationToken.used) {
return NextResponse.redirect(new URL("/login?verified=true", request.url));
}
if (verificationToken.expiresAt < new Date()) {
return NextResponse.redirect(new URL("/login?error=token-expired", request.url));
}
await prisma.$transaction([
prisma.user.update({
where: { id: verificationToken.userId },
data: { emailVerified: true },
}),
prisma.emailVerificationToken.update({
where: { id: verificationToken.id },
data: { used: true },
}),
]);
return NextResponse.redirect(new URL("/login?verified=true", request.url));
}

View File

@@ -1,10 +1,69 @@
import { NextResponse } from "next/server";
import { getQueue } from "@/lib/queue";
import { getRedis } from "@/lib/redis";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { validateApiKey } from "@/lib/api-key";
const GITHUB_URL_RE = /^https?:\/\/(www\.)?github\.com\/[\w.-]+\/[\w.-]+\/?$/;
async function checkUsageLimit(userId: string): Promise<{ allowed: boolean; message?: string }> {
const subscription = await prisma.subscription.findUnique({
where: { userId },
});
if (!subscription) {
return { allowed: false, message: "No subscription found" };
}
if (subscription.tier === "FREE") {
const redis = getRedis();
const today = new Date().toISOString().slice(0, 10);
const key = `cb:gen:${userId}:${today}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, 86400);
if (count > subscription.generationsLimit) {
return { allowed: false, message: "Daily generation limit reached. Upgrade for more." };
}
return { allowed: true };
}
if (subscription.generationsUsed >= subscription.generationsLimit) {
return { allowed: false, message: "Monthly generation limit reached. Upgrade for more." };
}
await prisma.subscription.update({
where: { userId },
data: { generationsUsed: { increment: 1 } },
});
return { allowed: true };
}
export async function POST(request: Request) {
let userId: string | null = null;
// Try API key auth first
const authHeader = request.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.slice(7);
const result = await validateApiKey(token);
if (result) {
userId = result.userId;
}
}
// Fall back to session auth
if (!userId) {
const session = await auth();
if (session?.user?.id) {
userId = session.user.id;
}
}
// Allow anonymous for now but without usage tracking
// (public generations still work but won't be saved to user history)
const body = await request.json();
const repoUrl: string = body.repoUrl?.trim();
@@ -15,6 +74,17 @@ export async function POST(request: Request) {
);
}
// Check usage limits for authenticated users
if (userId) {
const usage = await checkUsageLimit(userId);
if (!usage.allowed) {
return NextResponse.json(
{ error: usage.message },
{ status: 429 }
);
}
}
const generationId = `gen_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const redis = getRedis();
@@ -26,7 +96,7 @@ export async function POST(request: Request) {
);
const queue = getQueue();
await queue.add("generate", { repoUrl, generationId }, {
await queue.add("generate", { repoUrl, generationId, userId }, {
jobId: generationId,
removeOnComplete: { age: 3600 },
removeOnFail: false,

View File

@@ -0,0 +1,34 @@
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 generations = await prisma.generation.findMany({
where: { userId: session.user.id },
select: {
id: true,
repoUrl: true,
repoName: true,
status: true,
createdAt: true,
duration: true,
},
orderBy: { createdAt: "desc" },
take: 100,
});
return NextResponse.json({ generations }, { status: 200 });
} catch (error) {
console.error("Error fetching user generations:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

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

View File

@@ -0,0 +1,88 @@
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 MAX_KEYS_PER_USER = 10;
const keyCount = await prisma.apiKey.count({
where: { userId: session.user.id, revoked: false },
});
if (keyCount >= MAX_KEYS_PER_USER) {
return NextResponse.json(
{ error: `Maximum of ${MAX_KEYS_PER_USER} API keys allowed. Revoke an existing key first.` },
{ status: 400 }
);
}
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 = `cb_${rawHex}`;
const keyPrefix = fullKey.slice(0, 10);
const keyHash = createHash("sha256").update(fullKey).digest("hex");
const apiKey = await prisma.apiKey.create({
data: {
userId: session.user.id,
name,
keyHash,
keyPrefix,
},
select: {
id: true,
name: true,
keyPrefix: true,
createdAt: true,
},
});
return NextResponse.json(
{ ...apiKey, key: fullKey },
{ status: 201 }
);
} catch (error) {
console.error("Error creating API key:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,49 @@
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,
generationsUsed: true,
generationsLimit: true,
currentPeriodStart: true,
currentPeriodEnd: true,
stripeSubscriptionId: true,
},
},
},
});
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
return NextResponse.json({
...user,
subscription: user.subscription
? {
...user.subscription,
hasStripeSubscription: !!user.subscription.stripeSubscriptionId,
}
: null,
});
} catch {
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function POST() {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await prisma.generation.deleteMany({
where: { userId: session.user.id },
});
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,32 @@
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 totalGenerations = await prisma.generation.count({
where: { userId: session.user.id },
});
const completedGenerations = await prisma.generation.count({
where: { userId: session.user.id, status: "COMPLETED" },
});
const failedGenerations = await prisma.generation.count({
where: { userId: session.user.id, status: "FAILED" },
});
return NextResponse.json({
totalGenerations,
completedGenerations,
failedGenerations,
});
} catch {
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,101 @@
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 ALLOWED_ORIGINS = [
"https://codeboard.vectry.tech",
"http://localhost:3000",
];
const requestOrigin = request.headers.get("origin");
const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "")
? requestOrigin!
: "https://codeboard.vectry.tech";
const checkoutSession = await getStripe().checkout.sessions.create({
customer: stripeCustomerId,
mode: "subscription",
line_items: [{ price: resolvedPriceId, quantity: 1 }],
success_url: `${origin}/dashboard/settings?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/dashboard/settings`,
metadata: { userId },
});
return NextResponse.json({ url: checkoutSession.url }, { status: 200 });
} catch (error) {
console.error("Error creating checkout session:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,47 @@
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 ALLOWED_ORIGINS = [
"https://codeboard.vectry.tech",
"http://localhost:3000",
];
const requestOrigin = request.headers.get("origin");
const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "")
? requestOrigin!
: "https://codeboard.vectry.tech";
const portalSession = await getStripe().billingPortal.sessions.create({
customer: subscription.stripeCustomerId,
return_url: `${origin}/dashboard/settings`,
});
return NextResponse.json({ url: portalSession.url }, { status: 200 });
} catch (error) {
console.error("Error creating portal session:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,179 @@
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
export const runtime = "nodejs";
function tierFromPriceId(priceId: string | null): "FREE" | "STARTER" | "PRO" {
if (priceId === TIER_CONFIG.STARTER.priceId) return "STARTER";
if (priceId === TIER_CONFIG.PRO.priceId) return "PRO";
return "FREE";
}
function generationsLimitForTier(tier: "FREE" | "STARTER" | "PRO"): number {
return TIER_CONFIG[tier].generationsLimit;
}
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,
generationsLimit: generationsLimitForTier(tier),
generationsUsed: 0,
status: "ACTIVE",
currentPeriodStart: periodStart,
currentPeriodEnd: periodEnd,
},
create: {
userId,
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
stripePriceId: priceId,
tier,
generationsLimit: generationsLimitForTier(tier),
generationsUsed: 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,
generationsLimit: generationsLimitForTier(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",
generationsLimit: TIER_CONFIG.FREE.generationsLimit,
},
});
}
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: { generationsUsed: 0 },
});
}
export async function POST(request: Request) {
const body = await request.text();
const sig = request.headers.get("stripe-signature");
if (!sig) {
return NextResponse.json(
{ error: "Missing stripe-signature header" },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = getStripe().webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed");
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
);
}
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case "customer.subscription.updated":
await handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(
event.data.object as Stripe.Subscription
);
break;
case "invoice.paid":
await handleInvoicePaid(event.data.object as Stripe.Invoice);
break;
}
} catch (error) {
console.error(`Error handling ${event.type}:`, error);
return NextResponse.json(
{ error: "Webhook handler failed" },
{ status: 500 }
);
}
return NextResponse.json({ received: true }, { status: 200 });
}

View File

@@ -0,0 +1,139 @@
"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) setKeys(await res.json()); } catch (e) { console.error("Failed to fetch:", e); } 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 {}
};
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 (e) { console.error("Failed to create:", e); } 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 (e) { console.error("Failed to revoke:", e); } finally { setRevokingId(null); }
};
const formatDate = (d: string) => new Date(d).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 programmatic access</p></div>
<button onClick={() => { setShowCreateForm(true); setNewlyCreatedKey(null); }} className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 hover:bg-blue-400 text-white rounded-lg text-sm font-semibold transition-colors"><Plus className="w-4 h-4" /> Create New Key</button>
</div>
{newlyCreatedKey && (
<div className="bg-blue-500/5 border border-blue-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-blue-500/10 border border-blue-500/20 flex items-center justify-center shrink-0"><Key className="w-5 h-5 text-blue-400" /></div>
<div className="flex-1 min-w-0"><h3 className="text-sm font-semibold text-blue-300">API Key Created</h3><p className="text-xs text-blue-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")} aria-label="Copy"
className={cn("p-3 rounded-lg border transition-all shrink-0", copiedField === "new-key" ? "bg-blue-500/10 border-blue-500/30 text-blue-400" : "bg-neutral-800 border-neutral-700 text-neutral-400 hover:text-neutral-200")}>
{copiedField === "new-key" ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="flex items-center gap-2 px-3 py-2.5 bg-amber-500/5 border border-amber-500/20 rounded-lg"><AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" /><p className="text-xs text-amber-300/80">This key won&apos;t be shown again. Copy it now.</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-blue-400" /><h2 className="text-sm font-semibold">Create New API Key</h2></div>
<div>
<label htmlFor="key-name" className="text-xs text-neutral-500 font-medium block mb-1.5">Key Name (optional)</label>
<input id="key-name" type="text" value={newKeyName} onChange={(e) => setNewKeyName(e.target.value)} placeholder="e.g. Production, Staging"
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-blue-500/40 focus:ring-1 focus:ring-blue-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-blue-500 hover:bg-blue-400 text-white 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-blue-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 for programmatic access</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 transition-colors">
<div className="w-9 h-9 rounded-lg bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center shrink-0"><Key className="w-4 h-4 text-neutral-500" /></div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-neutral-200 truncate">{apiKey.name}</p>
<div className="flex items-center gap-3 mt-0.5">
<code className="text-xs font-mono text-neutral-500">{apiKey.keyPrefix}</code>
<span className="text-xs text-neutral-600">Created {formatDate(apiKey.createdAt)}</span>
{apiKey.lastUsedAt && <span className="text-xs text-neutral-600">Last used {formatDate(apiKey.lastUsedAt)}</span>}
</div>
</div>
{confirmRevokeId === apiKey.id ? (
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => setConfirmRevokeId(null)} className="px-3 py-1.5 text-xs text-neutral-400 hover:text-neutral-200 transition-colors">Cancel</button>
<button onClick={() => handleRevoke(apiKey.id)} disabled={revokingId === apiKey.id}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/20 border border-red-500/30 text-red-400 rounded-lg text-xs font-medium hover:bg-red-500/30 disabled:opacity-50 transition-colors">
{revokingId === apiKey.id ? <RefreshCw className="w-3 h-3 animate-spin" /> : <AlertTriangle className="w-3 h-3" />} Confirm
</button>
</div>
) : (
<button onClick={() => setConfirmRevokeId(apiKey.id)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-neutral-800 border border-neutral-700 text-neutral-500 rounded-lg text-xs font-medium opacity-0 group-hover:opacity-100 hover:text-red-400 hover:border-red-500/30 transition-all shrink-0">
<Trash2 className="w-3 h-3" /> Revoke
</button>
)}
</div>
))}
</div>
)}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,169 @@
"use client";
import { ReactNode, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import {
Code2,
FileText,
Key,
Settings,
Menu,
ChevronRight,
X,
AlertTriangle,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface NavItem {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
{ href: "/dashboard", label: "Generations", icon: FileText },
{ href: "/dashboard/keys", label: "API Keys", icon: Key },
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
];
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
return (
<div className="flex flex-col h-full bg-neutral-900 border-r border-neutral-800">
<div className="p-6 border-b border-neutral-800">
<Link href="/" className="flex items-center gap-3 group" onClick={onNavigate}>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:shadow-blue-500/30 transition-shadow">
<Code2 className="w-5 h-5 text-white" />
</div>
<div className="flex flex-col">
<span className="font-bold text-lg text-neutral-100">CodeBoard</span>
<span className="text-xs text-neutral-500">Dashboard</span>
</div>
</Link>
</div>
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive =
item.href === "/dashboard"
? pathname === "/dashboard"
: pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200",
isActive
? "bg-blue-500/10 text-blue-400 border border-blue-500/20"
: "text-neutral-400 hover:text-neutral-100 hover:bg-neutral-800/50"
)}
>
<Icon className="w-5 h-5" />
<span className="flex-1">{item.label}</span>
{isActive && <ChevronRight className="w-4 h-4" />}
</Link>
);
})}
</nav>
<div className="p-4 border-t border-neutral-800">
<div className="px-4 py-3 rounded-lg bg-neutral-800/50 border border-neutral-700/50">
<p className="text-xs text-neutral-500">CodeBoard v0.1.0</p>
</div>
</div>
</div>
);
}
function VerificationBanner() {
const { data: session } = useSession();
const [dismissed, setDismissed] = useState(false);
const [resending, setResending] = useState(false);
const [sent, setSent] = useState(false);
if (dismissed || !session?.user || session.user.isEmailVerified) {
return null;
}
async function handleResend() {
setResending(true);
try {
const res = await fetch("/api/auth/resend-verification", { method: "POST" });
if (res.ok) setSent(true);
} catch {} finally { setResending(false); }
}
return (
<div className="bg-amber-500/10 border-b border-amber-500/20 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
<p className="text-sm text-amber-200 truncate">
{sent ? "Verification email sent! Check your inbox." : "Please verify your email address. Check your inbox or"}
</p>
{!sent && (
<button onClick={handleResend} disabled={resending}
className="text-sm font-medium text-amber-400 hover:text-amber-300 transition-colors whitespace-nowrap inline-flex items-center gap-1">
{resending && <Loader2 className="w-3 h-3 animate-spin" />}
{resending ? "sending..." : "click to resend."}
</button>
)}
</div>
<button onClick={() => setDismissed(true)} aria-label="Dismiss" className="p-1 rounded text-amber-400/60 hover:text-amber-300 transition-colors shrink-0">
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}
export default function DashboardLayout({ children }: { children: ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="min-h-screen bg-neutral-950 flex">
<aside className="hidden lg:block w-64 h-screen sticky top-0">
<Sidebar />
</aside>
{sidebarOpen && (
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
)}
<aside className={cn(
"fixed inset-y-0 left-0 w-72 z-50 transform transition-transform duration-300 ease-in-out lg:hidden",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}>
<Sidebar onNavigate={() => setSidebarOpen(false)} />
</aside>
<main id="main-content" className="flex-1 min-w-0">
<VerificationBanner />
<header className="lg:hidden sticky top-0 z-30 bg-neutral-950/80 backdrop-blur-md border-b border-neutral-800 px-4 py-3">
<div className="flex items-center justify-between">
<button onClick={() => setSidebarOpen(true)} aria-label="Open menu"
className="p-2 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-400 hover:text-neutral-100 transition-colors">
<Menu className="w-5 h-5" />
</button>
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center">
<Code2 className="w-4 h-4 text-white" />
</div>
<span className="font-bold text-neutral-100">CodeBoard</span>
</Link>
<div className="w-9" />
</div>
</header>
<div className="p-4 sm:p-6 lg:p-8">{children}</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { FileText, Clock, CheckCircle, XCircle, Loader2, ExternalLink } from "lucide-react";
import { cn } from "@/lib/utils";
interface Generation {
id: string;
repoUrl: string;
repoName: string;
status: string;
createdAt: string;
duration: number | null;
}
export default function DashboardPage() {
const [generations, setGenerations] = useState<Generation[]>([]);
const [isLoading, setIsLoading] = useState(true);
const fetchGenerations = useCallback(async () => {
setIsLoading(true);
try {
const statsRes = await fetch("/api/generations/mine", { cache: "no-store" });
if (statsRes.ok) {
const data = await statsRes.json();
setGenerations(data.generations ?? []);
}
} catch (error) {
console.error("Failed to fetch generations:", error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchGenerations();
}, [fetchGenerations]);
const statusIcon = (status: string) => {
switch (status) {
case "COMPLETED": return <CheckCircle className="w-4 h-4 text-green-400" />;
case "FAILED": return <XCircle className="w-4 h-4 text-red-400" />;
default: return <Loader2 className="w-4 h-4 text-blue-400 animate-spin" />;
}
};
return (
<div className="space-y-8 max-w-4xl">
<div>
<h1 className="text-2xl font-bold text-neutral-100">My Generations</h1>
<p className="text-neutral-400 mt-1">Your architecture diagram generation history</p>
</div>
<div className="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-12 text-center">
<Loader2 className="w-6 h-6 text-blue-400 animate-spin mx-auto mb-3" />
<p className="text-sm text-neutral-500">Loading generations...</p>
</div>
) : generations.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">
<FileText className="w-6 h-6 text-neutral-600" />
</div>
<p className="text-sm text-neutral-400 font-medium">No generations yet</p>
<p className="text-xs text-neutral-600 mt-1">Generate your first architecture diagram from the <Link href="/" className="text-blue-400 hover:text-blue-300">home page</Link></p>
</div>
) : (
<div className="divide-y divide-neutral-800">
{generations.map((gen) => (
<Link key={gen.id} href={`/docs/${gen.id}`} className="flex items-center gap-4 px-6 py-4 hover:bg-neutral-800/30 transition-colors group">
{statusIcon(gen.status)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-neutral-200 truncate">{gen.repoName}</p>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-neutral-500 truncate">{gen.repoUrl}</span>
{gen.duration && (
<span className="text-xs text-neutral-600 flex items-center gap-1">
<Clock className="w-3 h-3" /> {gen.duration}s
</span>
)}
</div>
</div>
<span className="text-xs text-neutral-600">{new Date(gen.createdAt).toLocaleDateString()}</span>
<ExternalLink className="w-4 h-4 text-neutral-600 opacity-0 group-hover:opacity-100 transition-opacity" />
</Link>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
Settings,
Key,
Copy,
Check,
RefreshCw,
Database,
Trash2,
AlertTriangle,
CreditCard,
Crown,
Zap,
ArrowUpRight,
User,
Calendar,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface Stats {
totalGenerations: number;
completedGenerations: number;
failedGenerations: number;
}
interface AccountData {
id: string;
email: string;
name: string | null;
createdAt: string;
subscription: {
tier: "FREE" | "STARTER" | "PRO";
status: "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID";
generationsUsed: number;
generationsLimit: number;
currentPeriodStart: string | null;
currentPeriodEnd: string | null;
hasStripeSubscription: boolean;
} | null;
}
const TIERS = [
{
key: "FREE" as const,
name: "Free",
price: 0,
period: "day",
generations: 15,
description: "For getting started",
features: ["15 generations per day", "Basic diagram viewing", "Community support"],
},
{
key: "STARTER" as const,
name: "Starter",
price: 5,
period: "month",
generations: 1000,
description: "For regular use",
features: ["1,000 generations per month", "Generation history", "Priority support"],
},
{
key: "PRO" as const,
name: "Pro",
price: 20,
period: "month",
generations: 100000,
description: "For teams & heavy use",
features: ["100,000 generations per month", "Full history", "Dedicated support", "API access"],
},
];
export default function SettingsPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [account, setAccount] = useState<AccountData | null>(null);
const [isLoadingStats, setIsLoadingStats] = useState(true);
const [isLoadingAccount, setIsLoadingAccount] = useState(true);
const [isPurging, setIsPurging] = useState(false);
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
const [upgradingTier, setUpgradingTier] = useState<string | null>(null);
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
const fetchStats = useCallback(async () => {
setIsLoadingStats(true);
try {
const res = await fetch("/api/settings/stats", { cache: "no-store" });
if (res.ok) setStats(await res.json());
} catch (error) { console.error("Failed to fetch stats:", error); } finally { setIsLoadingStats(false); }
}, []);
const fetchAccount = useCallback(async () => {
setIsLoadingAccount(true);
try {
const res = await fetch("/api/settings/account", { cache: "no-store" });
if (res.ok) setAccount(await res.json());
} catch (error) { console.error("Failed to fetch account:", error); } finally { setIsLoadingAccount(false); }
}, []);
useEffect(() => { fetchStats(); fetchAccount(); }, [fetchStats, fetchAccount]);
const handlePurgeAll = async () => {
setIsPurging(true);
try {
const res = await fetch("/api/settings/purge", { method: "POST" });
if (res.ok) { setShowPurgeConfirm(false); fetchStats(); }
} catch (error) { console.error("Failed to purge:", error); } finally { setIsPurging(false); }
};
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 generationsUsed = account?.subscription?.generationsUsed ?? 0;
const generationsLimit = account?.subscription?.generationsLimit ?? 15;
const usagePercent = generationsLimit > 0 ? Math.min((generationsUsed / generationsLimit) * 100, 100) : 0;
return (
<div className="space-y-8 max-w-3xl">
<div>
<h1 className="text-2xl font-bold text-neutral-100">Settings</h1>
<p className="text-neutral-400 mt-1">Account, billing, and configuration</p>
</div>
{/* Account */}
<section className="space-y-4">
<div className="flex items-center gap-2 text-neutral-300">
<User className="w-5 h-5 text-blue-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 */}
<section className="space-y-4">
<div className="flex items-center gap-2 text-neutral-300">
<CreditCard className="w-5 h-5 text-blue-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-blue-500/10 border border-blue-500/20 text-blue-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">{generationsUsed.toLocaleString()} of {generationsLimit.toLocaleString()} generations used</span>
<span className="text-neutral-500 text-xs">{currentTier === "FREE" ? "15 generations/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-blue-500")} style={{ width: `${usagePercent}%` }} />
</div>
</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-blue-500/40 shadow-[0_0_24px_-6px_rgba(59,130,246,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-blue-500 text-white">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((f) => (<li key={f} className="flex items-start gap-2 text-xs text-neutral-400"><Check className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />{f}</li>))}
</ul>
{isCurrent ? (<div className="py-2 text-center text-xs font-medium text-blue-400 bg-blue-500/5 border border-blue-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-blue-500 hover:bg-blue-400 text-white 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>
{/* Data */}
<section className="space-y-4">
<div className="flex items-center gap-2 text-neutral-300">
<Database className="w-5 h-5 text-blue-400" />
<h2 className="text-lg font-semibold">Data & Storage</h2>
</div>
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-5">
{isLoadingStats ? (
<div className="grid grid-cols-3 gap-4">{Array.from({ length: 3 }).map((_, i) => (<div key={i} className="animate-pulse"><div className="h-4 w-16 bg-neutral-800 rounded mb-2" /><div className="h-8 w-12 bg-neutral-800 rounded" /></div>))}</div>
) : stats ? (
<div className="grid grid-cols-3 gap-4">
<div className="p-3 bg-neutral-800/50 rounded-lg"><p className="text-xs text-neutral-500">Total</p><p className="text-xl font-bold text-neutral-100 mt-1">{stats.totalGenerations.toLocaleString()}</p></div>
<div className="p-3 bg-neutral-800/50 rounded-lg"><p className="text-xs text-neutral-500">Completed</p><p className="text-xl font-bold text-neutral-100 mt-1">{stats.completedGenerations.toLocaleString()}</p></div>
<div className="p-3 bg-neutral-800/50 rounded-lg"><p className="text-xs text-neutral-500">Failed</p><p className="text-xl font-bold text-neutral-100 mt-1">{stats.failedGenerations.toLocaleString()}</p></div>
</div>
) : (<p className="text-sm text-neutral-500">Unable to load statistics</p>)}
<div className="pt-4 border-t border-neutral-800 flex items-center justify-between">
<div><p className="text-sm text-neutral-300 font-medium">Purge All Data</p><p className="text-xs text-neutral-500 mt-0.5">Permanently delete all your generations</p></div>
{showPurgeConfirm ? (
<div className="flex items-center gap-2">
<button onClick={() => setShowPurgeConfirm(false)} className="px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors">Cancel</button>
<button onClick={handlePurgeAll} disabled={isPurging} className="flex items-center gap-2 px-4 py-2 bg-red-500/20 border border-red-500/30 text-red-400 rounded-lg text-sm font-medium hover:bg-red-500/30 disabled:opacity-50 transition-colors">
{isPurging ? <RefreshCw className="w-4 h-4 animate-spin" /> : <AlertTriangle className="w-4 h-4" />} Confirm Purge
</button>
</div>
) : (
<button onClick={() => setShowPurgeConfirm(true)} className="flex items-center gap-2 px-4 py-2 bg-neutral-800 border border-neutral-700 text-neutral-400 rounded-lg text-sm font-medium hover:text-red-400 hover:border-red-500/30 transition-colors">
<Trash2 className="w-4 h-4" /> Purge
</button>
)}
</div>
</div>
</section>
{/* About */}
<section className="space-y-4">
<div className="flex items-center gap-2 text-neutral-300">
<Settings className="w-5 h-5 text-blue-400" />
<h2 className="text-lg font-semibold">About</h2>
</div>
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
<div className="grid grid-cols-2 gap-4 text-sm">
<div><p className="text-neutral-500">Version</p><p className="text-neutral-200 font-medium">0.1.0</p></div>
<div><p className="text-neutral-500">Service</p><p className="text-neutral-200 font-medium">CodeBoard</p></div>
<div><p className="text-neutral-500">Database</p><p className="text-neutral-200 font-medium">PostgreSQL</p></div>
<div><p className="text-neutral-500">License</p><p className="text-neutral-200 font-medium">MIT</p></div>
</div>
</div>
</section>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import { Navbar } from "@/components/navbar";
import { Footer } from "@/components/footer";
import { Providers } from "@/components/providers";
const inter = Inter({
subsets: ["latin"],
@@ -81,18 +82,20 @@ export default function RootLayout({
>
Skip to content
</a>
<div className="relative min-h-screen flex flex-col">
<div className="fixed inset-0 bg-gradient-radial pointer-events-none" aria-hidden="true" />
<div className="fixed inset-0 bg-grid pointer-events-none opacity-50" aria-hidden="true" />
<Navbar />
<main id="main-content" className="flex-1 relative">
{children}
</main>
<Footer />
</div>
<Providers>
<div className="relative min-h-screen flex flex-col">
<div className="fixed inset-0 bg-gradient-radial pointer-events-none" aria-hidden="true" />
<div className="fixed inset-0 bg-grid pointer-events-none opacity-50" aria-hidden="true" />
<Navbar />
<main id="main-content" className="flex-1 relative">
{children}
</main>
<Footer />
</div>
</Providers>
</body>
</html>
);

View File

@@ -1,3 +1,4 @@
import Link from "next/link";
import { RepoInput } from "@/components/repo-input";
import { ExampleRepoCard } from "@/components/example-repo-card";
import { ScrollSection } from "@/components/scroll-section";
@@ -17,6 +18,9 @@ import {
Terminal,
FileCode,
CheckCircle2,
Check,
Crown,
Zap,
} from "lucide-react";
export default function HomePage() {
@@ -74,6 +78,42 @@ export default function HomePage() {
},
];
const pricingTiers = [
{
name: "Free",
price: 0,
period: "forever",
description: "Get started with CodeBoard",
generations: "15 / day",
features: ["15 generations per day", "Public repository support", "Interactive documentation", "Architecture diagrams"],
cta: "Get Started",
href: "/register",
highlighted: false,
},
{
name: "Starter",
price: 5,
period: "month",
description: "For regular use",
generations: "1,000 / month",
features: ["1,000 generations per month", "Generation history", "API key access", "Priority support"],
cta: "Start Free Trial",
href: "/register",
highlighted: true,
},
{
name: "Pro",
price: 20,
period: "month",
description: "For teams & power users",
generations: "100,000 / month",
features: ["100,000 generations per month", "Full generation history", "Multiple API keys", "Dedicated support", "Custom integrations"],
cta: "Start Free Trial",
href: "/register",
highlighted: false,
},
];
const exampleRepos = [
{
name: "sindresorhus/p-limit",
@@ -271,8 +311,8 @@ export default function HomePage() {
</div>
<div className="hidden sm:block w-px bg-zinc-800" />
<div className="text-center">
<div className="text-2xl sm:text-3xl font-bold text-white">100%</div>
<div className="text-sm text-zinc-500">Free for public repos</div>
<div className="text-2xl sm:text-3xl font-bold text-white">Free</div>
<div className="text-sm text-zinc-500">tier to start</div>
</div>
<div className="hidden sm:block w-px bg-zinc-800" />
<div className="text-center">
@@ -402,6 +442,98 @@ export default function HomePage() {
</div>
</section>
<section id="pricing" className="py-20 lg:py-32">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<ScrollSection>
<div className="text-center mb-16">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass mb-6">
<Zap className="w-4 h-4 text-blue-400" />
<span className="text-sm text-zinc-300">Pricing</span>
</div>
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
Simple, Transparent <span className="gradient-text">Pricing</span>
</h2>
<p className="text-zinc-400 max-w-xl mx-auto">
Start free, scale when you need to
</p>
</div>
</ScrollSection>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
{pricingTiers.map((tier, i) => (
<ScrollSection key={tier.name} delay={i + 1}>
<div
className={`relative group h-full rounded-2xl p-8 transition-all duration-300 hover:-translate-y-1 ${
tier.highlighted
? "glass-strong border-blue-500/30 shadow-lg shadow-blue-500/10"
: "glass hover:bg-white/[0.05]"
}`}
>
{tier.highlighted && (
<>
<div className="absolute -inset-px rounded-2xl bg-gradient-to-b from-blue-500/20 via-transparent to-blue-500/10 -z-10" />
<div className="absolute -top-3 left-1/2 -translate-x-1/2 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-500/20 border border-blue-500/30 text-xs font-medium text-blue-300">
<Crown className="w-3 h-3" />
Most Popular
</div>
</>
)}
<div className="mb-6">
<h3 className="text-lg font-semibold text-white mb-1">{tier.name}</h3>
<p className="text-sm text-zinc-500">{tier.description}</p>
</div>
<div className="mb-6">
<div className="flex items-baseline gap-1">
{tier.price === 0 ? (
<span className="text-4xl font-bold text-white">Free</span>
) : (
<>
<span className="text-4xl font-bold text-white">${tier.price}</span>
<span className="text-zinc-500">/ {tier.period}</span>
</>
)}
</div>
<div className="mt-2 text-sm text-zinc-400">
<span className="text-blue-400 font-medium">{tier.generations}</span> generations
</div>
</div>
<div className="mb-8 space-y-3">
{tier.features.map((feature) => (
<div key={feature} className="flex items-start gap-3">
<div className={`flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center mt-0.5 ${
tier.highlighted
? "bg-blue-500/20 text-blue-400"
: "bg-white/10 text-zinc-400"
}`}>
<Check className="w-3 h-3" />
</div>
<span className="text-sm text-zinc-300">{feature}</span>
</div>
))}
</div>
<div className="mt-auto">
<Link
href={tier.href}
className={`block w-full text-center py-3 px-6 rounded-xl text-sm font-medium transition-all duration-200 ${
tier.highlighted
? "btn-primary"
: "glass border border-white/10 text-white hover:bg-white/10 hover:border-white/20"
}`}
>
{tier.cta}
</Link>
</div>
</div>
</ScrollSection>
))}
</div>
</div>
</section>
<section className="py-20 lg:py-32">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<ScrollSection>
@@ -452,7 +584,7 @@ export default function HomePage() {
<span className="hidden sm:inline"></span>
<span>Free for public repositories</span>
<span className="hidden sm:inline"></span>
<span>No signup required</span>
<span>Free tier available</span>
</div>
</div>
</div>

View File

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

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

@@ -0,0 +1,91 @@
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 { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
import authConfig from "./auth.config";
declare module "next-auth" {
interface Session {
user: {
id: string;
email: string;
name?: string | null;
image?: string | null;
isEmailVerified: boolean;
};
}
}
declare module "@auth/core/jwt" {
interface JWT {
id: string;
isEmailVerified: boolean;
}
}
const loginSchema = z.object({
email: z.string().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, request) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) return null;
const { email, password } = parsed.data;
const ip = (request instanceof Request
? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
: undefined) ?? "unknown";
const rl = await checkRateLimit(`login:${ip}`, AUTH_RATE_LIMITS.login);
if (!rl.allowed) return null;
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: {
async jwt({ token, user, trigger }) {
if (user) {
token.id = user.id as string;
}
if (trigger === "update" || user) {
const dbUser = await prisma.user.findUnique({
where: { id: token.id },
select: { emailVerified: true },
});
if (dbUser) {
token.isEmailVerified = dbUser.emailVerified;
}
}
return token;
},
session({ session, token }) {
session.user.id = token.id;
session.user.isEmailVerified = token.isEmailVerified;
return session;
},
},
});

View File

@@ -3,15 +3,18 @@
import { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { Menu, X, Github } from "lucide-react";
import { Menu, X, Github, LogIn, UserCircle } from "lucide-react";
import { useSession, signOut } from "next-auth/react";
import { CommandPalette } from "@/components/command-palette";
export function Navbar() {
const [isOpen, setIsOpen] = useState(false);
const { data: session, status } = useSession();
const navLinks = [
{ href: "/#how-it-works", label: "How it Works" },
{ href: "/#features", label: "Features" },
{ href: "/#pricing", label: "Pricing" },
];
return (
@@ -54,6 +57,42 @@ export function Navbar() {
<Github className="w-4 h-4" />
Source
</a>
{status === "loading" ? (
<div className="w-24 h-8" />
) : session ? (
<div className="flex items-center gap-6">
<Link
href="/dashboard"
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
>
<UserCircle className="w-4 h-4" />
Dashboard
</Link>
<button
onClick={() => signOut()}
className="text-sm text-zinc-400 hover:text-white transition-colors"
>
Sign Out
</button>
</div>
) : (
<div className="flex items-center gap-4">
<Link
href="/login"
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
>
<LogIn className="w-4 h-4" />
Log in
</Link>
<Link
href="/register"
className="px-4 py-2 bg-blue-500 hover:bg-blue-400 text-white rounded-lg text-sm font-semibold transition-colors"
>
Sign Up
</Link>
</div>
)}
</div>
<button
@@ -93,6 +132,50 @@ export function Navbar() {
<Github className="w-4 h-4" />
Source
</a>
{status !== "loading" && (
<div className="border-t border-white/5 pt-3 mt-3 space-y-3">
{session ? (
<>
<Link
href="/dashboard"
onClick={() => setIsOpen(false)}
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors py-2"
>
<UserCircle className="w-4 h-4" />
Dashboard
</Link>
<button
onClick={() => {
signOut();
setIsOpen(false);
}}
className="block text-sm text-zinc-400 hover:text-white transition-colors py-2"
>
Sign Out
</button>
</>
) : (
<>
<Link
href="/login"
onClick={() => setIsOpen(false)}
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors py-2"
>
<LogIn className="w-4 h-4" />
Log in
</Link>
<Link
href="/register"
onClick={() => setIsOpen(false)}
className="block text-center px-4 py-2 bg-blue-500 hover:bg-blue-400 text-white rounded-lg text-sm font-semibold transition-colors"
>
Sign Up
</Link>
</>
)}
</div>
)}
</div>
</div>
)}

View File

@@ -0,0 +1,8 @@
"use client";
import { SessionProvider } from "next-auth/react";
import { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

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

36
apps/web/src/lib/email.ts Normal file
View File

@@ -0,0 +1,36 @@
import nodemailer from "nodemailer";
interface SendEmailOptions {
to: string;
subject: string;
html: string;
}
export async function sendEmail({ to, subject, html }: SendEmailOptions) {
const password = process.env.EMAIL_PASSWORD;
if (!password) {
console.warn(
"[email] EMAIL_PASSWORD not set — skipping email send to:",
to
);
return;
}
const transporter = nodemailer.createTransport({
host: "smtp.migadu.com",
port: 465,
secure: true,
auth: {
user: "hunter@repi.fun",
pass: password,
},
});
await transporter.sendMail({
from: "CodeBoard <hunter@repi.fun>",
to,
subject,
html,
});
}

View File

@@ -0,0 +1,9 @@
import { PrismaClient } from "@codeboard/database";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View File

@@ -0,0 +1,53 @@
import { getRedis } from "./redis";
interface RateLimitConfig {
windowMs: number;
maxAttempts: number;
}
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
export async function checkRateLimit(
key: string,
config: RateLimitConfig
): Promise<RateLimitResult> {
const now = Date.now();
const windowStart = now - config.windowMs;
const redisKey = `rl:${key}`;
try {
const redis = getRedis();
await redis.zremrangebyscore(redisKey, 0, windowStart);
const count = await redis.zcard(redisKey);
if (count >= config.maxAttempts) {
const oldestEntry = await redis.zrange(redisKey, 0, 0, "WITHSCORES");
const resetAt = oldestEntry.length >= 2
? parseInt(oldestEntry[1], 10) + config.windowMs
: now + config.windowMs;
return { allowed: false, remaining: 0, resetAt };
}
await redis.zadd(redisKey, now, `${now}:${Math.random()}`);
await redis.pexpire(redisKey, config.windowMs);
return {
allowed: true,
remaining: config.maxAttempts - count - 1,
resetAt: now + config.windowMs,
};
} catch {
return { allowed: true, remaining: config.maxAttempts, resetAt: now + config.windowMs };
}
}
export const AUTH_RATE_LIMITS = {
login: { windowMs: 15 * 60 * 1000, maxAttempts: 10 },
register: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },
forgotPassword: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },
resetPassword: { windowMs: 15 * 60 * 1000, maxAttempts: 5 },
} as const;

View File

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

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,90 @@
import NextAuth from "next-auth";
import { NextResponse } from "next/server";
import authConfig from "./auth.config";
const { auth } = NextAuth(authConfig);
const publicPaths = [
"/",
"/docs",
"/generate",
"/history",
"/api/auth",
"/api/generate",
"/api/generations",
"/api/health",
"/api/stripe/webhook",
"/forgot-password",
"/reset-password",
"/verify-email",
];
function isPublicPath(pathname: string): boolean {
return publicPaths.some(
(p) => pathname === p || pathname.startsWith(`${p}/`)
);
}
const ALLOWED_ORIGINS = new Set([
"https://codeboard.vectry.tech",
"http://localhost:3000",
]);
function corsHeaders(origin: string | null): Record<string, string> {
const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin)
? origin
: "https://codeboard.vectry.tech";
return {
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
}
export default auth((req) => {
const { pathname } = req.nextUrl;
const isLoggedIn = !!req.auth;
const origin = req.headers.get("origin");
if (req.method === "OPTIONS") {
return new NextResponse(null, { status: 204, headers: corsHeaders(origin) });
}
const response = (() => {
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();
})();
if (pathname.startsWith("/api/")) {
const headers = corsHeaders(origin);
for (const [key, value] of Object.entries(headers)) {
response.headers.set(key, value);
}
}
return response;
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|og-image.png).*)"],
};

View File

@@ -8,6 +8,7 @@ import { generateDocs } from "./jobs/generate.js";
interface GenerateJobData {
repoUrl: string;
generationId: string;
userId?: string;
}
const redis = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379");
@@ -109,7 +110,8 @@ export async function processGenerationJob(
status: "COMPLETED",
progress: 100,
result: docs as any,
duration
duration,
userId: job.data.userId ?? null,
}
});