├── .env.example ├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── bin └── cli.js ├── components.json ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── prisma └── schema.prisma ├── public ├── icons │ └── google.svg ├── next.svg └── vercel.svg ├── src ├── app-config.tsx ├── app │ ├── (auth) │ │ ├── reset-password │ │ │ ├── actions.ts │ │ │ └── page.tsx │ │ ├── sign-in │ │ │ ├── actions.ts │ │ │ ├── email │ │ │ │ ├── actions.ts │ │ │ │ └── page.tsx │ │ │ ├── forgot-password │ │ │ │ ├── actions.ts │ │ │ │ └── page.tsx │ │ │ ├── magic-link-form.tsx │ │ │ ├── magic │ │ │ │ ├── error │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ └── sign-up │ │ │ ├── actions.ts │ │ │ └── page.tsx │ ├── (root) │ │ └── page.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ └── login │ │ │ └── magic │ │ │ └── route.ts │ ├── favicon.ico │ ├── globals.css │ └── layout.tsx ├── auth.ts ├── components │ ├── auth │ │ └── layout.tsx │ └── ui │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ └── sonner.tsx ├── data-access │ ├── magic-links.ts │ ├── reset-tokens.ts │ ├── sessions.ts │ ├── users.ts │ └── utils.ts ├── emails │ ├── magic-link.tsx │ └── reset-password.tsx ├── lib │ ├── db.ts │ ├── safe-action.ts │ ├── send-email.ts │ ├── session.ts │ └── utils.ts ├── middleware.ts ├── routes.ts ├── styles │ └── icons.ts └── use-cases │ ├── errors.ts │ ├── magic-link.tsx │ ├── users.tsx │ └── utils.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./dev.db" 2 | AUTH_SECRET= 3 | 4 | AUTH_GOOGLE_ID= 5 | AUTH_GOOGLE_SECRET= 6 | 7 | AUTH_RESEND_KEY= 8 | 9 | NEXT_PUBLIC_NODEMAILER_PW= 10 | NEXT_PUBLIC_NODEMAILER_EMAIL= 11 | 12 | 13 | HOST_NAME="http://localhost:3000" 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | /prisma/dev.db 39 | pnpm-lock.yaml 40 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "editorSuggestWidget.selectedBackground": "#231739", 4 | "sideBar.background": "#191521", 5 | "list.activeSelectionBackground": "#231739", 6 | "list.inactiveSelectionBackground": "#231739", 7 | "list.focusBackground": "#231739", 8 | "list.hoverBackground": "#231739", 9 | "terminalCursor.foreground": "#C45DFF", 10 | "activityBar.background": "#0F3418", 11 | "titleBar.activeBackground": "#164922", 12 | "titleBar.activeForeground": "#F3FCF5" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # NextAuth GitHub Template 3 | 4 | This is a GitHub template project using the latest version of NextAuth.js. It implements the following authentication methods: 5 | 6 | - Google Sign-in 7 | - Magic Link Sign-in 8 | - Sign-in with Email 9 | - Forgot Password 10 | - Reset Password 11 | 12 | This template provides a quick and easy way to get started with authentication in your Next.js projects. 13 | 14 | --- 15 | 16 | ## 🚀 Quick Start 17 | 18 | To get the project running locally, follow these steps: 19 | 20 | 1. Install dependencies: 21 | ```bash 22 | pnpm i 23 | ``` 24 | 25 | 2. Generate Prisma client: 26 | ```bash 27 | npx prisma generate 28 | ``` 29 | 30 | 3. Push the Prisma schema to your database: 31 | ```bash 32 | npx prisma db push 33 | ``` 34 | 35 | 4. Start the development server: 36 | ```bash 37 | pnpm run dev 38 | ``` 39 | 40 | --- 41 | 42 | ## 📁 Project Structure 43 | 44 | The project follows a structured approach with the latest Next.js layout: 45 | 46 | - **data-access/**: This folder contains all files interacting with the database. It includes Prisma models and database operations. 47 | 48 | - **use-cases/**: This folder contains logic implementations for different functionalities. Business logic is separated from data access to ensure modularity. 49 | 50 | - **emails/**: This folder contains templates for sending emails, such as reset password emails and magic link emails. 51 | 52 | The rest of the files follow the latest Next.js structure, leveraging App Router for an optimized developer experience. 53 | 54 | --- 55 | 56 | ## 🖼️ Screenshots 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 |
Screenshot 2024-09-15 at 2 32 19 PMScreenshot 2024-09-15 at 2 32 35 PM
Screenshot 2024-09-15 at 2 32 44 PMScreenshot 2024-09-15 at 2 32 54 PM
68 | 69 | 70 | 71 | 72 | --- 73 | 74 | ## 🛠️ Tech Stack 75 | 76 | - **Next.js** 77 | - **NextAuth.js** 78 | - **Prisma** 79 | - **PNPM** 80 | 81 | Feel free to modify this template according to your project's needs. Contributions and suggestions are always welcome! 82 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { execSync } = require("child_process"); 4 | const readline = require("readline"); 5 | 6 | const rl = readline.createInterface({ 7 | input: process.stdin, 8 | output: process.stdout 9 | }); 10 | 11 | const runCommand = (command) => { 12 | try { 13 | execSync(`${command}`, { stdio: "inherit" }); 14 | return true; 15 | } catch (error) { 16 | console.error(`Failed to execute ${command}`, error); 17 | return false; 18 | } 19 | }; 20 | 21 | const askQuestion = (query) => new Promise((resolve) => rl.question(query, resolve)); 22 | 23 | const main = async () => { 24 | const repoName = process.argv[2]; 25 | if (!repoName) { 26 | console.error("Please specify the project name"); 27 | process.exit(1); 28 | } 29 | 30 | const packageManager = await askQuestion("Would you like to use npm or pnpm? (npm/pnpm): "); 31 | if (packageManager !== "npm" && packageManager !== "pnpm") { 32 | console.error("Invalid package manager. Please choose either npm or pnpm."); 33 | process.exit(1); 34 | } 35 | 36 | const gitCheckoutCommand = `git clone --depth 1 https://github.com/Mihir2423/edit_bridge ${repoName}`; 37 | const installDepsCommand = `cd ${repoName} && ${packageManager} install`; 38 | 39 | console.log(`Cloning repository with the name ${repoName}`); 40 | 41 | if (runCommand(gitCheckoutCommand)) { 42 | console.log(`Installing dependencies for ${repoName} using ${packageManager}`); 43 | 44 | if (runCommand(installDepsCommand)) { 45 | console.log(` 46 | Successfully cloned and installed dependencies for ${repoName} 47 | To start the project, run the following commands: 48 | cd ${repoName} 49 | ${packageManager} start 50 | `); 51 | } 52 | } else { 53 | console.error(`Failed to clone repository ${repoName}`); 54 | } 55 | 56 | rl.close(); 57 | }; 58 | 59 | main(); 60 | -------------------------------------------------------------------------------- /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": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: "https", 7 | hostname: "**", 8 | port: "", 9 | pathname: "**/*", 10 | }, 11 | ], 12 | }, 13 | } 14 | 15 | export default nextConfig; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-auth-starter", 3 | "version": "0.1.2", 4 | "bin": "./bin/cli.js", 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "postinstall": "npx prisma generate" 11 | }, 12 | "dependencies": { 13 | "@auth/prisma-adapter": "^2.4.2", 14 | "@hookform/resolvers": "^3.9.0", 15 | "@prisma/client": "5.18.0", 16 | "@radix-ui/react-label": "^2.1.0", 17 | "@radix-ui/react-slot": "^1.1.0", 18 | "@react-email/components": "^0.0.22", 19 | "axios": "^1.7.4", 20 | "class-variance-authority": "^0.7.0", 21 | "clsx": "^2.1.1", 22 | "crypto": "^1.0.1", 23 | "lucide-react": "^0.427.0", 24 | "next": "14.2.5", 25 | "next-auth": "5.0.0-beta.20", 26 | "next-themes": "^0.3.0", 27 | "nodemailer": "^6.9.14", 28 | "react": "^18", 29 | "react-dom": "^18", 30 | "react-email": "^2.1.6", 31 | "react-hook-form": "^7.52.2", 32 | "resend": "^4.0.0", 33 | "sonner": "^1.5.0", 34 | "tailwind-merge": "^2.4.0", 35 | "tailwindcss-animate": "^1.0.7", 36 | "zod": "^3.23.8", 37 | "zsa": "^0.6.0", 38 | "zsa-react": "^0.2.2" 39 | }, 40 | "devDependencies": { 41 | "@types/node": "^20", 42 | "@types/nodemailer": "^6.4.15", 43 | "@types/react": "^18", 44 | "@types/react-dom": "^18", 45 | "eslint": "^8", 46 | "eslint-config-next": "14.2.5", 47 | "postcss": "^8", 48 | "prisma": "^5.18.0", 49 | "tailwindcss": "^3.4.1", 50 | "typescript": "^5" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /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 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id String @id @default(cuid()) 15 | name String? 16 | email String @unique 17 | emailVerified DateTime? 18 | image String? 19 | password String? 20 | role String? 21 | salt String? 22 | accounts Account[] 23 | sessions Session[] 24 | resetToken ResetToken? 25 | verifyEmailToken VerifyEmailToken? 26 | createdAt DateTime @default(now()) 27 | updatedAt DateTime @updatedAt 28 | 29 | @@map("users") 30 | } 31 | 32 | model Account { 33 | userId String 34 | type String 35 | provider String 36 | providerAccountId String 37 | refresh_token String? 38 | access_token String? 39 | expires_at Int? 40 | token_type String? 41 | scope String? 42 | id_token String? 43 | session_state String? 44 | 45 | createdAt DateTime @default(now()) 46 | updatedAt DateTime @updatedAt 47 | 48 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 49 | 50 | @@id([provider, providerAccountId]) 51 | @@map("accounts") 52 | } 53 | 54 | model Session { 55 | sessionToken String @unique 56 | userId String 57 | expires DateTime 58 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 59 | 60 | createdAt DateTime @default(now()) 61 | updatedAt DateTime @updatedAt 62 | 63 | @@map("sessions") 64 | } 65 | 66 | model VerificationToken { 67 | identifier String 68 | token String 69 | expires DateTime 70 | 71 | @@id([identifier, token]) 72 | @@map("verification_tokens") 73 | } 74 | 75 | model MagicLink { 76 | id Int @id @default(autoincrement()) 77 | email String @unique 78 | token String? 79 | tokenExpiresAt DateTime? 80 | 81 | @@map("magic_links") 82 | } 83 | 84 | model ResetToken { 85 | id Int @id @default(autoincrement()) 86 | userId String @unique 87 | token String? 88 | tokenExpiresAt DateTime? 89 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 90 | 91 | @@map("reset_tokens") 92 | } 93 | 94 | model VerifyEmailToken { 95 | id Int @id @default(autoincrement()) 96 | userId String @unique 97 | token String? 98 | tokenExpiresAt DateTime? 99 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 100 | 101 | @@map("verify_email_tokens") 102 | } 103 | -------------------------------------------------------------------------------- /public/icons/google.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app-config.tsx: -------------------------------------------------------------------------------- 1 | export const TOKEN_LENGTH = 32; 2 | export const TOKEN_TTL = 1000 * 60 * 5; // 5 min 3 | export const VERIFY_EMAIL_TTL = 1000 * 60 * 60 * 24 * 7; // 7 days 4 | 5 | export const applicationName = "StarterKit"; -------------------------------------------------------------------------------- /src/app/(auth)/reset-password/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { unauthenticatedAction } from "@/lib/safe-action"; 4 | import { changePasswordUseCase } from "@/use-cases/users"; 5 | import { z } from "zod"; 6 | 7 | export const changePasswordAction = unauthenticatedAction 8 | .createServerAction() 9 | .input( 10 | z.object({ 11 | token: z.string(), 12 | password: z.string().min(8), 13 | }) 14 | ) 15 | .handler(async ({ input: { token, password } }) => { 16 | await changePasswordUseCase(token, password); 17 | }); 18 | -------------------------------------------------------------------------------- /src/app/(auth)/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AuthLayout } from "@/components/auth/layout"; 4 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | Form, 8 | FormControl, 9 | FormField, 10 | FormItem, 11 | FormLabel, 12 | FormMessage, 13 | } from "@/components/ui/form"; 14 | import { Input } from "@/components/ui/input"; 15 | import { zodResolver } from "@hookform/resolvers/zod"; 16 | import { Loader2, Terminal } from "lucide-react"; 17 | import Link from "next/link"; 18 | import { useForm } from "react-hook-form"; 19 | import { z } from "zod"; 20 | import { useServerAction } from "zsa-react"; 21 | import { changePasswordAction } from "./actions"; 22 | 23 | const resetPasswordSchema = z 24 | .object({ 25 | password: z.string().min(8), 26 | token: z.string(), 27 | passwordConfirmation: z.string().min(8), 28 | }) 29 | .refine((data) => data.password === data.passwordConfirmation, { 30 | message: "Passwords don't match", 31 | path: ["passwordConfirmation"], 32 | }); 33 | 34 | const ResetPasswordPage = ({ 35 | searchParams, 36 | }: { 37 | searchParams: { token: string }; 38 | }) => { 39 | const form = useForm>({ 40 | resolver: zodResolver(resetPasswordSchema), 41 | defaultValues: { 42 | password: "", 43 | token: searchParams.token, 44 | passwordConfirmation: "", 45 | }, 46 | }); 47 | const { execute, isPending, isSuccess, error } = 48 | useServerAction(changePasswordAction); 49 | 50 | function onSubmit(values: z.infer) { 51 | execute({ 52 | token: values.token, 53 | password: values.password, 54 | }); 55 | } 56 | return ( 57 | 58 | {isSuccess && ( 59 | <> 60 | 61 | 62 | Password updated 63 | 64 | Your password has been successfully updated. 65 | 66 | 67 | 68 | 71 | 72 | )} 73 | {!isSuccess && ( 74 |
75 |
76 | 80 | ( 84 | 85 | Password 86 | 87 | 92 | 93 | 94 | 95 | )} 96 | /> 97 | ( 101 | 102 | Confirm Password 103 | 104 | 109 | 110 | 111 | 112 | )} 113 | /> 114 | {error && ( 115 | 116 | 117 | Uh-oh, we couldn't log you in 118 | {error?.message} 119 | 120 | )} 121 | 129 | 130 | 131 |
132 | )} 133 |
134 | ); 135 | }; 136 | 137 | export default ResetPasswordPage; 138 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/actions.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { unauthenticatedAction } from "@/lib/safe-action" 4 | import { sendMagicLinkUseCase } from "@/use-cases/magic-link" 5 | import { redirect } from "next/navigation" 6 | import { z } from "zod" 7 | 8 | export const signInLinkMagicAction = unauthenticatedAction.createServerAction() 9 | .input( 10 | z.object({ 11 | email: z.string().email(), 12 | }) 13 | ).handler(async ({ input }) => { 14 | await sendMagicLinkUseCase(input.email); 15 | redirect("/sign-in/magic"); 16 | }) -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/email/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { unauthenticatedAction } from "@/lib/safe-action"; 4 | import { createSessionUseCase, signInUseCase } from "@/use-cases/users"; 5 | import { redirect } from "next/navigation"; 6 | import { z } from "zod"; 7 | 8 | export const signInAction = unauthenticatedAction 9 | .createServerAction() 10 | .input( 11 | z.object({ 12 | email: z.string().email(), 13 | password: z.string().min(8), 14 | }) 15 | ) 16 | .handler(async ({ input }) => { 17 | const user = await signInUseCase(input.email, input.password); 18 | await createSessionUseCase(user.id, user.salt); 19 | redirect("/"); 20 | }); 21 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/email/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AuthLayout } from "@/components/auth/layout"; 4 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | Form, 8 | FormControl, 9 | FormField, 10 | FormItem, 11 | FormLabel, 12 | FormMessage, 13 | } from "@/components/ui/form"; 14 | import { Input } from "@/components/ui/input"; 15 | import { zodResolver } from "@hookform/resolvers/zod"; 16 | import { Loader2, Terminal } from "lucide-react"; 17 | import Link from "next/link"; 18 | import { useForm } from "react-hook-form"; 19 | import { toast } from "sonner"; 20 | import { z } from "zod"; 21 | import { useServerAction } from "zsa-react"; 22 | import { signInAction } from "./actions"; 23 | 24 | const signInSchema = z.object({ 25 | email: z.string().email(), 26 | password: z.string().min(8), 27 | }); 28 | 29 | type Props = {}; 30 | 31 | const EmailPage = (props: Props) => { 32 | const form = useForm>({ 33 | resolver: zodResolver(signInSchema), 34 | defaultValues: { 35 | email: "", 36 | password: "", 37 | }, 38 | }); 39 | const { execute, isPending, error } = useServerAction(signInAction, { 40 | onError({ err }) { 41 | toast.error("Something went wrong"); 42 | }, 43 | }); 44 | function onSubmit(values: z.infer) { 45 | execute(values); 46 | } 47 | return ( 48 | 49 |
50 | 54 | ( 58 | 59 | Email address 60 | 61 | 62 | 63 | 64 | 65 | )} 66 | /> 67 | ( 71 | 72 | Password 73 | 74 | 75 | 76 | 77 | 78 | )} 79 | /> 80 | {error && ( 81 | 82 | 83 | Uh-oh, we couldn't log you in 84 | {error?.message} 85 | 86 | )} 87 | 95 | 96 | 97 |
98 |
99 | 102 |
103 | 104 |
105 |
106 | or 107 |
108 |
109 |
110 | 111 | ); 112 | }; 113 | 114 | export default EmailPage; 115 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/forgot-password/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { resetPasswordUseCase } from "@/use-cases/users"; 4 | import { unauthenticatedAction } from "@/lib/safe-action"; 5 | import { z } from "zod"; 6 | 7 | export const resetPasswordAction = unauthenticatedAction 8 | .createServerAction() 9 | .input( 10 | z.object({ 11 | email: z.string().email(), 12 | }) 13 | ) 14 | .handler(async ({ input }) => { 15 | await resetPasswordUseCase(input.email); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AuthLayout } from "@/components/auth/layout"; 4 | import { zodResolver } from "@hookform/resolvers/zod"; 5 | import React from "react"; 6 | import { useForm } from "react-hook-form"; 7 | import { toast } from "sonner"; 8 | import { z } from "zod"; 9 | import { useServerAction } from "zsa-react"; 10 | import { signInAction } from "../email/actions"; 11 | import { Button } from "@/components/ui/button"; 12 | import { 13 | Form, 14 | FormControl, 15 | FormField, 16 | FormItem, 17 | FormLabel, 18 | FormMessage, 19 | } from "@/components/ui/form"; 20 | import { Input } from "@/components/ui/input"; 21 | import { Loader2, Terminal } from "lucide-react"; 22 | import { resetPasswordAction } from "./actions"; 23 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 24 | 25 | const fortgotPasswordSchema = z.object({ 26 | email: z.string().email(), 27 | }); 28 | 29 | type Props = {}; 30 | 31 | const ForgotPasswordPage = (props: Props) => { 32 | const { execute, isPending, isSuccess } = useServerAction( 33 | resetPasswordAction, 34 | { 35 | onError({ err }) { 36 | toast.error("An error occurred. Please try again."); 37 | }, 38 | } 39 | ); 40 | 41 | const form = useForm>({ 42 | resolver: zodResolver(fortgotPasswordSchema), 43 | defaultValues: { 44 | email: "", 45 | }, 46 | }); 47 | 48 | function onSubmit(values: z.infer) { 49 | execute(values); 50 | console.log(values); 51 | } 52 | return ( 53 | 57 | {isSuccess && ( 58 | 59 | 60 | Reset link sent 61 | 62 | We have sent you an email with a link to reset your password. 63 | 64 | 65 | )} 66 |
67 | 71 | ( 75 | 76 | Email address 77 | 78 | 79 | 80 | 81 | 82 | )} 83 | /> 84 | 92 | 93 | 94 |
95 | ); 96 | }; 97 | 98 | export default ForgotPasswordPage; 99 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/magic-link-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Form, 6 | FormControl, 7 | FormField, 8 | FormItem, 9 | FormLabel, 10 | FormMessage, 11 | } from "@/components/ui/form"; 12 | import { Input } from "@/components/ui/input"; 13 | import { zodResolver } from "@hookform/resolvers/zod"; 14 | import { Loader2 } from "lucide-react"; 15 | import { useForm } from "react-hook-form"; 16 | import { toast } from "sonner"; 17 | import { z } from "zod"; 18 | import { useServerAction } from "zsa-react"; 19 | import { signInLinkMagicAction } from "./actions"; 20 | 21 | const magicLinkSchema = z.object({ 22 | email: z.string().email({ 23 | message: "Enter a valid email address", 24 | }), 25 | }); 26 | 27 | type Props = {}; 28 | 29 | export const MagicLinkForm = (props: Props) => { 30 | const form = useForm>({ 31 | resolver: zodResolver(magicLinkSchema), 32 | defaultValues: { 33 | email: "", 34 | }, 35 | }); 36 | const { execute, isPending } = useServerAction(signInLinkMagicAction, { 37 | onError({ err }) { 38 | toast.message("Something went wrong"); 39 | }, 40 | }); 41 | async function onSubmit(values: z.infer) { 42 | if (!values.email || !values.email.trim()) { 43 | return; 44 | } 45 | execute(values); 46 | } 47 | return ( 48 |
49 |
50 | 54 | ( 58 | 59 | Email address 60 | 61 | 62 | 63 | 64 | 65 | )} 66 | /> 67 | 75 | 76 | 77 |
78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/magic/error/page.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { ExternalLink } from "lucide-react"; 3 | import Link from "next/link"; 4 | 5 | export default function MagicLinkPage() { 6 | return ( 7 |
8 |
15 |

16 | Something went wrong 17 |

18 |

19 | { 20 | "Sorry, this token was either expired or already used. Please try logging in again" 21 | } 22 |

23 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/magic/page.tsx: -------------------------------------------------------------------------------- 1 | import { MailIcon } from "lucide-react"; 2 | 3 | export default function MagicLinkPage() { 4 | return ( 5 |
6 |
12 | 13 |
14 |
21 |

22 | Check your email 23 |

24 |

25 | {"You're almost there! We've sent you a magic link to sign in."} 26 |

27 |
28 |

29 | Thank you! 30 |

31 |
32 | 33 | {"Just click the link in the email to sign in."}If you {"don't"} see 34 | the email, check your spam{" "} 35 | folder. 36 | 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | import { Fingerprint, Mail } from "lucide-react"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | import { MagicLinkForm } from "./magic-link-form"; 5 | import { signIn } from "@/auth"; 6 | import { AuthLayout } from "@/components/auth/layout"; 7 | type Props = {}; 8 | 9 | const SignInPage = (props: Props) => { 10 | return ( 11 | 12 |
{ 14 | "use server"; 15 | await signIn("google"); 16 | }} 17 | > 18 | 31 |
32 |
33 |
34 | or 35 |
36 |
37 | 38 |
39 |
40 | 41 |
42 |
43 | 44 | Other options 45 | 46 |
47 |
48 |
49 | 53 | 54 | Sign in with Email 55 | 56 |
57 | 58 | ); 59 | }; 60 | 61 | export default SignInPage; 62 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-up/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { unauthenticatedAction } from "@/lib/safe-action"; 4 | import { createSessionUseCase, registerUserUseCase } from "@/use-cases/users"; 5 | import { redirect } from "next/navigation"; 6 | import { z } from "zod"; 7 | 8 | export const signUpAction = unauthenticatedAction 9 | .createServerAction() 10 | .input( 11 | z.object({ 12 | email: z.string().email(), 13 | password: z.string().min(8), 14 | }) 15 | ) 16 | .handler(async ({ input }) => { 17 | const user = await registerUserUseCase(input.email, input.password); 18 | await createSessionUseCase(user.id, user.salt); 19 | return redirect("/sign-in/magic"); 20 | }); 21 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-up/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { AuthLayout } from "@/components/auth/layout"; 4 | import { zodResolver } from "@hookform/resolvers/zod"; 5 | import React from "react"; 6 | import { useForm } from "react-hook-form"; 7 | import { z } from "zod"; 8 | import { Button } from "@/components/ui/button"; 9 | import { 10 | Form, 11 | FormControl, 12 | FormField, 13 | FormItem, 14 | FormLabel, 15 | FormMessage, 16 | } from "@/components/ui/form"; 17 | import { Input } from "@/components/ui/input"; 18 | import { useServerAction } from "zsa-react"; 19 | import { signUpAction } from "./actions"; 20 | import { toast } from "sonner"; 21 | import { Loader2, Terminal } from "lucide-react"; 22 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 23 | 24 | const registrationSchema = z 25 | .object({ 26 | email: z.string().email(), 27 | password: z.string().min(8), 28 | passwordConfirmation: z.string().min(8), 29 | }) 30 | .refine((data) => data.password === data.passwordConfirmation, { 31 | message: "Passwords don't match", 32 | path: ["passwordConfirmation"], 33 | }); 34 | 35 | type Props = {}; 36 | 37 | const SignUpPage = (props: Props) => { 38 | const form = useForm>({ 39 | resolver: zodResolver(registrationSchema), 40 | defaultValues: { 41 | email: "", 42 | password: "", 43 | passwordConfirmation: "", 44 | }, 45 | }); 46 | const { execute, isPending, error } = useServerAction(signUpAction, { 47 | onError({ err }) { 48 | toast.error("Something went wrong"); 49 | }, 50 | }); 51 | 52 | function onSubmit(values: z.infer) { 53 | execute(values); 54 | } 55 | return ( 56 | 57 |
58 |
59 | 63 | ( 67 | 68 | Email address 69 | 70 | 71 | 72 | 73 | 74 | )} 75 | /> 76 | ( 80 | 81 | Password 82 | 83 | 84 | 85 | 86 | 87 | )} 88 | /> 89 | ( 93 | 94 | Confirm Password 95 | 96 | 101 | 102 | 103 | 104 | )} 105 | /> 106 | {error && ( 107 | 108 | 109 | Uh-oh, we couldn't log you in 110 | {error?.message} 111 | 112 | )} 113 | 121 | 122 | 123 |
124 |
125 | ); 126 | }; 127 | 128 | export default SignUpPage; 129 | -------------------------------------------------------------------------------- /src/app/(root)/page.tsx: -------------------------------------------------------------------------------- 1 | import { signOut } from "@/auth"; 2 | import { Button } from "@/components/ui/button"; 3 | 4 | export default function Home() { 5 | return ( 6 |
7 |
{ 9 | "use server"; 10 | await signOut(); 11 | }} 12 | > 13 | 14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "@/auth" 2 | export const { GET, POST } = handlers -------------------------------------------------------------------------------- /src/app/api/login/magic/route.ts: -------------------------------------------------------------------------------- 1 | import { auth, signIn } from "@/auth"; 2 | import prisma from "@/lib/db"; 3 | import { loginWithMagicLinkUseCase } from "@/use-cases/magic-link"; 4 | import { createSessionUseCase } from "@/use-cases/users"; 5 | import { PrismaAdapter } from "@auth/prisma-adapter"; 6 | import crypto from "crypto"; 7 | 8 | export async function GET(req: Request): Promise { 9 | try { 10 | const url = new URL(req.url); 11 | const token = url.searchParams.get("token"); 12 | if (!token) { 13 | return new Response(null, { 14 | status: 302, 15 | headers: { 16 | Location: "/sign-in/magic/error", 17 | }, 18 | }); 19 | } 20 | const user = await loginWithMagicLinkUseCase(token); 21 | if (!user) { 22 | return new Response(null, { 23 | status: 302, 24 | headers: { 25 | Location: "/sign-in/magic/error", 26 | }, 27 | }); 28 | } 29 | // create a session 30 | await createSessionUseCase(user.id, user.salt); 31 | return new Response(null, { 32 | status: 302, 33 | headers: { 34 | Location: "/", 35 | }, 36 | }); 37 | } catch (error) { 38 | console.error("Error signing in with magic link", error); 39 | return new Response(null, { 40 | status: 302, 41 | headers: { 42 | Location: "/sign-in/magic/error", 43 | }, 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mihir2423/next-js-auth-starter/39a1fc667b788c6fe6acd780673042322f12259f/src/app/favicon.ico -------------------------------------------------------------------------------- /src/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 | --card: 0 0% 100%; 10 | --card-foreground: 222.2 84% 4.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 84% 4.9%; 13 | --primary: 222.2 47.4% 11.2%; 14 | --primary-foreground: 210 40% 98%; 15 | --secondary: 210 40% 96.1%; 16 | --secondary-foreground: 222.2 47.4% 11.2%; 17 | --muted: 210 40% 96.1%; 18 | --muted-foreground: 215.4 16.3% 46.9%; 19 | --accent: 210 40% 96.1%; 20 | --accent-foreground: 222.2 47.4% 11.2%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 210 40% 98%; 23 | --border: 214.3 31.8% 91.4%; 24 | --input: 214.3 31.8% 91.4%; 25 | --ring: 222.2 84% 4.9%; 26 | --radius: 0.5rem; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | } 33 | 34 | .dark { 35 | --background: 222.2 84% 4.9%; 36 | --foreground: 210 40% 98%; 37 | --card: 222.2 84% 4.9%; 38 | --card-foreground: 210 40% 98%; 39 | --popover: 222.2 84% 4.9%; 40 | --popover-foreground: 210 40% 98%; 41 | --primary: 210 40% 98%; 42 | --primary-foreground: 222.2 47.4% 11.2%; 43 | --secondary: 217.2 32.6% 17.5%; 44 | --secondary-foreground: 210 40% 98%; 45 | --muted: 217.2 32.6% 17.5%; 46 | --muted-foreground: 215 20.2% 65.1%; 47 | --accent: 217.2 32.6% 17.5%; 48 | --accent-foreground: 210 40% 98%; 49 | --destructive: 0 62.8% 30.6%; 50 | --destructive-foreground: 210 40% 98%; 51 | --border: 217.2 32.6% 17.5%; 52 | --input: 217.2 32.6% 17.5%; 53 | --ring: 212.7 26.8% 83.9%; 54 | --chart-1: 220 70% 50%; 55 | --chart-2: 160 60% 45%; 56 | --chart-3: 30 80% 55%; 57 | --chart-4: 280 65% 60%; 58 | --chart-5: 340 75% 55%; 59 | } 60 | } 61 | 62 | @layer base { 63 | * { 64 | @apply border-border; 65 | } 66 | body { 67 | @apply bg-background text-foreground; 68 | } 69 | } -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { Toaster } from "@/components/ui/sonner"; 5 | 6 | const inter = Inter({ subsets: ["latin"] }); 7 | 8 | export const metadata: Metadata = { 9 | title: "Create Next App", 10 | description: "Generated by create next app", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: Readonly<{ 16 | children: React.ReactNode; 17 | }>) { 18 | return ( 19 | 20 | 21 | {children} 22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import { PrismaAdapter } from "@auth/prisma-adapter"; 3 | import prisma from "@/lib/db"; 4 | import Google from "next-auth/providers/google"; 5 | import CredentialsProvider from "next-auth/providers/credentials" 6 | 7 | export const { handlers, auth, signIn, signOut } = NextAuth({ 8 | adapter: PrismaAdapter(prisma), 9 | providers: [ 10 | Google, 11 | CredentialsProvider({ 12 | name: "Credentials", 13 | credentials: { 14 | id: { label: "ID", type: "text" }, 15 | salt: { label: "Salt", type: "text" }, 16 | }, 17 | async authorize(credentials) { 18 | if (!credentials?.id || !credentials?.salt) return null; 19 | const user = await prisma.user.findUnique({ 20 | where: { id: credentials.id as string, salt: credentials.salt as string }, 21 | }); 22 | if (!user) return null; 23 | return user; 24 | }, 25 | }), 26 | ], 27 | callbacks: { 28 | async session({ session, user }) { 29 | if (user) { 30 | session.user = user; 31 | } 32 | return session; 33 | }, 34 | }, 35 | session: { 36 | strategy: "jwt", 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /src/components/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Fingerprint } from "lucide-react"; 2 | import Link from "next/link"; 3 | import React from "react"; 4 | 5 | type Props = { 6 | children: React.ReactNode; 7 | type: "Sign-In" | "Sign-Up" | "Forgot Password" | "Reset Password"; 8 | text: string; 9 | }; 10 | 11 | export const AuthLayout = ({ children, type, text }: Props) => { 12 | return ( 13 |
14 |
21 |
28 |
29 | 30 |
31 |

32 | {type}{" "} 33 | {type !== "Forgot Password" && 34 | type !== "Reset Password" && 35 | "to Next App"} 36 |

37 |

{text}

38 |
39 | {children} 40 |
41 |
42 | 43 |

44 | {type === "Sign-In" 45 | ? "Don’t have an account?" 46 | : "Already have an account?"} 47 | 48 | {type === "Sign-In" ? "Sign up" : "Sign in"} 49 | 50 |

51 | 52 |
53 |
54 |
55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | success: 12 | "bg-success text-success-foreground [&>svg]:text-success-foreground", 13 | default: "bg-background dark:text-foreground", 14 | destructive: 15 | "text-black border-destructive/50 dark:bg-destructive dark:text-destructive-foreground dark:border-destructive [&>svg]:text-destructive-foreground", 16 | }, 17 | }, 18 | defaultVariants: { 19 | variant: "default", 20 | }, 21 | } 22 | ); 23 | 24 | const Alert = React.forwardRef< 25 | HTMLDivElement, 26 | React.HTMLAttributes & VariantProps 27 | >(({ className, variant, ...props }, ref) => ( 28 |
34 | )); 35 | Alert.displayName = "Alert"; 36 | 37 | const AlertTitle = React.forwardRef< 38 | HTMLParagraphElement, 39 | React.HTMLAttributes 40 | >(({ className, ...props }, ref) => ( 41 |
46 | )); 47 | AlertTitle.displayName = "AlertTitle"; 48 | 49 | const AlertDescription = React.forwardRef< 50 | HTMLParagraphElement, 51 | React.HTMLAttributes 52 | >(({ className, ...props }, ref) => ( 53 |
58 | )); 59 | AlertDescription.displayName = "AlertDescription"; 60 | 61 | export { Alert, AlertTitle, AlertDescription }; 62 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { Slot } from "@radix-ui/react-slot" 6 | import { 7 | Controller, 8 | ControllerProps, 9 | FieldPath, 10 | FieldValues, 11 | FormProvider, 12 | useFormContext, 13 | } from "react-hook-form" 14 | 15 | import { cn } from "@/lib/utils" 16 | import { Label } from "@/components/ui/label" 17 | 18 | const Form = FormProvider 19 | 20 | type FormFieldContextValue< 21 | TFieldValues extends FieldValues = FieldValues, 22 | TName extends FieldPath = FieldPath 23 | > = { 24 | name: TName 25 | } 26 | 27 | const FormFieldContext = React.createContext( 28 | {} as FormFieldContextValue 29 | ) 30 | 31 | const FormField = < 32 | TFieldValues extends FieldValues = FieldValues, 33 | TName extends FieldPath = FieldPath 34 | >({ 35 | ...props 36 | }: ControllerProps) => { 37 | return ( 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | const useFormField = () => { 45 | const fieldContext = React.useContext(FormFieldContext) 46 | const itemContext = React.useContext(FormItemContext) 47 | const { getFieldState, formState } = useFormContext() 48 | 49 | const fieldState = getFieldState(fieldContext.name, formState) 50 | 51 | if (!fieldContext) { 52 | throw new Error("useFormField should be used within ") 53 | } 54 | 55 | const { id } = itemContext 56 | 57 | return { 58 | id, 59 | name: fieldContext.name, 60 | formItemId: `${id}-form-item`, 61 | formDescriptionId: `${id}-form-item-description`, 62 | formMessageId: `${id}-form-item-message`, 63 | ...fieldState, 64 | } 65 | } 66 | 67 | type FormItemContextValue = { 68 | id: string 69 | } 70 | 71 | const FormItemContext = React.createContext( 72 | {} as FormItemContextValue 73 | ) 74 | 75 | const FormItem = React.forwardRef< 76 | HTMLDivElement, 77 | React.HTMLAttributes 78 | >(({ className, ...props }, ref) => { 79 | const id = React.useId() 80 | 81 | return ( 82 | 83 |
84 | 85 | ) 86 | }) 87 | FormItem.displayName = "FormItem" 88 | 89 | const FormLabel = React.forwardRef< 90 | React.ElementRef, 91 | React.ComponentPropsWithoutRef 92 | >(({ className, ...props }, ref) => { 93 | const { error, formItemId } = useFormField() 94 | 95 | return ( 96 |