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

+
+
+
+
+
+ + 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 &&

Please enter a valid email address

} +
+
+ {error && (

{error}

)} + +
+

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 ( +
+
{children}
+
+ ); +} 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.

+
+ )} + +
+
+
+ + 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 &&

Please enter a valid email address

} +
+
+ + 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 &&

Password must be at least 8 characters

} +
+
+
+ Forgot password? +
+ {error && (

{error}

)} + +
+

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

+
+
+
+
+
+ + 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" /> +
+
+ + 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 &&

Please enter a valid email address

} +
+
+ + 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 &&

Password must be at least 8 characters

} +
+
+ {error && (

{error}

)} + +
+

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

+
+
+
+
+ + 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 &&

Password must be at least 8 characters

} +
+
+ + 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 &&

Passwords do not match

} +
+
+ {error && (

{error}

)} + +
+

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 && (

{message}

)} + {error && (

{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 && ( +
+

Create New API Key

+
+ + 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 +
+ +
+ + + +
+
+

CodeBoard v0.1.0

+
+
+
+ ); +} + +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 ? ( +
+

Email

{account.email}

+

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 */} +
+
+ +

About

+
+
+
+

Version

0.1.0

+

Service

CodeBoard

+

Database

PostgreSQL

+

License

MIT

+
+
+
+
+ ); +} 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 -
-
)} diff --git a/apps/web/src/components/providers.tsx b/apps/web/src/components/providers.tsx new file mode 100644 index 0000000..8d42e47 --- /dev/null +++ b/apps/web/src/components/providers.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { SessionProvider } from "next-auth/react"; +import { ReactNode } from "react"; + +export function Providers({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/web/src/lib/api-key.ts b/apps/web/src/lib/api-key.ts new file mode 100644 index 0000000..811740f --- /dev/null +++ b/apps/web/src/lib/api-key.ts @@ -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, + }; +} diff --git a/apps/web/src/lib/email.ts b/apps/web/src/lib/email.ts new file mode 100644 index 0000000..fedd9e2 --- /dev/null +++ b/apps/web/src/lib/email.ts @@ -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 ", + to, + subject, + html, + }); +} diff --git a/apps/web/src/lib/prisma.ts b/apps/web/src/lib/prisma.ts new file mode 100644 index 0000000..dfed134 --- /dev/null +++ b/apps/web/src/lib/prisma.ts @@ -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; diff --git a/apps/web/src/lib/rate-limit.ts b/apps/web/src/lib/rate-limit.ts new file mode 100644 index 0000000..999c1f1 --- /dev/null +++ b/apps/web/src/lib/rate-limit.ts @@ -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 { + 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; diff --git a/apps/web/src/lib/stripe.ts b/apps/web/src/lib/stripe.ts new file mode 100644 index 0000000..e5c93a4 --- /dev/null +++ b/apps/web/src/lib/stripe.ts @@ -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; diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/apps/web/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts new file mode 100644 index 0000000..2d6fe13 --- /dev/null +++ b/apps/web/src/middleware.ts @@ -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 { + 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).*)"], +}; diff --git a/apps/worker/src/processor.ts b/apps/worker/src/processor.ts index 73b8e75..abd3a3c 100644 --- a/apps/worker/src/processor.ts +++ b/apps/worker/src/processor.ts @@ -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, } }); diff --git a/docker-compose.yml b/docker-compose.yml index 0a07e50..345dbe4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,19 @@ services: environment: - REDIS_URL=redis://redis:6379 - DATABASE_URL=postgresql://codeboard:codeboard@postgres:5432/codeboard + - AUTH_SECRET=${AUTH_SECRET:-} + - AUTH_URL=https://codeboard.vectry.tech + - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} + - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-} + - STRIPE_STARTER_PRICE_ID=price_1SzMQbR8i0An4Wz70Elgk5Zd + - STRIPE_PRO_PRICE_ID=price_1SzMQrR8i0An4Wz7UseMs0yy + - EMAIL_FROM=CodeBoard + - EMAIL_HOST=smtp.migadu.com + - EMAIL_PORT=465 + - EMAIL_SECURE=true + - EMAIL_USER=hunter@repi.fun + - EMAIL_PASSWORD=${EMAIL_PASSWORD:-} + - NEXT_PUBLIC_APP_URL=https://codeboard.vectry.tech depends_on: redis: condition: service_started diff --git a/package-lock.json b/package-lock.json index d6cdb86..dd992b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,19 +26,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", @@ -46,6 +55,15 @@ "typescript": "^5.7" } }, + "apps/web/node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "apps/worker": { "name": "@codeboard/worker", "version": "0.0.1", @@ -121,6 +139,35 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "node_modules/@auth/core": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", + "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1116,6 +1163,15 @@ "node": ">= 10" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@prisma/client": { "version": "6.19.2", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", @@ -1958,6 +2014,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -2284,6 +2347,16 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.22", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.22.tgz", + "integrity": "sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -2393,6 +2466,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bullmq": { "version": "5.67.3", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.67.3.tgz", @@ -2614,6 +2696,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -4012,6 +4103,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5226,6 +5326,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.30", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", + "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -5322,6 +5449,17 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "optional": true, + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nypm": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", @@ -5347,6 +5485,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.4.tgz", + "integrity": "sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -5555,6 +5702,25 @@ "node": ">=4" } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prisma": { "version": "6.19.2", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", @@ -5964,6 +6130,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stripe": { + "version": "20.3.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz", + "integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -6011,6 +6194,16 @@ "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "license": "MIT" }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", @@ -6495,6 +6688,15 @@ "node": ">= 8" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 2cbf8c8..bcd96d3 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -7,6 +7,112 @@ generator client { provider = "prisma-client-js" } +model User { + id String @id @default(cuid()) + email String @unique + passwordHash String + name String? + emailVerified Boolean @default(false) + image String? + stripeCustomerId String? @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + subscription Subscription? + apiKeys ApiKey[] + generations Generation[] + passwordResetTokens PasswordResetToken[] + emailVerificationTokens EmailVerificationToken[] + + @@index([email]) +} + +model Subscription { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + tier SubscriptionTier @default(FREE) + stripeCustomerId String? @unique + stripeSubscriptionId String? @unique + stripePriceId String? + + currentPeriodStart DateTime? + currentPeriodEnd DateTime? + + generationsUsed Int @default(0) + generationsLimit Int @default(15) + + status SubscriptionStatus @default(ACTIVE) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([stripeCustomerId]) + @@index([stripeSubscriptionId]) +} + +model ApiKey { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + name String @default("Default") + keyHash String @unique + keyPrefix String + lastUsedAt DateTime? + + revoked Boolean @default(false) + createdAt DateTime @default(now()) + + @@index([keyHash]) + @@index([userId]) +} + +model PasswordResetToken { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + token String @unique + expiresAt DateTime + used Boolean @default(false) + + createdAt DateTime @default(now()) + + @@index([token]) + @@index([userId]) +} + +model EmailVerificationToken { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + token String @unique + expiresAt DateTime + used Boolean @default(false) + + createdAt DateTime @default(now()) + + @@index([token]) + @@index([userId]) +} + +enum SubscriptionTier { + FREE + STARTER + PRO +} + +enum SubscriptionStatus { + ACTIVE + PAST_DUE + CANCELED + UNPAID +} + model Generation { id String @id @default(cuid()) repoUrl String @@ -27,16 +133,7 @@ model Generation { @@unique([repoUrl, commitHash]) @@index([repoUrl]) @@index([status]) -} - -model User { - id String @id @default(cuid()) - githubId String @unique - login String - email String? - avatarUrl String? - createdAt DateTime @default(now()) - generations Generation[] + @@index([userId]) } enum Status {