diff --git a/apps/web/package.json b/apps/web/package.json index efe4a5b..8b66320 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "lucide-react": "^0.469.0", "next": "^15.1.0", "next-auth": "^5.0.0-beta.30", + "nodemailer": "^6.10.1", "react": "^19.0.0", "react-dom": "^19.0.0", "shiki": "^3.22.0", @@ -30,6 +31,7 @@ "@types/bcryptjs": "^2.4.6", "@types/dagre": "^0.7.53", "@types/node": "^22.0.0", + "@types/nodemailer": "^7.0.9", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "postcss": "^8.5.0", diff --git a/apps/web/src/app/(auth)/forgot-password/page.tsx b/apps/web/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..356a38f --- /dev/null +++ b/apps/web/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Activity, 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. Please try again."); + setLoading(false); + } + } + + if (submitted) { + return ( +
+
+
+ +
+
+

+ Check your email +

+

+ If an account exists for that email, we sent a password reset + link. It expires in 1 hour. +

+
+
+ +

+ + Back to sign in + +

+
+ ); + } + + return ( +
+
+
+ +
+
+

+ Reset your password +

+

+ Enter your email and we'll send you a reset link +

+
+
+ +
+
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className={cn( + "w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", + email && !emailValid + ? "border-red-500/50 focus:border-red-500" + : "border-neutral-800 focus:border-emerald-500" + )} + /> + {email && !emailValid && ( +

+ Please enter a valid email address +

+ )} +
+
+ + {error && ( +
+

{error}

+
+ )} + + +
+ +

+ Remember your password?{" "} + + Sign in + +

+
+ ); +} diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx index 6fae869..e5607aa 100644 --- a/apps/web/src/app/(auth)/login/page.tsx +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -1,14 +1,24 @@ "use client"; -import { useState } from "react"; +import { Suspense, useState } from "react"; import { signIn } from "next-auth/react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; -import { Activity, Loader2 } from "lucide-react"; +import { Activity, CheckCircle, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; export default function LoginPage() { + return ( + + + + ); +} + +function LoginForm() { const router = useRouter(); + const searchParams = useSearchParams(); + const verified = searchParams.get("verified") === "true"; const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); @@ -62,6 +72,15 @@ export default function LoginPage() { + {verified && ( +
+ +

+ Email verified! You can now sign in. +

+
+ )} +
@@ -123,6 +142,15 @@ export default function LoginPage() {
+
+ + Forgot password? + +
+ {error && (

{error}

diff --git a/apps/web/src/app/(auth)/reset-password/page.tsx b/apps/web/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..e64738d --- /dev/null +++ b/apps/web/src/app/(auth)/reset-password/page.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { Suspense, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { Activity, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export default function ResetPasswordPage() { + return ( + + + + ); +} + +function ResetPasswordForm() { + const searchParams = useSearchParams(); + const token = searchParams.get("token"); + + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(false); + + const passwordValid = password.length >= 8; + const passwordsMatch = password === confirmPassword; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (!passwordValid) { + setError("Password must be at least 8 characters"); + return; + } + if (!passwordsMatch) { + setError("Passwords do not match"); + return; + } + if (!token) { + setError("Invalid reset link"); + return; + } + + setLoading(true); + + try { + const res = await fetch("/api/auth/reset-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, password }), + }); + + if (!res.ok) { + const data: { error?: string } = await res.json(); + setError(data.error ?? "Something went wrong"); + setLoading(false); + return; + } + + setSuccess(true); + } catch { + setError("Something went wrong. Please try again."); + setLoading(false); + } + } + + if (!token) { + return ( +
+
+
+ +
+
+

+ Invalid reset link +

+

+ This password reset link is invalid or has expired. +

+
+
+ +

+ + Request a new reset link + +

+
+ ); + } + + if (success) { + return ( +
+
+
+ +
+
+

+ Password reset +

+

+ Your password has been successfully reset. +

+
+
+ +

+ + Sign in with your new password + +

+
+ ); + } + + return ( +
+
+
+ +
+
+

+ Set new password +

+

+ Enter your new password below +

+
+
+ + +
+
+ + setPassword(e.target.value)} + placeholder="••••••••" + className={cn( + "w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", + password && !passwordValid + ? "border-red-500/50 focus:border-red-500" + : "border-neutral-800 focus:border-emerald-500" + )} + /> + {password && !passwordValid && ( +

+ Password must be at least 8 characters +

+ )} +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="••••••••" + className={cn( + "w-full px-3 py-2.5 bg-neutral-950 border rounded-lg text-sm text-neutral-100 placeholder-neutral-500 outline-none transition-colors", + confirmPassword && !passwordsMatch + ? "border-red-500/50 focus:border-red-500" + : "border-neutral-800 focus:border-emerald-500" + )} + /> + {confirmPassword && !passwordsMatch && ( +

Passwords do not match

+ )} +
+
+ + {error && ( +
+

{error}

+
+ )} + + + + +

+ Remember your password?{" "} + + Sign in + +

+
+ ); +} diff --git a/apps/web/src/app/(auth)/verify-email/page.tsx b/apps/web/src/app/(auth)/verify-email/page.tsx new file mode 100644 index 0000000..8cf587d --- /dev/null +++ b/apps/web/src/app/(auth)/verify-email/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Activity, 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. Please try again."); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+ +
+
+

+ Check your email +

+

+ We sent a verification link to your inbox +

+
+
+ +
+
+
+ +
+
+

+ Click the link in the email to verify your account. The link expires + in 24 hours. +

+
+ + {message && ( +
+

{message}

+
+ )} + + {error && ( +
+

{error}

+
+ )} + + + +

+ Already verified?{" "} + + Sign in + +

+
+ ); +} diff --git a/apps/web/src/app/api/auth/forgot-password/route.ts b/apps/web/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..d342784 --- /dev/null +++ b/apps/web/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { randomBytes, createHash } from "crypto"; +import { z } from "zod"; +import nodemailer from "nodemailer"; +import { prisma } from "@/lib/prisma"; + +const forgotPasswordSchema = z.object({ + email: z.email("Invalid email address"), +}); + +const transporter = nodemailer.createTransport({ + host: "smtp.migadu.com", + port: 465, + secure: true, + auth: { + user: "hunter@repi.fun", + pass: process.env.EMAIL_PASSWORD, + }, +}); + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export async function POST(request: Request) { + try { + 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 }, + }); + + // Always return success to prevent email enumeration + 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://agentlens.vectry.tech/reset-password?token=${rawToken}`; + + await transporter.sendMail({ + from: '"AgentLens" ', + to: normalizedEmail, + subject: "Reset your AgentLens password", + text: `You requested a password reset for your AgentLens account.\n\nClick the link below to set a new password:\n${resetUrl}\n\nThis link expires in 1 hour.\n\nIf you did not request this, you can safely ignore this email.`, + html: ` +
+

Reset your password

+

+ You requested a password reset for your AgentLens account. Click the button below to set a new password. +

+ + Reset password + +

+ This link expires in 1 hour. If you did not request this, you can safely ignore this email. +

+
+ `, + }); + + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/auth/register/route.ts b/apps/web/src/app/api/auth/register/route.ts index 152c42a..68314b3 100644 --- a/apps/web/src/app/api/auth/register/route.ts +++ b/apps/web/src/app/api/auth/register/route.ts @@ -1,7 +1,9 @@ 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"; const registerSchema = z.object({ email: z.email("Invalid email address"), @@ -57,6 +59,45 @@ export async function POST(request: Request) { }, }); + // Send verification email (non-blocking — don't fail registration on email errors) + 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), // 24 hours + }, + }); + + const verifyUrl = `https://agentlens.vectry.tech/verify-email?token=${rawToken}`; + await sendEmail({ + to: user.email, + subject: "Verify your AgentLens email", + html: ` +
+

Verify your email

+

+ Thanks for signing up for AgentLens. Click the link below to verify your email address. +

+ + Verify Email + +

+ This link expires in 24 hours. If you didn't create an account, you can safely ignore this email. +

+
+ `, + }); + } catch (emailError) { + console.error("[register] Failed to send verification email:", emailError); + } + return NextResponse.json(user, { status: 201 }); } catch { return NextResponse.json( diff --git a/apps/web/src/app/api/auth/resend-verification/route.ts b/apps/web/src/app/api/auth/resend-verification/route.ts new file mode 100644 index 0000000..bdeaa5c --- /dev/null +++ b/apps/web/src/app/api/auth/resend-verification/route.ts @@ -0,0 +1,78 @@ +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://agentlens.vectry.tech/verify-email?token=${rawToken}`; + await sendEmail({ + to: user.email, + subject: "Verify your AgentLens email", + html: ` +
+

Verify your email

+

+ Click the link below to verify your email address for AgentLens. +

+ + Verify Email + +

+ This link expires in 24 hours. +

+
+ `, + }); + + return NextResponse.json({ success: true }); +} diff --git a/apps/web/src/app/api/auth/reset-password/route.ts b/apps/web/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..a33a94a --- /dev/null +++ b/apps/web/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { createHash } from "crypto"; +import { hash } from "bcryptjs"; +import { z } from "zod"; +import { prisma } from "@/lib/prisma"; + +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 body: unknown = await request.json(); + const parsed = resetPasswordSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "Invalid input" }, + { status: 400 } + ); + } + + const { token, password } = parsed.data; + const tokenHash = hashToken(token); + + const resetToken = await prisma.passwordResetToken.findUnique({ + where: { token: tokenHash }, + include: { user: true }, + }); + + if (!resetToken || resetToken.used || resetToken.expiresAt < new Date()) { + return NextResponse.json( + { error: "Invalid or expired reset link" }, + { status: 400 } + ); + } + + const passwordHash = await hash(password, 12); + + await prisma.$transaction([ + prisma.user.update({ + where: { id: resetToken.userId }, + data: { passwordHash }, + }), + prisma.passwordResetToken.update({ + where: { id: resetToken.id }, + data: { used: true }, + }), + ]); + + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/auth/verify-email/route.ts b/apps/web/src/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000..b835c19 --- /dev/null +++ b/apps/web/src/app/api/auth/verify-email/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: NextRequest) { + const rawToken = request.nextUrl.searchParams.get("token"); + + if (!rawToken) { + return NextResponse.redirect( + new URL("/login?error=missing-token", request.url) + ); + } + + const tokenHash = crypto + .createHash("sha256") + .update(rawToken) + .digest("hex"); + + const verificationToken = await prisma.emailVerificationToken.findUnique({ + where: { token: tokenHash }, + include: { user: true }, + }); + + if (!verificationToken) { + return NextResponse.redirect( + new URL("/login?error=invalid-token", request.url) + ); + } + + if (verificationToken.used) { + return NextResponse.redirect( + new URL("/login?verified=true", request.url) + ); + } + + if (verificationToken.expiresAt < new Date()) { + return NextResponse.redirect( + new URL("/login?error=token-expired", request.url) + ); + } + + await prisma.$transaction([ + prisma.user.update({ + where: { id: verificationToken.userId }, + data: { emailVerified: true }, + }), + prisma.emailVerificationToken.update({ + where: { id: verificationToken.id }, + data: { used: true }, + }), + ]); + + return NextResponse.redirect(new URL("/login?verified=true", request.url)); +} diff --git a/apps/web/src/app/dashboard/layout.tsx b/apps/web/src/app/dashboard/layout.tsx index 33738e9..69481f9 100644 --- a/apps/web/src/app/dashboard/layout.tsx +++ b/apps/web/src/app/dashboard/layout.tsx @@ -3,6 +3,7 @@ import { ReactNode, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useSession } from "next-auth/react"; import { Activity, GitBranch, @@ -10,6 +11,9 @@ import { Settings, Menu, ChevronRight, + X, + AlertTriangle, + Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -101,6 +105,61 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) { ); } +function VerificationBanner() { + const { data: session } = useSession(); + const [dismissed, setDismissed] = useState(false); + const [resending, setResending] = useState(false); + const [sent, setSent] = useState(false); + + if (dismissed || !session?.user || session.user.isEmailVerified) { + return null; + } + + async function handleResend() { + setResending(true); + try { + const res = await fetch("/api/auth/resend-verification", { method: "POST" }); + if (res.ok) { + setSent(true); + } + } catch { + } finally { + setResending(false); + } + } + + return ( +
+
+
+ +

+ {sent + ? "Verification email sent! Check your inbox." + : "Please verify your email address. Check your inbox or"} +

+ {!sent && ( + + )} +
+ +
+
+ ); +} + export default function DashboardLayout({ children }: { children: ReactNode }) { const [sidebarOpen, setSidebarOpen] = useState(false); @@ -131,6 +190,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) { {/* Main Content */}
+ {/* Mobile Header */}
diff --git a/apps/web/src/auth.ts b/apps/web/src/auth.ts index 41a0fa4..5469aec 100644 --- a/apps/web/src/auth.ts +++ b/apps/web/src/auth.ts @@ -12,6 +12,7 @@ declare module "next-auth" { email: string; name?: string | null; image?: string | null; + isEmailVerified: boolean; }; } } @@ -19,6 +20,7 @@ declare module "next-auth" { declare module "@auth/core/jwt" { interface JWT { id: string; + isEmailVerified: boolean; } } @@ -58,14 +60,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }), ], callbacks: { - jwt({ token, user }) { + 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; }, }, diff --git a/apps/web/src/lib/email.ts b/apps/web/src/lib/email.ts new file mode 100644 index 0000000..8ecb169 --- /dev/null +++ b/apps/web/src/lib/email.ts @@ -0,0 +1,36 @@ +import nodemailer from "nodemailer"; + +interface SendEmailOptions { + to: string; + subject: string; + html: string; +} + +export async function sendEmail({ to, subject, html }: SendEmailOptions) { + const password = process.env.EMAIL_PASSWORD; + + if (!password) { + console.warn( + "[email] EMAIL_PASSWORD not set — skipping email send to:", + to + ); + return; + } + + const transporter = nodemailer.createTransport({ + host: "smtp.migadu.com", + port: 465, + secure: true, + auth: { + user: "hunter@repi.fun", + pass: password, + }, + }); + + await transporter.sendMail({ + from: "AgentLens ", + to, + subject, + html, + }); +} diff --git a/docker-compose.yml b/docker-compose.yml index a2f0c64..39d67f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: - STRIPE_WEBHOOK_SECRET=whsec_ZGT3JCrEK6GWP3cIMvYfrfLplZ3rMn0m - STRIPE_STARTER_PRICE_ID=price_1SzJUlR8i0An4Wz7gZeYgzBY - STRIPE_PRO_PRICE_ID=price_1SzJVWR8i0An4Wz755hBrxzn + - EMAIL_PASSWORD=${EMAIL_PASSWORD:-} depends_on: redis: condition: service_started diff --git a/package-lock.json b/package-lock.json index f4bf21e..ca4eabf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "lucide-react": "^0.469.0", "next": "^15.1.0", "next-auth": "^5.0.0-beta.30", + "nodemailer": "^6.10.1", "react": "^19.0.0", "react-dom": "^19.0.0", "shiki": "^3.22.0", @@ -39,6 +40,7 @@ "@types/bcryptjs": "^2.4.6", "@types/dagre": "^0.7.53", "@types/node": "^22.0.0", + "@types/nodemailer": "^7.0.9", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "postcss": "^8.5.0", @@ -2136,6 +2138,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.13", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", @@ -3467,6 +3479,15 @@ "devOptional": true, "license": "MIT" }, + "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", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nypm": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", @@ -4449,7 +4470,7 @@ }, "packages/opencode-plugin": { "name": "opencode-agentlens", - "version": "0.1.6", + "version": "0.1.7", "license": "MIT", "dependencies": { "agentlens-sdk": "*" @@ -4525,7 +4546,7 @@ }, "packages/sdk-ts": { "name": "agentlens-sdk", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "devDependencies": { "tsup": "^8.3.0", diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 0c15eac..1bc965a 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -14,6 +14,7 @@ model User { email String @unique passwordHash String name String? + emailVerified Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -21,10 +22,42 @@ model User { subscription Subscription? apiKeys ApiKey[] traces Trace[] + passwordResetTokens PasswordResetToken[] + emailVerificationTokens EmailVerificationToken[] @@index([email]) } +model PasswordResetToken { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + token String @unique // SHA-256 hash of the raw token + 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 // SHA-256 hash of the raw token + expiresAt DateTime + used Boolean @default(false) + + createdAt DateTime @default(now()) + + @@index([token]) + @@index([userId]) +} + model ApiKey { id String @id @default(cuid()) userId String