├── .eslintrc.json ├── app ├── api │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ └── admin │ │ └── route.ts ├── favicon.ico ├── auth │ ├── login │ │ └── page.tsx │ ├── reset │ │ └── page.tsx │ ├── error │ │ └── page.tsx │ ├── register │ │ └── page.tsx │ ├── new-password │ │ └── page.tsx │ ├── new-verification │ │ └── page.tsx │ └── layout.tsx ├── (protected) │ ├── server │ │ └── page.tsx │ ├── client │ │ └── page.tsx │ ├── layout.tsx │ ├── _components │ │ └── navbar.tsx │ ├── admin │ │ └── page.tsx │ └── settings │ │ └── page.tsx ├── layout.tsx ├── page.tsx └── globals.css ├── next.config.js ├── postcss.config.js ├── actions ├── logout.ts ├── admin.ts ├── reset.ts ├── new-verification.ts ├── register.ts ├── new-password.ts ├── settings.ts └── login.ts ├── hooks ├── use-current-user.ts └── use-current-role.ts ├── lib ├── utils.ts ├── db.ts ├── auth.ts ├── mail.ts └── token.ts ├── data ├── account.ts ├── two-factor-confirmation.ts ├── user.ts ├── two-factor-token.ts ├── verification-token.ts └── password-reset-token.ts ├── next-auth.d.ts ├── components.json ├── components ├── auth │ ├── logout-button.tsx │ ├── back-button.tsx │ ├── error-card.tsx │ ├── header.tsx │ ├── role-gate.tsx │ ├── login-button.tsx │ ├── card-wrapper.tsx │ ├── social.tsx │ ├── user-button.tsx │ ├── new-verification-form.tsx │ ├── reset-form.tsx │ ├── new-password-form.tsx │ ├── register-form.tsx │ └── login-form.tsx ├── form-success.tsx ├── form-error.tsx ├── ui │ ├── label.tsx │ ├── input.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── badge.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── form.tsx │ ├── select.tsx │ └── dropdown-menu.tsx └── user-info.tsx ├── .gitignore ├── public ├── vercel.svg └── next.svg ├── tsconfig.json ├── routes.ts ├── auth.config.ts ├── middleware.ts ├── README.md ├── package.json ├── schemas └── index.ts ├── prisma └── schema.prisma ├── tailwind.config.ts └── auth.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from "@/auth"; 2 | 3 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hafisn07/next-auth-v5-advanced-guide-2024/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /actions/logout.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { signOut } from "@/auth"; 4 | 5 | export const logout = async () => { 6 | await signOut(); 7 | }; 8 | -------------------------------------------------------------------------------- /hooks/use-current-user.ts: -------------------------------------------------------------------------------- 1 | import { useSession } from "next-auth/react"; 2 | 3 | export const useCurrentUser = () => { 4 | const session = useSession(); 5 | 6 | return session.data?.user; 7 | }; 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /hooks/use-current-role.ts: -------------------------------------------------------------------------------- 1 | import { useSession } from "next-auth/react"; 2 | 3 | export const useCurrentRole = () => { 4 | const session = useSession(); 5 | 6 | return session.data?.user?.role; 7 | }; 8 | -------------------------------------------------------------------------------- /app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginForm } from "@/components/auth/login-form"; 2 | 3 | const LoginPage = () => { 4 | return ( 5 | 6 | ); 7 | }; 8 | 9 | export default LoginPage; 10 | -------------------------------------------------------------------------------- /app/auth/reset/page.tsx: -------------------------------------------------------------------------------- 1 | import { ResetForm } from "@/components/auth/reset-form"; 2 | 3 | const ResetPage = () => { 4 | return ( 5 | 6 | ); 7 | }; 8 | 9 | export default ResetPage; 10 | -------------------------------------------------------------------------------- /app/auth/error/page.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorCard } from "@/components/auth/error-card"; 2 | 3 | const AuthErrorPage = () => { 4 | return ( 5 | 6 | ); 7 | }; 8 | 9 | export default AuthErrorPage; 10 | -------------------------------------------------------------------------------- /app/auth/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { RegisterForm } from "@/components/auth/register-form"; 2 | 3 | const RegisterPage = () => { 4 | return ( 5 | 6 | ); 7 | }; 8 | 9 | export default RegisterPage; 10 | -------------------------------------------------------------------------------- /app/auth/new-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { NewPasswordForm } from "@/components/auth/new-password-form"; 2 | 3 | const NewPasswordPage = () => { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | export default NewPasswordPage; -------------------------------------------------------------------------------- /app/auth/new-verification/page.tsx: -------------------------------------------------------------------------------- 1 | import { NewVerificationForm } from "@/components/auth/new-verification-form"; 2 | 3 | const NewVerificationPage = () => { 4 | return ; 5 | }; 6 | 7 | export default NewVerificationPage; 8 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | declare global { 4 | var prisma: PrismaClient | undefined; 5 | } 6 | 7 | export const db = globalThis.prisma || new PrismaClient(); 8 | 9 | if (process.env.NODE_ENV !== "production") globalThis.prisma; 10 | -------------------------------------------------------------------------------- /lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth"; 2 | 3 | export const currentUser = async () => { 4 | const session = await auth(); 5 | 6 | return session?.user; 7 | }; 8 | 9 | export const currentRole = async () => { 10 | const session = await auth(); 11 | 12 | return session?.user?.role; 13 | }; 14 | -------------------------------------------------------------------------------- /data/account.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export const getAccountByUserId = async (userId: string) => { 4 | try { 5 | const account = await db.account.findFirst({ 6 | where: { userId }, 7 | }); 8 | 9 | return account; 10 | } catch { 11 | return null; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /app/(protected)/server/page.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from "@/lib/auth"; 2 | import { UserInfo } from "@/components/user-info"; 3 | 4 | const ServerPage = async () => { 5 | const user = await currentUser(); 6 | 7 | return ; 8 | }; 9 | 10 | export default ServerPage; 11 | -------------------------------------------------------------------------------- /app/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | const AuthLayout = ({ children }: { children: React.ReactNode }) => { 2 | return ( 3 |
4 | {children} 5 |
6 | ); 7 | }; 8 | 9 | export default AuthLayout; 10 | -------------------------------------------------------------------------------- /app/(protected)/client/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCurrentUser } from "@/hooks/use-current-user"; 4 | import { UserInfo } from "@/components/user-info"; 5 | 6 | const ClientPage = () => { 7 | const user = useCurrentUser(); 8 | 9 | return ; 10 | }; 11 | 12 | export default ClientPage; 13 | -------------------------------------------------------------------------------- /data/two-factor-confirmation.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export const getTwoFactorConfirmationByUserId = async (userId: string) => { 4 | try { 5 | const twoFactorConfirmation = await db.twoFactorConfirmation.findUnique({ 6 | where: { userId }, 7 | }); 8 | 9 | return twoFactorConfirmation; 10 | } catch { 11 | return null; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from "@prisma/client"; 2 | import NextAuth, { type DefaultSession } from "next-auth"; 3 | 4 | export type ExtendedUser = DefaultSession["user"] & { 5 | role: UserRole; 6 | isTwoFactorEnabled: boolean; 7 | isOAuth: boolean; 8 | }; 9 | 10 | declare module "next-auth" { 11 | interface Session { 12 | user: ExtendedUser; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /actions/admin.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { currentRole } from "@/lib/auth"; 4 | import { UserRole } from "@prisma/client"; 5 | 6 | export const admin = async () => { 7 | const role = await currentRole(); 8 | 9 | if (role === UserRole.ADMIN) { 10 | return { success: "Allowed Server Action!" }; 11 | } 12 | 13 | return { error: "Forbidden Server Action!" }; 14 | }; 15 | -------------------------------------------------------------------------------- /app/api/admin/route.ts: -------------------------------------------------------------------------------- 1 | import { currentRole } from "@/lib/auth"; 2 | import { UserRole } from "@prisma/client"; 3 | import { NextResponse } from "next/server"; 4 | 5 | export async function GET() { 6 | const role = await currentRole(); 7 | 8 | if (role === UserRole.ADMIN) { 9 | return new NextResponse(null, { status: 200 }); 10 | } 11 | 12 | return new NextResponse(null, { status: 403 }); 13 | } 14 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 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/logout-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { logout } from "@/actions/logout"; 4 | 5 | interface LogoutButtonProps { 6 | children?: React.ReactNode; 7 | } 8 | 9 | export const LogoutButton = ({ children }: LogoutButtonProps) => { 10 | const onClick = () => { 11 | logout(); 12 | }; 13 | 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /components/auth/back-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | 7 | interface BackButtonProps { 8 | href: string; 9 | label: string; 10 | } 11 | 12 | export const BackButton = ({ href, label }: BackButtonProps) => { 13 | return ( 14 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /data/user.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export const getUserByEmail = async (email: string) => { 4 | try { 5 | const user = await db.user.findUnique({ where: { email } }); 6 | 7 | return user; 8 | } catch { 9 | return null; 10 | } 11 | }; 12 | 13 | export const getUserById = async (id: string) => { 14 | try { 15 | const user = await db.user.findUnique({ where: { id } }); 16 | 17 | return user; 18 | } catch { 19 | return null; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /components/form-success.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircledIcon } from "@radix-ui/react-icons"; 2 | 3 | interface FormSuccessProps { 4 | message?: string; 5 | } 6 | 7 | export const FormSuccess = ({ message }: FormSuccessProps) => { 8 | if (!message) return null; 9 | 10 | return ( 11 |
12 | 13 |

{message}

14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /components/form-error.tsx: -------------------------------------------------------------------------------- 1 | import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; 2 | 3 | interface FormErrorProps { 4 | message?: string; 5 | } 6 | 7 | export const FormError = ({ message }: FormErrorProps) => { 8 | if (!message) return null; 9 | 10 | return ( 11 |
12 | 13 |

{message}

14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /app/(protected)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "./_components/navbar"; 2 | 3 | interface ProtectedLayoutProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | const ProtectedLayout = ({ children }: ProtectedLayoutProps) => { 8 | return ( 9 |
10 | 11 | {children} 12 |
13 | ); 14 | }; 15 | 16 | export default ProtectedLayout; 17 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /components/auth/error-card.tsx: -------------------------------------------------------------------------------- 1 | import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; 2 | import { CardWrapper } from "@/components/auth/card-wrapper"; 3 | 4 | export const ErrorCard = () => { 5 | return ( 6 | 11 |
12 | 13 |
14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /components/auth/header.tsx: -------------------------------------------------------------------------------- 1 | import { Poppins } from "next/font/google"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const font = Poppins({ 6 | subsets: ["latin"], 7 | weight: ["600"], 8 | }); 9 | 10 | interface HeaderProps { 11 | label: string; 12 | } 13 | 14 | export const Header = ({ label }: HeaderProps) => { 15 | return ( 16 |
17 |

🔐 Auth

18 |

{label}

19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/two-factor-token.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export const getTwoFactorTokenByToken = async (token: string) => { 4 | try { 5 | const twoFactorToken = await db.twoFactorToken.findUnique({ 6 | where: { token }, 7 | }); 8 | 9 | return twoFactorToken; 10 | } catch { 11 | return null; 12 | } 13 | }; 14 | 15 | export const getTwoFactorTokenByEmail = async (email: string) => { 16 | try { 17 | const twoFactorToken = await db.twoFactorToken.findFirst({ 18 | where: { email }, 19 | }); 20 | 21 | return twoFactorToken; 22 | } catch { 23 | return null; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /components/auth/role-gate.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { UserRole } from "@prisma/client"; 4 | 5 | import { useCurrentRole } from "@/hooks/use-current-role"; 6 | import { FormError } from "@/components/form-error"; 7 | 8 | interface RoleGateProps { 9 | children: React.ReactNode; 10 | allowedRole: UserRole; 11 | } 12 | 13 | export const RoleGate = ({ children, allowedRole }: RoleGateProps) => { 14 | const role = useCurrentRole(); 15 | 16 | if (role !== allowedRole) { 17 | return ( 18 | 19 | ); 20 | } 21 | 22 | return <>{children}; 23 | }; 24 | -------------------------------------------------------------------------------- /data/verification-token.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export const getVerificationTokenByToken = async (token: string) => { 4 | try { 5 | const verificationToken = await db.verificationToken.findUnique({ 6 | where: { token }, 7 | }); 8 | 9 | return verificationToken; 10 | } catch { 11 | return null; 12 | } 13 | }; 14 | 15 | export const getVerificationTokenByEmail = async (email: string) => { 16 | try { 17 | const verificationToken = await db.verificationToken.findFirst({ 18 | where: { email }, 19 | }); 20 | 21 | return verificationToken; 22 | } catch { 23 | return null; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /data/password-reset-token.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export const getPasswordResetTokenByToken = async (token: string) => { 4 | try { 5 | const passwordResetToken = await db.passwordResetToken.findUnique({ 6 | where: { token }, 7 | }); 8 | 9 | return passwordResetToken; 10 | } catch { 11 | return null; 12 | } 13 | }; 14 | 15 | export const getPasswordResetokenByEmail = async (email: string) => { 16 | try { 17 | const passwordResetToken = await db.passwordResetToken.findFirst({ 18 | where: { email }, 19 | }); 20 | 21 | return passwordResetToken; 22 | } catch { 23 | return null; 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { auth } from "@/auth"; 5 | import { SessionProvider } from "next-auth/react"; 6 | import { Toaster } from "@/components/ui/sonner"; 7 | 8 | const inter = Inter({ subsets: ["latin"] }); 9 | 10 | export const metadata: Metadata = { 11 | title: "Create Next App", 12 | description: "Generated by create next app", 13 | }; 14 | 15 | export default async function RootLayout({ 16 | children, 17 | }: { 18 | children: React.ReactNode; 19 | }) { 20 | const session = await auth(); 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | {children} 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /routes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An array of routes that are accessible to the public. 3 | * These routes do not require authentication. 4 | * @type {string[]} 5 | */ 6 | export const publicRoutes = ["/", "/auth/new-verification"]; 7 | 8 | /** 9 | * An array of routes that are used for authentication. 10 | * These routes will redirect logged in users to /settings. 11 | * @type {string[]} 12 | */ 13 | export const authRoutes = [ 14 | "/auth/login", 15 | "/auth/register", 16 | "/auth/error", 17 | "/auth/reset", 18 | "/auth/new-password", 19 | ]; 20 | 21 | /** 22 | * The prefix for api authentication routes. 23 | * Routes that starts with this prefix are used for API authentication purposes. 24 | * @type {string} 25 | */ 26 | export const apiAuthPrefix = "/api/auth"; 27 | 28 | /** 29 | * The default redirect path after a successful login. 30 | * @type {string} 31 | */ 32 | export const DEFAULT_LOGIN_REDIRECT = "/settings"; 33 | -------------------------------------------------------------------------------- /actions/reset.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import * as z from "zod"; 4 | 5 | import { ResetSchema } from "@/schemas"; 6 | import { getUserByEmail } from "@/data/user"; 7 | import { sendPasswordResetEmail } from "@/lib/mail"; 8 | import { generatePasswordResetToken } from "@/lib/token"; 9 | 10 | export const reset = async (values: z.infer) => { 11 | const validateFields = ResetSchema.safeParse(values); 12 | 13 | if (!validateFields.success) { 14 | return { error: "Invalid email!" }; 15 | } 16 | 17 | const { email } = validateFields.data; 18 | 19 | const existingUser = await getUserByEmail(email); 20 | 21 | if (!existingUser) { 22 | return { error: "Email not found!" }; 23 | } 24 | 25 | const passwordResetToken = await generatePasswordResetToken(email); 26 | await sendPasswordResetEmail( 27 | passwordResetToken.email, 28 | passwordResetToken.token 29 | ); 30 | 31 | return { success: "Reset email sent!" }; 32 | }; 33 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner } from "sonner" 5 | 6 | type ToasterProps = React.ComponentProps 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme() 10 | 11 | return ( 12 | 28 | ) 29 | } 30 | 31 | export { Toaster } 32 | -------------------------------------------------------------------------------- /components/auth/login-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | 5 | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; 6 | import { LoginForm } from "@/components/auth/login-form"; 7 | 8 | interface LoginButtonProps { 9 | children: React.ReactNode; 10 | mode?: "modal" | "redirect"; 11 | asChild?: boolean; 12 | } 13 | 14 | export const LoginButton = ({ 15 | children, 16 | mode = "redirect", 17 | asChild, 18 | }: LoginButtonProps) => { 19 | const router = useRouter(); 20 | 21 | const onClick = () => { 22 | router.push("/auth/login"); 23 | }; 24 | 25 | if (mode === "modal") { 26 | return ( 27 | 28 | {children} 29 | 30 | 31 | 32 | 33 | ); 34 | } 35 | 36 | return ( 37 | 38 | {children} 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /actions/new-verification.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getUserByEmail } from "@/data/user"; 4 | import { getVerificationTokenByToken } from "@/data/verification-token"; 5 | import { db } from "@/lib/db"; 6 | 7 | export const newVerification = async (token: string) => { 8 | const existingToken = await getVerificationTokenByToken(token); 9 | 10 | if (!existingToken) { 11 | return { error: "Token does not exists!" }; 12 | } 13 | 14 | const hasExpired = new Date(existingToken.expires) < new Date(); 15 | 16 | if (hasExpired) { 17 | return { error: "Token expired!" }; 18 | } 19 | 20 | const existingUser = await getUserByEmail(existingToken.email); 21 | 22 | if (!existingUser) { 23 | return { error: "Email does not exists!" }; 24 | } 25 | 26 | await db.user.update({ 27 | where: { id: existingUser.id }, 28 | data: { 29 | emailVerified: new Date(), 30 | email: existingToken.email, 31 | }, 32 | }); 33 | 34 | await db.verificationToken.delete({ 35 | where: { id: existingToken.id }, 36 | }); 37 | 38 | return { success: "Email verified!" }; 39 | }; 40 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Poppins } from "next/font/google"; 2 | import { cn } from "@/lib/utils"; 3 | 4 | import { Button } from "@/components/ui/button"; 5 | import { LoginButton } from "@/components/auth/login-button"; 6 | 7 | const font = Poppins({ 8 | subsets: ["latin"], 9 | weight: ["600"], 10 | }); 11 | 12 | export default function Home() { 13 | return ( 14 |
15 |
16 |

22 | 🔐 Auth 23 |

24 |

A simple authentication service!

25 |
26 | 27 | 30 | 31 |
32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /components/auth/card-wrapper.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Card, 5 | CardContent, 6 | CardFooter, 7 | CardHeader, 8 | } from "@/components/ui/card"; 9 | import { Header } from "@/components/auth/header"; 10 | import { Social } from "@/components/auth/social"; 11 | import { BackButton } from "@/components/auth/back-button"; 12 | 13 | interface CardWrapperProps { 14 | children: React.ReactNode; 15 | headerLabel: string; 16 | backButtonLabel: string; 17 | backButtonHref: string; 18 | showSocial?: boolean; 19 | } 20 | 21 | export const CardWrapper = ({ 22 | children, 23 | headerLabel, 24 | backButtonLabel, 25 | backButtonHref, 26 | showSocial, 27 | }: CardWrapperProps) => { 28 | return ( 29 | 30 | 31 |
32 | 33 | {children} 34 | {showSocial && ( 35 | 36 | 37 | 38 | )} 39 | 40 | 41 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /components/auth/social.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { signIn } from "next-auth/react"; 4 | import { FcGoogle } from "react-icons/fc"; 5 | import { FaGithub } from "react-icons/fa"; 6 | import { useSearchParams } from "next/navigation"; 7 | 8 | import { Button } from "@/components/ui/button"; 9 | import { DEFAULT_LOGIN_REDIRECT } from "@/routes"; 10 | 11 | export const Social = () => { 12 | const searchParams = useSearchParams(); 13 | const callbackUrl = searchParams.get("callbackUrl"); 14 | 15 | const onClick = (provider: "google" | "github") => { 16 | signIn(provider, { 17 | callbackUrl: callbackUrl || DEFAULT_LOGIN_REDIRECT, 18 | }); 19 | }; 20 | 21 | return ( 22 |
23 | 31 | 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /lib/mail.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from "resend"; 2 | 3 | const resend = new Resend(process.env.RESEND_API_KEY); 4 | 5 | const domain = process.env.NEXT_PUBLIC_APP_URL; 6 | 7 | export const sendTwoFactorTokenEmail = async (email: string, token: string) => { 8 | await resend.emails.send({ 9 | from: "onboarding@resend.dev", 10 | to: email, 11 | subject: "2FA code", 12 | html: `

Your 2FA code is ${token}

`, 13 | }); 14 | }; 15 | 16 | export const sendPasswordResetEmail = async (email: string, token: string) => { 17 | const resetLink = `${domain}/auth/new-password?token=${token}`; 18 | 19 | await resend.emails.send({ 20 | from: "onboarding@resend.dev", 21 | to: email, 22 | subject: "Reset your password", 23 | html: `

Click here to reset your password.

`, 24 | }); 25 | }; 26 | 27 | export const sendVerificationEmail = async (email: string, token: string) => { 28 | const confirmLink = `${domain}/auth/new-verification?token=${token}`; 29 | 30 | await resend.emails.send({ 31 | from: "onboarding@resend.dev", 32 | to: email, 33 | subject: "Confirm your email", 34 | html: `

Click here to confirm email.

`, 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /actions/register.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import * as z from "zod"; 4 | import bcrypt from "bcryptjs"; 5 | import { db } from "@/lib/db"; 6 | import { RegisterSchema } from "@/schemas"; 7 | import { getUserByEmail } from "@/data/user"; 8 | import { generateVerificationToken } from "@/lib/token"; 9 | import { sendVerificationEmail } from "@/lib/mail"; 10 | 11 | export const register = async (values: z.infer) => { 12 | const validatedFields = RegisterSchema.safeParse(values); 13 | 14 | if (!validatedFields.success) { 15 | return { error: "Invalid fields" }; 16 | } 17 | 18 | const { email, password, name } = validatedFields.data; 19 | const hashedPassword = await bcrypt.hash(password, 10); 20 | 21 | const existingUser = await getUserByEmail(email); 22 | 23 | if (existingUser) { 24 | return { error: "Email already in use!" }; 25 | } 26 | 27 | await db.user.create({ 28 | data: { 29 | name, 30 | email, 31 | password: hashedPassword, 32 | }, 33 | }); 34 | 35 | const verificationToken = await generateVerificationToken(email); 36 | await sendVerificationEmail(verificationToken.email, verificationToken.token); 37 | 38 | return { success: "Confirmation email sent!" }; 39 | }; 40 | -------------------------------------------------------------------------------- /components/auth/user-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FaUser } from "react-icons/fa"; 4 | import { ExitIcon } from "@radix-ui/react-icons"; 5 | 6 | import { 7 | DropdownMenu, 8 | DropdownMenuContent, 9 | DropdownMenuItem, 10 | DropdownMenuTrigger, 11 | } from "@/components/ui/dropdown-menu"; 12 | import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 13 | import { useCurrentUser } from "@/hooks/use-current-user"; 14 | import { LogoutButton } from "@/components/auth/logout-button"; 15 | 16 | export const UserButton = () => { 17 | const user = useCurrentUser(); 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | Logout 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcryptjs"; 2 | import type { NextAuthConfig } from "next-auth"; 3 | import Credentials from "next-auth/providers/credentials"; 4 | import Github from "next-auth/providers/github"; 5 | import Google from "next-auth/providers/google"; 6 | 7 | import { LoginSchema } from "@/schemas"; 8 | import { getUserByEmail } from "@/data/user"; 9 | 10 | export default { 11 | providers: [ 12 | Google({ 13 | clientId: process.env.GOOGLE_CLIENT_ID, 14 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 15 | }), 16 | Github({ 17 | clientId: process.env.GITHUB_CLIENT_ID, 18 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 19 | }), 20 | Credentials({ 21 | async authorize(credentials) { 22 | const validatedFields = LoginSchema.safeParse(credentials); 23 | 24 | if (validatedFields.success) { 25 | const { email, password } = validatedFields.data; 26 | 27 | const user = await getUserByEmail(email); 28 | if (!user || !user.password) return null; 29 | 30 | const passwordsMatch = await bcrypt.compare(password, user.password); 31 | 32 | if (passwordsMatch) return user; 33 | } 34 | 35 | return null; 36 | }, 37 | }), 38 | ], 39 | } satisfies NextAuthConfig; 40 | -------------------------------------------------------------------------------- /app/(protected)/_components/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { UserButton } from "@/components/auth/user-button"; 8 | 9 | export const Navbar = () => { 10 | const pathname = usePathname(); 11 | 12 | return ( 13 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/ui/badge.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 badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | success: "border-transparent bg-emerald-500 text-primary-foreground", 19 | }, 20 | }, 21 | defaultVariants: { 22 | variant: "default", 23 | }, 24 | } 25 | ) 26 | 27 | export interface BadgeProps 28 | extends React.HTMLAttributes, 29 | VariantProps {} 30 | 31 | function Badge({ className, variant, ...props }: BadgeProps) { 32 | return ( 33 |
34 | ) 35 | } 36 | 37 | export { Badge, badgeVariants } 38 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | 3 | import authConfig from "@/auth.config"; 4 | import { 5 | DEFAULT_LOGIN_REDIRECT, 6 | apiAuthPrefix, 7 | authRoutes, 8 | publicRoutes, 9 | } from "@/routes"; 10 | 11 | const { auth } = NextAuth(authConfig); 12 | 13 | export default auth((req) => { 14 | const { nextUrl } = req; 15 | const isLoggedIn = !!req.auth; 16 | 17 | const isApiAuthRoute = nextUrl.pathname.startsWith(apiAuthPrefix); 18 | const isPublicRoute = publicRoutes.includes(nextUrl.pathname); 19 | const isAuthRoute = authRoutes.includes(nextUrl.pathname); 20 | 21 | if (isApiAuthRoute) { 22 | return null; 23 | } 24 | 25 | if (isAuthRoute) { 26 | if (isLoggedIn) { 27 | return Response.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl)); 28 | } 29 | 30 | return null; 31 | } 32 | 33 | if (!isLoggedIn && !isPublicRoute) { 34 | let callbackUrl = nextUrl.pathname; 35 | if (nextUrl.search) { 36 | callbackUrl += nextUrl.search; 37 | } 38 | 39 | const encodedCallbackUrl = encodeURIComponent(callbackUrl); 40 | 41 | return Response.redirect( 42 | new URL(`/auth/login?callbackUrl=${encodedCallbackUrl}`, nextUrl) 43 | ); 44 | } 45 | 46 | return null; 47 | }); 48 | 49 | // Optionally, don't invoke Middleware on some paths 50 | export const config = { 51 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"], 52 | }; 53 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /actions/new-password.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import * as z from "zod"; 4 | import bcrypt from "bcryptjs"; 5 | 6 | import { NewPasswordSchema } from "@/schemas"; 7 | import { getPasswordResetTokenByToken } from "@/data/password-reset-token"; 8 | import { getUserByEmail } from "@/data/user"; 9 | import { db } from "@/lib/db"; 10 | 11 | export const newPassword = async ( 12 | values: z.infer, 13 | token: string | null 14 | ) => { 15 | if (!token) { 16 | return { error: "Missing token!" }; 17 | } 18 | 19 | const validateFields = NewPasswordSchema.safeParse(values); 20 | 21 | if (!validateFields.success) { 22 | return { error: "Invalid fields!" }; 23 | } 24 | 25 | const { password } = validateFields.data; 26 | 27 | const existingToken = await getPasswordResetTokenByToken(token); 28 | 29 | if (!existingToken) { 30 | return { error: "Invalid token!" }; 31 | } 32 | 33 | const hasExpired = new Date(existingToken.expires) < new Date(); 34 | 35 | if (hasExpired) { 36 | return { error: "Token expired!" }; 37 | } 38 | 39 | const existingUser = await getUserByEmail(existingToken.email); 40 | 41 | if (!existingUser) { 42 | return { error: "Email not found!" }; 43 | } 44 | 45 | const hashedPassword = await bcrypt.hash(password, 10); 46 | 47 | await db.user.update({ 48 | where: { id: existingUser.id }, 49 | data: { password: hashedPassword }, 50 | }); 51 | 52 | await db.passwordResetToken.delete({ 53 | where: { id: existingToken.id }, 54 | }); 55 | 56 | return { success: "Password updated!" }; 57 | }; 58 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /components/auth/new-verification-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useEffect, useState } from "react"; 4 | import { BeatLoader } from "react-spinners"; 5 | import { useSearchParams } from "next/navigation"; 6 | 7 | import { newVerification } from "@/actions/new-verification"; 8 | import { CardWrapper } from "@/components/auth/card-wrapper"; 9 | import { FormError } from "@/components/form-error"; 10 | import { FormSuccess } from "@/components/form-success"; 11 | 12 | export const NewVerificationForm = () => { 13 | const [error, setError] = useState(""); 14 | const [success, setSuccess] = useState(""); 15 | 16 | const searchParams = useSearchParams(); 17 | 18 | const token = searchParams.get("token"); 19 | 20 | const onSubmit = useCallback(() => { 21 | if (success || error) return; 22 | 23 | if (!token) { 24 | setError("Missing token!"); 25 | 26 | return; 27 | } 28 | 29 | newVerification(token) 30 | .then((data) => { 31 | setSuccess(data.success); 32 | setError(data.error); 33 | }) 34 | .catch(() => { 35 | setError("Something went wrong!"); 36 | }); 37 | }, [token, success, error]); 38 | 39 | useEffect(() => { 40 | onSubmit(); 41 | }, [onSubmit]); 42 | 43 | return ( 44 | 49 |
50 | {!success && !error && } 51 | 52 | {!success && } 53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-auth-v5-advanced-guide-2024", 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.14", 14 | "@hookform/resolvers": "^3.3.4", 15 | "@prisma/client": "^5.8.0", 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 | "bcrypt": "^5.1.1", 25 | "bcryptjs": "^2.4.3", 26 | "class-variance-authority": "^0.7.0", 27 | "clsx": "^2.1.0", 28 | "next": "14.0.4", 29 | "next-auth": "^5.0.0-beta.4", 30 | "next-themes": "^0.2.1", 31 | "react": "^18", 32 | "react-dom": "^18", 33 | "react-hook-form": "^7.49.2", 34 | "react-icons": "^4.12.0", 35 | "react-spinners": "^0.13.8", 36 | "resend": "^2.1.0", 37 | "sonner": "^1.3.1", 38 | "tailwind-merge": "^2.2.0", 39 | "tailwindcss-animate": "^1.0.7", 40 | "uuid": "^9.0.1", 41 | "zod": "^3.22.4" 42 | }, 43 | "devDependencies": { 44 | "@types/bcrypt": "^5.0.2", 45 | "@types/bcryptjs": "^2.4.6", 46 | "@types/node": "^20", 47 | "@types/react": "^18", 48 | "@types/react-dom": "^18", 49 | "@types/uuid": "^9.0.7", 50 | "autoprefixer": "^10.0.1", 51 | "eslint": "^8", 52 | "eslint-config-next": "14.0.4", 53 | "postcss": "^8", 54 | "prisma": "^5.8.0", 55 | "tailwindcss": "^3.3.0", 56 | "typescript": "^5" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /schemas/index.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | import { UserRole } from "@prisma/client"; 3 | 4 | export const SettingsSchema = z 5 | .object({ 6 | name: z.optional(z.string()), 7 | isTwoFactorEnabled: z.optional(z.boolean()), 8 | role: z.enum([UserRole.ADMIN, UserRole.USER]), 9 | email: z.optional(z.string().email()), 10 | password: z.optional(z.string().min(6)), 11 | newPassword: z.optional(z.string().min(6)), 12 | }) 13 | .refine( 14 | (data) => { 15 | if (data.password && !data.newPassword) { 16 | return false; 17 | } 18 | 19 | return true; 20 | }, 21 | { 22 | message: "New password is required!", 23 | path: ["newPassword"], 24 | } 25 | ) 26 | .refine( 27 | (data) => { 28 | if (data.newPassword && !data.password) { 29 | return false; 30 | } 31 | 32 | return true; 33 | }, 34 | { 35 | message: "Password is required!", 36 | path: ["password"], 37 | } 38 | ); 39 | 40 | export const NewPasswordSchema = z.object({ 41 | password: z.string().min(6, { 42 | message: "Minimum 6 characters required", 43 | }), 44 | }); 45 | 46 | export const ResetSchema = z.object({ 47 | email: z.string().email({ 48 | message: "Email is required", 49 | }), 50 | }); 51 | 52 | export const LoginSchema = z.object({ 53 | email: z.string().email({ 54 | message: "Email is required", 55 | }), 56 | password: z.string().min(1, { 57 | message: "Password is required", 58 | }), 59 | code: z.optional(z.string()), 60 | }); 61 | 62 | export const RegisterSchema = z.object({ 63 | email: z.string().email({ 64 | message: "Email is required", 65 | }), 66 | password: z.string().min(6, { 67 | message: "Minimum 6 characters required", 68 | }), 69 | name: z.string().min(1, { 70 | message: "Name is required", 71 | }), 72 | }); 73 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root { 8 | height: 100%; 9 | } 10 | 11 | @layer base { 12 | :root { 13 | --background: 0 0% 100%; 14 | --foreground: 222.2 84% 4.9%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 222.2 84% 4.9%; 18 | 19 | --popover: 0 0% 100%; 20 | --popover-foreground: 222.2 84% 4.9%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --muted: 210 40% 96.1%; 29 | --muted-foreground: 215.4 16.3% 46.9%; 30 | 31 | --accent: 210 40% 96.1%; 32 | --accent-foreground: 222.2 47.4% 11.2%; 33 | 34 | --destructive: 0 84.2% 60.2%; 35 | --destructive-foreground: 210 40% 98%; 36 | 37 | --border: 214.3 31.8% 91.4%; 38 | --input: 214.3 31.8% 91.4%; 39 | --ring: 222.2 84% 4.9%; 40 | 41 | --radius: 0.5rem; 42 | } 43 | 44 | .dark { 45 | --background: 222.2 84% 4.9%; 46 | --foreground: 210 40% 98%; 47 | 48 | --card: 222.2 84% 4.9%; 49 | --card-foreground: 210 40% 98%; 50 | 51 | --popover: 222.2 84% 4.9%; 52 | --popover-foreground: 210 40% 98%; 53 | 54 | --primary: 210 40% 98%; 55 | --primary-foreground: 222.2 47.4% 11.2%; 56 | 57 | --secondary: 217.2 32.6% 17.5%; 58 | --secondary-foreground: 210 40% 98%; 59 | 60 | --muted: 217.2 32.6% 17.5%; 61 | --muted-foreground: 215 20.2% 65.1%; 62 | 63 | --accent: 217.2 32.6% 17.5%; 64 | --accent-foreground: 210 40% 98%; 65 | 66 | --destructive: 0 62.8% 30.6%; 67 | --destructive-foreground: 210 40% 98%; 68 | 69 | --border: 217.2 32.6% 17.5%; 70 | --input: 217.2 32.6% 17.5%; 71 | --ring: 212.7 26.8% 83.9%; 72 | } 73 | } 74 | 75 | @layer base { 76 | * { 77 | @apply border-border; 78 | } 79 | body { 80 | @apply bg-background text-foreground; 81 | } 82 | } -------------------------------------------------------------------------------- /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 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /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 |

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

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

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /app/(protected)/admin/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { admin } from "@/actions/admin"; 4 | import { RoleGate } from "@/components/auth/role-gate"; 5 | import { FormSuccess } from "@/components/form-success"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Card, CardContent, CardHeader } from "@/components/ui/card"; 8 | import { UserRole } from "@prisma/client"; 9 | import { toast } from "sonner"; 10 | 11 | const AdminPage = () => { 12 | const onServerActionClick = () => { 13 | admin() 14 | .then((data) => { 15 | if (data.error) { 16 | toast.error(data.error); 17 | } 18 | 19 | if (data.success) { 20 | toast.success(data.success); 21 | } 22 | }) 23 | } 24 | 25 | const onApiRouteClick = () => { 26 | fetch("/api/admin") 27 | .then((response) => { 28 | if (response.ok) { 29 | toast.success("Allowed API Route!"); 30 | } else { 31 | toast.error("Forbidden API Route!"); 32 | } 33 | }) 34 | } 35 | 36 | return ( 37 | 38 | 39 |

40 | 🔑 Admin 41 |

42 |
43 | 44 | 45 | 48 | 49 |
50 |

51 | Admin-only API Route 52 |

53 | 56 |
57 | 58 |
59 |

60 | Admin-only Server Action 61 |

62 | 65 |
66 |
67 |
68 | ); 69 | }; 70 | 71 | export default AdminPage; -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // prisma/schema.prisma 2 | datasource db { 3 | provider = "postgresql" 4 | url = env("DATABASE_URL") 5 | directUrl = env("DIRECT_URL") 6 | } 7 | 8 | generator client { 9 | provider = "prisma-client-js" 10 | } 11 | 12 | enum UserRole { 13 | ADMIN 14 | USER 15 | } 16 | 17 | model User { 18 | id String @id @default(cuid()) 19 | name String? 20 | email String? @unique 21 | emailVerified DateTime? 22 | image String? 23 | password String? 24 | role UserRole @default(USER) 25 | accounts Account[] 26 | isTwoFactorEnabled Boolean @default(false) 27 | twoFactorConfirmation TwoFactorConfirmation? 28 | } 29 | 30 | model Account { 31 | id String @id @default(cuid()) 32 | userId String 33 | type String 34 | provider String 35 | providerAccountId String 36 | refresh_token String? @db.Text 37 | access_token String? @db.Text 38 | expires_at Int? 39 | token_type String? 40 | scope String? 41 | id_token String? @db.Text 42 | session_state String? 43 | 44 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 45 | 46 | @@unique([provider, providerAccountId]) 47 | } 48 | 49 | model VerificationToken { 50 | id String @id @default(cuid()) 51 | email String 52 | token String @unique 53 | expires DateTime 54 | 55 | @@unique([email, token]) 56 | } 57 | 58 | model PasswordResetToken { 59 | id String @id @default(cuid()) 60 | email String 61 | token String @unique 62 | expires DateTime 63 | 64 | @@unique([email, token]) 65 | } 66 | 67 | model TwoFactorToken { 68 | id String @id @default(cuid()) 69 | email String 70 | token String @unique 71 | expires DateTime 72 | 73 | @@unique([email, token]) 74 | } 75 | 76 | model TwoFactorConfirmation { 77 | id String @id @default(cuid()) 78 | 79 | userId String 80 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 81 | 82 | @@unique([userId]) 83 | } 84 | -------------------------------------------------------------------------------- /lib/token.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | import { db } from "@/lib/db"; 5 | import { getVerificationTokenByEmail } from "@/data/verification-token"; 6 | import { getPasswordResetokenByEmail } from "@/data/password-reset-token"; 7 | import { getTwoFactorTokenByEmail } from "@/data/two-factor-token"; 8 | 9 | export const generateTwoFactorToken = async (email: string) => { 10 | const token = crypto.randomInt(100_000, 1000_000).toString(); 11 | const expires = new Date(new Date().getTime() + 3600 * 1000); 12 | 13 | const existingToken = await getTwoFactorTokenByEmail(email); 14 | 15 | if (existingToken) { 16 | await db.twoFactorToken.delete({ 17 | where: { 18 | id: existingToken.id, 19 | }, 20 | }); 21 | } 22 | 23 | const twoFactorToken = await db.twoFactorToken.create({ 24 | data: { 25 | email, 26 | token, 27 | expires, 28 | }, 29 | }); 30 | 31 | return twoFactorToken; 32 | }; 33 | 34 | export const generatePasswordResetToken = async (email: string) => { 35 | const token = uuidv4(); 36 | const expires = new Date(new Date().getTime() + 5 * 60 * 1000); 37 | 38 | const existingToken = await getPasswordResetokenByEmail(email); 39 | 40 | if (existingToken) { 41 | await db.passwordResetToken.delete({ 42 | where: { 43 | id: existingToken.id, 44 | }, 45 | }); 46 | } 47 | 48 | const passwordResetToken = await db.passwordResetToken.create({ 49 | data: { 50 | email, 51 | token, 52 | expires, 53 | }, 54 | }); 55 | 56 | return passwordResetToken; 57 | }; 58 | 59 | export const generateVerificationToken = async (email: string) => { 60 | const token = uuidv4(); 61 | const expires = new Date(new Date().getTime() + 3600 * 1000); 62 | 63 | const existingToken = await getVerificationTokenByEmail(email); 64 | 65 | if (existingToken) { 66 | await db.verificationToken.delete({ 67 | where: { 68 | id: existingToken.id, 69 | }, 70 | }); 71 | } 72 | 73 | const verificationToken = await db.verificationToken.create({ 74 | data: { 75 | email, 76 | token, 77 | expires, 78 | }, 79 | }); 80 | 81 | return verificationToken; 82 | }; 83 | -------------------------------------------------------------------------------- /components/user-info.tsx: -------------------------------------------------------------------------------- 1 | import { ExtendedUser } from "@/next-auth"; 2 | import { Card, CardContent, CardHeader } from "@/components/ui/card"; 3 | import { Badge } from "@/components/ui/badge"; 4 | 5 | interface UserInfoProps { 6 | user?: ExtendedUser; 7 | label: string; 8 | } 9 | 10 | export const UserInfo = ({ user, label }: UserInfoProps) => { 11 | return ( 12 | 13 | 14 |

{label}

15 |
16 | 17 |
18 |

ID

19 |

20 | {user?.id} 21 |

22 |
23 |
24 |

Name

25 |

26 | {user?.name} 27 |

28 |
29 |
30 |

Email

31 |

32 | {user?.email} 33 |

34 |
35 |
36 |

Role

37 |

38 | {user?.role} 39 |

40 |
41 | 42 |
43 |

Two Factor Authentication

44 | 45 | {user?.isTwoFactorEnabled ? "ON" : "OFF"} 46 | 47 |
48 |
49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /actions/settings.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import * as z from "zod"; 4 | import bcrypt from "bcryptjs"; 5 | 6 | import { update } from "@/auth"; 7 | import { db } from "@/lib/db"; 8 | import { SettingsSchema } from "@/schemas"; 9 | import { getUserByEmail, getUserById } from "@/data/user"; 10 | import { currentUser } from "@/lib/auth"; 11 | import { generateVerificationToken } from "@/lib/token"; 12 | import { sendVerificationEmail } from "@/lib/mail"; 13 | 14 | export const settings = async (values: z.infer) => { 15 | const user = await currentUser(); 16 | 17 | if (!user) { 18 | return { error: "Unauthorized" }; 19 | } 20 | 21 | const dbUser = await getUserById(user.id); 22 | 23 | if (!dbUser) { 24 | return { error: "Unauthorized" }; 25 | } 26 | 27 | if (user.isOAuth) { 28 | values.email = undefined; 29 | values.password = undefined; 30 | values.newPassword = undefined; 31 | values.isTwoFactorEnabled = undefined; 32 | } 33 | 34 | if (values.email && values.email !== user.email) { 35 | const existingUser = await getUserByEmail(values.email); 36 | 37 | if (existingUser && existingUser.id !== user.id) { 38 | return { error: "Email already in use!" }; 39 | } 40 | 41 | const verificationToken = await generateVerificationToken(values.email); 42 | await sendVerificationEmail( 43 | verificationToken.email, 44 | verificationToken.token 45 | ); 46 | 47 | return { success: "Verification email sent!" }; 48 | } 49 | 50 | if (values.password && values.newPassword && dbUser.password) { 51 | const passwordsMatch = await bcrypt.compare( 52 | values.password, 53 | dbUser.password 54 | ); 55 | 56 | if (!passwordsMatch) { 57 | return { error: "Incorrect password!" }; 58 | } 59 | 60 | const hashedPassword = await bcrypt.hash(values.newPassword, 10); 61 | values.password = hashedPassword; 62 | values.newPassword = undefined; 63 | } 64 | 65 | const updatedUser = await db.user.update({ 66 | where: { id: dbUser.id }, 67 | data: { 68 | ...values, 69 | }, 70 | }); 71 | 72 | update({ 73 | user: { 74 | name: updatedUser.name, 75 | email: updatedUser.email, 76 | isTwoFactorEnabled: updatedUser.isTwoFactorEnabled, 77 | role: updatedUser.role, 78 | }, 79 | }); 80 | 81 | return { success: "Settings Updated!" }; 82 | }; 83 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /components/auth/reset-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import { useState, useTransition } from "react"; 5 | import { useForm } from "react-hook-form"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | 8 | import { ResetSchema } from "@/schemas"; 9 | import { 10 | Form, 11 | FormControl, 12 | FormField, 13 | FormItem, 14 | FormLabel, 15 | FormMessage, 16 | } from "@/components/ui/form"; 17 | import { CardWrapper } from "@/components/auth/card-wrapper"; 18 | import { Input } from "@/components/ui/input"; 19 | import { Button } from "@/components/ui/button"; 20 | import { FormError } from "@/components/form-error"; 21 | import { FormSuccess } from "@/components/form-success"; 22 | import { reset } from "@/actions/reset"; 23 | 24 | export const ResetForm = () => { 25 | const [error, setError] = useState(""); 26 | const [success, setSuccess] = useState(""); 27 | const [isPending, startTransition] = useTransition(); 28 | 29 | const form = useForm>({ 30 | resolver: zodResolver(ResetSchema), 31 | defaultValues: { 32 | email: "", 33 | }, 34 | }); 35 | 36 | const onSubmit = (values: z.infer) => { 37 | setError(""); 38 | setSuccess(""); 39 | 40 | startTransition(() => { 41 | reset(values).then((data) => { 42 | setError(data?.error); 43 | setSuccess(data?.success); 44 | }); 45 | }); 46 | }; 47 | 48 | return ( 49 | 54 |
55 | 56 |
57 | ( 61 | 62 | Email 63 | 64 | 70 | 71 | 72 | 73 | )} 74 | /> 75 |
76 | 77 | 78 | 81 | 82 | 83 |
84 | ); 85 | }; 86 | -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth"; 2 | import { UserRole } from "@prisma/client"; 3 | import { PrismaAdapter } from "@auth/prisma-adapter"; 4 | 5 | import { getUserById } from "@/data/user"; 6 | import { db } from "@/lib/db"; 7 | import authConfig from "@/auth.config"; 8 | import { getTwoFactorConfirmationByUserId } from "./data/two-factor-confirmation"; 9 | import { getAccountByUserId } from "@/data/account"; 10 | 11 | export const { 12 | handlers: { GET, POST }, 13 | auth, 14 | signIn, 15 | signOut, 16 | update, 17 | } = NextAuth({ 18 | pages: { 19 | signIn: "/auth/login", 20 | error: "/auth/error", 21 | }, 22 | events: { 23 | async linkAccount({ user }) { 24 | await db.user.update({ 25 | where: { id: user.id }, 26 | data: { emailVerified: new Date() }, 27 | }); 28 | }, 29 | }, 30 | callbacks: { 31 | async signIn({ user, account }) { 32 | //Allow OAuth without email verification 33 | if (account?.provider !== "credentials") return true; 34 | 35 | const existingUser = await getUserById(user.id); 36 | 37 | //Prevent sign in without email verification 38 | if (!existingUser?.emailVerified) return false; 39 | 40 | //2FA check 41 | if (existingUser.isTwoFactorEnabled) { 42 | const twoFactorConfirmation = await getTwoFactorConfirmationByUserId( 43 | existingUser.id 44 | ); 45 | 46 | if (!twoFactorConfirmation) return false; 47 | 48 | //Delete the two factor confirmation for next sign in 49 | await db.twoFactorConfirmation.delete({ 50 | where: { id: twoFactorConfirmation.id }, 51 | }); 52 | } 53 | 54 | return true; 55 | }, 56 | 57 | async session({ token, session }) { 58 | if (token.sub && session.user) { 59 | session.user.id = token.sub; 60 | } 61 | 62 | if (token.role && session.user) { 63 | session.user.role = token.role as UserRole; 64 | } 65 | 66 | if (session.user) { 67 | session.user.name = token.name; 68 | session.user.email = token.email; 69 | session.user.isOAuth = token.isOAuth as boolean; 70 | session.user.isTwoFactorEnabled = token.isTwoFactorEnabled as boolean; 71 | } 72 | 73 | return session; 74 | }, 75 | async jwt({ token }) { 76 | if (!token.sub) return token; 77 | 78 | const existingUser = await getUserById(token.sub); 79 | 80 | if (!existingUser) return token; 81 | 82 | const existingAccount = await getAccountByUserId(existingUser.id); 83 | 84 | token.isOAuth = !!existingAccount; 85 | token.name = existingUser.name; 86 | token.email = existingUser.email; 87 | token.role = existingUser.role; 88 | token.isTwoFactorEnabled = existingUser.isTwoFactorEnabled; 89 | 90 | return token; 91 | }, 92 | }, 93 | adapter: PrismaAdapter(db), 94 | session: { strategy: "jwt" }, 95 | ...authConfig, 96 | }); 97 | -------------------------------------------------------------------------------- /components/auth/new-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import { useSearchParams } from "next/navigation"; 5 | import { useState, useTransition } from "react"; 6 | import { useForm } from "react-hook-form"; 7 | import { zodResolver } from "@hookform/resolvers/zod"; 8 | 9 | import { NewPasswordSchema } from "@/schemas"; 10 | import { 11 | Form, 12 | FormControl, 13 | FormField, 14 | FormItem, 15 | FormLabel, 16 | FormMessage, 17 | } from "@/components/ui/form"; 18 | import { CardWrapper } from "@/components/auth/card-wrapper"; 19 | import { Input } from "@/components/ui/input"; 20 | import { Button } from "@/components/ui/button"; 21 | import { FormError } from "@/components/form-error"; 22 | import { FormSuccess } from "@/components/form-success"; 23 | import { newPassword } from "@/actions/new-password"; 24 | 25 | export const NewPasswordForm = () => { 26 | const searchParams = useSearchParams(); 27 | const token = searchParams.get("token"); 28 | 29 | const [error, setError] = useState(""); 30 | const [success, setSuccess] = useState(""); 31 | const [isPending, startTransition] = useTransition(); 32 | 33 | const form = useForm>({ 34 | resolver: zodResolver(NewPasswordSchema), 35 | defaultValues: { 36 | password: "", 37 | }, 38 | }); 39 | 40 | const onSubmit = (values: z.infer) => { 41 | setError(""); 42 | setSuccess(""); 43 | 44 | startTransition(() => { 45 | newPassword(values, token) 46 | .then((data) => { 47 | setError(data?.error); 48 | setSuccess(data?.success); 49 | }); 50 | }); 51 | }; 52 | 53 | return ( 54 | 59 |
60 | 61 |
62 | ( 66 | 67 | Password 68 | 69 | 75 | 76 | 77 | 78 | )} 79 | /> 80 |
81 | 82 | 83 | 86 | 87 | 88 |
89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /actions/login.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import * as z from "zod"; 4 | import bcrypt from "bcryptjs"; 5 | import { AuthError } from "next-auth"; 6 | 7 | import { db } from "@/lib/db"; 8 | import { signIn } from "@/auth"; 9 | import { LoginSchema } from "@/schemas"; 10 | import { getUserByEmail } from "@/data/user"; 11 | import { getTwoFactorTokenByEmail } from "@/data/two-factor-token"; 12 | import { sendVerificationEmail, sendTwoFactorTokenEmail } from "@/lib/mail"; 13 | import { DEFAULT_LOGIN_REDIRECT } from "@/routes"; 14 | import { generateVerificationToken, generateTwoFactorToken } from "@/lib/token"; 15 | import { getTwoFactorConfirmationByUserId } from "@/data/two-factor-confirmation"; 16 | 17 | export const login = async ( 18 | values: z.infer, 19 | callbackUrl?: string | null 20 | ) => { 21 | const validatedFields = LoginSchema.safeParse(values); 22 | 23 | if (!validatedFields.success) { 24 | return { error: "Invalid fields!" }; 25 | } 26 | 27 | const { email, password, code } = validatedFields.data; 28 | 29 | const existingUser = await getUserByEmail(email); 30 | 31 | if (!existingUser || !existingUser.email || !existingUser.password) { 32 | return { error: "Email does not exist!" }; 33 | } 34 | 35 | if (!existingUser.emailVerified) { 36 | const verificationToken = await generateVerificationToken( 37 | existingUser.email 38 | ); 39 | 40 | await sendVerificationEmail( 41 | verificationToken.email, 42 | verificationToken.token 43 | ); 44 | 45 | return { success: "Confirmation email sent!" }; 46 | } 47 | 48 | if (existingUser.isTwoFactorEnabled && existingUser.email) { 49 | // Check password before proceeding with 2FA 50 | const isPasswordValid = await bcrypt.compare( 51 | password, 52 | existingUser.password 53 | ); 54 | 55 | if (!isPasswordValid) { 56 | return { error: "Invalid credentials!" }; 57 | } 58 | 59 | if (code) { 60 | const twoFactorToken = await getTwoFactorTokenByEmail(existingUser.email); 61 | 62 | if (!twoFactorToken) { 63 | return { error: "Invalid code!" }; 64 | } 65 | 66 | if (twoFactorToken.token !== code) { 67 | return { error: "Invalid code!" }; 68 | } 69 | 70 | const hasExpired = new Date(twoFactorToken.expires) < new Date(); 71 | 72 | if (hasExpired) { 73 | return { error: "Code expired!" }; 74 | } 75 | 76 | await db.twoFactorToken.delete({ 77 | where: { id: twoFactorToken.id }, 78 | }); 79 | 80 | const existingConfirmation = await getTwoFactorConfirmationByUserId( 81 | existingUser.id 82 | ); 83 | 84 | if (existingConfirmation) { 85 | await db.twoFactorConfirmation.delete({ 86 | where: { id: existingConfirmation.id }, 87 | }); 88 | } 89 | 90 | await db.twoFactorConfirmation.create({ 91 | data: { userId: existingUser.id }, 92 | }); 93 | } else { 94 | const twoFactorToken = await generateTwoFactorToken(existingUser.email); 95 | await sendTwoFactorTokenEmail(twoFactorToken.email, twoFactorToken.token); 96 | 97 | return { twoFactor: true }; 98 | } 99 | } 100 | 101 | try { 102 | await signIn("credentials", { 103 | email, 104 | password, 105 | redirectTo: callbackUrl || DEFAULT_LOGIN_REDIRECT, 106 | }); 107 | } catch (error) { 108 | if (error instanceof AuthError) { 109 | switch (error.type) { 110 | case "CredentialsSignin": 111 | return { error: "Invalid credentials!" }; 112 | default: 113 | return { error: "Something went wrong!" }; 114 | } 115 | } 116 | 117 | throw error; 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /components/auth/register-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import { useState, useTransition } from "react"; 5 | import { useForm } from "react-hook-form"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | import { RegisterSchema } from "@/schemas"; 8 | import { 9 | Form, 10 | FormControl, 11 | FormField, 12 | FormItem, 13 | FormLabel, 14 | FormMessage, 15 | } from "@/components/ui/form"; 16 | import { CardWrapper } from "@/components/auth/card-wrapper"; 17 | import { Input } from "@/components/ui/input"; 18 | import { Button } from "@/components/ui/button"; 19 | import { FormError } from "@/components/form-error"; 20 | import { FormSuccess } from "@/components/form-success"; 21 | import { register } from "@/actions/register"; 22 | 23 | export const RegisterForm = () => { 24 | const [error, setError] = useState(""); 25 | const [success, setSuccess] = useState(""); 26 | const [isPending, startTransition] = useTransition(); 27 | 28 | const form = useForm>({ 29 | resolver: zodResolver(RegisterSchema), 30 | defaultValues: { 31 | email: "", 32 | password: "", 33 | name: "", 34 | }, 35 | }); 36 | 37 | const onSubmit = (values: z.infer) => { 38 | setError(""); 39 | setSuccess(""); 40 | 41 | startTransition(() => { 42 | register(values).then((data) => { 43 | setError(data.error); 44 | setSuccess(data.success); 45 | }); 46 | }); 47 | }; 48 | 49 | return ( 50 | 56 |
57 | 58 |
59 | ( 63 | 64 | Name 65 | 66 | 71 | 72 | 73 | 74 | )} 75 | /> 76 | ( 80 | 81 | Email 82 | 83 | 89 | 90 | 91 | 92 | )} 93 | /> 94 | ( 98 | 99 | Password 100 | 101 | 107 | 108 | 109 | 110 | )} 111 | /> 112 |
113 | 114 | 115 | 118 | 119 | 120 |
121 | ); 122 | }; 123 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { Cross2Icon } from "@radix-ui/react-icons" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /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 |