feat: add subscription service — user auth, Stripe billing, API keys, dashboard
- NextAuth v5 with email+password credentials, JWT sessions - Registration, login, email verification, password reset flows - Stripe integration: Free (15/day), Starter ($5/1k/mo), Pro ($20/100k/mo) - API key management (cb_ prefix) with hash-based validation - Dashboard with generations history, settings, billing management - Rate limiting: Redis daily counter (free), DB monthly (paid) - Generate route auth: Bearer API key + session, anonymous allowed - Worker userId propagation for generation history - Pricing section on landing page, auth-aware navbar - Middleware with route protection, CORS for codeboard.vectry.tech - Docker env vars for auth, Stripe, email (smtp.migadu.com)
This commit is contained in:
12
.env.example
12
.env.example
@@ -2,7 +2,11 @@ DATABASE_URL=postgresql://codeboard:codeboard@localhost:5432/codeboard
|
|||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
GITHUB_CLIENT_ID=
|
LLM_MODEL=
|
||||||
GITHUB_CLIENT_SECRET=
|
LLM_BASE_URL=
|
||||||
NEXTAUTH_SECRET=
|
AUTH_SECRET=
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
STRIPE_STARTER_PRICE_ID=
|
||||||
|
STRIPE_PRO_PRICE_ID=
|
||||||
|
EMAIL_PASSWORD=
|
||||||
|
|||||||
@@ -14,19 +14,28 @@
|
|||||||
"@codeboard/database": "*",
|
"@codeboard/database": "*",
|
||||||
"@codeboard/shared": "*",
|
"@codeboard/shared": "*",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mermaid": "^11.4.0",
|
"mermaid": "^11.4.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"nodemailer": "^7.0.7",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"postcss": "^8.5.0",
|
"postcss": "^8.5.0",
|
||||||
|
|||||||
70
apps/web/src/app/(auth)/forgot-password/page.tsx
Normal file
70
apps/web/src/app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Code2, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (!emailValid) { setError("Please enter a valid email address"); return; }
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/forgot-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }) });
|
||||||
|
if (!res.ok) { const data: { error?: string } = await res.json(); setError(data.error ?? "Something went wrong"); setLoading(false); return; }
|
||||||
|
setSubmitted(true);
|
||||||
|
} catch { setError("Something went wrong."); setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitted) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-100">Check your email</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-400">If an account exists for that email, we sent a password reset link. It expires in 1 hour.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm text-neutral-400"><Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Back to sign in</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-100">Reset your password</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-400">Enter your email and we'll send you a reset link</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-neutral-300">Email</label>
|
||||||
|
<input id="email" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", email && !emailValid ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{email && !emailValid && <p className="text-xs text-red-400">Please enter a valid email address</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3"><p className="text-sm text-red-400">{error}</p></div>)}
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className={cn("w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors", loading ? "bg-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{loading ? "Sending..." : "Send reset link"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-neutral-400">Remember your password?{" "}<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
apps/web/src/app/(auth)/layout.tsx
Normal file
11
apps/web/src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-neutral-950 flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-md">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/web/src/app/(auth)/login/page.tsx
Normal file
89
apps/web/src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Code2, CheckCircle, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const verified = searchParams.get("verified") === "true";
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
const passwordValid = password.length >= 8;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (!emailValid) { setError("Please enter a valid email address"); return; }
|
||||||
|
if (!passwordValid) { setError("Password must be at least 8 characters"); return; }
|
||||||
|
setLoading(true);
|
||||||
|
const result = await signIn("credentials", { email, password, redirect: false });
|
||||||
|
if (result?.error) { setError("Invalid email or password"); setLoading(false); return; }
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||||
|
<Code2 className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-100">Welcome back</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-400">Sign in to your CodeBoard account</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{verified && (
|
||||||
|
<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 px-4 py-3 flex items-center gap-2">
|
||||||
|
<CheckCircle className="w-4 h-4 text-blue-400 shrink-0" />
|
||||||
|
<p className="text-sm text-blue-400">Email verified! You can now sign in.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-neutral-300">Email</label>
|
||||||
|
<input id="email" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", email && !emailValid ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{email && !emailValid && <p className="text-xs text-red-400">Please enter a valid email address</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-neutral-300">Password</label>
|
||||||
|
<input id="password" type="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", password && !passwordValid ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{password && !passwordValid && <p className="text-xs text-red-400">Password must be at least 8 characters</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Link href="/forgot-password" className="text-sm text-neutral-500 hover:text-blue-400 transition-colors">Forgot password?</Link>
|
||||||
|
</div>
|
||||||
|
{error && (<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3"><p className="text-sm text-red-400">{error}</p></div>)}
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className={cn("w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors", loading ? "bg-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-neutral-400">Don't have an account?{" "}<Link href="/register" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Create one</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
apps/web/src/app/(auth)/register/page.tsx
Normal file
83
apps/web/src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Code2, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
const passwordValid = password.length >= 8;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (!emailValid) { setError("Please enter a valid email address"); return; }
|
||||||
|
if (!passwordValid) { setError("Password must be at least 8 characters"); return; }
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password, ...(name.trim() ? { name: name.trim() } : {}) }),
|
||||||
|
});
|
||||||
|
if (res.status === 429) { const data: { error?: string } = await res.json(); setError(data.error ?? "Too many attempts."); setLoading(false); return; }
|
||||||
|
if (!res.ok) { const data: { error?: string } = await res.json(); setError(data.error ?? "Registration failed"); setLoading(false); return; }
|
||||||
|
const result = await signIn("credentials", { email, password, redirect: false });
|
||||||
|
if (result?.error) { router.push("/login"); return; }
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
} catch { setError("Something went wrong."); setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
|
||||||
|
<Code2 className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-100">Create your account</h1>
|
||||||
|
<p className="mt-1 text-sm text-neutral-400">Start generating architecture diagrams with CodeBoard</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-neutral-300">Name <span className="text-neutral-500 font-normal">(optional)</span></label>
|
||||||
|
<input id="name" type="text" autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Jane Doe"
|
||||||
|
className="w-full px-3 py-2.5 bg-neutral-950 border border-neutral-800 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-neutral-300">Email</label>
|
||||||
|
<input id="email" type="email" autoComplete="email" required value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@example.com"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", email && !emailValid ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{email && !emailValid && <p className="text-xs text-red-400">Please enter a valid email address</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-neutral-300">Password</label>
|
||||||
|
<input id="password" type="password" autoComplete="new-password" required value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", password && !passwordValid ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{password && !passwordValid && <p className="text-xs text-red-400">Password must be at least 8 characters</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3"><p className="text-sm text-red-400">{error}</p></div>)}
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className={cn("w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors", loading ? "bg-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{loading ? "Creating account…" : "Create account"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-neutral-400">Already have an account?{" "}<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/web/src/app/(auth)/reset-password/page.tsx
Normal file
93
apps/web/src/app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Code2, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return (<Suspense><ResetPasswordForm /></Suspense>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetPasswordForm() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const passwordValid = password.length >= 8;
|
||||||
|
const passwordsMatch = password === confirmPassword;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (!passwordValid) { setError("Password must be at least 8 characters"); return; }
|
||||||
|
if (!passwordsMatch) { setError("Passwords do not match"); return; }
|
||||||
|
if (!token) { setError("Invalid reset link"); return; }
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/reset-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, password }) });
|
||||||
|
if (!res.ok) { const data: { error?: string } = await res.json(); setError(data.error ?? "Something went wrong"); setLoading(false); return; }
|
||||||
|
setSuccess(true);
|
||||||
|
} catch { setError("Something went wrong."); setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
|
||||||
|
<div className="text-center"><h1 className="text-2xl font-bold text-neutral-100">Invalid reset link</h1><p className="mt-1 text-sm text-neutral-400">This password reset link is invalid or has expired.</p></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm text-neutral-400"><Link href="/forgot-password" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Request a new reset link</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
|
||||||
|
<div className="text-center"><h1 className="text-2xl font-bold text-neutral-100">Password reset</h1><p className="mt-1 text-sm text-neutral-400">Your password has been successfully reset.</p></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm text-neutral-400"><Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in with your new password</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
|
||||||
|
<div className="text-center"><h1 className="text-2xl font-bold text-neutral-100">Set new password</h1><p className="mt-1 text-sm text-neutral-400">Enter your new password below</p></div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-neutral-300">New password</label>
|
||||||
|
<input id="password" type="password" autoComplete="new-password" required value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", password && !passwordValid ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{password && !passwordValid && <p className="text-xs text-red-400">Password must be at least 8 characters</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-300">Confirm password</label>
|
||||||
|
<input id="confirmPassword" type="password" autoComplete="new-password" required value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} placeholder="••••••••"
|
||||||
|
className={cn("w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", confirmPassword && !passwordsMatch ? "border-red-500/50 focus:border-red-500" : "border-neutral-800 focus:border-blue-500")} />
|
||||||
|
{confirmPassword && !passwordsMatch && <p className="text-xs text-red-400">Passwords do not match</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3"><p className="text-sm text-red-400">{error}</p></div>)}
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className={cn("w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors", loading ? "bg-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{loading ? "Resetting..." : "Reset password"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-neutral-400">Remember your password?{" "}<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/web/src/app/(auth)/verify-email/page.tsx
Normal file
44
apps/web/src/app/(auth)/verify-email/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Code2, Loader2, Mail } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function VerifyEmailPage() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleResend() {
|
||||||
|
setLoading(true); setMessage(""); setError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/resend-verification", { method: "POST" });
|
||||||
|
if (!res.ok) { const data: { error?: string } = await res.json(); setError(data.error ?? "Failed to resend email"); setLoading(false); return; }
|
||||||
|
setMessage("Verification email sent! Check your inbox.");
|
||||||
|
} catch { setError("Something went wrong."); } finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20"><Code2 className="w-6 h-6 text-white" /></div>
|
||||||
|
<div className="text-center"><h1 className="text-2xl font-bold text-neutral-100">Check your email</h1><p className="mt-1 text-sm text-neutral-400">We sent a verification link to your inbox</p></div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-neutral-900 border border-neutral-800 p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-blue-500/10 border border-blue-500/20 flex items-center justify-center"><Mail className="w-8 h-8 text-blue-400" /></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-400 text-center leading-relaxed">Click the link in the email to verify your account. The link expires in 24 hours.</p>
|
||||||
|
</div>
|
||||||
|
{message && (<div className="rounded-lg bg-blue-500/10 border border-blue-500/20 px-4 py-3"><p className="text-sm text-blue-400">{message}</p></div>)}
|
||||||
|
{error && (<div className="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3"><p className="text-sm text-red-400">{error}</p></div>)}
|
||||||
|
<button onClick={handleResend} disabled={loading}
|
||||||
|
className={cn("w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors", loading ? "bg-blue-500/50 text-white/50 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-400 text-white")}>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
{loading ? "Sending..." : "Resend verification email"}
|
||||||
|
</button>
|
||||||
|
<p className="text-center text-sm text-neutral-400">Already verified?{" "}<Link href="/login" className="text-blue-400 hover:text-blue-300 font-medium transition-colors">Sign in</Link></p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
3
apps/web/src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
92
apps/web/src/app/api/auth/forgot-password/route.ts
Normal file
92
apps/web/src/app/api/auth/forgot-password/route.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomBytes, createHash } from "crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendEmail } from "@/lib/email";
|
||||||
|
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const forgotPasswordSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
const rl = await checkRateLimit(`forgot:${ip}`, AUTH_RATE_LIMITS.forgotPassword);
|
||||||
|
if (!rl.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Too many requests. Please try again later." },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: unknown = await request.json();
|
||||||
|
const parsed = forgotPasswordSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email } = parsed.data;
|
||||||
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: normalizedEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.passwordResetToken.updateMany({
|
||||||
|
where: { userId: user.id, used: false },
|
||||||
|
data: { used: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawToken = randomBytes(32).toString("hex");
|
||||||
|
const tokenHash = hashToken(rawToken);
|
||||||
|
|
||||||
|
await prisma.passwordResetToken.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
token: tokenHash,
|
||||||
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetUrl = `https://codeboard.vectry.tech/reset-password?token=${rawToken}`;
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: normalizedEmail,
|
||||||
|
subject: "Reset your CodeBoard password",
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||||
|
<h2 style="color: #f5f5f5; font-size: 20px; margin-bottom: 16px;">Reset your password</h2>
|
||||||
|
<p style="color: #a3a3a3; font-size: 14px; line-height: 1.6; margin-bottom: 24px;">
|
||||||
|
You requested a password reset for your CodeBoard account. Click the button below to set a new password.
|
||||||
|
</p>
|
||||||
|
<a href="${resetUrl}" style="display: inline-block; background-color: #3b82f6; color: #fff; font-weight: 600; font-size: 14px; padding: 12px 24px; border-radius: 8px; text-decoration: none;">
|
||||||
|
Reset password
|
||||||
|
</a>
|
||||||
|
<p style="color: #737373; font-size: 12px; line-height: 1.5; margin-top: 32px;">
|
||||||
|
This link expires in 1 hour. If you did not request this, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
apps/web/src/app/api/auth/register/route.ts
Normal file
117
apps/web/src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendEmail } from "@/lib/email";
|
||||||
|
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
const rl = await checkRateLimit(`register:${ip}`, AUTH_RATE_LIMITS.register);
|
||||||
|
if (!rl.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Too many registration attempts. Please try again later." },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: unknown = await request.json();
|
||||||
|
const parsed = registerSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password, name } = parsed.data;
|
||||||
|
const normalizedEmail = email.toLowerCase();
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { email: normalizedEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "If this email is available, a confirmation email will be sent." },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hash(password, 12);
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: normalizedEmail,
|
||||||
|
passwordHash,
|
||||||
|
name: name ?? null,
|
||||||
|
subscription: {
|
||||||
|
create: {
|
||||||
|
tier: "FREE",
|
||||||
|
generationsLimit: 15,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex");
|
||||||
|
|
||||||
|
await prisma.emailVerificationToken.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
token: tokenHash,
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyUrl = `https://codeboard.vectry.tech/verify-email?token=${rawToken}`;
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "Verify your CodeBoard email",
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||||
|
<h2 style="color: #e5e5e5; margin-bottom: 16px;">Verify your email</h2>
|
||||||
|
<p style="color: #a3a3a3; line-height: 1.6;">
|
||||||
|
Thanks for signing up for CodeBoard. Click the link below to verify your email address.
|
||||||
|
</p>
|
||||||
|
<a href="${verifyUrl}" style="display: inline-block; margin-top: 24px; padding: 12px 24px; background-color: #3b82f6; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600;">
|
||||||
|
Verify Email
|
||||||
|
</a>
|
||||||
|
<p style="color: #737373; font-size: 13px; margin-top: 32px;">
|
||||||
|
This link expires in 24 hours. If you didn't create an account, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (emailError) {
|
||||||
|
console.error("[register] Failed to send verification email:", emailError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: "If this email is available, a confirmation email will be sent." },
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
apps/web/src/app/api/auth/resend-verification/route.ts
Normal file
75
apps/web/src/app/api/auth/resend-verification/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendEmail } from "@/lib/email";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.emailVerified) {
|
||||||
|
return NextResponse.json({ error: "Email already verified" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestToken = await prisma.emailVerificationToken.findFirst({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (latestToken && Date.now() - latestToken.createdAt.getTime() < 60_000) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Please wait 60 seconds before requesting another email" },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.emailVerificationToken.updateMany({
|
||||||
|
where: { userId: user.id, used: false },
|
||||||
|
data: { used: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex");
|
||||||
|
|
||||||
|
await prisma.emailVerificationToken.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
token: tokenHash,
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyUrl = `https://codeboard.vectry.tech/verify-email?token=${rawToken}`;
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "Verify your CodeBoard email",
|
||||||
|
html: `
|
||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||||
|
<h2 style="color: #e5e5e5; margin-bottom: 16px;">Verify your email</h2>
|
||||||
|
<p style="color: #a3a3a3; line-height: 1.6;">
|
||||||
|
Click the link below to verify your email address for CodeBoard.
|
||||||
|
</p>
|
||||||
|
<a href="${verifyUrl}" style="display: inline-block; margin-top: 24px; padding: 12px 24px; background-color: #3b82f6; color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600;">
|
||||||
|
Verify Email
|
||||||
|
</a>
|
||||||
|
<p style="color: #737373; font-size: 13px; margin-top: 32px;">
|
||||||
|
This link expires in 24 hours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
73
apps/web/src/app/api/auth/reset-password/route.ts
Normal file
73
apps/web/src/app/api/auth/reset-password/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const resetPasswordSchema = z.object({
|
||||||
|
token: z.string().min(1, "Token is required"),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
const rl = await checkRateLimit(`reset:${ip}`, AUTH_RATE_LIMITS.resetPassword);
|
||||||
|
if (!rl.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Too many attempts. Please try again later." },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: unknown = await request.json();
|
||||||
|
const parsed = resetPasswordSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, password } = parsed.data;
|
||||||
|
const tokenHash = hashToken(token);
|
||||||
|
|
||||||
|
const resetToken = await prisma.passwordResetToken.findUnique({
|
||||||
|
where: { token: tokenHash },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid or expired reset link" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hash(password, 12);
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: resetToken.userId },
|
||||||
|
data: { passwordHash },
|
||||||
|
}),
|
||||||
|
prisma.passwordResetToken.update({
|
||||||
|
where: { id: resetToken.id },
|
||||||
|
data: { used: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
apps/web/src/app/api/auth/verify-email/route.ts
Normal file
43
apps/web/src/app/api/auth/verify-email/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const rawToken = request.nextUrl.searchParams.get("token");
|
||||||
|
|
||||||
|
if (!rawToken) {
|
||||||
|
return NextResponse.redirect(new URL("/login?error=missing-token", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenHash = crypto.createHash("sha256").update(rawToken).digest("hex");
|
||||||
|
|
||||||
|
const verificationToken = await prisma.emailVerificationToken.findUnique({
|
||||||
|
where: { token: tokenHash },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verificationToken) {
|
||||||
|
return NextResponse.redirect(new URL("/login?error=invalid-token", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verificationToken.used) {
|
||||||
|
return NextResponse.redirect(new URL("/login?verified=true", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verificationToken.expiresAt < new Date()) {
|
||||||
|
return NextResponse.redirect(new URL("/login?error=token-expired", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: verificationToken.userId },
|
||||||
|
data: { emailVerified: true },
|
||||||
|
}),
|
||||||
|
prisma.emailVerificationToken.update({
|
||||||
|
where: { id: verificationToken.id },
|
||||||
|
data: { used: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL("/login?verified=true", request.url));
|
||||||
|
}
|
||||||
@@ -1,10 +1,69 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getQueue } from "@/lib/queue";
|
import { getQueue } from "@/lib/queue";
|
||||||
import { getRedis } from "@/lib/redis";
|
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.-]+\/?$/;
|
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) {
|
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 body = await request.json();
|
||||||
const repoUrl: string = body.repoUrl?.trim();
|
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 generationId = `gen_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
const redis = getRedis();
|
const redis = getRedis();
|
||||||
@@ -26,7 +96,7 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const queue = getQueue();
|
const queue = getQueue();
|
||||||
await queue.add("generate", { repoUrl, generationId }, {
|
await queue.add("generate", { repoUrl, generationId, userId }, {
|
||||||
jobId: generationId,
|
jobId: generationId,
|
||||||
removeOnComplete: { age: 3600 },
|
removeOnComplete: { age: 3600 },
|
||||||
removeOnFail: false,
|
removeOnFail: false,
|
||||||
|
|||||||
34
apps/web/src/app/api/generations/mine/route.ts
Normal file
34
apps/web/src/app/api/generations/mine/route.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const generations = await prisma.generation.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
repoUrl: true,
|
||||||
|
repoName: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
duration: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ generations }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user generations:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
apps/web/src/app/api/keys/[id]/route.ts
Normal file
38
apps/web/src/app/api/keys/[id]/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id)
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const apiKey = await prisma.apiKey.findFirst({
|
||||||
|
where: { id, userId: session.user.id, revoked: false },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.apiKey.update({
|
||||||
|
where: { id: apiKey.id },
|
||||||
|
data: { revoked: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error revoking API key:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
apps/web/src/app/api/keys/route.ts
Normal file
88
apps/web/src/app/api/keys/route.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomBytes, createHash } from "crypto";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id)
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const keys = await prisma.apiKey.findMany({
|
||||||
|
where: { userId: session.user.id, revoked: false },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
keyPrefix: true,
|
||||||
|
createdAt: true,
|
||||||
|
lastUsedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(keys, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error listing API keys:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id)
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const MAX_KEYS_PER_USER = 10;
|
||||||
|
const keyCount = await prisma.apiKey.count({
|
||||||
|
where: { userId: session.user.id, revoked: false },
|
||||||
|
});
|
||||||
|
if (keyCount >= MAX_KEYS_PER_USER) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Maximum of ${MAX_KEYS_PER_USER} API keys allowed. Revoke an existing key first.` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const name =
|
||||||
|
typeof body.name === "string" && body.name.trim()
|
||||||
|
? body.name.trim()
|
||||||
|
: "Default";
|
||||||
|
|
||||||
|
const rawHex = randomBytes(24).toString("hex");
|
||||||
|
const fullKey = `cb_${rawHex}`;
|
||||||
|
const keyPrefix = fullKey.slice(0, 10);
|
||||||
|
const keyHash = createHash("sha256").update(fullKey).digest("hex");
|
||||||
|
|
||||||
|
const apiKey = await prisma.apiKey.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
name,
|
||||||
|
keyHash,
|
||||||
|
keyPrefix,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
keyPrefix: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ...apiKey, key: fullKey },
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating API key:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/web/src/app/api/settings/account/route.ts
Normal file
49
apps/web/src/app/api/settings/account/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
subscription: {
|
||||||
|
select: {
|
||||||
|
tier: true,
|
||||||
|
status: true,
|
||||||
|
generationsUsed: true,
|
||||||
|
generationsLimit: true,
|
||||||
|
currentPeriodStart: true,
|
||||||
|
currentPeriodEnd: true,
|
||||||
|
stripeSubscriptionId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...user,
|
||||||
|
subscription: user.subscription
|
||||||
|
? {
|
||||||
|
...user.subscription,
|
||||||
|
hasStripeSubscription: !!user.subscription.stripeSubscriptionId,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/web/src/app/api/settings/purge/route.ts
Normal file
20
apps/web/src/app/api/settings/purge/route.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.generation.deleteMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/web/src/app/api/settings/stats/route.ts
Normal file
32
apps/web/src/app/api/settings/stats/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalGenerations = await prisma.generation.count({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedGenerations = await prisma.generation.count({
|
||||||
|
where: { userId: session.user.id, status: "COMPLETED" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const failedGenerations = await prisma.generation.count({
|
||||||
|
where: { userId: session.user.id, status: "FAILED" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
totalGenerations,
|
||||||
|
completedGenerations,
|
||||||
|
failedGenerations,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
101
apps/web/src/app/api/stripe/checkout/route.ts
Normal file
101
apps/web/src/app/api/stripe/checkout/route.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { priceId, tierKey } = body as {
|
||||||
|
priceId?: string;
|
||||||
|
tierKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolvedPriceId = priceId;
|
||||||
|
|
||||||
|
if (!resolvedPriceId && tierKey) {
|
||||||
|
const tierConfig =
|
||||||
|
TIER_CONFIG[tierKey as keyof typeof TIER_CONFIG];
|
||||||
|
if (tierConfig && "priceId" in tierConfig) {
|
||||||
|
resolvedPriceId = tierConfig.priceId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedPriceId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "priceId or tierKey is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPriceIds = [TIER_CONFIG.STARTER.priceId, TIER_CONFIG.PRO.priceId];
|
||||||
|
if (!validPriceIds.includes(resolvedPriceId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid priceId" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
let subscription = await prisma.subscription.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
let stripeCustomerId = subscription?.stripeCustomerId;
|
||||||
|
|
||||||
|
if (!stripeCustomerId) {
|
||||||
|
const customer = await getStripe().customers.create({
|
||||||
|
email: session.user.email,
|
||||||
|
name: session.user.name ?? undefined,
|
||||||
|
metadata: { userId },
|
||||||
|
});
|
||||||
|
stripeCustomerId = customer.id;
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: { userId },
|
||||||
|
data: { stripeCustomerId },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
subscription = await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
stripeCustomerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
"https://codeboard.vectry.tech",
|
||||||
|
"http://localhost:3000",
|
||||||
|
];
|
||||||
|
const requestOrigin = request.headers.get("origin");
|
||||||
|
const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "")
|
||||||
|
? requestOrigin!
|
||||||
|
: "https://codeboard.vectry.tech";
|
||||||
|
|
||||||
|
const checkoutSession = await getStripe().checkout.sessions.create({
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
mode: "subscription",
|
||||||
|
line_items: [{ price: resolvedPriceId, quantity: 1 }],
|
||||||
|
success_url: `${origin}/dashboard/settings?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${origin}/dashboard/settings`,
|
||||||
|
metadata: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ url: checkoutSession.url }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating checkout session:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/web/src/app/api/stripe/portal/route.ts
Normal file
47
apps/web/src/app/api/stripe/portal/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getStripe } from "@/lib/stripe";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await prisma.subscription.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
select: { stripeCustomerId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription?.stripeCustomerId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No active subscription to manage" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
"https://codeboard.vectry.tech",
|
||||||
|
"http://localhost:3000",
|
||||||
|
];
|
||||||
|
const requestOrigin = request.headers.get("origin");
|
||||||
|
const origin = ALLOWED_ORIGINS.includes(requestOrigin ?? "")
|
||||||
|
? requestOrigin!
|
||||||
|
: "https://codeboard.vectry.tech";
|
||||||
|
|
||||||
|
const portalSession = await getStripe().billingPortal.sessions.create({
|
||||||
|
customer: subscription.stripeCustomerId,
|
||||||
|
return_url: `${origin}/dashboard/settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ url: portalSession.url }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating portal session:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
apps/web/src/app/api/stripe/webhook/route.ts
Normal file
179
apps/web/src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getStripe, TIER_CONFIG } from "@/lib/stripe";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
function tierFromPriceId(priceId: string | null): "FREE" | "STARTER" | "PRO" {
|
||||||
|
if (priceId === TIER_CONFIG.STARTER.priceId) return "STARTER";
|
||||||
|
if (priceId === TIER_CONFIG.PRO.priceId) return "PRO";
|
||||||
|
return "FREE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function generationsLimitForTier(tier: "FREE" | "STARTER" | "PRO"): number {
|
||||||
|
return TIER_CONFIG[tier].generationsLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCheckoutCompleted(
|
||||||
|
checkoutSession: Stripe.Checkout.Session
|
||||||
|
) {
|
||||||
|
const userId = checkoutSession.metadata?.userId;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
const subscriptionId = checkoutSession.subscription as string;
|
||||||
|
const customerId = checkoutSession.customer as string;
|
||||||
|
|
||||||
|
const sub = await getStripe().subscriptions.retrieve(subscriptionId);
|
||||||
|
const firstItem = sub.items.data[0];
|
||||||
|
const priceId = firstItem?.price?.id ?? null;
|
||||||
|
const tier = tierFromPriceId(priceId);
|
||||||
|
const periodStart = firstItem?.current_period_start
|
||||||
|
? new Date(firstItem.current_period_start * 1000)
|
||||||
|
: new Date();
|
||||||
|
const periodEnd = firstItem?.current_period_end
|
||||||
|
? new Date(firstItem.current_period_end * 1000)
|
||||||
|
: new Date();
|
||||||
|
|
||||||
|
await prisma.subscription.upsert({
|
||||||
|
where: { userId },
|
||||||
|
update: {
|
||||||
|
stripeCustomerId: customerId,
|
||||||
|
stripeSubscriptionId: subscriptionId,
|
||||||
|
stripePriceId: priceId,
|
||||||
|
tier,
|
||||||
|
generationsLimit: generationsLimitForTier(tier),
|
||||||
|
generationsUsed: 0,
|
||||||
|
status: "ACTIVE",
|
||||||
|
currentPeriodStart: periodStart,
|
||||||
|
currentPeriodEnd: periodEnd,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId,
|
||||||
|
stripeCustomerId: customerId,
|
||||||
|
stripeSubscriptionId: subscriptionId,
|
||||||
|
stripePriceId: priceId,
|
||||||
|
tier,
|
||||||
|
generationsLimit: generationsLimitForTier(tier),
|
||||||
|
generationsUsed: 0,
|
||||||
|
status: "ACTIVE",
|
||||||
|
currentPeriodStart: periodStart,
|
||||||
|
currentPeriodEnd: periodEnd,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubscriptionUpdated(sub: Stripe.Subscription) {
|
||||||
|
const firstItem = sub.items.data[0];
|
||||||
|
const priceId = firstItem?.price?.id ?? null;
|
||||||
|
const tier = tierFromPriceId(priceId);
|
||||||
|
|
||||||
|
const statusMap: Record<string, "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID"> = {
|
||||||
|
active: "ACTIVE",
|
||||||
|
past_due: "PAST_DUE",
|
||||||
|
canceled: "CANCELED",
|
||||||
|
unpaid: "UNPAID",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbStatus = statusMap[sub.status] ?? "ACTIVE";
|
||||||
|
const periodStart = firstItem?.current_period_start
|
||||||
|
? new Date(firstItem.current_period_start * 1000)
|
||||||
|
: undefined;
|
||||||
|
const periodEnd = firstItem?.current_period_end
|
||||||
|
? new Date(firstItem.current_period_end * 1000)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await prisma.subscription.updateMany({
|
||||||
|
where: { stripeSubscriptionId: sub.id },
|
||||||
|
data: {
|
||||||
|
tier,
|
||||||
|
stripePriceId: priceId,
|
||||||
|
generationsLimit: generationsLimitForTier(tier),
|
||||||
|
status: dbStatus,
|
||||||
|
...(periodStart && { currentPeriodStart: periodStart }),
|
||||||
|
...(periodEnd && { currentPeriodEnd: periodEnd }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubscriptionDeleted(sub: Stripe.Subscription) {
|
||||||
|
await prisma.subscription.updateMany({
|
||||||
|
where: { stripeSubscriptionId: sub.id },
|
||||||
|
data: {
|
||||||
|
status: "CANCELED",
|
||||||
|
tier: "FREE",
|
||||||
|
generationsLimit: TIER_CONFIG.FREE.generationsLimit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInvoicePaid(invoice: Stripe.Invoice) {
|
||||||
|
const subDetail = invoice.parent?.subscription_details?.subscription;
|
||||||
|
const subscriptionId =
|
||||||
|
typeof subDetail === "string" ? subDetail : subDetail?.id;
|
||||||
|
|
||||||
|
if (!subscriptionId) return;
|
||||||
|
|
||||||
|
await prisma.subscription.updateMany({
|
||||||
|
where: { stripeSubscriptionId: subscriptionId },
|
||||||
|
data: { generationsUsed: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const body = await request.text();
|
||||||
|
const sig = request.headers.get("stripe-signature");
|
||||||
|
|
||||||
|
if (!sig) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing stripe-signature header" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
try {
|
||||||
|
event = getStripe().webhooks.constructEvent(
|
||||||
|
body,
|
||||||
|
sig,
|
||||||
|
process.env.STRIPE_WEBHOOK_SECRET!
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Webhook signature verification failed");
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid signature" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case "checkout.session.completed":
|
||||||
|
await handleCheckoutCompleted(
|
||||||
|
event.data.object as Stripe.Checkout.Session
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "customer.subscription.updated":
|
||||||
|
await handleSubscriptionUpdated(
|
||||||
|
event.data.object as Stripe.Subscription
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "customer.subscription.deleted":
|
||||||
|
await handleSubscriptionDeleted(
|
||||||
|
event.data.object as Stripe.Subscription
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "invoice.paid":
|
||||||
|
await handleInvoicePaid(event.data.object as Stripe.Invoice);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error handling ${event.type}:`, error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Webhook handler failed" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ received: true }, { status: 200 });
|
||||||
|
}
|
||||||
139
apps/web/src/app/dashboard/keys/page.tsx
Normal file
139
apps/web/src/app/dashboard/keys/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Key, Plus, Copy, Check, Trash2, AlertTriangle, RefreshCw, Shield } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ApiKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
keyPrefix: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewKeyResponse extends ApiKey { key: string; }
|
||||||
|
|
||||||
|
export default function ApiKeysPage() {
|
||||||
|
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
|
const [newlyCreatedKey, setNewlyCreatedKey] = useState<NewKeyResponse | null>(null);
|
||||||
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||||
|
const [revokingId, setRevokingId] = useState<string | null>(null);
|
||||||
|
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchKeys = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try { const res = await fetch("/api/keys", { cache: "no-store" }); if (res.ok) setKeys(await res.json()); } catch (e) { console.error("Failed to fetch:", e); } finally { setIsLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchKeys(); }, [fetchKeys]);
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string, field: string) => {
|
||||||
|
try { await navigator.clipboard.writeText(text); setCopiedField(field); setTimeout(() => setCopiedField(null), 2000); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/keys", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newKeyName.trim() || undefined }) });
|
||||||
|
if (res.ok) { const data: NewKeyResponse = await res.json(); setNewlyCreatedKey(data); setShowCreateForm(false); setNewKeyName(""); fetchKeys(); }
|
||||||
|
} catch (e) { console.error("Failed to create:", e); } finally { setIsCreating(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async (id: string) => {
|
||||||
|
setRevokingId(id);
|
||||||
|
try { const res = await fetch(`/api/keys/${id}`, { method: "DELETE" }); if (res.ok) { setConfirmRevokeId(null); fetchKeys(); } } catch (e) { console.error("Failed to revoke:", e); } finally { setRevokingId(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (d: string) => new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-3xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div><h1 className="text-2xl font-bold text-neutral-100">API Keys</h1><p className="text-neutral-400 mt-1">Manage API keys for programmatic access</p></div>
|
||||||
|
<button onClick={() => { setShowCreateForm(true); setNewlyCreatedKey(null); }} className="flex items-center gap-2 px-4 py-2.5 bg-blue-500 hover:bg-blue-400 text-white rounded-lg text-sm font-semibold transition-colors"><Plus className="w-4 h-4" /> Create New Key</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newlyCreatedKey && (
|
||||||
|
<div className="bg-blue-500/5 border border-blue-500/20 rounded-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center shrink-0"><Key className="w-5 h-5 text-blue-400" /></div>
|
||||||
|
<div className="flex-1 min-w-0"><h3 className="text-sm font-semibold text-blue-300">API Key Created</h3><p className="text-xs text-blue-400/60 mt-0.5">{newlyCreatedKey.name}</p></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 px-4 py-3 bg-neutral-950 border border-neutral-800 rounded-lg font-mono text-sm text-neutral-200 truncate select-all">{newlyCreatedKey.key}</div>
|
||||||
|
<button onClick={() => copyToClipboard(newlyCreatedKey.key, "new-key")} aria-label="Copy"
|
||||||
|
className={cn("p-3 rounded-lg border transition-all shrink-0", copiedField === "new-key" ? "bg-blue-500/10 border-blue-500/30 text-blue-400" : "bg-neutral-800 border-neutral-700 text-neutral-400 hover:text-neutral-200")}>
|
||||||
|
{copiedField === "new-key" ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5 bg-amber-500/5 border border-amber-500/20 rounded-lg"><AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" /><p className="text-xs text-amber-300/80">This key won't be shown again. Copy it now.</p></div>
|
||||||
|
<button onClick={() => setNewlyCreatedKey(null)} className="text-xs text-neutral-500 hover:text-neutral-300 transition-colors">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreateForm && !newlyCreatedKey && (
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-300"><Plus className="w-5 h-5 text-blue-400" /><h2 className="text-sm font-semibold">Create New API Key</h2></div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="key-name" className="text-xs text-neutral-500 font-medium block mb-1.5">Key Name (optional)</label>
|
||||||
|
<input id="key-name" type="text" value={newKeyName} onChange={(e) => setNewKeyName(e.target.value)} placeholder="e.g. Production, Staging"
|
||||||
|
className="w-full px-4 py-2.5 bg-neutral-950 border border-neutral-800 rounded-lg text-sm text-neutral-200 placeholder:text-neutral-600 focus:outline-none focus:border-blue-500/40 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={handleCreate} disabled={isCreating} className="flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-400 text-white rounded-lg text-sm font-semibold disabled:opacity-50 transition-colors">
|
||||||
|
{isCreating ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Key className="w-4 h-4" />} Generate Key
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setShowCreateForm(false); setNewKeyName(""); }} className="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-300"><Shield className="w-5 h-5 text-blue-400" /><h2 className="text-lg font-semibold">Active Keys</h2></div>
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-6 space-y-4">{Array.from({ length: 3 }).map((_, i) => (<div key={i} className="flex items-center gap-4 animate-pulse"><div className="w-8 h-8 bg-neutral-800 rounded-lg" /><div className="flex-1 space-y-2"><div className="h-4 w-32 bg-neutral-800 rounded" /><div className="h-3 w-48 bg-neutral-800 rounded" /></div><div className="h-8 w-20 bg-neutral-800 rounded" /></div>))}</div>
|
||||||
|
) : keys.length === 0 ? (
|
||||||
|
<div className="p-12 text-center"><div className="w-12 h-12 rounded-xl bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center mx-auto mb-4"><Key className="w-6 h-6 text-neutral-600" /></div><p className="text-sm text-neutral-400 font-medium">No API keys yet</p><p className="text-xs text-neutral-600 mt-1">Create one for programmatic access</p></div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-neutral-800">
|
||||||
|
{keys.map((apiKey) => (
|
||||||
|
<div key={apiKey.id} className="flex items-center gap-4 px-6 py-4 group transition-colors">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center shrink-0"><Key className="w-4 h-4 text-neutral-500" /></div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-neutral-200 truncate">{apiKey.name}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
<code className="text-xs font-mono text-neutral-500">{apiKey.keyPrefix}••••••••</code>
|
||||||
|
<span className="text-xs text-neutral-600">Created {formatDate(apiKey.createdAt)}</span>
|
||||||
|
{apiKey.lastUsedAt && <span className="text-xs text-neutral-600">Last used {formatDate(apiKey.lastUsedAt)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{confirmRevokeId === apiKey.id ? (
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button onClick={() => setConfirmRevokeId(null)} className="px-3 py-1.5 text-xs text-neutral-400 hover:text-neutral-200 transition-colors">Cancel</button>
|
||||||
|
<button onClick={() => handleRevoke(apiKey.id)} disabled={revokingId === apiKey.id}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/20 border border-red-500/30 text-red-400 rounded-lg text-xs font-medium hover:bg-red-500/30 disabled:opacity-50 transition-colors">
|
||||||
|
{revokingId === apiKey.id ? <RefreshCw className="w-3 h-3 animate-spin" /> : <AlertTriangle className="w-3 h-3" />} Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setConfirmRevokeId(apiKey.id)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-neutral-800 border border-neutral-700 text-neutral-500 rounded-lg text-xs font-medium opacity-0 group-hover:opacity-100 hover:text-red-400 hover:border-red-500/30 transition-all shrink-0">
|
||||||
|
<Trash2 className="w-3 h-3" /> Revoke
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
apps/web/src/app/dashboard/layout.tsx
Normal file
169
apps/web/src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import {
|
||||||
|
Code2,
|
||||||
|
FileText,
|
||||||
|
Key,
|
||||||
|
Settings,
|
||||||
|
Menu,
|
||||||
|
ChevronRight,
|
||||||
|
X,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ href: "/dashboard", label: "Generations", icon: FileText },
|
||||||
|
{ href: "/dashboard/keys", label: "API Keys", icon: Key },
|
||||||
|
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-neutral-900 border-r border-neutral-800">
|
||||||
|
<div className="p-6 border-b border-neutral-800">
|
||||||
|
<Link href="/" className="flex items-center gap-3 group" onClick={onNavigate}>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:shadow-blue-500/30 transition-shadow">
|
||||||
|
<Code2 className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold text-lg text-neutral-100">CodeBoard</span>
|
||||||
|
<span className="text-xs text-neutral-500">Dashboard</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive =
|
||||||
|
item.href === "/dashboard"
|
||||||
|
? pathname === "/dashboard"
|
||||||
|
: pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
onClick={onNavigate}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200",
|
||||||
|
isActive
|
||||||
|
? "bg-blue-500/10 text-blue-400 border border-blue-500/20"
|
||||||
|
: "text-neutral-400 hover:text-neutral-100 hover:bg-neutral-800/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
{isActive && <ChevronRight className="w-4 h-4" />}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-neutral-800">
|
||||||
|
<div className="px-4 py-3 rounded-lg bg-neutral-800/50 border border-neutral-700/50">
|
||||||
|
<p className="text-xs text-neutral-500">CodeBoard v0.1.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerificationBanner() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [resending, setResending] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
if (dismissed || !session?.user || session.user.isEmailVerified) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResend() {
|
||||||
|
setResending(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/resend-verification", { method: "POST" });
|
||||||
|
if (res.ok) setSent(true);
|
||||||
|
} catch {} finally { setResending(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-amber-500/10 border-b border-amber-500/20 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
|
||||||
|
<p className="text-sm text-amber-200 truncate">
|
||||||
|
{sent ? "Verification email sent! Check your inbox." : "Please verify your email address. Check your inbox or"}
|
||||||
|
</p>
|
||||||
|
{!sent && (
|
||||||
|
<button onClick={handleResend} disabled={resending}
|
||||||
|
className="text-sm font-medium text-amber-400 hover:text-amber-300 transition-colors whitespace-nowrap inline-flex items-center gap-1">
|
||||||
|
{resending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||||
|
{resending ? "sending..." : "click to resend."}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setDismissed(true)} aria-label="Dismiss" className="p-1 rounded text-amber-400/60 hover:text-amber-300 transition-colors shrink-0">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-neutral-950 flex">
|
||||||
|
<aside className="hidden lg:block w-64 h-screen sticky top-0">
|
||||||
|
<Sidebar />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside className={cn(
|
||||||
|
"fixed inset-y-0 left-0 w-72 z-50 transform transition-transform duration-300 ease-in-out lg:hidden",
|
||||||
|
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
|
)}>
|
||||||
|
<Sidebar onNavigate={() => setSidebarOpen(false)} />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main id="main-content" className="flex-1 min-w-0">
|
||||||
|
<VerificationBanner />
|
||||||
|
<header className="lg:hidden sticky top-0 z-30 bg-neutral-950/80 backdrop-blur-md border-b border-neutral-800 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button onClick={() => setSidebarOpen(true)} aria-label="Open menu"
|
||||||
|
className="p-2 rounded-lg bg-neutral-900 border border-neutral-800 text-neutral-400 hover:text-neutral-100 transition-colors">
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center">
|
||||||
|
<Code2 className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-neutral-100">CodeBoard</span>
|
||||||
|
</Link>
|
||||||
|
<div className="w-9" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="p-4 sm:p-6 lg:p-8">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
apps/web/src/app/dashboard/page.tsx
Normal file
94
apps/web/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { FileText, Clock, CheckCircle, XCircle, Loader2, ExternalLink } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Generation {
|
||||||
|
id: string;
|
||||||
|
repoUrl: string;
|
||||||
|
repoName: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
duration: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [generations, setGenerations] = useState<Generation[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchGenerations = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const statsRes = await fetch("/api/generations/mine", { cache: "no-store" });
|
||||||
|
if (statsRes.ok) {
|
||||||
|
const data = await statsRes.json();
|
||||||
|
setGenerations(data.generations ?? []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch generations:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGenerations();
|
||||||
|
}, [fetchGenerations]);
|
||||||
|
|
||||||
|
const statusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "COMPLETED": return <CheckCircle className="w-4 h-4 text-green-400" />;
|
||||||
|
case "FAILED": return <XCircle className="w-4 h-4 text-red-400" />;
|
||||||
|
default: return <Loader2 className="w-4 h-4 text-blue-400 animate-spin" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-4xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-100">My Generations</h1>
|
||||||
|
<p className="text-neutral-400 mt-1">Your architecture diagram generation history</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<Loader2 className="w-6 h-6 text-blue-400 animate-spin mx-auto mb-3" />
|
||||||
|
<p className="text-sm text-neutral-500">Loading generations...</p>
|
||||||
|
</div>
|
||||||
|
) : generations.length === 0 ? (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-neutral-800/50 border border-neutral-700/50 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<FileText className="w-6 h-6 text-neutral-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-400 font-medium">No generations yet</p>
|
||||||
|
<p className="text-xs text-neutral-600 mt-1">Generate your first architecture diagram from the <Link href="/" className="text-blue-400 hover:text-blue-300">home page</Link></p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-neutral-800">
|
||||||
|
{generations.map((gen) => (
|
||||||
|
<Link key={gen.id} href={`/docs/${gen.id}`} className="flex items-center gap-4 px-6 py-4 hover:bg-neutral-800/30 transition-colors group">
|
||||||
|
{statusIcon(gen.status)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-neutral-200 truncate">{gen.repoName}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
<span className="text-xs text-neutral-500 truncate">{gen.repoUrl}</span>
|
||||||
|
{gen.duration && (
|
||||||
|
<span className="text-xs text-neutral-600 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" /> {gen.duration}s
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-neutral-600">{new Date(gen.createdAt).toLocaleDateString()}</span>
|
||||||
|
<ExternalLink className="w-4 h-4 text-neutral-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
apps/web/src/app/dashboard/settings/page.tsx
Normal file
271
apps/web/src/app/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
Key,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
RefreshCw,
|
||||||
|
Database,
|
||||||
|
Trash2,
|
||||||
|
AlertTriangle,
|
||||||
|
CreditCard,
|
||||||
|
Crown,
|
||||||
|
Zap,
|
||||||
|
ArrowUpRight,
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
totalGenerations: number;
|
||||||
|
completedGenerations: number;
|
||||||
|
failedGenerations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountData {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
subscription: {
|
||||||
|
tier: "FREE" | "STARTER" | "PRO";
|
||||||
|
status: "ACTIVE" | "PAST_DUE" | "CANCELED" | "UNPAID";
|
||||||
|
generationsUsed: number;
|
||||||
|
generationsLimit: number;
|
||||||
|
currentPeriodStart: string | null;
|
||||||
|
currentPeriodEnd: string | null;
|
||||||
|
hasStripeSubscription: boolean;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{
|
||||||
|
key: "FREE" as const,
|
||||||
|
name: "Free",
|
||||||
|
price: 0,
|
||||||
|
period: "day",
|
||||||
|
generations: 15,
|
||||||
|
description: "For getting started",
|
||||||
|
features: ["15 generations per day", "Basic diagram viewing", "Community support"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "STARTER" as const,
|
||||||
|
name: "Starter",
|
||||||
|
price: 5,
|
||||||
|
period: "month",
|
||||||
|
generations: 1000,
|
||||||
|
description: "For regular use",
|
||||||
|
features: ["1,000 generations per month", "Generation history", "Priority support"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "PRO" as const,
|
||||||
|
name: "Pro",
|
||||||
|
price: 20,
|
||||||
|
period: "month",
|
||||||
|
generations: 100000,
|
||||||
|
description: "For teams & heavy use",
|
||||||
|
features: ["100,000 generations per month", "Full history", "Dedicated support", "API access"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [account, setAccount] = useState<AccountData | null>(null);
|
||||||
|
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
||||||
|
const [isLoadingAccount, setIsLoadingAccount] = useState(true);
|
||||||
|
const [isPurging, setIsPurging] = useState(false);
|
||||||
|
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
|
||||||
|
const [upgradingTier, setUpgradingTier] = useState<string | null>(null);
|
||||||
|
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
|
||||||
|
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
setIsLoadingStats(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/stats", { cache: "no-store" });
|
||||||
|
if (res.ok) setStats(await res.json());
|
||||||
|
} catch (error) { console.error("Failed to fetch stats:", error); } finally { setIsLoadingStats(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAccount = useCallback(async () => {
|
||||||
|
setIsLoadingAccount(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/account", { cache: "no-store" });
|
||||||
|
if (res.ok) setAccount(await res.json());
|
||||||
|
} catch (error) { console.error("Failed to fetch account:", error); } finally { setIsLoadingAccount(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchStats(); fetchAccount(); }, [fetchStats, fetchAccount]);
|
||||||
|
|
||||||
|
const handlePurgeAll = async () => {
|
||||||
|
setIsPurging(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/purge", { method: "POST" });
|
||||||
|
if (res.ok) { setShowPurgeConfirm(false); fetchStats(); }
|
||||||
|
} catch (error) { console.error("Failed to purge:", error); } finally { setIsPurging(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpgrade = async (tierKey: string) => {
|
||||||
|
setUpgradingTier(tierKey);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stripe/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tierKey }) });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.url) window.location.href = data.url;
|
||||||
|
} catch (error) { console.error("Failed to create checkout:", error); } finally { setUpgradingTier(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManageSubscription = async () => {
|
||||||
|
setIsOpeningPortal(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stripe/portal", { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.url) window.location.href = data.url;
|
||||||
|
} catch (error) { console.error("Failed to open portal:", error); } finally { setIsOpeningPortal(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentTier = account?.subscription?.tier ?? "FREE";
|
||||||
|
const generationsUsed = account?.subscription?.generationsUsed ?? 0;
|
||||||
|
const generationsLimit = account?.subscription?.generationsLimit ?? 15;
|
||||||
|
const usagePercent = generationsLimit > 0 ? Math.min((generationsUsed / generationsLimit) * 100, 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 max-w-3xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-100">Settings</h1>
|
||||||
|
<p className="text-neutral-400 mt-1">Account, billing, and configuration</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-300">
|
||||||
|
<User className="w-5 h-5 text-blue-400" />
|
||||||
|
<h2 className="text-lg font-semibold">Account</h2>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
|
||||||
|
{isLoadingAccount ? (
|
||||||
|
<div className="flex items-center gap-3 text-neutral-500"><Loader2 className="w-4 h-4 animate-spin" /><span className="text-sm">Loading account...</span></div>
|
||||||
|
) : account ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||||
|
<div><p className="text-xs text-neutral-500 font-medium mb-1">Email</p><p className="text-sm text-neutral-200 font-medium">{account.email}</p></div>
|
||||||
|
<div><p className="text-xs text-neutral-500 font-medium mb-1">Name</p><p className="text-sm text-neutral-200 font-medium">{account.name ?? "\u2014"}</p></div>
|
||||||
|
<div><p className="text-xs text-neutral-500 font-medium mb-1">Member since</p><div className="flex items-center gap-1.5 text-sm text-neutral-200 font-medium"><Calendar className="w-3.5 h-3.5 text-neutral-500" />{new Date(account.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</div></div>
|
||||||
|
</div>
|
||||||
|
) : (<p className="text-sm text-neutral-500">Unable to load account info</p>)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Subscription */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-300">
|
||||||
|
<CreditCard className="w-5 h-5 text-blue-400" />
|
||||||
|
<h2 className="text-lg font-semibold">Subscription & Billing</h2>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-neutral-400">Current plan</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-500/10 border border-blue-500/20 text-blue-400">
|
||||||
|
{currentTier === "PRO" && <Crown className="w-3 h-3" />}
|
||||||
|
{currentTier === "STARTER" && <Zap className="w-3 h-3" />}
|
||||||
|
{currentTier}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{currentTier !== "FREE" && account?.subscription?.hasStripeSubscription && (
|
||||||
|
<button onClick={handleManageSubscription} disabled={isOpeningPortal}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-neutral-400 bg-neutral-800 border border-neutral-700 rounded-lg hover:text-neutral-200 hover:border-neutral-600 transition-colors disabled:opacity-50">
|
||||||
|
{isOpeningPortal ? <Loader2 className="w-3 h-3 animate-spin" /> : <ArrowUpRight className="w-3 h-3" />} Manage Subscription
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-neutral-400">{generationsUsed.toLocaleString()} of {generationsLimit.toLocaleString()} generations used</span>
|
||||||
|
<span className="text-neutral-500 text-xs">{currentTier === "FREE" ? "15 generations/day" : "This billing period"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-neutral-800 rounded-full overflow-hidden">
|
||||||
|
<div className={cn("h-full rounded-full transition-all duration-500", usagePercent > 90 ? "bg-amber-500" : "bg-blue-500")} style={{ width: `${usagePercent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{TIERS.map((tier) => {
|
||||||
|
const isCurrent = currentTier === tier.key;
|
||||||
|
const tierOrder = { FREE: 0, STARTER: 1, PRO: 2 };
|
||||||
|
const isUpgrade = tierOrder[tier.key] > tierOrder[currentTier];
|
||||||
|
const isDowngrade = tierOrder[tier.key] < tierOrder[currentTier];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={tier.key} className={cn("relative bg-neutral-900 border rounded-xl p-5 flex flex-col transition-colors", isCurrent ? "border-blue-500/40 shadow-[0_0_24px_-6px_rgba(59,130,246,0.12)]" : "border-neutral-800 hover:border-neutral-700")}>
|
||||||
|
{isCurrent && (<div className="absolute -top-2.5 left-4"><span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-blue-500 text-white">Current</span></div>)}
|
||||||
|
<div className="mb-4"><h3 className="text-base font-semibold text-neutral-100">{tier.name}</h3><p className="text-xs text-neutral-500 mt-0.5">{tier.description}</p></div>
|
||||||
|
<div className="mb-4"><span className="text-2xl font-bold text-neutral-100">${tier.price}</span><span className="text-sm text-neutral-500">/{tier.period}</span></div>
|
||||||
|
<ul className="space-y-2 mb-5 flex-1">
|
||||||
|
{tier.features.map((f) => (<li key={f} className="flex items-start gap-2 text-xs text-neutral-400"><Check className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />{f}</li>))}
|
||||||
|
</ul>
|
||||||
|
{isCurrent ? (<div className="py-2 text-center text-xs font-medium text-blue-400 bg-blue-500/5 border border-blue-500/10 rounded-lg">Active plan</div>)
|
||||||
|
: isUpgrade ? (<button onClick={() => handleUpgrade(tier.key)} disabled={upgradingTier === tier.key} className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium bg-blue-500 hover:bg-blue-400 text-white rounded-lg transition-colors disabled:opacity-50">{upgradingTier === tier.key ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Zap className="w-3.5 h-3.5" />} Upgrade</button>)
|
||||||
|
: isDowngrade ? (<button onClick={handleManageSubscription} disabled={isOpeningPortal} className="flex items-center justify-center gap-1.5 py-2 text-sm font-medium bg-neutral-800 border border-neutral-700 text-neutral-300 rounded-lg hover:text-neutral-100 transition-colors disabled:opacity-50">{isOpeningPortal && <Loader2 className="w-3.5 h-3.5 animate-spin" />} Downgrade</button>)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Data */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-300">
|
||||||
|
<Database className="w-5 h-5 text-blue-400" />
|
||||||
|
<h2 className="text-lg font-semibold">Data & Storage</h2>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 space-y-5">
|
||||||
|
{isLoadingStats ? (
|
||||||
|
<div className="grid grid-cols-3 gap-4">{Array.from({ length: 3 }).map((_, i) => (<div key={i} className="animate-pulse"><div className="h-4 w-16 bg-neutral-800 rounded mb-2" /><div className="h-8 w-12 bg-neutral-800 rounded" /></div>))}</div>
|
||||||
|
) : stats ? (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="p-3 bg-neutral-800/50 rounded-lg"><p className="text-xs text-neutral-500">Total</p><p className="text-xl font-bold text-neutral-100 mt-1">{stats.totalGenerations.toLocaleString()}</p></div>
|
||||||
|
<div className="p-3 bg-neutral-800/50 rounded-lg"><p className="text-xs text-neutral-500">Completed</p><p className="text-xl font-bold text-neutral-100 mt-1">{stats.completedGenerations.toLocaleString()}</p></div>
|
||||||
|
<div className="p-3 bg-neutral-800/50 rounded-lg"><p className="text-xs text-neutral-500">Failed</p><p className="text-xl font-bold text-neutral-100 mt-1">{stats.failedGenerations.toLocaleString()}</p></div>
|
||||||
|
</div>
|
||||||
|
) : (<p className="text-sm text-neutral-500">Unable to load statistics</p>)}
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-neutral-800 flex items-center justify-between">
|
||||||
|
<div><p className="text-sm text-neutral-300 font-medium">Purge All Data</p><p className="text-xs text-neutral-500 mt-0.5">Permanently delete all your generations</p></div>
|
||||||
|
{showPurgeConfirm ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setShowPurgeConfirm(false)} className="px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 transition-colors">Cancel</button>
|
||||||
|
<button onClick={handlePurgeAll} disabled={isPurging} className="flex items-center gap-2 px-4 py-2 bg-red-500/20 border border-red-500/30 text-red-400 rounded-lg text-sm font-medium hover:bg-red-500/30 disabled:opacity-50 transition-colors">
|
||||||
|
{isPurging ? <RefreshCw className="w-4 h-4 animate-spin" /> : <AlertTriangle className="w-4 h-4" />} Confirm Purge
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setShowPurgeConfirm(true)} className="flex items-center gap-2 px-4 py-2 bg-neutral-800 border border-neutral-700 text-neutral-400 rounded-lg text-sm font-medium hover:text-red-400 hover:border-red-500/30 transition-colors">
|
||||||
|
<Trash2 className="w-4 h-4" /> Purge
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* About */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-neutral-300">
|
||||||
|
<Settings className="w-5 h-5 text-blue-400" />
|
||||||
|
<h2 className="text-lg font-semibold">About</h2>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div><p className="text-neutral-500">Version</p><p className="text-neutral-200 font-medium">0.1.0</p></div>
|
||||||
|
<div><p className="text-neutral-500">Service</p><p className="text-neutral-200 font-medium">CodeBoard</p></div>
|
||||||
|
<div><p className="text-neutral-500">Database</p><p className="text-neutral-200 font-medium">PostgreSQL</p></div>
|
||||||
|
<div><p className="text-neutral-500">License</p><p className="text-neutral-200 font-medium">MIT</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Inter, JetBrains_Mono } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Navbar } from "@/components/navbar";
|
import { Navbar } from "@/components/navbar";
|
||||||
import { Footer } from "@/components/footer";
|
import { Footer } from "@/components/footer";
|
||||||
|
import { Providers } from "@/components/providers";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -81,18 +82,20 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
Skip to content
|
Skip to content
|
||||||
</a>
|
</a>
|
||||||
<div className="relative min-h-screen flex flex-col">
|
<Providers>
|
||||||
<div className="fixed inset-0 bg-gradient-radial pointer-events-none" aria-hidden="true" />
|
<div className="relative min-h-screen flex flex-col">
|
||||||
<div className="fixed inset-0 bg-grid pointer-events-none opacity-50" aria-hidden="true" />
|
<div className="fixed inset-0 bg-gradient-radial pointer-events-none" aria-hidden="true" />
|
||||||
|
<div className="fixed inset-0 bg-grid pointer-events-none opacity-50" aria-hidden="true" />
|
||||||
<Navbar />
|
|
||||||
|
<Navbar />
|
||||||
<main id="main-content" className="flex-1 relative">
|
|
||||||
{children}
|
<main id="main-content" className="flex-1 relative">
|
||||||
</main>
|
{children}
|
||||||
|
</main>
|
||||||
<Footer />
|
|
||||||
</div>
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { RepoInput } from "@/components/repo-input";
|
import { RepoInput } from "@/components/repo-input";
|
||||||
import { ExampleRepoCard } from "@/components/example-repo-card";
|
import { ExampleRepoCard } from "@/components/example-repo-card";
|
||||||
import { ScrollSection } from "@/components/scroll-section";
|
import { ScrollSection } from "@/components/scroll-section";
|
||||||
@@ -17,6 +18,9 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
FileCode,
|
FileCode,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Check,
|
||||||
|
Crown,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
@@ -74,6 +78,42 @@ export default function HomePage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const pricingTiers = [
|
||||||
|
{
|
||||||
|
name: "Free",
|
||||||
|
price: 0,
|
||||||
|
period: "forever",
|
||||||
|
description: "Get started with CodeBoard",
|
||||||
|
generations: "15 / day",
|
||||||
|
features: ["15 generations per day", "Public repository support", "Interactive documentation", "Architecture diagrams"],
|
||||||
|
cta: "Get Started",
|
||||||
|
href: "/register",
|
||||||
|
highlighted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Starter",
|
||||||
|
price: 5,
|
||||||
|
period: "month",
|
||||||
|
description: "For regular use",
|
||||||
|
generations: "1,000 / month",
|
||||||
|
features: ["1,000 generations per month", "Generation history", "API key access", "Priority support"],
|
||||||
|
cta: "Start Free Trial",
|
||||||
|
href: "/register",
|
||||||
|
highlighted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pro",
|
||||||
|
price: 20,
|
||||||
|
period: "month",
|
||||||
|
description: "For teams & power users",
|
||||||
|
generations: "100,000 / month",
|
||||||
|
features: ["100,000 generations per month", "Full generation history", "Multiple API keys", "Dedicated support", "Custom integrations"],
|
||||||
|
cta: "Start Free Trial",
|
||||||
|
href: "/register",
|
||||||
|
highlighted: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const exampleRepos = [
|
const exampleRepos = [
|
||||||
{
|
{
|
||||||
name: "sindresorhus/p-limit",
|
name: "sindresorhus/p-limit",
|
||||||
@@ -271,8 +311,8 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block w-px bg-zinc-800" />
|
<div className="hidden sm:block w-px bg-zinc-800" />
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl sm:text-3xl font-bold text-white">100%</div>
|
<div className="text-2xl sm:text-3xl font-bold text-white">Free</div>
|
||||||
<div className="text-sm text-zinc-500">Free for public repos</div>
|
<div className="text-sm text-zinc-500">tier to start</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block w-px bg-zinc-800" />
|
<div className="hidden sm:block w-px bg-zinc-800" />
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -402,6 +442,98 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="pricing" className="py-20 lg:py-32">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<ScrollSection>
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass mb-6">
|
||||||
|
<Zap className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-sm text-zinc-300">Pricing</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||||
|
Simple, Transparent <span className="gradient-text">Pricing</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-zinc-400 max-w-xl mx-auto">
|
||||||
|
Start free, scale when you need to
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollSection>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||||
|
{pricingTiers.map((tier, i) => (
|
||||||
|
<ScrollSection key={tier.name} delay={i + 1}>
|
||||||
|
<div
|
||||||
|
className={`relative group h-full rounded-2xl p-8 transition-all duration-300 hover:-translate-y-1 ${
|
||||||
|
tier.highlighted
|
||||||
|
? "glass-strong border-blue-500/30 shadow-lg shadow-blue-500/10"
|
||||||
|
: "glass hover:bg-white/[0.05]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tier.highlighted && (
|
||||||
|
<>
|
||||||
|
<div className="absolute -inset-px rounded-2xl bg-gradient-to-b from-blue-500/20 via-transparent to-blue-500/10 -z-10" />
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2 inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-500/20 border border-blue-500/30 text-xs font-medium text-blue-300">
|
||||||
|
<Crown className="w-3 h-3" />
|
||||||
|
Most Popular
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">{tier.name}</h3>
|
||||||
|
<p className="text-sm text-zinc-500">{tier.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
{tier.price === 0 ? (
|
||||||
|
<span className="text-4xl font-bold text-white">Free</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-4xl font-bold text-white">${tier.price}</span>
|
||||||
|
<span className="text-zinc-500">/ {tier.period}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-zinc-400">
|
||||||
|
<span className="text-blue-400 font-medium">{tier.generations}</span> generations
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8 space-y-3">
|
||||||
|
{tier.features.map((feature) => (
|
||||||
|
<div key={feature} className="flex items-start gap-3">
|
||||||
|
<div className={`flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center mt-0.5 ${
|
||||||
|
tier.highlighted
|
||||||
|
? "bg-blue-500/20 text-blue-400"
|
||||||
|
: "bg-white/10 text-zinc-400"
|
||||||
|
}`}>
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-zinc-300">{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto">
|
||||||
|
<Link
|
||||||
|
href={tier.href}
|
||||||
|
className={`block w-full text-center py-3 px-6 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||||
|
tier.highlighted
|
||||||
|
? "btn-primary"
|
||||||
|
: "glass border border-white/10 text-white hover:bg-white/10 hover:border-white/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tier.cta}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollSection>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="py-20 lg:py-32">
|
<section className="py-20 lg:py-32">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<ScrollSection>
|
<ScrollSection>
|
||||||
@@ -452,7 +584,7 @@ export default function HomePage() {
|
|||||||
<span className="hidden sm:inline">•</span>
|
<span className="hidden sm:inline">•</span>
|
||||||
<span>Free for public repositories</span>
|
<span>Free for public repositories</span>
|
||||||
<span className="hidden sm:inline">•</span>
|
<span className="hidden sm:inline">•</span>
|
||||||
<span>No signup required</span>
|
<span>Free tier available</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
9
apps/web/src/auth.config.ts
Normal file
9
apps/web/src/auth.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { NextAuthConfig } from "next-auth";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
providers: [],
|
||||||
|
session: { strategy: "jwt", maxAge: 7 * 24 * 60 * 60 },
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
},
|
||||||
|
} satisfies NextAuthConfig;
|
||||||
91
apps/web/src/auth.ts
Normal file
91
apps/web/src/auth.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import { compare } from "bcryptjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { checkRateLimit, AUTH_RATE_LIMITS } from "@/lib/rate-limit";
|
||||||
|
import authConfig from "./auth.config";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string | null;
|
||||||
|
image?: string | null;
|
||||||
|
isEmailVerified: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@auth/core/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
id: string;
|
||||||
|
isEmailVerified: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
...authConfig,
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials, request) {
|
||||||
|
const parsed = loginSchema.safeParse(credentials);
|
||||||
|
if (!parsed.success) return null;
|
||||||
|
|
||||||
|
const { email, password } = parsed.data;
|
||||||
|
const ip = (request instanceof Request
|
||||||
|
? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
||||||
|
: undefined) ?? "unknown";
|
||||||
|
|
||||||
|
const rl = await checkRateLimit(`login:${ip}`, AUTH_RATE_LIMITS.login);
|
||||||
|
if (!rl.allowed) return null;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: email.toLowerCase() },
|
||||||
|
});
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const isValid = await compare(password, user.passwordHash);
|
||||||
|
if (!isValid) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user, trigger }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id as string;
|
||||||
|
}
|
||||||
|
if (trigger === "update" || user) {
|
||||||
|
const dbUser = await prisma.user.findUnique({
|
||||||
|
where: { id: token.id },
|
||||||
|
select: { emailVerified: true },
|
||||||
|
});
|
||||||
|
if (dbUser) {
|
||||||
|
token.isEmailVerified = dbUser.emailVerified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
session({ session, token }) {
|
||||||
|
session.user.id = token.id;
|
||||||
|
session.user.isEmailVerified = token.isEmailVerified;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,15 +3,18 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Menu, X, Github } from "lucide-react";
|
import { Menu, X, Github, LogIn, UserCircle } from "lucide-react";
|
||||||
|
import { useSession, signOut } from "next-auth/react";
|
||||||
import { CommandPalette } from "@/components/command-palette";
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: "/#how-it-works", label: "How it Works" },
|
{ href: "/#how-it-works", label: "How it Works" },
|
||||||
{ href: "/#features", label: "Features" },
|
{ href: "/#features", label: "Features" },
|
||||||
|
{ href: "/#pricing", label: "Pricing" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,6 +57,42 @@ export function Navbar() {
|
|||||||
<Github className="w-4 h-4" />
|
<Github className="w-4 h-4" />
|
||||||
Source
|
Source
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{status === "loading" ? (
|
||||||
|
<div className="w-24 h-8" />
|
||||||
|
) : session ? (
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<UserCircle className="w-4 h-4" />
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => signOut()}
|
||||||
|
className="text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="px-4 py-2 bg-blue-500 hover:bg-blue-400 text-white rounded-lg text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -93,6 +132,50 @@ export function Navbar() {
|
|||||||
<Github className="w-4 h-4" />
|
<Github className="w-4 h-4" />
|
||||||
Source
|
Source
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{status !== "loading" && (
|
||||||
|
<div className="border-t border-white/5 pt-3 mt-3 space-y-3">
|
||||||
|
{session ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors py-2"
|
||||||
|
>
|
||||||
|
<UserCircle className="w-4 h-4" />
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
signOut();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className="block text-sm text-zinc-400 hover:text-white transition-colors py-2"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors py-2"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="block text-center px-4 py-2 bg-blue-500 hover:bg-blue-400 text-white rounded-lg text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
Sign Up
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
8
apps/web/src/components/providers.tsx
Normal file
8
apps/web/src/components/providers.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
|
}
|
||||||
33
apps/web/src/lib/api-key.ts
Normal file
33
apps/web/src/lib/api-key.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { createHash } from "crypto";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function validateApiKey(bearerToken: string) {
|
||||||
|
const keyHash = createHash("sha256").update(bearerToken).digest("hex");
|
||||||
|
|
||||||
|
const apiKey = await prisma.apiKey.findFirst({
|
||||||
|
where: { keyHash, revoked: false },
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
include: {
|
||||||
|
subscription: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiKey) return null;
|
||||||
|
|
||||||
|
prisma.apiKey
|
||||||
|
.update({
|
||||||
|
where: { id: apiKey.id },
|
||||||
|
data: { lastUsedAt: new Date() },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: apiKey.userId,
|
||||||
|
user: apiKey.user,
|
||||||
|
subscription: apiKey.user.subscription,
|
||||||
|
apiKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
36
apps/web/src/lib/email.ts
Normal file
36
apps/web/src/lib/email.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
interface SendEmailOptions {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmail({ to, subject, html }: SendEmailOptions) {
|
||||||
|
const password = process.env.EMAIL_PASSWORD;
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
console.warn(
|
||||||
|
"[email] EMAIL_PASSWORD not set — skipping email send to:",
|
||||||
|
to
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: "smtp.migadu.com",
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
auth: {
|
||||||
|
user: "hunter@repi.fun",
|
||||||
|
pass: password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: "CodeBoard <hunter@repi.fun>",
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
}
|
||||||
9
apps/web/src/lib/prisma.ts
Normal file
9
apps/web/src/lib/prisma.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { PrismaClient } from "@codeboard/database";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
53
apps/web/src/lib/rate-limit.ts
Normal file
53
apps/web/src/lib/rate-limit.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { getRedis } from "./redis";
|
||||||
|
|
||||||
|
interface RateLimitConfig {
|
||||||
|
windowMs: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RateLimitResult {
|
||||||
|
allowed: boolean;
|
||||||
|
remaining: number;
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkRateLimit(
|
||||||
|
key: string,
|
||||||
|
config: RateLimitConfig
|
||||||
|
): Promise<RateLimitResult> {
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - config.windowMs;
|
||||||
|
const redisKey = `rl:${key}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redis = getRedis();
|
||||||
|
await redis.zremrangebyscore(redisKey, 0, windowStart);
|
||||||
|
const count = await redis.zcard(redisKey);
|
||||||
|
|
||||||
|
if (count >= config.maxAttempts) {
|
||||||
|
const oldestEntry = await redis.zrange(redisKey, 0, 0, "WITHSCORES");
|
||||||
|
const resetAt = oldestEntry.length >= 2
|
||||||
|
? parseInt(oldestEntry[1], 10) + config.windowMs
|
||||||
|
: now + config.windowMs;
|
||||||
|
return { allowed: false, remaining: 0, resetAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.zadd(redisKey, now, `${now}:${Math.random()}`);
|
||||||
|
await redis.pexpire(redisKey, config.windowMs);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: config.maxAttempts - count - 1,
|
||||||
|
resetAt: now + config.windowMs,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { allowed: true, remaining: config.maxAttempts, resetAt: now + config.windowMs };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTH_RATE_LIMITS = {
|
||||||
|
login: { windowMs: 15 * 60 * 1000, maxAttempts: 10 },
|
||||||
|
register: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },
|
||||||
|
forgotPassword: { windowMs: 60 * 60 * 1000, maxAttempts: 5 },
|
||||||
|
resetPassword: { windowMs: 15 * 60 * 1000, maxAttempts: 5 },
|
||||||
|
} as const;
|
||||||
35
apps/web/src/lib/stripe.ts
Normal file
35
apps/web/src/lib/stripe.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
let _stripe: Stripe | null = null;
|
||||||
|
|
||||||
|
export function getStripe(): Stripe {
|
||||||
|
if (!_stripe) {
|
||||||
|
const key = process.env.STRIPE_SECRET_KEY;
|
||||||
|
if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
|
||||||
|
_stripe = new Stripe(key, { apiVersion: "2026-01-28.clover" });
|
||||||
|
}
|
||||||
|
return _stripe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIER_CONFIG = {
|
||||||
|
FREE: {
|
||||||
|
name: "Free",
|
||||||
|
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;
|
||||||
6
apps/web/src/lib/utils.ts
Normal file
6
apps/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
90
apps/web/src/middleware.ts
Normal file
90
apps/web/src/middleware.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import authConfig from "./auth.config";
|
||||||
|
|
||||||
|
const { auth } = NextAuth(authConfig);
|
||||||
|
|
||||||
|
const publicPaths = [
|
||||||
|
"/",
|
||||||
|
"/docs",
|
||||||
|
"/generate",
|
||||||
|
"/history",
|
||||||
|
"/api/auth",
|
||||||
|
"/api/generate",
|
||||||
|
"/api/generations",
|
||||||
|
"/api/health",
|
||||||
|
"/api/stripe/webhook",
|
||||||
|
"/forgot-password",
|
||||||
|
"/reset-password",
|
||||||
|
"/verify-email",
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPublicPath(pathname: string): boolean {
|
||||||
|
return publicPaths.some(
|
||||||
|
(p) => pathname === p || pathname.startsWith(`${p}/`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = new Set([
|
||||||
|
"https://codeboard.vectry.tech",
|
||||||
|
"http://localhost:3000",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function corsHeaders(origin: string | null): Record<string, string> {
|
||||||
|
const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin)
|
||||||
|
? origin
|
||||||
|
: "https://codeboard.vectry.tech";
|
||||||
|
return {
|
||||||
|
"Access-Control-Allow-Origin": allowedOrigin,
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||||
|
"Access-Control-Max-Age": "86400",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default auth((req) => {
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
const isLoggedIn = !!req.auth;
|
||||||
|
const origin = req.headers.get("origin");
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new NextResponse(null, { status: 204, headers: corsHeaders(origin) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = (() => {
|
||||||
|
if (isPublicPath(pathname)) {
|
||||||
|
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/login" || pathname === "/register") {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", req.nextUrl.origin));
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/dashboard") && !isLoggedIn) {
|
||||||
|
const loginUrl = new URL("/login", req.nextUrl.origin);
|
||||||
|
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api/")) {
|
||||||
|
const headers = corsHeaders(origin);
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
response.headers.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|og-image.png).*)"],
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import { generateDocs } from "./jobs/generate.js";
|
|||||||
interface GenerateJobData {
|
interface GenerateJobData {
|
||||||
repoUrl: string;
|
repoUrl: string;
|
||||||
generationId: string;
|
generationId: string;
|
||||||
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const redis = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379");
|
const redis = new IORedis(process.env.REDIS_URL ?? "redis://localhost:6379");
|
||||||
@@ -109,7 +110,8 @@ export async function processGenerationJob(
|
|||||||
status: "COMPLETED",
|
status: "COMPLETED",
|
||||||
progress: 100,
|
progress: 100,
|
||||||
result: docs as any,
|
result: docs as any,
|
||||||
duration
|
duration,
|
||||||
|
userId: job.data.userId ?? null,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
- DATABASE_URL=postgresql://codeboard:codeboard@postgres:5432/codeboard
|
- 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 <noreply@vectry.tech>
|
||||||
|
- 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:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|||||||
204
package-lock.json
generated
204
package-lock.json
generated
@@ -26,19 +26,28 @@
|
|||||||
"@codeboard/database": "*",
|
"@codeboard/database": "*",
|
||||||
"@codeboard/shared": "*",
|
"@codeboard/shared": "*",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"ioredis": "^5.4.0",
|
"ioredis": "^5.4.0",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mermaid": "^11.4.0",
|
"mermaid": "^11.4.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
|
"next-auth": "^5.0.0-beta.30",
|
||||||
|
"nodemailer": "^7.0.7",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^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": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"postcss": "^8.5.0",
|
"postcss": "^8.5.0",
|
||||||
@@ -46,6 +55,15 @@
|
|||||||
"typescript": "^5.7"
|
"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": {
|
"apps/worker": {
|
||||||
"name": "@codeboard/worker",
|
"name": "@codeboard/worker",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
@@ -121,6 +139,35 @@
|
|||||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -1116,6 +1163,15 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.2",
|
"version": "6.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz",
|
||||||
@@ -1958,6 +2014,13 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@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": {
|
"node_modules/@types/d3": {
|
||||||
"version": "7.4.3",
|
"version": "7.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||||
@@ -2284,6 +2347,16 @@
|
|||||||
"form-data": "^4.0.4"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -2393,6 +2466,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/bullmq": {
|
||||||
"version": "5.67.3",
|
"version": "5.67.3",
|
||||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.67.3.tgz",
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.67.3.tgz",
|
||||||
@@ -2614,6 +2696,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cluster-key-slot": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
@@ -4012,6 +4103,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
|
||||||
|
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"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": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -5322,6 +5449,17 @@
|
|||||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
"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": {
|
"node_modules/nypm": {
|
||||||
"version": "0.6.5",
|
"version": "0.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
|
||||||
@@ -5347,6 +5485,15 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth4webapi": {
|
||||||
|
"version": "3.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.4.tgz",
|
||||||
|
"integrity": "sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ohash": {
|
"node_modules/ohash": {
|
||||||
"version": "2.0.11",
|
"version": "2.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||||
@@ -5555,6 +5702,25 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/prisma": {
|
||||||
"version": "6.19.2",
|
"version": "6.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
|
||||||
@@ -5964,6 +6130,23 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stripe": {
|
||||||
|
"version": "20.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz",
|
||||||
|
"integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/style-to-js": {
|
"node_modules/style-to-js": {
|
||||||
"version": "1.1.21",
|
"version": "1.1.21",
|
||||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||||
@@ -6011,6 +6194,16 @@
|
|||||||
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
|
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.1.18",
|
"version": "4.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
@@ -6495,6 +6688,15 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/zwitch": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||||
|
|||||||
@@ -7,6 +7,112 @@ generator client {
|
|||||||
provider = "prisma-client-js"
|
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 {
|
model Generation {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
repoUrl String
|
repoUrl String
|
||||||
@@ -27,16 +133,7 @@ model Generation {
|
|||||||
@@unique([repoUrl, commitHash])
|
@@unique([repoUrl, commitHash])
|
||||||
@@index([repoUrl])
|
@@index([repoUrl])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
@@index([userId])
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
githubId String @unique
|
|
||||||
login String
|
|
||||||
email String?
|
|
||||||
avatarUrl String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
generations Generation[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Status {
|
enum Status {
|
||||||
|
|||||||
Reference in New Issue
Block a user