├── .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 |
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 |
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 |
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 |
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 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------