├── .eslintrc.json ├── app ├── api │ └── auth │ │ └── [...nextauth] │ │ └── route.ts ├── favicon.ico ├── dashboard │ └── page.tsx ├── page.tsx ├── verify-email │ └── page.tsx ├── (auth) │ ├── sign-in │ │ └── page.tsx │ ├── register │ │ └── page.tsx │ └── layout.tsx ├── layout.tsx └── globals.css ├── next.config.mjs ├── postcss.config.js ├── lib ├── utils.ts ├── database.ts ├── mail.ts └── token.ts ├── components.json ├── components ├── auth │ ├── form-error.tsx │ ├── form-success.tsx │ ├── back-button.tsx │ ├── auth-header.tsx │ ├── card-wrapper.tsx │ ├── login-form.tsx │ └── register-form.tsx ├── ui │ ├── label.tsx │ ├── input.tsx │ ├── button.tsx │ ├── card.tsx │ └── form.tsx └── verify-email-form.tsx ├── .gitignore ├── public ├── vercel.svg └── next.svg ├── tsconfig.json ├── data ├── user.ts └── verification-token.ts ├── schemas └── index.ts ├── prisma └── schema.prisma ├── actions ├── new-verification.ts ├── login.ts └── register.ts ├── auth.config.ts ├── package.json ├── README.md ├── auth.ts └── tailwind.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from '@/auth' -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bwestwood11/verification-email-token-authjs/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import {auth} from '@/auth' 2 | 3 | const DashboardPage = async () => { 4 | const session = await auth() 5 | return ( 6 |
Hi {session?.user?.email}
7 | ) 8 | } 9 | 10 | export default DashboardPage -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 | Home Page 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /app/verify-email/page.tsx: -------------------------------------------------------------------------------- 1 | import VerifyEmailForm from '@/components/verify-email-form' 2 | import React from 'react' 3 | 4 | const VerifyEmailPage = () => { 5 | return ( 6 |
7 | 8 |
9 | ) 10 | } 11 | 12 | export default VerifyEmailPage -------------------------------------------------------------------------------- /app/(auth)/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | import LoginForm from "@/components/auth/login-form" 2 | 3 | 4 | const SignInPage = () => { 5 | return ( 6 |
7 | 8 |
9 | ) 10 | } 11 | 12 | export default SignInPage -------------------------------------------------------------------------------- /app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import RegisterForm from "@/components/auth/register-form" 2 | 3 | 4 | const RegisterPage = () => { 5 | return ( 6 |
7 | 8 |
9 | ) 10 | } 11 | 12 | export default RegisterPage -------------------------------------------------------------------------------- /lib/database.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | declare global { 4 | var prisma: PrismaClient | undefined; 5 | } 6 | 7 | export const database = globalThis.prisma || new PrismaClient(); 8 | 9 | if (process.env.NODE_ENV !== "production") { 10 | globalThis.prisma = database; 11 | } -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/auth/form-error.tsx: -------------------------------------------------------------------------------- 1 | import { BsExclamationCircleFill } from "react-icons/bs"; 2 | 3 | interface FormSuccessProps { 4 | message?: string; 5 | } 6 | 7 | export const FormError = ({ message }: FormSuccessProps) => { 8 | if (!message) return null; 9 | return ( 10 |
11 | 12 |

{message}

13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /components/auth/form-success.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCheckIcon } from "lucide-react"; 2 | 3 | interface FormSuccessProps { 4 | message?: string; 5 | } 6 | 7 | export const FormSuccess = ({message}: FormSuccessProps) => { 8 | if (!message) return null; 9 | return ( 10 |
11 | 12 |

{message}

13 |
14 | ) 15 | 16 | } -------------------------------------------------------------------------------- /components/auth/back-button.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import Link from "next/link"; 3 | 4 | interface BackButtonProps { 5 | label: string; 6 | href: string; 7 | } 8 | 9 | export const BackButton = ({ label, href }: BackButtonProps) => { 10 | return ( 11 | 16 | ) 17 | 18 | } -------------------------------------------------------------------------------- /components/auth/auth-header.tsx: -------------------------------------------------------------------------------- 1 | interface HeaderProps { 2 | label: string; 3 | title: string; 4 | } 5 | 6 | const AuthHeader = ({ 7 | title, 8 | label 9 | }: HeaderProps) => { 10 | return ( 11 |
12 |

{title}

13 |

14 | {label} 15 |

16 |
17 | ) 18 | } 19 | 20 | export default AuthHeader; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /lib/mail.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from 'resend'; 2 | 3 | const resend = new Resend(process.env.RESEND_API_KEY) 4 | 5 | const domain = "http://localhost:3000" 6 | 7 | export const sendVerificationEmail = async (email: string, token: string) => { 8 | const confirmationLink = `${domain}/verify-email?token=${token}` 9 | 10 | await resend.emails.send({ 11 | from: "onboarding@resend.dev", 12 | to: email, 13 | subject: "Verify your email", 14 | html: `

Click here to verify your email.

` 15 | }) 16 | } -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const AuthLayout = ({children}: {children: React.ReactNode}) => { 4 | return ( 5 |
6 |
7 |
8 | {children} 9 |
10 | 11 |
12 | ) 13 | } 14 | 15 | export default AuthLayout -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /data/user.ts: -------------------------------------------------------------------------------- 1 | import { database } from "@/lib/database"; 2 | 3 | export const getUserByEmail = async (email: string) => { 4 | try { 5 | const lowerCaseEmail = email.toLowerCase(); 6 | const user = await database.user.findUnique({ 7 | where: { 8 | email: lowerCaseEmail 9 | } 10 | }) 11 | 12 | return user; 13 | } catch (error) { 14 | return null 15 | } 16 | } 17 | 18 | export const getUserById = async (id:string) => { 19 | try { 20 | const user = await database.user.findUnique({ 21 | where: { 22 | id 23 | } 24 | }); 25 | 26 | return user; 27 | } catch (error) { 28 | return null 29 | } 30 | } -------------------------------------------------------------------------------- /data/verification-token.ts: -------------------------------------------------------------------------------- 1 | import { database } from "@/lib/database"; 2 | 3 | export const getVerificationTokenByEmail = async (email: string) => { 4 | try { 5 | const verificationToken = await database.verificationToken.findFirst({ 6 | where: { 7 | email: email 8 | } 9 | }) 10 | 11 | return verificationToken; 12 | } catch (error) { 13 | console.log(error); 14 | } 15 | 16 | } 17 | 18 | export const getVerificationTokenByToken = async (token: string) => { 19 | try { 20 | const verificationToken = await database.verificationToken.findFirst({ 21 | where: { 22 | token: token 23 | } 24 | }) 25 | 26 | return verificationToken; 27 | } catch (error) { 28 | console.log(error); 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter as FontSans } from "next/font/google" 3 | import "./globals.css"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | const fontSans = FontSans({ 7 | subsets: ["latin"], 8 | variable: "--font-sans", 9 | }) 10 | 11 | export const metadata: Metadata = { 12 | title: "Create Next App", 13 | description: "Generated by create next app", 14 | }; 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: Readonly<{ 19 | children: React.ReactNode; 20 | }>) { 21 | return ( 22 | 23 | {children} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /schemas/index.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | 3 | export const RegisterSchema = z.object({ 4 | email: z.string().email({ 5 | message: " Please enter a valid email address." 6 | }), 7 | name: z.string().min(1, { 8 | message: "Name is required." 9 | }), 10 | password: z.string().min(6, { 11 | message: "Password must be at least 6 characters long." 12 | }), 13 | passwordConfirmation: z.string().min(6, { 14 | message: "Password must be at least 6 characters long." 15 | }) 16 | }) 17 | 18 | export const LoginSchema = z.object({ 19 | email: z.string().email({ 20 | message: "Please enter a valid email address", 21 | }), 22 | password: z.string().min(1, { 23 | message: "Please enter a valid password", 24 | }), 25 | }); -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | datasource db { 12 | provider = "mongodb" 13 | url = env("DATABASE_URL") 14 | } 15 | 16 | model User { 17 | id String @id @default(auto()) @map("_id") @db.ObjectId 18 | name String 19 | email String @unique 20 | emailVerified DateTime? 21 | password String 22 | } 23 | 24 | model VerificationToken { 25 | id String @id @default(auto()) @map("_id") @db.ObjectId 26 | email String 27 | token String 28 | expires DateTime 29 | 30 | @@unique([email, token]) 31 | } 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /components/auth/card-wrapper.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Card, 5 | CardContent, 6 | CardHeader, 7 | CardFooter, 8 | } from "@/components/ui/card"; 9 | import AuthHeader from "./auth-header"; 10 | import { BackButton } from "./back-button"; 11 | 12 | interface CardWrapperProps { 13 | children: React.ReactNode; 14 | headerLabel: string; 15 | backButtonLabel: string; 16 | title: string; 17 | showSocial?: boolean; 18 | backButtonHref: string; 19 | } 20 | 21 | const CardWrapper = ({ children, headerLabel, backButtonLabel, backButtonHref, title, showSocial}: CardWrapperProps) => { 22 | return ( 23 | 24 | 25 | 26 | 27 | {children} 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | export default CardWrapper; 36 | -------------------------------------------------------------------------------- /lib/token.ts: -------------------------------------------------------------------------------- 1 | import { getVerificationTokenByEmail } from '@/data/verification-token'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import { database } from './database'; 4 | 5 | export const generateVerificationToken = async (email: string) => { 6 | // Generate a random token 7 | const token = uuidv4(); 8 | const expires = new Date().getTime() + 1000 * 60 * 60 * 1; // 1 hours 9 | 10 | // Check if a token already exists for the user 11 | const existingToken = await getVerificationTokenByEmail(email) 12 | 13 | if(existingToken) { 14 | await database.verificationToken.delete({ 15 | where: { 16 | id: existingToken.id 17 | } 18 | }) 19 | } 20 | 21 | // Create a new verification token 22 | const verificationToken = await database.verificationToken.create({ 23 | data: { 24 | email, 25 | token, 26 | expires: new Date(expires) 27 | } 28 | }) 29 | 30 | return verificationToken; 31 | } -------------------------------------------------------------------------------- /actions/new-verification.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { database } from "@/lib/database" 4 | import { getUserByEmail } from "@/data/user" 5 | import { getVerificationTokenByToken} from "@/data/verification-token" 6 | 7 | export const newVerification = async (token: string) => { 8 | const existingToken = await getVerificationTokenByToken(token) 9 | 10 | if(!existingToken) { 11 | return { error: "Invalid token" } 12 | } 13 | 14 | const hasExpired = new Date(existingToken.expires) < new Date() 15 | 16 | if(hasExpired) { 17 | return { error: "Token has expired" } 18 | } 19 | 20 | const existingUser = await getUserByEmail(existingToken.email) 21 | 22 | 23 | if(!existingUser) { 24 | return { error: "User not found" } 25 | } 26 | 27 | await database.user.update({ 28 | where: { 29 | id: existingUser.id 30 | }, 31 | data: { 32 | emailVerified: new Date(), 33 | email: existingToken.email 34 | } 35 | }) 36 | 37 | await database.verificationToken.delete({ 38 | where: { 39 | id: existingToken.id 40 | } 41 | }) 42 | 43 | return { success: "Email verified" } 44 | } -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import { NextAuthConfig } from "next-auth"; 2 | import Credentials from "next-auth/providers/credentials"; 3 | import bcrypt from "bcryptjs"; 4 | import { getUserByEmail } from "./data/user"; 5 | import { LoginSchema } from "./schemas"; 6 | 7 | 8 | export default { 9 | providers: [ 10 | Credentials({ 11 | async authorize(credentials) { 12 | const validatedCredentials = LoginSchema.safeParse(credentials) 13 | 14 | if (!validatedCredentials.success) { 15 | return null; 16 | } 17 | 18 | const { email, password } = validatedCredentials.data; 19 | // console.log("password", password) 20 | 21 | const user = await getUserByEmail(email); 22 | if (!user || !user.password) { 23 | return null; 24 | } 25 | 26 | const passwordsMatch = await bcrypt.compare(password, user.password); 27 | // console.log("passwordsMatch", passwordsMatch); 28 | 29 | if (passwordsMatch) { 30 | return user; 31 | } 32 | 33 | return null; 34 | } 35 | }) 36 | ] 37 | } satisfies NextAuthConfig; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "verification-token-tutorial", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@auth/prisma-adapter": "^1.5.1", 13 | "@hookform/resolvers": "^3.3.4", 14 | "@prisma/client": "^5.11.0", 15 | "@radix-ui/react-label": "^2.0.2", 16 | "@radix-ui/react-slot": "^1.0.2", 17 | "bcryptjs": "^2.4.3", 18 | "class-variance-authority": "^0.7.0", 19 | "clsx": "^2.1.0", 20 | "lucide-react": "^0.363.0", 21 | "next": "14.1.4", 22 | "next-auth": "^5.0.0-beta.16", 23 | "react": "^18", 24 | "react-dom": "^18", 25 | "react-hook-form": "^7.51.2", 26 | "react-icons": "^5.0.1", 27 | "resend": "^3.2.0", 28 | "tailwind-merge": "^2.2.2", 29 | "tailwindcss-animate": "^1.0.7", 30 | "uuidv4": "^6.2.13", 31 | "zod": "^3.22.4" 32 | }, 33 | "devDependencies": { 34 | "@types/bcryptjs": "^2.4.6", 35 | "@types/node": "^20.11.30", 36 | "@types/react": "^18", 37 | "@types/react-dom": "^18", 38 | "autoprefixer": "^10.0.1", 39 | "eslint": "^8", 40 | "eslint-config-next": "14.1.4", 41 | "postcss": "^8", 42 | "prisma": "^5.11.0", 43 | "tailwindcss": "^3.3.0", 44 | "ts-node": "^10.9.2", 45 | "typescript": "^5.4.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /actions/login.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import * as z from "zod"; 3 | import { LoginSchema } from "@/schemas"; 4 | import { getUserByEmail } from "@/data/user"; 5 | import { signIn } from "@/auth"; 6 | import { AuthError } from "next-auth"; 7 | 8 | export const login = async (data: z.infer) => { 9 | // Validate the input data 10 | const validatedData = LoginSchema.parse(data); 11 | 12 | // If the data is invalid, return an error 13 | if (!validatedData) { 14 | return { error: "Invalid input data" }; 15 | } 16 | 17 | // Destructure the validated data 18 | const { email, password } = validatedData; 19 | 20 | // Check if user exists 21 | const userExists = await getUserByEmail(email); 22 | 23 | // If the user does not exist, return an error 24 | if (!userExists || !userExists.email || !userExists.password) { 25 | return { error: "User does not exist" }; 26 | } 27 | 28 | try { 29 | await signIn("credentials", { 30 | email: userExists.email, 31 | password: password, 32 | redirectTo: "/dashboard", 33 | }); 34 | } catch (error) { 35 | if (error instanceof AuthError) { 36 | 37 | switch (error.type) { 38 | case "CredentialsSignin": 39 | return { error: "Invalid credentials" }; 40 | default: 41 | return { error: "Please confirm yours email address" }; 42 | } 43 | } 44 | 45 | throw error; 46 | } 47 | 48 | return { success: "User logged in successfully" }; 49 | }; 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import authConfig from "./auth.config"; 3 | import { PrismaAdapter } from "@auth/prisma-adapter"; 4 | import { database } from "./lib/database"; 5 | import { getUserById } from "./data/user"; 6 | 7 | export const { 8 | handlers: { GET, POST }, 9 | auth, 10 | signIn, 11 | signOut, 12 | } = NextAuth({ 13 | callbacks: { 14 | async signIn({ user, account }) { 15 | if (account?.provider !== "credentials") { 16 | return true; 17 | } 18 | 19 | const existingUser = await getUserById(user.id ?? ""); 20 | 21 | if(!existingUser?.emailVerified) { 22 | return false; 23 | } 24 | 25 | return true 26 | }, 27 | async session({ token, session }) { 28 | // console.log("token in session", token); 29 | // console.log("session in session", session); 30 | return { 31 | ...session, 32 | user: { 33 | ...session.user, 34 | id: token.sub, 35 | isOAuth: token.isOauth, 36 | }, 37 | }; 38 | }, 39 | async jwt({ token }) { 40 | // console.log("token in jwt", token); 41 | if (!token.sub) return token; 42 | const existingUser = await getUserById(token.sub); 43 | 44 | if (!existingUser) return token; 45 | token.name = existingUser.name; 46 | token.email = existingUser.email; 47 | 48 | return token; 49 | }, 50 | }, 51 | ...authConfig, 52 | session: { 53 | strategy: "jwt", 54 | }, 55 | adapter: PrismaAdapter(database), 56 | }); -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } -------------------------------------------------------------------------------- /components/verify-email-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useSearchParams } from "next/navigation" 4 | import { useEffect, useState, useCallback } from "react" 5 | import CardWrapper from "./auth/card-wrapper" 6 | import { FormSuccess } from "./auth/form-success" 7 | import { FormError } from "./auth/form-error" 8 | import { newVerification } from "@/actions/new-verification" 9 | 10 | const VerifyEmailForm = () => { 11 | const [error, setError] = useState(undefined); 12 | const [success, setSuccess] = useState(undefined); 13 | const searchParams = useSearchParams(); 14 | const token = searchParams.get("token") 15 | 16 | const onSubmit = useCallback(() => { 17 | if (success || error) { 18 | return 19 | } 20 | 21 | if(!token) { 22 | setError("No token provided") 23 | return 24 | } 25 | 26 | newVerification(token).then((data) => { 27 | if (data.success) { 28 | setSuccess(data.success) 29 | } 30 | if (data.error) { 31 | setError(data.error) 32 | } 33 | }).catch((error) => { 34 | console.error(error) 35 | setError("An unexpected error occurred") 36 | }) 37 | }, [token, success, error]) 38 | 39 | useEffect(() => { 40 | onSubmit() 41 | }, []) 42 | 43 | return ( 44 | 50 |
51 | {!success && !error &&

Loading

} 52 | 53 | {!success && } 54 |
55 |
56 | ) 57 | } 58 | 59 | export default VerifyEmailForm -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | './pages/**/*.{ts,tsx}', 7 | './components/**/*.{ts,tsx}', 8 | './app/**/*.{ts,tsx}', 9 | './src/**/*.{ts,tsx}', 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | }, 76 | }, 77 | plugins: [require("tailwindcss-animate")], 78 | } satisfies Config 79 | 80 | export default config -------------------------------------------------------------------------------- /actions/register.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import * as z from "zod"; 4 | import { database } from "@/lib/database"; 5 | import bcrypt from "bcryptjs"; 6 | import { RegisterSchema } from "@/schemas"; 7 | import { generateVerificationToken } from "@/lib/token"; 8 | import { sendVerificationEmail } from "@/lib/mail"; 9 | 10 | 11 | export const register = async (data: z.infer) => { 12 | try { 13 | // Validate the input data 14 | const validatedData = RegisterSchema.parse(data); 15 | 16 | // If the data is invalid, return an error 17 | if (!validatedData) { 18 | return { error: "Invalid input data" }; 19 | } 20 | 21 | // Destructure the validated data 22 | const { email, name, password, passwordConfirmation } = validatedData; 23 | 24 | // Check if passwords match 25 | if (password !== passwordConfirmation) { 26 | return { error: "Passwords do not match" }; 27 | } 28 | 29 | // Hash the password 30 | const hashedPassword = await bcrypt.hash(password, 10); 31 | 32 | // Check to see if user already exists 33 | const userExists = await database.user.findFirst({ 34 | where: { 35 | email, 36 | }, 37 | }); 38 | 39 | // If the user exists, return an error 40 | if (userExists) { 41 | return { error: "Email already is in use. Please try another one." }; 42 | } 43 | 44 | const lowerCaseEmail = email.toLowerCase(); 45 | 46 | // Create the user 47 | const user = await database.user.create({ 48 | data: { 49 | email: lowerCaseEmail, 50 | name, 51 | password: hashedPassword, 52 | }, 53 | }); 54 | 55 | // Generate a verification token 56 | const verificationToken = await generateVerificationToken(email) 57 | 58 | await sendVerificationEmail(email, verificationToken.token) 59 | 60 | return { success: "Email Verification was sent" }; 61 | } catch (error) { 62 | // Handle the error, specifically check for a 503 error 63 | console.error("Database error:", error); 64 | 65 | if ((error as { code: string }).code === "ETIMEDOUT") { 66 | return { 67 | error: "Unable to connect to the database. Please try again later.", 68 | }; 69 | } else if ((error as { code: string }).code === "503") { 70 | return { 71 | error: "Service temporarily unavailable. Please try again later.", 72 | }; 73 | } else { 74 | return { error: "An unexpected error occurred. Please try again later." }; 75 | } 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /components/auth/login-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useForm } from "react-hook-form"; 4 | import { 5 | Form, 6 | FormControl, 7 | FormField, 8 | FormItem, 9 | FormLabel, 10 | FormMessage, 11 | } from "@/components/ui/form"; 12 | import CardWrapper from "./card-wrapper"; 13 | import { zodResolver } from "@hookform/resolvers/zod"; 14 | import { LoginSchema } from "@/schemas"; 15 | import { Input } from "@/components/ui/input"; 16 | import { z } from "zod"; 17 | import { Button } from "../ui/button"; 18 | import { useState } from "react"; 19 | import { FormError } from "./form-error"; 20 | import { login } from "@/actions/login"; 21 | import Link from "next/link"; 22 | 23 | const LoginForm = () => { 24 | const [loading, setLoading] = useState(false); 25 | const [error, setError] = useState(""); 26 | 27 | const form = useForm>({ 28 | resolver: zodResolver(LoginSchema), 29 | defaultValues: { 30 | email: "", 31 | password: "", 32 | }, 33 | }); 34 | 35 | const onSubmit = async (data: z.infer) => { 36 | setLoading(true); 37 | login(data).then((res) => { 38 | if (res?.error) { 39 | setError(res?.error); 40 | setLoading(false); 41 | } else { 42 | setLoading(false); 43 | } 44 | }); 45 | }; 46 | 47 | return ( 48 | 55 |
56 | 57 |
58 | ( 62 | 63 | Email 64 | 65 | 70 | 71 | 72 | 73 | )} 74 | /> 75 | ( 79 | 80 | Password 81 | 82 | 83 | 84 | 85 | 86 | )} 87 | /> 88 | 96 |
97 | 98 | 101 | 102 | 103 |
104 | ); 105 | }; 106 | 107 | export default LoginForm; 108 | -------------------------------------------------------------------------------- /components/auth/register-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useForm } from "react-hook-form" 4 | import { 5 | Form, 6 | FormControl, 7 | FormDescription, 8 | FormField, 9 | FormItem, 10 | FormLabel, 11 | FormMessage 12 | } from "@/components/ui/form"; 13 | import CardWrapper from "./card-wrapper" 14 | import { zodResolver } from "@hookform/resolvers/zod" 15 | import { RegisterSchema } from "@/schemas" 16 | import { Input } from "@/components/ui/input" 17 | import { z } from "zod" 18 | import { Button } from "../ui/button"; 19 | import { useState } from "react"; 20 | import { register } from "@/actions/register"; 21 | import { FormSuccess } from "./form-success"; 22 | import { FormError } from "./form-error"; 23 | 24 | 25 | 26 | 27 | const RegisterForm = () => { 28 | const [loading, setLoading] = useState(false); 29 | const [error, setError] = useState(""); 30 | const [success, setSuccess] = useState(""); 31 | 32 | 33 | const form = useForm>({ 34 | resolver: zodResolver(RegisterSchema), 35 | defaultValues: { 36 | email: "", 37 | name: "", 38 | password: "", 39 | passwordConfirmation: "" 40 | } 41 | }) 42 | 43 | const onSubmit = async (data: z.infer) => { 44 | setLoading(true) 45 | register(data).then((res) => { 46 | if (res.error) { 47 | setError(res.error) 48 | setLoading(false) 49 | } 50 | if (res.success) { 51 | setSuccess(res.success) 52 | setLoading(false) 53 | } 54 | }) 55 | } 56 | 57 | return ( 58 | 65 |
66 | 67 |
68 | ( 72 | 73 | Email 74 | 75 | 76 | 77 | 78 | 79 | )} 80 | /> 81 | ( 85 | 86 | Name 87 | 88 | 89 | 90 | 91 | 92 | )} 93 | /> 94 | ( 98 | 99 | Password 100 | 101 | 102 | 103 | 104 | 105 | )} 106 | /> 107 | ( 111 | 112 | Confirm Password 113 | 114 | 115 | 116 | 117 | 118 | )} 119 | /> 120 | 121 |
122 | 123 | 124 | 127 | 128 | 129 |
130 | ) 131 | } 132 | 133 | export default RegisterForm -------------------------------------------------------------------------------- /components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |