diff --git a/.env.example b/.env.example
index b0333f7..c7b79b7 100644
--- a/.env.example
+++ b/.env.example
@@ -2,7 +2,11 @@ DATABASE_URL=postgresql://codeboard:codeboard@localhost:5432/codeboard
REDIS_URL=redis://localhost:6379
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
-GITHUB_CLIENT_ID=
-GITHUB_CLIENT_SECRET=
-NEXTAUTH_SECRET=
-NEXTAUTH_URL=http://localhost:3000
+LLM_MODEL=
+LLM_BASE_URL=
+AUTH_SECRET=
+STRIPE_SECRET_KEY=
+STRIPE_WEBHOOK_SECRET=
+STRIPE_STARTER_PRICE_ID=
+STRIPE_PRO_PRICE_ID=
+EMAIL_PASSWORD=
diff --git a/apps/web/package.json b/apps/web/package.json
index 56b71ba..6ebbb66 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -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",
diff --git a/apps/web/src/app/(auth)/forgot-password/page.tsx b/apps/web/src/app/(auth)/forgot-password/page.tsx
new file mode 100644
index 0000000..6ce0c2a
--- /dev/null
+++ b/apps/web/src/app/(auth)/forgot-password/page.tsx
@@ -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 (
+
+
+
+
+
Check your email
+
If an account exists for that email, we sent a password reset link. It expires in 1 hour.
+
+
+
Back to sign in
+
+ );
+ }
+
+ return (
+
+
+
+
+
Reset your password
+
Enter your email and we'll send you a reset link
+
+
+
+
Remember your password?{" "}Sign in
+
+ );
+}
diff --git a/apps/web/src/app/(auth)/layout.tsx b/apps/web/src/app/(auth)/layout.tsx
new file mode 100644
index 0000000..5abb6c8
--- /dev/null
+++ b/apps/web/src/app/(auth)/layout.tsx
@@ -0,0 +1,11 @@
+export default function AuthLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx
new file mode 100644
index 0000000..eb89d1a
--- /dev/null
+++ b/apps/web/src/app/(auth)/login/page.tsx
@@ -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 (
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+
Welcome back
+
Sign in to your CodeBoard account
+
+
+
+ {verified && (
+
+
+
Email verified! You can now sign in.
+
+ )}
+
+
+
Don't have an account?{" "}Create one
+
+ );
+}
diff --git a/apps/web/src/app/(auth)/register/page.tsx b/apps/web/src/app/(auth)/register/page.tsx
new file mode 100644
index 0000000..978ddbc
--- /dev/null
+++ b/apps/web/src/app/(auth)/register/page.tsx
@@ -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 (
+
+
+
+
+
+
+
Create your account
+
Start generating architecture diagrams with CodeBoard
+
+
+
+
Already have an account?{" "}Sign in
+
+ );
+}
diff --git a/apps/web/src/app/(auth)/reset-password/page.tsx b/apps/web/src/app/(auth)/reset-password/page.tsx
new file mode 100644
index 0000000..c04ea71
--- /dev/null
+++ b/apps/web/src/app/(auth)/reset-password/page.tsx
@@ -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 ();
+}
+
+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 (
+
+
+
+
Invalid reset link
This password reset link is invalid or has expired.
+
+
Request a new reset link
+
+ );
+ }
+
+ if (success) {
+ return (
+
+
+
+
Password reset
Your password has been successfully reset.
+
+
Sign in with your new password
+
+ );
+ }
+
+ return (
+
+
+
+
Set new password
Enter your new password below
+
+
+
Remember your password?{" "}Sign in
+
+ );
+}
diff --git a/apps/web/src/app/(auth)/verify-email/page.tsx b/apps/web/src/app/(auth)/verify-email/page.tsx
new file mode 100644
index 0000000..f3006d0
--- /dev/null
+++ b/apps/web/src/app/(auth)/verify-email/page.tsx
@@ -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 (
+
+
+
+
Check your email
We sent a verification link to your inbox
+
+
+
+
Click the link in the email to verify your account. The link expires in 24 hours.
+
+ {message && (
)}
+ {error && (
)}
+
+
Already verified?{" "}Sign in
+
+ );
+}
diff --git a/apps/web/src/app/api/auth/[...nextauth]/route.ts b/apps/web/src/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..86c9f3d
--- /dev/null
+++ b/apps/web/src/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,3 @@
+import { handlers } from "@/auth";
+
+export const { GET, POST } = handlers;
diff --git a/apps/web/src/app/api/auth/forgot-password/route.ts b/apps/web/src/app/api/auth/forgot-password/route.ts
new file mode 100644
index 0000000..d9e522b
--- /dev/null
+++ b/apps/web/src/app/api/auth/forgot-password/route.ts
@@ -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: `
+
+
Reset your password
+
+ You requested a password reset for your CodeBoard account. Click the button below to set a new password.
+
+
+ Reset password
+
+
+ This link expires in 1 hour. If you did not request this, you can safely ignore this email.
+
+
+ `,
+ });
+
+ return NextResponse.json({ success: true });
+ } catch {
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/auth/register/route.ts b/apps/web/src/app/api/auth/register/route.ts
new file mode 100644
index 0000000..20ccc2a
--- /dev/null
+++ b/apps/web/src/app/api/auth/register/route.ts
@@ -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: `
+
+
Verify your email
+
+ Thanks for signing up for CodeBoard. Click the link below to verify your email address.
+
+
+ Verify Email
+
+
+ This link expires in 24 hours. If you didn't create an account, you can safely ignore this email.
+
+
+ `,
+ });
+ } 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 }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/auth/resend-verification/route.ts b/apps/web/src/app/api/auth/resend-verification/route.ts
new file mode 100644
index 0000000..7d369fa
--- /dev/null
+++ b/apps/web/src/app/api/auth/resend-verification/route.ts
@@ -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: `
+
+
Verify your email
+
+ Click the link below to verify your email address for CodeBoard.
+
+
+ Verify Email
+
+
+ This link expires in 24 hours.
+
+
+ `,
+ });
+
+ return NextResponse.json({ success: true });
+}
diff --git a/apps/web/src/app/api/auth/reset-password/route.ts b/apps/web/src/app/api/auth/reset-password/route.ts
new file mode 100644
index 0000000..ca9bc5a
--- /dev/null
+++ b/apps/web/src/app/api/auth/reset-password/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/auth/verify-email/route.ts b/apps/web/src/app/api/auth/verify-email/route.ts
new file mode 100644
index 0000000..b5961d0
--- /dev/null
+++ b/apps/web/src/app/api/auth/verify-email/route.ts
@@ -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));
+}
diff --git a/apps/web/src/app/api/generate/route.ts b/apps/web/src/app/api/generate/route.ts
index cc7831e..39e9f53 100644
--- a/apps/web/src/app/api/generate/route.ts
+++ b/apps/web/src/app/api/generate/route.ts
@@ -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,
diff --git a/apps/web/src/app/api/generations/mine/route.ts b/apps/web/src/app/api/generations/mine/route.ts
new file mode 100644
index 0000000..ba582f4
--- /dev/null
+++ b/apps/web/src/app/api/generations/mine/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/keys/[id]/route.ts b/apps/web/src/app/api/keys/[id]/route.ts
new file mode 100644
index 0000000..ad76ae0
--- /dev/null
+++ b/apps/web/src/app/api/keys/[id]/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/keys/route.ts b/apps/web/src/app/api/keys/route.ts
new file mode 100644
index 0000000..34ada4e
--- /dev/null
+++ b/apps/web/src/app/api/keys/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/settings/account/route.ts b/apps/web/src/app/api/settings/account/route.ts
new file mode 100644
index 0000000..1127e77
--- /dev/null
+++ b/apps/web/src/app/api/settings/account/route.ts
@@ -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 });
+ }
+}
diff --git a/apps/web/src/app/api/settings/purge/route.ts b/apps/web/src/app/api/settings/purge/route.ts
new file mode 100644
index 0000000..70d822f
--- /dev/null
+++ b/apps/web/src/app/api/settings/purge/route.ts
@@ -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 });
+ }
+}
diff --git a/apps/web/src/app/api/settings/stats/route.ts b/apps/web/src/app/api/settings/stats/route.ts
new file mode 100644
index 0000000..9618dd0
--- /dev/null
+++ b/apps/web/src/app/api/settings/stats/route.ts
@@ -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 });
+ }
+}
diff --git a/apps/web/src/app/api/stripe/checkout/route.ts b/apps/web/src/app/api/stripe/checkout/route.ts
new file mode 100644
index 0000000..7461639
--- /dev/null
+++ b/apps/web/src/app/api/stripe/checkout/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/stripe/portal/route.ts b/apps/web/src/app/api/stripe/portal/route.ts
new file mode 100644
index 0000000..b798a67
--- /dev/null
+++ b/apps/web/src/app/api/stripe/portal/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/stripe/webhook/route.ts b/apps/web/src/app/api/stripe/webhook/route.ts
new file mode 100644
index 0000000..dee745a
--- /dev/null
+++ b/apps/web/src/app/api/stripe/webhook/route.ts
@@ -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 = {
+ 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 });
+}
diff --git a/apps/web/src/app/dashboard/keys/page.tsx b/apps/web/src/app/dashboard/keys/page.tsx
new file mode 100644
index 0000000..21f44f4
--- /dev/null
+++ b/apps/web/src/app/dashboard/keys/page.tsx
@@ -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([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isCreating, setIsCreating] = useState(false);
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ const [newKeyName, setNewKeyName] = useState("");
+ const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
+ const [copiedField, setCopiedField] = useState(null);
+ const [revokingId, setRevokingId] = useState(null);
+ const [confirmRevokeId, setConfirmRevokeId] = useState(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 (
+
+
+
API Keys
Manage API keys for programmatic access
+
+
+
+ {newlyCreatedKey && (
+
+
+
+
API Key Created
{newlyCreatedKey.name}
+
+
+
{newlyCreatedKey.key}
+
+
+
This key won't be shown again. Copy it now.
+
+
+ )}
+
+ {showCreateForm && !newlyCreatedKey && (
+
+
+
+
+ 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 />
+
+
+
+
+
+
+ )}
+
+
+
Active Keys
+
+ {isLoading ? (
+
{Array.from({ length: 3 }).map((_, i) => (
))}
+ ) : keys.length === 0 ? (
+
No API keys yet
Create one for programmatic access
+ ) : (
+
+ {keys.map((apiKey) => (
+
+
+
+
{apiKey.name}
+
+ {apiKey.keyPrefix}••••••••
+ Created {formatDate(apiKey.createdAt)}
+ {apiKey.lastUsedAt && Last used {formatDate(apiKey.lastUsedAt)}}
+
+
+ {confirmRevokeId === apiKey.id ? (
+
+
+
+
+ ) : (
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/dashboard/layout.tsx b/apps/web/src/app/dashboard/layout.tsx
new file mode 100644
index 0000000..a6dfac7
--- /dev/null
+++ b/apps/web/src/app/dashboard/layout.tsx
@@ -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 (
+
+
+
+
+
+
+
+ CodeBoard
+ Dashboard
+
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+ {sent ? "Verification email sent! Check your inbox." : "Please verify your email address. Check your inbox or"}
+
+ {!sent && (
+
+ )}
+
+
+
+
+ );
+}
+
+export default function DashboardLayout({ children }: { children: ReactNode }) {
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+
+ return (
+
+
+
+ {sidebarOpen && (
+
setSidebarOpen(false)} />
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
CodeBoard
+
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx
new file mode 100644
index 0000000..5afbd53
--- /dev/null
+++ b/apps/web/src/app/dashboard/page.tsx
@@ -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
([]);
+ 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 ;
+ case "FAILED": return ;
+ default: return ;
+ }
+ };
+
+ return (
+
+
+
My Generations
+
Your architecture diagram generation history
+
+
+
+ {isLoading ? (
+
+
+
Loading generations...
+
+ ) : generations.length === 0 ? (
+
+
+
+
+
No generations yet
+
Generate your first architecture diagram from the home page
+
+ ) : (
+
+ {generations.map((gen) => (
+
+ {statusIcon(gen.status)}
+
+
{gen.repoName}
+
+ {gen.repoUrl}
+ {gen.duration && (
+
+ {gen.duration}s
+
+ )}
+
+
+
{new Date(gen.createdAt).toLocaleDateString()}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/app/dashboard/settings/page.tsx b/apps/web/src/app/dashboard/settings/page.tsx
new file mode 100644
index 0000000..b6f7fea
--- /dev/null
+++ b/apps/web/src/app/dashboard/settings/page.tsx
@@ -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(null);
+ const [account, setAccount] = useState(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(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 (
+
+
+
Settings
+
Account, billing, and configuration
+
+
+ {/* Account */}
+
+
+
+
Account
+
+
+ {isLoadingAccount ? (
+
Loading account...
+ ) : account ? (
+
+
+
Name
{account.name ?? "\u2014"}
+
Member since
{new Date(account.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+
+ ) : (
Unable to load account info
)}
+
+
+
+ {/* Subscription */}
+
+
+
+
Subscription & Billing
+
+
+
+
+ Current plan
+
+ {currentTier === "PRO" && }
+ {currentTier === "STARTER" && }
+ {currentTier}
+
+
+ {currentTier !== "FREE" && account?.subscription?.hasStripeSubscription && (
+
+ )}
+
+
+
+ {generationsUsed.toLocaleString()} of {generationsLimit.toLocaleString()} generations used
+ {currentTier === "FREE" ? "15 generations/day" : "This billing period"}
+
+
+
90 ? "bg-amber-500" : "bg-blue-500")} style={{ width: `${usagePercent}%` }} />
+
+
+
+
+
+ {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 (
+
+ {isCurrent && (
Current
)}
+
{tier.name}
{tier.description}
+
${tier.price}/{tier.period}
+
+ {tier.features.map((f) => (- {f}
))}
+
+ {isCurrent ? (
Active plan
)
+ : isUpgrade ? (
)
+ : isDowngrade ? (
)
+ : null}
+
+ );
+ })}
+
+
+
+ {/* Data */}
+
+
+
+
Data & Storage
+
+
+ {isLoadingStats ? (
+
{Array.from({ length: 3 }).map((_, i) => (
))}
+ ) : stats ? (
+
+
Total
{stats.totalGenerations.toLocaleString()}
+
Completed
{stats.completedGenerations.toLocaleString()}
+
Failed
{stats.failedGenerations.toLocaleString()}
+
+ ) : (
Unable to load statistics
)}
+
+
+
Purge All Data
Permanently delete all your generations
+ {showPurgeConfirm ? (
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* About */}
+
+
+ );
+}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 1ccced4..6d238ba 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -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
-
-
-
-
-
-
-
- {children}
-
-
-
-
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+