Add email verification

This commit is contained in:
MartinBraquet
2025-07-28 12:25:42 +02:00
parent 6b0e343412
commit 5b84688c63
10 changed files with 702 additions and 109 deletions

View File

@@ -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;
}
}

View File

@@ -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 });

View File

@@ -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));
}
}

64
app/auth/error/page.tsx Normal file
View File

@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8 text-center">
<div className="rounded-full bg-red-100 p-3 inline-flex items-center justify-center">
<svg
className="h-12 w-12 text-red-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
Verification Error
</h2>
<p className="mt-2 text-sm text-gray-600">{errorMessage}</p>
<div className="mt-6 space-y-4">
<Link
href="/register"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Back to Registration
</Link>
<Link
href="/login"
className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go to Login
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import Link from "next/link";
export default function VerificationSuccess() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8 text-center">
<div className="rounded-full bg-green-100 p-3 inline-flex items-center justify-center">
<svg
className="h-12 w-12 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
Email Verified Successfully!
</h2>
<p className="mt-2 text-sm text-gray-600">
Your email has been successfully verified. You can now log in to your account.
</p>
<div className="mt-6">
<Link
href="/login"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Go to Login
</Link>
</div>
</div>
</div>
);
}

View File

@@ -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<string | null>(null);
const searchParams = useSearchParams();
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="name" className="sr-only">
Name
</label>
<input
id="name"
name="name"
type="text"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Full name"
/>
{registrationSuccess ? (
<div className="text-center">
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<svg
className="h-6 w-6 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
id="email"
name="email"
type="email"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Email address"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password"
/>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
Check your email
</h2>
<p className="mt-2 text-sm text-gray-600">
We've sent a verification link to <span className="font-medium">{registeredEmail}</span>.
Please click the link in the email to verify your account.
</p>
<p className="mt-4 text-sm text-gray-500">
Didn't receive the email? Check your spam folder or{' '}
<button
type="button"
className="font-medium text-blue-600 hover:text-blue-500"
onClick={() => setRegistrationSuccess(false)}
>
try again
</button>
.
</p>
<div className="mt-6">
<Link
href="/login"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Back to Login
</Link>
</div>
</div>
{error && (
<div className="text-red-500 text-sm text-center">{error}</div>
)}
<div className="space-y-4">
<button
type="submit"
disabled={isLoading}
className={`group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${isLoading ? 'opacity-70 cursor-not-allowed' : ''}`}
>
{isLoading ? 'Creating account...' : 'Sign up with Email'}
</button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gray-50 text-gray-500">Or sign up with</span>
</div>
) : (
<div>
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create your account
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="name" className="sr-only">
Name
</label>
<input
id="name"
name="name"
type="text"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Full name"
/>
</div>
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
id="email"
name="email"
type="email"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Email address"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password"
/>
</div>
</div>
<button
type="button"
onClick={handleGoogleSignUp}
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-70 disabled:cursor-not-allowed"
>
<FcGoogle className="w-5 h-5" />
Continue with Google
</button>
{error && (
<div className="text-red-500 text-sm text-center">{error}</div>
)}
<div className="space-y-4">
<button
type="submit"
disabled={isLoading}
className={`group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${isLoading ? 'opacity-70 cursor-not-allowed' : ''}`}
>
{isLoading ? 'Creating account...' : 'Sign up with Email'}
</button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gray-50 text-gray-500">Or sign up with</span>
</div>
</div>
<button
type="button"
onClick={handleGoogleSignUp}
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-70 disabled:cursor-not-allowed"
>
<FcGoogle className="w-5 h-5"/>
Continue with Google
</button>
</div>
</form>
<div className="text-center text-sm">
<p className="text-gray-600">
Already have an account?{' '}
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500">
Sign in
</Link>
</p>
</div>
</div>
</form>
<div className="text-center text-sm">
<p className="text-gray-600">
Already have an account?{' '}
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500">
Sign in
</Link>
</p>
</div>
)
}
</div>
</div>
);

View File

@@ -0,0 +1,85 @@
import * as React from 'react';
interface VerificationEmailProps {
url: string;
}
export function VerificationEmail({ url }: VerificationEmailProps) {
return (
<div style={container}>
<div style={content}>
<h1 style={heading}>Verify your email</h1>
<p style={text}>
Thanks for signing up! Please verify your email address by clicking the button below:
</p>
<a href={url} style={button}>
Verify Email
</a>
<p style={text}>
If you didn't create an account, you can safely ignore this email.
</p>
<p style={text}>
<small style={smallText}>
Or copy and paste this link into your browser:<br />
<a href={url} style={link}>
{url}
</a>
</small>
</p>
</div>
</div>
);
}
// 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,
};

257
package-lock.json generated
View File

@@ -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": {

View File

@@ -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",

View File

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