23 | 🔐 Auth 24 |
25 |26 | A simple authentication service 27 |
28 |42 | 🔑 Admin 43 |
44 |54 | Admin-only API Route 55 |
56 | 59 |63 | Admin-only Server Action 64 |
65 | 68 |84 | ⚙️ Settings 85 |
86 |26 | A simple authentication service 27 |
28 |26 | {label} 27 |
28 |{message}
16 |{message}
16 |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 | -------------------------------------------------------------------------------- /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.InputHTMLAttributes22 | {label} 23 |
24 |28 | ID 29 |
30 |31 | {user?.id} 32 | 33 |
34 |37 | Name 38 |
39 |40 | {user?.name} 41 |
42 |45 | Email 46 |
47 |48 | {user?.email} 49 |
50 |53 | Role 54 |
55 |56 | {user?.role} 57 |
58 |62 | Two Factor Authentication 63 |
64 |Click here to confirm email.
`, 13 | }); 14 | }; 15 | 16 | // sending password reset email 17 | export const sendPasswordResetEmail = async (email: string, token: string) => { 18 | const resetLink = `${domain}/auth/new-password?token=${token}`; 19 | 20 | await resend.emails.send({ 21 | from: "onboarding@resend.dev", 22 | to: email, 23 | subject: "Reset your password", 24 | html: `Click here to reset password.
`, 25 | }); 26 | }; 27 | 28 | // sending two factor token email 29 | export const sendTwoFactorTokenEmail = async (email: string, token: string) => { 30 | await resend.emails.send({ 31 | from: "onboarding@resend.dev", 32 | to: email, 33 | subject: "2FA Code", 34 | html: `Your 2FA code: ${token}
`, 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /lib/token.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | import { db } from "./database.connection"; 4 | import { getVerificationTokenByEmail } from "./actions/auth/verification-token"; 5 | import { getPasswordResetTokenByEmail } from "./actions/auth/password-reset-token"; 6 | import { getTwoFactorTokenByEmail } from "./actions/auth/two-factor-token"; 7 | export const generateTwoFactorToken = async (email: string) => { 8 | const token = crypto.randomInt(100_000, 1_000_000).toString(); 9 | const expires = new Date(new Date().getTime() + 5 * 60 * 1000); 10 | 11 | const existingToken = await getTwoFactorTokenByEmail(email); 12 | 13 | if (existingToken) { 14 | await db.twoFactorToken.delete({ 15 | where: { 16 | id: existingToken.id, 17 | }, 18 | }); 19 | } 20 | 21 | const twoFactorToken = await db.twoFactorToken.create({ 22 | data: { 23 | email, 24 | token, 25 | expires, 26 | }, 27 | }); 28 | 29 | return twoFactorToken; 30 | }; 31 | 32 | export const generateVerificationToken = async (email: string) => { 33 | const token = uuidv4(); 34 | const expires = new Date(new Date().getTime() + 3600 * 1000); // * expiring in 1 hour 35 | 36 | const existingToken = await getVerificationTokenByEmail(email); 37 | 38 | if (existingToken) { 39 | // * if token exists, delete it 40 | await db.verificationToken.delete({ 41 | where: { 42 | id: existingToken.id, 43 | }, 44 | }); 45 | } 46 | 47 | const verficationToken = await db.verificationToken.create({ 48 | data: { 49 | email, 50 | token, 51 | expires, 52 | }, 53 | }); 54 | 55 | return verficationToken; 56 | }; 57 | 58 | // generating password reset token 59 | export const generatePasswordResetToken = async (email: string) => { 60 | const token = uuidv4(); 61 | const expires = new Date(new Date().getTime() + 3600 * 1000); 62 | 63 | const existingToken = await getPasswordResetTokenByEmail(email); 64 | 65 | if (existingToken) { 66 | await db.passwordResetToken.delete({ 67 | where: { id: existingToken.id }, 68 | }); 69 | } 70 | 71 | const passwordResetToken = await db.passwordResetToken.create({ 72 | data: { 73 | email, 74 | token, 75 | expires, 76 | }, 77 | }); 78 | 79 | return passwordResetToken; 80 | }; 81 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | // * middleware works on the edge 2 | 3 | import authConfig from "./auth.config"; 4 | import NextAuth from "next-auth"; 5 | import { 6 | publicRoutes, 7 | authRoutes, 8 | apiAuthPrefix, 9 | DEFAULT_LOGIN_REDIRECT, 10 | } from "./route"; 11 | const { auth } = NextAuth(authConfig); 12 | 13 | export default auth((req) => { 14 | const { nextUrl } = req; 15 | const isLoggedIn = !!req.auth; 16 | 17 | const isPublicRoute = publicRoutes.includes(nextUrl.pathname); 18 | const isAuthRoute = authRoutes.includes(nextUrl.pathname); 19 | const isApiAuthRoute = nextUrl.pathname.startsWith(apiAuthPrefix); 20 | 21 | // order of if condition matters here 22 | if (isApiAuthRoute) { 23 | return null; 24 | } 25 | 26 | if (isAuthRoute) { 27 | if (isLoggedIn) { 28 | return Response.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl)); 29 | } else { 30 | return null; 31 | } 32 | } 33 | 34 | if (!isLoggedIn && !isPublicRoute) { 35 | // this is done to redirect to the same page after login 36 | let callbackUrl = nextUrl.pathname; 37 | if (nextUrl.search) { 38 | callbackUrl += nextUrl.search; 39 | } 40 | 41 | const encodedCallbackUrl = encodeURIComponent(callbackUrl); 42 | 43 | return Response.redirect( 44 | new URL(`/auth/login?callbackUrl=${encodedCallbackUrl}`, nextUrl) 45 | ); 46 | } 47 | return null; 48 | }); 49 | 50 | // Optionally, don't invoke Middleware on some paths 51 | export const config = { 52 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"], 53 | }; 54 | -------------------------------------------------------------------------------- /next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from "@prisma/client"; 2 | import NextAuth from "next-auth"; 3 | 4 | export type ExtendedUser = DefaultSession["user"] & { 5 | role: UserRole; 6 | isTwoFacorEnabled: boolean; 7 | isOAuth: boolean; 8 | }; 9 | 10 | declare module "next-auth" { 11 | interface Session { 12 | user: ExtendedUser; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-auth-v5", 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 | "postinstall": "prisma generate" 11 | }, 12 | "dependencies": { 13 | "@auth/prisma-adapter": "^1.0.13", 14 | "@hookform/resolvers": "^3.3.3", 15 | "@prisma/client": "^5.7.1", 16 | "@radix-ui/react-avatar": "^1.0.4", 17 | "@radix-ui/react-dialog": "^1.0.5", 18 | "@radix-ui/react-dropdown-menu": "^2.0.6", 19 | "@radix-ui/react-icons": "^1.3.0", 20 | "@radix-ui/react-label": "^2.0.2", 21 | "@radix-ui/react-select": "^2.0.0", 22 | "@radix-ui/react-slot": "^1.0.2", 23 | "@radix-ui/react-switch": "^1.0.3", 24 | "@types/bcryptjs": "^2.4.6", 25 | "babel-preset-es2015": "^6.24.1", 26 | "bcryptjs": "^2.4.3", 27 | "class-variance-authority": "^0.7.0", 28 | "clsx": "^2.1.0", 29 | "next": "14.0.4", 30 | "next-auth": "^5.0.0-beta.4", 31 | "next-themes": "^0.2.1", 32 | "react": "^18", 33 | "react-dom": "^18", 34 | "react-hook-form": "^7.49.2", 35 | "react-icons": "^4.12.0", 36 | "react-spinners": "^0.13.8", 37 | "resend": "^2.1.0", 38 | "sonner": "^1.4.0", 39 | "tailwind-merge": "^2.2.0", 40 | "tailwindcss-animate": "^1.0.7", 41 | "uuid": "^9.0.1", 42 | "zod": "^3.22.4" 43 | }, 44 | "devDependencies": { 45 | "@types/node": "^20", 46 | "@types/react": "^18", 47 | "@types/react-dom": "^18", 48 | "@types/uuid": "^9.0.7", 49 | "autoprefixer": "^10.0.1", 50 | "eslint": "^8", 51 | "eslint-config-next": "14.0.4", 52 | "postcss": "^8", 53 | "prisma": "^5.7.1", 54 | "tailwindcss": "^3.3.0", 55 | "typescript": "^5" 56 | } 57 | } -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /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 | // prisma/schema.prisma 9 | datasource db { 10 | provider = "postgresql" 11 | url = env("DATABASE_URL") 12 | directUrl = env("DIRECT_URL") 13 | } 14 | 15 | enum UserRole{ 16 | ADMIN 17 | USER 18 | } 19 | 20 | 21 | model User { 22 | id String @id @default(cuid()) 23 | name String? 24 | email String? @unique 25 | emailVerified DateTime? 26 | image String? 27 | password String? 28 | accounts Account[] 29 | role UserRole @default(USER) 30 | isTwoFactorEnabled Boolean @default(false) 31 | twoFactorConfirmation TwoFactorConfirmation? 32 | } 33 | 34 | 35 | 36 | model Account { 37 | id String @id @default(cuid()) 38 | userId String 39 | type String 40 | provider String 41 | providerAccountId String 42 | refresh_token String? @db.Text 43 | access_token String? @db.Text 44 | expires_at Int? 45 | token_type String? 46 | scope String? 47 | id_token String? @db.Text 48 | session_state String? 49 | 50 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 51 | 52 | @@unique([provider, providerAccountId]) 53 | } 54 | 55 | 56 | // * for email verifiication (64) 57 | model VerificationToken { 58 | id String @id @default(cuid()) 59 | email String 60 | token String @unique 61 | expires DateTime 62 | @@unique([email, token]) // only unique token for specific email 63 | } 64 | 65 | // * for password reset 66 | model PasswordResetToken { 67 | id String @id @default(cuid()) 68 | email String 69 | token String @unique 70 | expires DateTime 71 | 72 | @@unique([email, token]) 73 | } 74 | 75 | model TwoFactorToken { 76 | id String @id @default(cuid()) 77 | email String 78 | token String @unique 79 | expires DateTime 80 | 81 | @@unique([email, token]) 82 | } 83 | 84 | model TwoFactorConfirmation { 85 | id String @id @default(cuid()) 86 | 87 | userId String 88 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 89 | 90 | @@unique([userId]) 91 | } -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /route.ts: -------------------------------------------------------------------------------- 1 | // * an array of roues that are public 2 | // * These roues do not require authentication 3 | // @ @type {string[]} 4 | export const publicRoutes = ["/", "/auth/new-verification"]; 5 | 6 | // * an array of roues that are used for authentication 7 | // * These routes will redirect logged in users to /settings 8 | // @ @type {string[]} 9 | export const authRoutes = [ 10 | "/auth/login", 11 | "/auth/register", 12 | "/auth/error", 13 | "/auth/reset", 14 | "/auth/new-password", 15 | ]; 16 | 17 | /** 18 | * The prefix for API authentication routes 19 | * Routes that start with this prefix are used for API authentication purposes 20 | * @type {string} 21 | */ 22 | export const apiAuthPrefix = "/api/auth"; 23 | 24 | /** 25 | * The default redirect path after logging in 26 | * @type {string} 27 | */ 28 | export const DEFAULT_LOGIN_REDIRECT = "/settings"; 29 | -------------------------------------------------------------------------------- /schema/index.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from "@prisma/client"; 2 | import * as z from "zod"; 3 | 4 | export const LoginSchema = z.object({ 5 | email: z.string().email({ 6 | message: "Email is required", 7 | }), 8 | password: z.string().min(1, { 9 | message: "Password is required", 10 | }), 11 | code: z.optional(z.string()), 12 | }); 13 | 14 | export const RegisterSchema = z.object({ 15 | email: z.string().email({ 16 | message: "Email is required", 17 | }), 18 | password: z.string().min(6, { 19 | message: "Minimum 6 characters required", 20 | }), 21 | name: z.string().min(1, { 22 | message: "Name is required", 23 | }), 24 | }); 25 | 26 | // reset schema 27 | export const ResetSchema = z.object({ 28 | email: z.string().email({ 29 | message: "Email is required", 30 | }), 31 | }); 32 | 33 | // new password schema 34 | export const NewPasswordSchema = z.object({ 35 | password: z.string().min(6, { 36 | message: "Minimum of 6 characters required", 37 | }), 38 | }); 39 | 40 | 41 | // settings page schema 42 | export const SettingsSchema = z.object({ 43 | name: z.optional(z.string()), 44 | isTwoFactorEnabled: z.optional(z.boolean()), 45 | role: z.enum([UserRole.ADMIN, UserRole.USER]), 46 | email: z.optional(z.string().email()), 47 | password: z.optional(z.string().min(6)), 48 | newPassword: z.optional(z.string().min(6)), 49 | }); -------------------------------------------------------------------------------- /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 | "custom-teal": "#30cfd0", 23 | "custom-indigo": "#330867", 24 | border: "hsl(var(--border))", 25 | input: "hsl(var(--input))", 26 | ring: "hsl(var(--ring))", 27 | background: "hsl(var(--background))", 28 | foreground: "hsl(var(--foreground))", 29 | primary: { 30 | DEFAULT: "hsl(var(--primary))", 31 | foreground: "hsl(var(--primary-foreground))", 32 | }, 33 | secondary: { 34 | DEFAULT: "hsl(var(--secondary))", 35 | foreground: "hsl(var(--secondary-foreground))", 36 | }, 37 | destructive: { 38 | DEFAULT: "hsl(var(--destructive))", 39 | foreground: "hsl(var(--destructive-foreground))", 40 | }, 41 | muted: { 42 | DEFAULT: "hsl(var(--muted))", 43 | foreground: "hsl(var(--muted-foreground))", 44 | }, 45 | accent: { 46 | DEFAULT: "hsl(var(--accent))", 47 | foreground: "hsl(var(--accent-foreground))", 48 | }, 49 | popover: { 50 | DEFAULT: "hsl(var(--popover))", 51 | foreground: "hsl(var(--popover-foreground))", 52 | }, 53 | card: { 54 | DEFAULT: "hsl(var(--card))", 55 | foreground: "hsl(var(--card-foreground))", 56 | }, 57 | }, 58 | borderRadius: { 59 | lg: "var(--radius)", 60 | md: "calc(var(--radius) - 2px)", 61 | sm: "calc(var(--radius) - 4px)", 62 | }, 63 | keyframes: { 64 | "accordion-down": { 65 | from: { height: "0" }, 66 | to: { height: "var(--radix-accordion-content-height)" }, 67 | }, 68 | "accordion-up": { 69 | from: { height: "var(--radix-accordion-content-height)" }, 70 | to: { height: "0" }, 71 | }, 72 | }, 73 | animation: { 74 | "accordion-down": "accordion-down 0.2s ease-out", 75 | "accordion-up": "accordion-up 0.2s ease-out", 76 | }, 77 | backgroundImage: { 78 | "custom-gradient": "linear-gradient(to top, #30cfd0 0%, #330867 100%)", 79 | }, 80 | }, 81 | }, 82 | plugins: [require("tailwindcss-animate")], 83 | } satisfies Config; 84 | 85 | export default config -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | output: { 5 | filename: "my-first-webpack.bundle.js", 6 | }, 7 | module: { 8 | rules: [ 9 | { 10 | test: /node_modules\/@mapbox\/node-pre-gyp\/lib\/util\/nw-pre-gyp\/index.html$/, 11 | use: "null-loader", 12 | }, 13 | ], 14 | }, 15 | }; 16 | --------------------------------------------------------------------------------