From 5b84688c633293ebfef69347fd8954f382557faf Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Mon, 28 Jul 2025 12:25:42 +0200 Subject: [PATCH] Add email verification --- app/api/auth/[...nextauth]/route.ts | 12 +- app/api/auth/signup/route.ts | 52 ++++- app/api/auth/verify-email/route.ts | 43 +++++ app/auth/error/page.tsx | 64 ++++++ app/auth/verification-success/page.tsx | 39 ++++ app/register/page.tsx | 247 ++++++++++++++---------- emails/VerificationEmail.tsx | 85 ++++++++ package-lock.json | 257 ++++++++++++++++++++++++- package.json | 6 +- prisma/schema.prisma | 6 +- 10 files changed, 702 insertions(+), 109 deletions(-) create mode 100644 app/api/auth/verify-email/route.ts create mode 100644 app/auth/error/page.tsx create mode 100644 app/auth/verification-success/page.tsx create mode 100644 emails/VerificationEmail.tsx diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 69677e0e..37d7347a 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -90,7 +90,17 @@ export {authHandler as GET, authHandler as POST}; declare module "next-auth" { interface Session { - user: { id: string; name: string; email: string ; image: string }; + user: { + id: string; + name: string; + email: string; + image: string; + emailVerified?: Date | null; + }; + } + + interface User { + emailVerified?: Date | null; } } diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts index d6f62469..8add3626 100644 --- a/app/api/auth/signup/route.ts +++ b/app/api/auth/signup/route.ts @@ -1,6 +1,17 @@ import bcrypt from "bcryptjs"; import { NextResponse } from "next/server"; -import { prisma }from "@/lib/prisma"; +import { prisma } from "@/lib/prisma"; +import { Resend } from "resend"; +import { render } from "@react-email/render"; +import { VerificationEmail } from "@/emails/VerificationEmail"; +import { v4 as uuidv4 } from 'uuid'; + +const resend = new Resend(process.env.RESEND_API_KEY); + +// Helper function to generate a verification token +const generateVerificationToken = () => { + return uuidv4(); +}; export async function POST(req: Request) { try { @@ -16,11 +27,46 @@ export async function POST(req: Request) { } const hashedPassword = await bcrypt.hash(password, 10); + const verificationToken = generateVerificationToken(); + const verificationTokenExpires = new Date(); + verificationTokenExpires.setHours(verificationTokenExpires.getHours() + 24); // Token expires in 24 hours + + // Create user with verification token const user = await prisma.user.create({ - data: { email, password: hashedPassword, name }, + data: { + email, + password: hashedPassword, + name, + emailVerified: null, // Will be set when email is verified + verificationToken, + verificationTokenExpires, + }, }); - return NextResponse.json({ message: "User created", userId: user.id }, { status: 201 }); + // Send verification email. TODO once we have a domain + // You can only send testing emails to your own email address. + // To send emails to other recipients, please verify a domain at resend.com/domains, + // and change the `from` address to an email using this domain. + // const verificationUrl = `${process.env.NEXTAUTH_URL}/api/auth/verify-email?token=${verificationToken}`; + // const emailHtml = await render(VerificationEmail({ url: verificationUrl })); + // try { + // let payload = { + // from: `BayesBond <${process.env.EMAIL_FROM!}>`, + // to: email, + // subject: 'Verify your email', + // html: emailHtml, + // }; + // console.log(`Verification email: ${payload}`); + // await resend.emails.send(payload); + // } catch (emailError) { + // console.error('Failed to send verification email:', emailError); + // } + + return NextResponse.json({ + message: "User created. Please check your email to verify your account.", + userId: user.id + }, { status: 201 }); + } catch (error) { console.error(error); return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); diff --git a/app/api/auth/verify-email/route.ts b/app/api/auth/verify-email/route.ts new file mode 100644 index 00000000..80481d81 --- /dev/null +++ b/app/api/auth/verify-email/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const token = searchParams.get('token'); + + if (!token) { + return NextResponse.redirect(new URL('/auth/error?error=InvalidToken', req.url)); + } + + // Find user with this verification token + const user = await prisma.user.findFirst({ + where: { + verificationToken: token, + verificationTokenExpires: { + gt: new Date(), // Check if token is not expired + }, + }, + }); + + if (!user) { + return NextResponse.redirect(new URL('/auth/error?error=InvalidOrExpiredToken', req.url)); + } + + // Update user as verified + await prisma.user.update({ + where: { id: user.id }, + data: { + emailVerified: new Date(), + verificationToken: null, + verificationTokenExpires: null, + }, + }); + + // Redirect to success page + return NextResponse.redirect(new URL('/auth/verification-success', req.url)); + } catch (error) { + console.error('Email verification error:', error); + return NextResponse.redirect(new URL('/auth/error?error=VerificationFailed', req.url)); + } +} diff --git a/app/auth/error/page.tsx b/app/auth/error/page.tsx new file mode 100644 index 00000000..af2ae828 --- /dev/null +++ b/app/auth/error/page.tsx @@ -0,0 +1,64 @@ +import Link from "next/link"; + +export default function AuthError({ + searchParams, +}: { + searchParams?: { [key: string]: string | string[] | undefined }; +}) { + const error = searchParams?.error; + let errorMessage = "An error occurred during authentication."; + + switch (error) { + case "InvalidToken": + errorMessage = "The verification link is invalid."; + break; + case "InvalidOrExpiredToken": + errorMessage = "The verification link is invalid or has expired. Please request a new one."; + break; + case "VerificationFailed": + errorMessage = "Email verification failed. Please try again later."; + break; + default: + errorMessage = "An unexpected error occurred. Please try again."; + } + + return ( +
+
+
+ + + +
+

+ Verification Error +

+

{errorMessage}

+
+ + Back to Registration + + + Go to Login + +
+
+
+ ); +} diff --git a/app/auth/verification-success/page.tsx b/app/auth/verification-success/page.tsx new file mode 100644 index 00000000..09ee3790 --- /dev/null +++ b/app/auth/verification-success/page.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; + +export default function VerificationSuccess() { + return ( +
+
+
+ + + +
+

+ Email Verified Successfully! +

+

+ Your email has been successfully verified. You can now log in to your account. +

+
+ + Go to Login + +
+
+
+ ); +} diff --git a/app/register/page.tsx b/app/register/page.tsx index 55e49330..30fc9193 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -1,20 +1,22 @@ "use client"; -import { useState } from "react"; -import { useRouter } from "next/navigation"; +import {useState} from "react"; import Link from "next/link"; -import { signIn } from "next-auth/react"; -import { FcGoogle } from "react-icons/fc"; +import {signIn} from "next-auth/react"; +import {FcGoogle} from "react-icons/fc"; +import {useSearchParams} from "next/navigation"; export default function RegisterPage() { - const router = useRouter(); - const [error, setError] = useState(null); + const searchParams = useSearchParams(); + const [error, setError] = useState(searchParams.get('error')); const [isLoading, setIsLoading] = useState(false); + const [registrationSuccess, setRegistrationSuccess] = useState(false); + const [registeredEmail, setRegisteredEmail] = useState(''); const handleGoogleSignUp = async () => { try { setIsLoading(true); - await signIn('google', { callbackUrl: '/' }); + await signIn('google', {callbackUrl: '/'}); } catch (error) { setError('Failed to sign up with Google'); setIsLoading(false); @@ -26,7 +28,7 @@ export default function RegisterPage() { event.preventDefault(); setIsLoading(true); setError(null); - + const formData = new FormData(event.currentTarget); const email = formData.get("email") as string; const password = formData.get("password") as string; @@ -39,8 +41,8 @@ export default function RegisterPage() { const res = await fetch("/api/auth/signup", { method: "POST", - body: JSON.stringify({ email, password, name }), - headers: { "Content-Type": "application/json" }, + body: JSON.stringify({email, password, name}), + headers: {"Content-Type": "application/json"}, }); const data = await res.json(); @@ -48,19 +50,21 @@ export default function RegisterPage() { throw new Error(data.error || "Registration failed"); } + // Show a success message with email verification notice + // setRegistrationSuccess(true); + // setRegisteredEmail(email); + // Sign in after successful registration const response = await signIn("credentials", { email, password, - redirect: false, + redirect: true, + callbackUrl: "/", }); if (response?.error) { throw new Error("Failed to sign in after registration"); } - - router.push("/"); - router.refresh(); } catch (error) { setError(error instanceof Error ? error.message : "Registration failed"); setIsLoading(false); @@ -70,95 +74,144 @@ export default function RegisterPage() { return (
-
-

- Create your account -

-
-
-
-
- - + {registrationSuccess ? ( +
+
+ + +
-
- - -
-
- - +

+ Check your email +

+

+ We've sent a verification link to {registeredEmail}. + Please click the link in the email to verify your account. +

+

+ Didn't receive the email? Check your spam folder or{' '} + + . +

+
+ + Back to Login +
- - {error && ( -
{error}
- )} - -
- - -
-
-
-
-
- Or sign up with -
+ ) : ( +
+
+

+ Create your account +

+ +
+
+ + +
+
+ + +
+
+ + +
+
- + {error && ( +
{error}
+ )} + +
+ + +
+
+
+
+
+ Or sign up with +
+
+ + +
+ +
+

+ Already have an account?{' '} + + Sign in + +

+
- -
-

- Already have an account?{' '} - - Sign in - -

-
+ ) + }
); diff --git a/emails/VerificationEmail.tsx b/emails/VerificationEmail.tsx new file mode 100644 index 00000000..c5c9682a --- /dev/null +++ b/emails/VerificationEmail.tsx @@ -0,0 +1,85 @@ +import * as React from 'react'; + +interface VerificationEmailProps { + url: string; +} + +export function VerificationEmail({ url }: VerificationEmailProps) { + return ( +
+
+

Verify your email

+

+ Thanks for signing up! Please verify your email address by clicking the button below: +

+ + Verify Email + +

+ If you didn't create an account, you can safely ignore this email. +

+

+ + Or copy and paste this link into your browser:
+ + {url} + +
+

+
+
+ ); +} + +// Email styles +const container = { + backgroundColor: '#f6f9fc', + padding: '20px 0', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', +}; + +const content = { + maxWidth: '600px', + margin: '0 auto', + backgroundColor: '#ffffff', + padding: '30px', + borderRadius: '8px', + boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)', +}; + +const heading = { + color: '#2d3748', + fontSize: '24px', + fontWeight: 'bold', + margin: '0 0 20px 0', + padding: 0, +}; + +const text = { + color: '#4a5568', + fontSize: '16px', + lineHeight: '1.5', + margin: '0 0 20px 0', +}; + +const smallText = { + fontSize: '14px', + color: '#718096', + lineHeight: '1.5', +}; + +const button = { + display: 'inline-block', + backgroundColor: '#3182ce', + color: '#ffffff', + textDecoration: 'none', + padding: '12px 24px', + borderRadius: '4px', + fontWeight: '600', + marginBottom: '20px', +}; + +const link = { + color: '#3182ce', + wordBreak: 'break-all' as const, +}; diff --git a/package-lock.json b/package-lock.json index 62374e3c..fd4f9b2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^6.12.0", "@prisma/extension-accelerate": "^2.0.2", + "@react-email/render": "^1.1.3", + "@types/uuid": "^10.0.0", "bcryptjs": "^3.0.2", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.503.0", @@ -20,7 +22,9 @@ "next-auth": "^4.24.11", "react": "^19.1.0", "react-dom": "^19.0.0", - "react-icons": "^5.5.0" + "react-icons": "^5.5.0", + "resend": "^4.7.0", + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -1518,6 +1522,23 @@ "@prisma/debug": "6.12.0" } }, + "node_modules/@react-email/render": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.3.tgz", + "integrity": "sha512-TjjF1tdTmOqYEIWWg9wMx5q9JbQRbWmnG7owQbSGEHkNfc/c/vBu7hjfrki907lgQEAkYac9KPTyIjOKhvhJCg==", + "dependencies": { + "html-to-text": "^9.0.5", + "prettier": "^3.5.3", + "react-promise-suspense": "^0.3.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1530,6 +1551,18 @@ "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", "dev": true }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", + "dependencies": { + "domhandler": "^5.0.3", + "selderee": "^0.11.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1603,6 +1636,11 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.38.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", @@ -2800,6 +2838,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2867,6 +2913,57 @@ "node": ">=0.10.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2901,6 +2998,17 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -3979,6 +4087,39 @@ "node": ">= 0.4" } }, + "node_modules/html-to-text": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", + "dependencies": { + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4620,6 +4761,14 @@ "node": ">=0.10" } }, + "node_modules/leac": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", + "integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4935,6 +5084,14 @@ } } }, + "node_modules/next-auth/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -5203,6 +5360,18 @@ "node": ">=6" } }, + "node_modules/parseley": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", + "dependencies": { + "leac": "^0.6.0", + "peberminta": "^0.9.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5249,6 +5418,14 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/peberminta": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5430,6 +5607,20 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", @@ -5533,6 +5724,19 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/react-promise-suspense": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", + "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==", + "dependencies": { + "fast-deep-equal": "^2.0.1" + } + }, + "node_modules/react-promise-suspense/node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==" + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5596,6 +5800,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resend": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-4.7.0.tgz", + "integrity": "sha512-30IbXGBUbmDweQH2IlO53XOXX7ndjYV9xFZ8IEBiWqefqQ/qmTsgrX0Ab6MUnmobJXbpdReVv+iXGRQPubQL5Q==", + "dependencies": { + "@react-email/render": "1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/resend/node_modules/@react-email/render": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.1.2.tgz", + "integrity": "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw==", + "dependencies": { + "html-to-text": "^9.0.5", + "prettier": "^3.5.3", + "react-promise-suspense": "^0.3.4" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -5743,6 +5975,17 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" }, + "node_modules/selderee": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", + "dependencies": { + "parseley": "^0.12.0" + }, + "funding": { + "url": "https://ko-fi.com/killymxi" + } + }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -6695,11 +6938,15 @@ "dev": true }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/which": { diff --git a/package.json b/package.json index 6e4f7354..afa02b58 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^6.12.0", "@prisma/extension-accelerate": "^2.0.2", + "@react-email/render": "^1.1.3", + "@types/uuid": "^10.0.0", "bcryptjs": "^3.0.2", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.503.0", @@ -21,7 +23,9 @@ "next-auth": "^4.24.11", "react": "^19.1.0", "react-dom": "^19.0.0", - "react-icons": "^5.5.0" + "react-icons": "^5.5.0", + "resend": "^4.7.0", + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3c0aab56..0ce0374c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,8 +12,10 @@ model User { name String? email String? @unique password String? // <-- Add this for email/password auth - emailVerified DateTime? - image String? + emailVerified DateTime? + verificationToken String? @unique + verificationTokenExpires DateTime? + image String? accounts Account[] sessions Session[] // Optional for WebAuthn support