├── README.md ├── src ├── app │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ └── admin │ │ │ └── route.ts │ ├── favicon.ico │ ├── fonts │ │ ├── GeistVF.woff │ │ └── GeistMonoVF.woff │ ├── auth │ │ ├── error │ │ │ └── page.tsx │ │ ├── login │ │ │ └── page.tsx │ │ ├── reset │ │ │ └── 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 │ ├── page.tsx │ ├── layout.tsx │ └── globals.css ├── lib │ ├── utils.ts │ ├── db.ts │ ├── auth.ts │ ├── mail.ts │ └── tokens.ts ├── components │ ├── auth │ │ ├── logout-button.tsx │ │ ├── error-card.tsx │ │ ├── back-button.tsx │ │ ├── header.tsx │ │ ├── role-gate.tsx │ │ ├── social.tsx │ │ ├── login-button.tsx │ │ ├── card-wrapper.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 └── middleware.ts ├── next.config.mjs ├── actions ├── logout.ts ├── admin.ts ├── reset.ts ├── new-verification.ts ├── register.ts ├── new-password.ts ├── setting.ts └── login.ts ├── postcss.config.mjs ├── hooks ├── use-current-role.ts └── use-current-user.ts ├── .eslintrc.json ├── data ├── account.ts ├── two-factor-confirmation.ts ├── user.ts ├── two-fator-token.ts ├── password-reset-token.ts └── verficiation-token.ts ├── next-auth.d.ts ├── components.json ├── .gitignore ├── tsconfig.json ├── routes.ts ├── auth.config.ts ├── package.json ├── tailwind.config.ts ├── schemas └── index.ts ├── prisma └── schema.prisma └── auth.ts /README.md: -------------------------------------------------------------------------------- 1 | # NEXT_AUTH -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from '../../../../../auth' -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-zain-hunzai/auth-2Fa/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-zain-hunzai/auth-2Fa/HEAD/src/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /src/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-with-zain-hunzai/auth-2Fa/HEAD/src/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /actions/logout.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { signOut } from "../auth" 4 | 5 | export const logout = async () => { 6 | //some server stuff 7 | await signOut(); 8 | } -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/app/auth/error/page.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorCard } from "@/components/auth/error-card" 2 | 3 | const AuthErrorPage = () => { 4 | return ( 5 | 6 | ) 7 | } 8 | export default AuthErrorPage -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } 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 | 5 | const session = useSession(); 6 | 7 | return session.data?.user.role 8 | } -------------------------------------------------------------------------------- /hooks/use-current-user.ts: -------------------------------------------------------------------------------- 1 | 2 | import { useSession } from "next-auth/react"; 3 | 4 | export const useCurrentUser = () => { 5 | const session = useSession(); 6 | 7 | return session.data?.user 8 | } 9 | -------------------------------------------------------------------------------- /src/app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import LoginForm from "@/components/auth/login-form" 2 | 3 | const LoginPage = () => { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | export default LoginPage -------------------------------------------------------------------------------- /src/app/auth/reset/page.tsx: -------------------------------------------------------------------------------- 1 | import ResetForm from "@/components/auth/reset-form" 2 | 3 | const ResetPage = () => { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | 11 | export default ResetPage 12 | -------------------------------------------------------------------------------- /src/app/auth/register/page.tsx: -------------------------------------------------------------------------------- 1 | import RegisterForm from "@/components/auth/register-form" 2 | 3 | const RegisterPage = () => { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | export default RegisterPage -------------------------------------------------------------------------------- /src/app/auth/new-password/page.tsx: -------------------------------------------------------------------------------- 1 | import ForgetForm from "@/components/auth/new-password-form" 2 | 3 | 4 | const NewPasswordPage = () => { 5 | return ( 6 |
7 | 8 |
9 | ) 10 | } 11 | 12 | export default NewPasswordPage 13 | -------------------------------------------------------------------------------- /src/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 = db -------------------------------------------------------------------------------- /src/app/auth/new-verification/page.tsx: -------------------------------------------------------------------------------- 1 | import NewVerificationForm from "@/components/auth/new-verification-form"; 2 | 3 | const NewVerificationPage = () => { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | 11 | export default NewVerificationPage; -------------------------------------------------------------------------------- /src/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 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "react/no-unescaped-entities": "off", 5 | "@typescript-eslint/quotes": "off", 6 | "quotes": [ 7 | 0 8 | ], 9 | "avoidEscape": 0, 10 | "allowTemplateLiterals": 0, 11 | "no-useless-escape": 0 12 | } 13 | } -------------------------------------------------------------------------------- /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 | return account 9 | } catch { 10 | return null 11 | } 12 | } -------------------------------------------------------------------------------- /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 | return { error: "Forbidden Server Action" } 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 | return twoFactorConfirmation; 9 | } catch { 10 | return null; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/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 | } -------------------------------------------------------------------------------- /src/app/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const AuthLayout = ( 4 | { children }: { children: React.ReactNode } 5 | ) => { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | } 12 | 13 | export default AuthLayout; 14 | -------------------------------------------------------------------------------- /src/app/(protected)/server/page.tsx: -------------------------------------------------------------------------------- 1 | import { UserInfo } from "@/components/user-info"; 2 | import { currentUser } from "@/lib/auth"; 3 | 4 | 5 | const ServerPage = async () => { 6 | const user = await currentUser(); 7 | return ( 8 |
9 | 12 |
13 | ); 14 | } 15 | 16 | export default ServerPage 17 | -------------------------------------------------------------------------------- /next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { type DefaultSession } from "next-auth" 2 | 3 | export type ExtendedUser = DefaultSession["user"] & { 4 | role: "ADMIN" | "USER" 5 | isTwoFactorEnabled: boolean; 6 | isOAuth: boolean; 7 | }; 8 | 9 | declare module "next-auth" { 10 | interface Session { 11 | user: ExtendedUser; 12 | } 13 | } 14 | 15 | 16 | import { JWT } from "next-auth/jwt" 17 | 18 | declare module "next-auth/jwt" { 19 | interface JWT { 20 | role?: "ADMIN" | "USER" 21 | } 22 | } -------------------------------------------------------------------------------- /src/app/(protected)/client/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { UserInfo } from "@/components/user-info"; 4 | import { useCurrentUser } from "../../../../hooks/use-current-user"; 5 | 6 | const ClientPage = () => { 7 | const user = useCurrentUser(); 8 | 9 | return ( 10 |
11 | 15 |
16 | ); 17 | } 18 | 19 | export default ClientPage; 20 | -------------------------------------------------------------------------------- /src/components/auth/logout-button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { logout } from "../../../actions/logout"; 3 | 4 | interface LogoutButtonProps { 5 | children?: React.ReactNode; 6 | } 7 | 8 | export const LogoutButton = ({ children }: 9 | LogoutButtonProps) => { 10 | const onClick = () => { 11 | logout(); 12 | }; 13 | 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /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": "src/app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /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 | }; -------------------------------------------------------------------------------- /src/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 = ({ 8 | message, 9 | }: FormSuccessProps) => { 10 | if (!message) return null 11 | 12 | return ( 13 |
14 | 15 |

{message}

16 |
17 | ) 18 | } -------------------------------------------------------------------------------- /src/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 = ({ 8 | message, 9 | }: FormErrorProps) => { 10 | if (!message) return null 11 | 12 | return ( 13 |
14 | 15 |

{message}

16 |
17 | ) 18 | } -------------------------------------------------------------------------------- /src/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 -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/components/auth/error-card.tsx: -------------------------------------------------------------------------------- 1 | import { CardWrapper } from "@/components/auth/card-wrapper"; 2 | import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; 3 | 4 | export const ErrorCard = () => { 5 | return ( 6 | 11 |
12 | 13 |
14 |
15 | ); 16 | }; -------------------------------------------------------------------------------- /src/components/auth/back-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import Link from "next/link"; 5 | 6 | interface BackButtonProps { 7 | href: string; 8 | label: string; 9 | } 10 | export const BackButton = ({ 11 | href, 12 | label, 13 | }: BackButtonProps) => { 14 | return ( 15 | 24 | ) 25 | } -------------------------------------------------------------------------------- /data/two-fator-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 | return twoFactorToken 9 | } catch { 10 | return null 11 | } 12 | } 13 | 14 | 15 | export const getTwoFactorTokenByEmail = async (email: string) => { 16 | try { 17 | const twoFactorToken = await db.twoFactorToken.findFirst({ 18 | where: { email } 19 | }); 20 | return twoFactorToken 21 | } catch { 22 | return null 23 | } 24 | } -------------------------------------------------------------------------------- /src/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

19 |

20 | {label} 21 |

22 |
23 | ) 24 | } -------------------------------------------------------------------------------- /data/password-reset-token.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db" 2 | 3 | export const getPasswordResetTokenByToken = async (token: string) => { 4 | 5 | try { 6 | const passwordResetToken = await db.passwordResetToken.findUnique({ 7 | where: { token } 8 | }); 9 | return passwordResetToken; 10 | 11 | } catch { 12 | return null; 13 | } 14 | }; 15 | 16 | export const getPasswordResetTokenByEmail = async (email: string) => { 17 | 18 | try { 19 | const passwordResetToken = await db.passwordResetToken.findFirst({ 20 | where: { email } 21 | }); 22 | return passwordResetToken; 23 | 24 | } catch { 25 | return null; 26 | } 27 | }; -------------------------------------------------------------------------------- /data/verficiation-token.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | 3 | export const getVerificationTokenByToken = async ( 4 | token: string 5 | ) => { 6 | try { 7 | const verificationToken = await db.verificationToken.findFirst({ 8 | where: { token } 9 | }); 10 | return verificationToken; 11 | } catch { 12 | return null 13 | } 14 | } 15 | 16 | 17 | export const getVerificationTokenByEmail = async ( 18 | email: string 19 | ) => { 20 | try { 21 | const verificationToken = await db.verificationToken.findFirst({ 22 | where: { email } 23 | }); 24 | return verificationToken; 25 | } catch { 26 | return null 27 | } 28 | } -------------------------------------------------------------------------------- /src/components/auth/role-gate.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { UserRole } from "@prisma/client"; 3 | import { useCurrentRole } from "../../../hooks/use-current-role"; 4 | import { FormError } from "../form-error"; 5 | 6 | interface RoleGateProps { 7 | children: React.ReactNode; 8 | allowedRole: UserRole; 9 | }; 10 | 11 | export const RoleGate = ({ 12 | children, 13 | allowedRole 14 | }: RoleGateProps) => { 15 | const role = useCurrentRole() 16 | 17 | if (role !== allowedRole) { 18 | return ( 19 | 20 | ) 21 | } 22 | 23 | return ( 24 | <> 25 | {children} 26 | 27 | ); 28 | }; -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "bundler", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "incremental": true, 19 | "plugins": [ 20 | { 21 | "name": "next" 22 | } 23 | ], 24 | "paths": { 25 | "@/*": [ 26 | "./src/*" 27 | ] 28 | }, 29 | "target": "ES2017" 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts" 36 | ], 37 | "exclude": [ 38 | "node_modules" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /src/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 = [ 7 | '/', 8 | '/auth/new-verification' 9 | ]; 10 | 11 | /** 12 | * An array of routes that are used for authentication 13 | * These routes will redirect logged-in users to /settings 14 | * @type {string[]} 15 | */ 16 | export const authRoutes = [ 17 | "/auth/login", 18 | "/auth/register", 19 | "/auth/error", 20 | "/auth/reset", 21 | "/auth/new-password" 22 | ]; 23 | 24 | /** 25 | * The prefix for API authentication routes 26 | * Routes that start with this prefix are used for API authentication purposes 27 | * @type {string} 28 | */ 29 | export const apiAuthPrefix = "/api/auth"; 30 | 31 | /** 32 | * The default redirect path after logging in 33 | * @type {string} 34 | */ 35 | export const DEFAULT_LOGIN_REDIRECT = '/settings'; 36 | -------------------------------------------------------------------------------- /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/tokens"; 9 | 10 | export const reset = async (values: z.infer) => { 11 | const validatedFields = ResetSchema.safeParse(values); 12 | 13 | if (!validatedFields.success) { 14 | return { error: "Invalid email!" }; 15 | } 16 | 17 | const { email } = validatedFields.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 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Poppins } from "next/font/google" 2 | import { cn } from "../lib/utils" 3 | import { Button } from "../components/ui/button"; 4 | import { LoginButton } from "../components/auth/login-button"; 5 | const font = Poppins({ 6 | subsets: ["latin"], 7 | weight: ["600"] 8 | }) 9 | export default function Home() { 10 | return ( 11 |
12 |
13 |

15 | 🔐 Auth 16 |

17 |

18 | A simple authentication service 19 |

20 |
21 | 22 | 25 | 26 |
27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /actions/new-verification.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { db } from "@/lib/db" 4 | import { getUserByEmail } from "../data/user" 5 | import { getVerificationTokenByToken } from "../data/verficiation-token" 6 | 7 | export const newVerification = async (token: string) => { 8 | 9 | const existingToken = await getVerificationTokenByToken(token); 10 | 11 | if (!existingToken) { 12 | return { error: "Token does not exist" } 13 | } 14 | 15 | const hasExpired = new Date(existingToken.expires) < new Date(); 16 | 17 | if (hasExpired) { 18 | return { error: "Token has expired!" } 19 | } 20 | 21 | const existingUser = await getUserByEmail(existingToken.email); 22 | 23 | if (!existingUser) { 24 | return { error: "Email does not exist!" } 25 | } 26 | 27 | await db.user.update({ 28 | where: { id: existingUser.id }, 29 | data: { 30 | emailVerified: new Date(), 31 | email: existingUser.email 32 | } 33 | }); 34 | 35 | await db.verificationToken.delete({ 36 | where: { id: existingToken.id } 37 | }); 38 | 39 | return { success: "Email verified!" } 40 | } -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import { SessionProvider } from "next-auth/react"; 4 | import { auth } from "../../auth"; 5 | import "./globals.css"; 6 | import { Session } from "inspector/promises"; 7 | import { Toaster } from "@/components/ui/sonner" 8 | 9 | const geistSans = localFont({ 10 | src: "./fonts/GeistVF.woff", 11 | variable: "--font-geist-sans", 12 | weight: "100 900", 13 | }); 14 | const geistMono = localFont({ 15 | src: "./fonts/GeistMonoVF.woff", 16 | variable: "--font-geist-mono", 17 | weight: "100 900", 18 | }); 19 | 20 | export const metadata: Metadata = { 21 | title: "Auth 2FA", 22 | description: "Generated by create next app", 23 | }; 24 | 25 | export default async function RootLayout({ 26 | children, 27 | }: Readonly<{ 28 | children: React.ReactNode; 29 | }>) { 30 | const session = await auth() 31 | return ( 32 | 33 | 34 | 37 | 38 | {children} 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/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 { Button } from "../ui/button" 7 | import { useSearchParams } from "next/navigation" 8 | 9 | const DEFAULT_LOGIN_REDIRECT = "/settings" 10 | 11 | export const Social = () => { 12 | const searchParams = useSearchParams(); 13 | const callbackUrl = searchParams.get("callbackUrl"); 14 | const onClick = (provider: "google" | "github") => { 15 | signIn(provider, { 16 | callbackUrl: callbackUrl || DEFAULT_LOGIN_REDIRECT 17 | }).catch(err => console.error(err)); // Error handling 18 | } 19 | 20 | return ( 21 |
22 | 30 | 38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/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_PUBLICE_APP_URL; 6 | 7 | export const sendTwoFactorTokenEmail = async ( 8 | email: string, 9 | token: string 10 | ) => { 11 | await resend.emails.send({ 12 | from: "onboarding@resend.dev", 13 | to: email, 14 | subject: "2Fa Code", 15 | html: `

Your 2Fa code ${token}

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

here to confrim email

` 29 | }) 30 | } 31 | 32 | export const sendVerificationEmail = async ( 33 | email: string, 34 | token: string) => { 35 | const confirmLink = `${domain}/auth/new-verification?token=${token}`; 36 | 37 | 38 | await resend.emails.send({ 39 | from: "onboarding@resend.dev", 40 | to: email, 41 | subject: "Confrim your email", 42 | html: `

here to Reset password

` 43 | }) 44 | } -------------------------------------------------------------------------------- /actions/register.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | import * as z from "zod" 3 | import bcrypt from "bcrypt" 4 | import { db } from "@/lib/db"; 5 | import { RegisterSchema } from "../schemas"; 6 | import { getUserByEmail } from "../data/user"; 7 | import { generateVerificationToken } from "@/lib/tokens"; 8 | import { sendVerificationEmail } from "@/lib/mail"; 9 | 10 | export const register = async (values: z.infer) => { 11 | const validatedFields = RegisterSchema.safeParse(values); 12 | 13 | if (!validatedFields.success) { 14 | return { error: "Invalid fields" }; 15 | } 16 | 17 | const { email, password, name } = validatedFields.data; 18 | const hashedPassword = await bcrypt.hash(password, 10) 19 | 20 | const existingUser = await getUserByEmail(email); 21 | 22 | if (existingUser) { 23 | return { error: "Email already in use!" } 24 | } 25 | 26 | await db.user.create({ 27 | data: { 28 | name, 29 | email, 30 | password: hashedPassword 31 | }, 32 | }); 33 | 34 | 35 | const verficationToken = await generateVerificationToken(email) 36 | await sendVerificationEmail( 37 | verficationToken.email, 38 | verficationToken.token, 39 | ) 40 | 41 | return { success: "Confirmation email sent!" }; 42 | } -------------------------------------------------------------------------------- /src/components/auth/login-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useRouter } from "next/navigation"; 3 | 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogTrigger, 8 | DialogTitle, 9 | } from "@/components/ui/dialog"; 10 | import LoginForm from "./login-form"; 11 | 12 | interface LoginButtonProps { 13 | children: React.ReactNode; 14 | mode?: "modal" | "redirect"; 15 | asChild?: boolean; 16 | } 17 | 18 | export const LoginButton = ({ 19 | children, 20 | mode = "redirect", 21 | asChild, 22 | }: LoginButtonProps) => { 23 | const router = useRouter(); 24 | 25 | const onclick = () => { 26 | router.push("/auth/login"); 27 | }; 28 | 29 | if (mode === "modal") { 30 | return ( 31 | 32 | 33 | {children} 34 | 35 | 36 | {/* Add an accessible title */} 37 | Login 38 | 39 | 40 | 41 | ); 42 | } 43 | return ( 44 | 45 | {children} 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/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 | 34 | {children} 35 | 36 | {showSocial && ( 37 | 38 | 39 | 40 | )} 41 | 42 | 46 | 47 | 48 | ) 49 | } -------------------------------------------------------------------------------- /src/components/auth/user-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuTrigger, 8 | } from '@/components/ui/dropdown-menu'; 9 | 10 | import { 11 | Avatar, 12 | AvatarImage, 13 | AvatarFallback 14 | } from "@/components/ui/avatar"; 15 | import { FaUser } from 'react-icons/fa'; 16 | import { useCurrentUser } from '../../../hooks/use-current-user'; 17 | import { LogoutButton } from './logout-button'; 18 | import { ExitIcon } from '@radix-ui/react-icons'; 19 | 20 | export const UserButton = () => { 21 | const user = useCurrentUser(); 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | Logout 37 | 38 | 39 | 40 | 41 | ); 42 | } -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /actions/new-password.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import * as z from "zod"; 4 | import bcrypt from "bcryptjs" 5 | import { NewPasswordSchema } from "../schemas"; 6 | import { getPasswordResetTokenByToken } from "../data/password-reset-token"; 7 | import { getUserByEmail } from "../data/user"; 8 | import { db } from "@/lib/db"; 9 | 10 | export const newPassword = async ( 11 | values: z.infer, 12 | token?: string | null, 13 | ) => { 14 | if (!token) { 15 | return { error: "Missing token!" } 16 | } 17 | 18 | const validatedFields = NewPasswordSchema.safeParse(values) 19 | 20 | if (!validatedFields.success) { 21 | return { error: "Invalid fields!" }; 22 | } 23 | 24 | const { password } = validatedFields.data; 25 | 26 | const existingToken = await getPasswordResetTokenByToken(token) 27 | 28 | if (!existingToken) { 29 | return { error: "Invalid token!" } 30 | } 31 | 32 | const hasExpired = new Date(existingToken.expires) < new Date(); 33 | 34 | if (hasExpired) { 35 | return { error: "Token has expired!" }; 36 | } 37 | 38 | const existingUser = await getUserByEmail(existingToken.email); 39 | 40 | if (!existingUser) { 41 | return { error: "Email does not exist!" } 42 | } 43 | 44 | const hashedPassword = await bcrypt.hash(password, 10); 45 | 46 | await db.user.update({ 47 | where: { id: existingUser.id }, 48 | data: { password: hashedPassword } 49 | }) 50 | 51 | await db.passwordResetToken.delete({ 52 | where: { id: existingToken.id } 53 | }); 54 | 55 | return { success: "password updated!" } 56 | } -------------------------------------------------------------------------------- /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 { LoginSchema } from "./schemas"; 5 | import { getUserByEmail } from "./data/user"; 6 | import Github from "next-auth/providers/github" 7 | import Google from "next-auth/providers/google" 8 | export default { 9 | providers: [ 10 | Github({ 11 | clientId: process.env.GITHUB_CLIENT_ID, 12 | clientSecret: process.env.GITHUB_CLIENT_SECRET 13 | }), 14 | Google 15 | ({ 16 | clientId: process.env.GOOGLE_CLIENT_ID, 17 | clientSecret: process.env.GOOGLE_CLIENT_SECRET 18 | }), 19 | Credentials({ 20 | async authorize(credentials) { 21 | const validatedFields = LoginSchema.safeParse(credentials); 22 | 23 | if (validatedFields.success) { 24 | const { email, password } = validatedFields.data; 25 | 26 | const user = await getUserByEmail(email); 27 | 28 | // Check if user and password exist, and if the password is defined 29 | if (!user || typeof user.password !== 'string') { 30 | return null; 31 | } 32 | 33 | // Compare the provided password with the stored hash 34 | const passwordsMatch = await bcrypt.compare( 35 | password, 36 | user.password 37 | ); 38 | 39 | if (passwordsMatch) return user; 40 | } 41 | return null; 42 | } 43 | }) 44 | ] 45 | } satisfies NextAuthConfig; 46 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import authConfig from "../auth.config"; 2 | import NextAuth from "next-auth"; 3 | import { 4 | DEFAULT_LOGIN_REDIRECT, 5 | apiAuthPrefix, 6 | publicRoutes, 7 | authRoutes, 8 | } from "../routes"; 9 | import { NextResponse } from "next/server"; 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 | // Allow API auth routes 22 | if (isApiAuthRoute) { 23 | return NextResponse.next(); 24 | } 25 | 26 | // Redirect logged-in users away from auth routes 27 | if (isAuthRoute) { 28 | if (isLoggedIn) { 29 | return NextResponse.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl)); 30 | } 31 | return NextResponse.next(); 32 | } 33 | 34 | // Handle unauthenticated access to protected routes 35 | if (!isLoggedIn && !isPublicRoute) { 36 | let callbackUrl = nextUrl.pathname; 37 | if (nextUrl.search) { 38 | callbackUrl += nextUrl.search; 39 | } 40 | 41 | const encodedCallbackUrl = encodeURIComponent(callbackUrl); 42 | return NextResponse.redirect( 43 | new URL(`/auth/login?callbackUrl=${encodedCallbackUrl}`, nextUrl) 44 | ); 45 | } 46 | 47 | return NextResponse.next(); 48 | }); 49 | 50 | export const config = { 51 | matcher: [ 52 | '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', 53 | '/(api|trpc)(.*)', 54 | ], 55 | }; 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-auth-2fa", 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": "^2.5.0", 14 | "@hookform/resolvers": "^3.9.0", 15 | "@prisma/client": "^5.21.1", 16 | "@radix-ui/react-avatar": "^1.1.1", 17 | "@radix-ui/react-dialog": "^1.1.2", 18 | "@radix-ui/react-dropdown-menu": "^2.1.2", 19 | "@radix-ui/react-icons": "^1.3.0", 20 | "@radix-ui/react-label": "^2.1.0", 21 | "@radix-ui/react-select": "^2.1.2", 22 | "@radix-ui/react-slot": "^1.1.0", 23 | "@radix-ui/react-switch": "^1.1.1", 24 | "bcrypt": "^5.1.1", 25 | "bcryptjs": "^2.4.3", 26 | "class-variance-authority": "^0.7.0", 27 | "clsx": "^2.1.1", 28 | "lucide-react": "^0.441.0", 29 | "next": "^15.0.3", 30 | "next-auth": "^5.0.0-beta.25", 31 | "next-themes": "^0.3.0", 32 | "react": "^18", 33 | "react-dom": "^18", 34 | "react-hook-form": "^7.53.0", 35 | "react-icons": "^5.3.0", 36 | "react-spinners": "^0.14.1", 37 | "resend": "^4.0.0", 38 | "shadcn-ui": "^0.2.3", 39 | "sonner": "^1.5.0", 40 | "tailwind-merge": "^2.5.2", 41 | "tailwindcss-animate": "^1.0.7", 42 | "three": "^0.169.0", 43 | "uuid": "^10.0.0", 44 | "zod": "^3.23.8" 45 | }, 46 | "devDependencies": { 47 | "@types/bcrypt": "^5.0.2", 48 | "@types/bcryptjs": "^2.4.6", 49 | "@types/node": "^20", 50 | "@types/react": "^18", 51 | "@types/react-dom": "^18", 52 | "@types/uuid": "^10.0.0", 53 | "eslint": "^8", 54 | "eslint-config-next": "14.2.11", 55 | "postcss": "^8", 56 | "prisma": "^5.21.1", 57 | "tailwindcss": "^3.4.1", 58 | "typescript": "^5.6.3" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app/(protected)/_components/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname } from "next/navigation"; 5 | import { Button } from "@/components/ui/button"; 6 | import { UserButton } from "@/components/auth/user-button"; 7 | 8 | export const Navbar = () => { 9 | const pathname = usePathname(); 10 | 11 | return ( 12 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | card: { 16 | DEFAULT: 'hsl(var(--card))', 17 | foreground: 'hsl(var(--card-foreground))' 18 | }, 19 | popover: { 20 | DEFAULT: 'hsl(var(--popover))', 21 | foreground: 'hsl(var(--popover-foreground))' 22 | }, 23 | primary: { 24 | DEFAULT: 'hsl(var(--primary))', 25 | foreground: 'hsl(var(--primary-foreground))' 26 | }, 27 | secondary: { 28 | DEFAULT: 'hsl(var(--secondary))', 29 | foreground: 'hsl(var(--secondary-foreground))' 30 | }, 31 | muted: { 32 | DEFAULT: 'hsl(var(--muted))', 33 | foreground: 'hsl(var(--muted-foreground))' 34 | }, 35 | accent: { 36 | DEFAULT: 'hsl(var(--accent))', 37 | foreground: 'hsl(var(--accent-foreground))' 38 | }, 39 | destructive: { 40 | DEFAULT: 'hsl(var(--destructive))', 41 | foreground: 'hsl(var(--destructive-foreground))' 42 | }, 43 | border: 'hsl(var(--border))', 44 | input: 'hsl(var(--input))', 45 | ring: 'hsl(var(--ring))', 46 | chart: { 47 | '1': 'hsl(var(--chart-1))', 48 | '2': 'hsl(var(--chart-2))', 49 | '3': 'hsl(var(--chart-3))', 50 | '4': 'hsl(var(--chart-4))', 51 | '5': 'hsl(var(--chart-5))' 52 | } 53 | }, 54 | borderRadius: { 55 | lg: 'var(--radius)', 56 | md: 'calc(var(--radius) - 2px)', 57 | sm: 'calc(var(--radius) - 4px)' 58 | } 59 | } 60 | }, 61 | plugins: [require("tailwindcss-animate")], 62 | }; 63 | export default config; 64 | -------------------------------------------------------------------------------- /schemas/index.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from "@prisma/client"; 2 | import * as z from "zod"; 3 | import { newPassword } from "../actions/new-password"; 4 | 5 | export const SettingsSchema = z.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((data) => { 14 | if (data.password && !data.newPassword) { 15 | return false 16 | } 17 | return true 18 | },{ 19 | message:"New password is required?", 20 | path:["newPassword"] 21 | }) 22 | .refine((data) => { 23 | if (data.newPassword && !data.password) { 24 | return false 25 | } 26 | return true 27 | },{ 28 | message:"password is required?", 29 | path:["password"] 30 | }) 31 | 32 | export const NewPasswordSchema = z.object({ 33 | password: z.string().min(6, { 34 | message: "Password must be at least 6 characters", 35 | }), 36 | }); 37 | 38 | 39 | export const ResetSchema = z.object({ 40 | email: z.string().email({ 41 | message: "Email is required" 42 | }), 43 | }) 44 | 45 | 46 | export const LoginSchema = z.object({ 47 | email: z.string().email({ 48 | message: "Email is required" 49 | }), 50 | password: z.string().min(6, { 51 | message: "Password must be at least 6 characters" 52 | }), 53 | 54 | code: z.optional(z.string()) 55 | }) 56 | 57 | 58 | 59 | export const RegisterSchema = z.object({ 60 | email: z.string().email({ 61 | message: "Email is required" 62 | }), 63 | password: z.string().min(6, 64 | { 65 | message: "Password must be at least 6 characters" 66 | }), 67 | name: z.string().min(3, { 68 | message: "Name is required" 69 | }), 70 | }) -------------------------------------------------------------------------------- /src/components/auth/new-verification-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { CardWrapper } from "./card-wrapper"; 4 | import { useCallback, useEffect, useState } from "react"; 5 | import { BeatLoader } from "react-spinners"; 6 | import { useSearchParams } from "next/navigation"; 7 | import { newVerification } from "../../../actions/new-verification"; 8 | import { FormError } from "@/components/form-error"; 9 | import { FormSuccess } from "@/components/form-success"; 10 | 11 | const NewVerificationForm = () => { 12 | const [error, setError] = useState() 13 | const [success, setSuccess] = useState() 14 | 15 | const searchParams = useSearchParams(); 16 | 17 | const token = searchParams.get("token") 18 | 19 | const onSubmit = useCallback(() => { 20 | 21 | if (success || error) return; 22 | 23 | if (!token) { 24 | setError("Missing Token!"); 25 | return; 26 | } 27 | 28 | newVerification(token) 29 | .then((data) => { 30 | setSuccess(data.success) 31 | setError(data.error) 32 | }) 33 | .catch(() => { 34 | setError("Somethings went wrong!") 35 | }) 36 | }, [token, success, error]) 37 | 38 | useEffect(() => { 39 | onSubmit(); 40 | }, [onSubmit]) 41 | return ( 42 | 46 | 47 |
48 | {!success && !error && 49 | } 50 | 51 | 52 | {!success && ( 53 | 54 | )} 55 | 56 |
57 |
58 | ) 59 | } 60 | 61 | export default NewVerificationForm 62 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium 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 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/lib/tokens.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | import { db } from "@/lib/db"; 4 | import { getVerificationTokenByEmail } from "../../data/verficiation-token"; 5 | import { getPasswordResetTokenByEmail } from "../../data/password-reset-token"; 6 | import { getTwoFactorTokenByEmail } from "../../data/two-fator-token"; 7 | 8 | export const generateTwoFactorToken = async (email: string) => { 9 | const token = crypto.randomInt(100_000, 1_000_000).toString(); 10 | const expires = new Date(new Date().getTime() + 5 * 60 * 1000); 11 | 12 | const existingToken = await getTwoFactorTokenByEmail(email); 13 | 14 | if (existingToken) { 15 | await db.twoFactorToken.delete({ 16 | where: { 17 | id: existingToken.id, 18 | }, 19 | }); 20 | } 21 | 22 | const twoFactorToken = await db.twoFactorToken.create({ 23 | data: { 24 | email, 25 | token, 26 | expires, 27 | }, 28 | }); 29 | 30 | return twoFactorToken; 31 | }; 32 | 33 | export const generatePasswordResetToken = async (email: string) => { 34 | const token = uuidv4(); 35 | const expires = new Date(new Date().getTime() + 3600 * 1000); 36 | 37 | const existingToken = await getPasswordResetTokenByEmail(email); 38 | 39 | if (existingToken) { 40 | await db.passwordResetToken.delete({ 41 | where: { id: existingToken.id }, 42 | }); 43 | } 44 | 45 | const passwordResetToken = await db.passwordResetToken.create({ 46 | data: { 47 | email, 48 | token, 49 | expires, 50 | }, 51 | }); 52 | 53 | return passwordResetToken; 54 | }; 55 | 56 | export const generateVerificationToken = async (email: string) => { 57 | const token = uuidv4(); 58 | const expires = new Date(new Date().getTime() + 3600 * 1000); 59 | 60 | const existingToken = await getVerificationTokenByEmail(email); 61 | 62 | if (existingToken) { 63 | await db.verificationToken.delete({ 64 | where: { 65 | id: existingToken.id, 66 | }, 67 | }); 68 | } 69 | 70 | const verificationToken = await db.verificationToken.create({ 71 | data: { 72 | email, 73 | token, 74 | expires, 75 | }, 76 | }); 77 | 78 | return verificationToken; 79 | }; 80 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root{ 8 | height: 100%; 9 | } 10 | :root { 11 | --background: #ffffff; 12 | --foreground: #171717; 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | :root { 17 | --background: #0a0a0a; 18 | --foreground: #ededed; 19 | } 20 | } 21 | 22 | body { 23 | color: var(--foreground); 24 | background: var(--background); 25 | font-family: Arial, Helvetica, sans-serif; 26 | } 27 | 28 | @layer utilities { 29 | .text-balance { 30 | text-wrap: balance; 31 | } 32 | } 33 | 34 | @layer base { 35 | :root { 36 | --background: 0 0% 100%; 37 | --foreground: 240 10% 3.9%; 38 | --card: 0 0% 100%; 39 | --card-foreground: 240 10% 3.9%; 40 | --popover: 0 0% 100%; 41 | --popover-foreground: 240 10% 3.9%; 42 | --primary: 240 5.9% 10%; 43 | --primary-foreground: 0 0% 98%; 44 | --secondary: 240 4.8% 95.9%; 45 | --secondary-foreground: 240 5.9% 10%; 46 | --muted: 240 4.8% 95.9%; 47 | --muted-foreground: 240 3.8% 46.1%; 48 | --accent: 240 4.8% 95.9%; 49 | --accent-foreground: 240 5.9% 10%; 50 | --destructive: 0 84.2% 60.2%; 51 | --destructive-foreground: 0 0% 98%; 52 | --border: 240 5.9% 90%; 53 | --input: 240 5.9% 90%; 54 | --ring: 240 10% 3.9%; 55 | --chart-1: 12 76% 61%; 56 | --chart-2: 173 58% 39%; 57 | --chart-3: 197 37% 24%; 58 | --chart-4: 43 74% 66%; 59 | --chart-5: 27 87% 67%; 60 | --radius: 0.5rem; 61 | } 62 | .dark { 63 | --background: 240 10% 3.9%; 64 | --foreground: 0 0% 98%; 65 | --card: 240 10% 3.9%; 66 | --card-foreground: 0 0% 98%; 67 | --popover: 240 10% 3.9%; 68 | --popover-foreground: 0 0% 98%; 69 | --primary: 0 0% 98%; 70 | --primary-foreground: 240 5.9% 10%; 71 | --secondary: 240 3.7% 15.9%; 72 | --secondary-foreground: 0 0% 98%; 73 | --muted: 240 3.7% 15.9%; 74 | --muted-foreground: 240 5% 64.9%; 75 | --accent: 240 3.7% 15.9%; 76 | --accent-foreground: 0 0% 98%; 77 | --destructive: 0 62.8% 30.6%; 78 | --destructive-foreground: 0 0% 98%; 79 | --border: 240 3.7% 15.9%; 80 | --input: 240 3.7% 15.9%; 81 | --ring: 240 4.9% 83.9%; 82 | --chart-1: 220 70% 50%; 83 | --chart-2: 160 60% 45%; 84 | --chart-3: 30 80% 55%; 85 | --chart-4: 280 65% 60%; 86 | --chart-5: 340 75% 55%; 87 | } 88 | } 89 | 90 | @layer base { 91 | * { 92 | @apply border-border; 93 | } 94 | body { 95 | @apply bg-background text-foreground; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | datasource db { 12 | provider = "postgresql" 13 | url = env("DATABASE_URL") 14 | } 15 | 16 | enum UserRole { 17 | ADMIN 18 | USER 19 | } 20 | 21 | model User { 22 | id String @id @default(cuid()) 23 | name String? 24 | email String? @unique 25 | emailVerified DateTime? @map("email_verified") 26 | image String? 27 | password String? 28 | role UserRole @default(USER) 29 | accounts Account[] 30 | isTwoFactorEnabled Boolean @default(false) 31 | twoFactorConfirmation TwoFactorConfirmation? 32 | 33 | @@map("users") 34 | } 35 | 36 | model Account { 37 | id String @id @default(cuid()) 38 | userId String @map("user_id") 39 | type String 40 | provider String 41 | providerAccountId String @map("provider_account_id") 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 | @@map("accounts") 54 | } 55 | 56 | model VerificationToken { 57 | id String @id @default(cuid()) 58 | email String 59 | token String @unique 60 | expires DateTime 61 | 62 | @@unique([email, token]) 63 | } 64 | 65 | model PasswordResetToken { 66 | id String @id @default(cuid()) 67 | email String 68 | token String @unique 69 | expires DateTime 70 | 71 | @@unique([email, token]) 72 | } 73 | 74 | model TwoFactorToken { 75 | id String @id @default(cuid()) 76 | email String 77 | token String @unique 78 | expires DateTime 79 | 80 | @@unique([email, token]) 81 | } 82 | 83 | model TwoFactorConfirmation { 84 | id String @id @default(cuid()) 85 | userId String 86 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 87 | 88 | @@unique([userId]) 89 | } 90 | -------------------------------------------------------------------------------- /src/app/(protected)/admin/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { RoleGate } from "@/components/auth/role-gate"; 4 | import { FormSuccess } from "@/components/form-success"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Card, CardContent, CardHeader } from "@/components/ui/card"; 7 | import { UserRole } from "@prisma/client"; 8 | import { toast } from "sonner"; 9 | import { admin } from "../../../../actions/admin"; 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 | return ( 36 | 37 | 38 |

39 | 🔑 Admin 40 |

41 |
42 | 43 | 44 | 46 | 47 |
48 |

49 | Admin Only Api Route 50 |

51 | 54 |
55 |
56 |

57 | Admin Only Server Action 58 |

59 | 62 |
63 |
64 |
65 | ) 66 | } 67 | export default AdminPage -------------------------------------------------------------------------------- /actions/setting.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import * as z from "zod"; 4 | import bcrypt from "bcryptjs" 5 | import { db } from "@/lib/db" 6 | import { SettingsSchema } from "../schemas"; 7 | import { getUserByEmail, getUserById } from "../data/user"; 8 | import { currentUser } from "@/lib/auth"; 9 | import { generateVerificationToken } from "@/lib/tokens"; 10 | import { sendVerificationEmail } from "@/lib/mail"; 11 | import { update } from "../auth"; 12 | 13 | export const settings = async ( 14 | values: z.infer 15 | ) => { 16 | const user = await currentUser(); 17 | 18 | if (!user || !user.id) { 19 | return { error: "Unauthorized" }; 20 | } 21 | 22 | const dbUser = await getUserById(user.id); 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 | const verficationToken = await generateVerificationToken( 41 | values.email 42 | ); 43 | await sendVerificationEmail( 44 | verficationToken.email, 45 | verficationToken.token, 46 | ); 47 | return { success: "Verfication 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( 61 | values.newPassword, 62 | 10 63 | ); 64 | values.password = hashedPassword; 65 | values.newPassword = undefined; 66 | } 67 | 68 | 69 | const updatedUser = await db.user.update({ 70 | where: { id: dbUser.id }, 71 | data: { 72 | ...values, 73 | }, 74 | }); 75 | update({ 76 | user: { 77 | name: updatedUser.name, 78 | email: updatedUser.email, 79 | isTwoFactorEnabled: updatedUser.isTwoFactorEnabled, 80 | role: updatedUser.role, 81 | } 82 | }) 83 | 84 | return { success: "Settings Updated!" } 85 | } -------------------------------------------------------------------------------- /src/components/auth/reset-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import * as z from "zod"; 3 | import { useForm } from "react-hook-form"; 4 | import { zodResolver } from "@hookform/resolvers/zod"; 5 | import { CardWrapper } from "./card-wrapper"; 6 | import { ResetSchema } from "../../../schemas"; 7 | import { 8 | Form, 9 | FormControl, 10 | FormField, 11 | FormItem, 12 | FormLabel, 13 | FormMessage, 14 | } from "@/components/ui/form"; 15 | import { Input } from "@/components/ui/input"; 16 | import { Button } from "@/components/ui/button"; 17 | import { FormError } from "@/components/form-error"; 18 | import { FormSuccess } from "@/components/form-success"; 19 | import { reset } from "../../../actions/reset"; 20 | import { useState, useTransition } from "react"; 21 | 22 | const ResetForm = () => { 23 | const [error, setError] = useState(""); 24 | const [success, setSuccess] = useState(""); 25 | const [isPending, startTransition] = useTransition(); 26 | 27 | const form = useForm>({ 28 | resolver: zodResolver(ResetSchema), 29 | defaultValues: { 30 | email: "", 31 | }, 32 | }); 33 | 34 | const onSubmit = (values: z.infer) => { 35 | setError(""); 36 | setSuccess(""); 37 | 38 | startTransition(() => { 39 | reset(values) 40 | .then((data) => { 41 | if (data?.error) { 42 | setError(data.error); 43 | } else { 44 | setSuccess(data.success); 45 | } 46 | }) 47 | }) 48 | } 49 | 50 | return ( 51 | 56 |
57 | 58 |
59 | ( 63 | 64 | Email 65 | 66 | 67 | 68 | 69 | 70 | )} 71 | /> 72 |
73 | 74 | 75 | 78 | 79 | 80 |
81 | ); 82 | }; 83 | 84 | export default ResetForm; 85 | -------------------------------------------------------------------------------- /src/components/user-info.tsx: -------------------------------------------------------------------------------- 1 | import { ExtendedUser } from "../../next-auth"; 2 | import { Badge } from "./ui/badge"; 3 | import { Card, CardHeader, CardContent } from "./ui/card"; 4 | 5 | interface UserInfoProps { 6 | user?: ExtendedUser; 7 | label: string; 8 | } 9 | 10 | export const UserInfo = ({ 11 | user, 12 | label, 13 | }: UserInfoProps) => { 14 | return ( 15 | 16 | 17 |

18 | {label} 19 |

20 |
21 | 22 |
23 |

24 | Name 25 |

26 |

27 | {user?.name} 28 |

29 |
30 |
31 |

32 | ID 33 |

34 |

35 | {user?.id} 36 |

37 |
38 |
39 |

40 | Email 41 |

42 |

43 | {user?.email} 44 |

45 |
46 |
47 |

48 | Role 49 |

50 |

51 | {user?.role} 52 |

53 |
54 |
55 |

56 | Two Factor Authentication 57 |

58 | 59 | {user?.isTwoFactorEnabled ? "ON" : "OFF"} 60 | 61 |
62 |
63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth" 2 | import authConfig from "./auth.config" 3 | import { getUserById } from "./data/user" 4 | import { PrismaAdapter } from "@auth/prisma-adapter" 5 | import { db } from "@/lib/db" 6 | import { getTwoFactorConfirmationByUserId } from "./data/two-factor-confirmation" 7 | import { getAccountByUserId } from "./data/account" 8 | 9 | 10 | 11 | 12 | export const { 13 | handlers: { GET, POST }, 14 | auth, 15 | signIn, 16 | signOut, 17 | update 18 | } = NextAuth({ 19 | pages: { 20 | signIn: "/auth/login", 21 | error: "/auth/error" 22 | }, 23 | events: { 24 | async linkAccount({ user }) { 25 | await db.user.update({ 26 | where: { id: user.id }, 27 | data: { emailVerified: new Date() } 28 | }) 29 | } 30 | }, 31 | callbacks: { 32 | async signIn({ user, account }) { 33 | if (account?.provider !== "credentials") return true; 34 | 35 | 36 | if (!user?.id) { 37 | console.log("User ID not found."); 38 | return false; 39 | } 40 | 41 | 42 | const existingUser = await getUserById(user.id); 43 | 44 | 45 | // Prevent sign in without email verification 46 | if (!existingUser?.emailVerified) { 47 | console.log("Email not verified."); 48 | return false; 49 | } 50 | 51 | 52 | // Check if 2FA is enabled for the user 53 | if (existingUser.isTwoFactorEnabled) { 54 | const twoFactorConfirmation = await getTwoFactorConfirmationByUserId(existingUser.id); 55 | 56 | 57 | console.log({ twoFactorConfirmation }) 58 | 59 | 60 | if (!twoFactorConfirmation) return false; 61 | 62 | 63 | await db.twoFactorConfirmation.delete({ 64 | where: { id: twoFactorConfirmation.id } 65 | }) 66 | } 67 | 68 | 69 | return true; // Allow login if all checks pass 70 | }, 71 | async session({ token, session }) { 72 | if (token.sub && session.user) { 73 | session.user.id = token.sub; 74 | } 75 | 76 | 77 | if (token.role && session.user) { 78 | session.user.role = token.role; 79 | } 80 | 81 | if (session.user) { 82 | session.user.isTwoFactorEnabled = token.isTwoFactorEnabled as boolean; 83 | } 84 | 85 | if (session.user) { 86 | session.user.name = token.name; 87 | session.user.email = token.email as string; 88 | session.user.isOAuth = token.isOAuth as boolean; 89 | } 90 | 91 | return session; 92 | }, 93 | async jwt({ token }) { 94 | if (!token.sub) return token; 95 | const existingUser = await getUserById(token.sub); 96 | 97 | 98 | if (!existingUser) return token; 99 | 100 | const existingAccount = await getAccountByUserId( 101 | existingUser.id 102 | ) 103 | token.isOAuth = !!existingAccount 104 | token.name = existingUser.name; 105 | token.email = existingUser.email 106 | token.role = existingUser.role; 107 | 108 | token.isTwoFactorEnabled = existingUser.isTwoFactorEnabled; 109 | 110 | 111 | return token; 112 | } 113 | }, 114 | adapter: PrismaAdapter(db), 115 | session: { strategy: "jwt" }, 116 | ...authConfig 117 | }) 118 | -------------------------------------------------------------------------------- /src/components/auth/new-password-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import * as z from "zod"; 3 | import { useForm } from "react-hook-form"; 4 | import { zodResolver } from "@hookform/resolvers/zod"; 5 | import { CardWrapper } from "./card-wrapper"; 6 | import { NewPasswordSchema } from "../../../schemas"; // Ensure the schema is correct 7 | import { useSearchParams } from "next/navigation"; 8 | import { 9 | Form, 10 | FormControl, 11 | FormField, 12 | FormItem, 13 | FormLabel, 14 | FormMessage, 15 | } from "@/components/ui/form"; 16 | import { Input } from "@/components/ui/input"; 17 | import { Button } from "@/components/ui/button"; 18 | import { FormError } from "@/components/form-error"; 19 | import { FormSuccess } from "@/components/form-success"; 20 | import { newPassword } from "../../../actions/new-password"; 21 | import { useState, useTransition } from "react"; 22 | 23 | const NewPasswordForm = () => { 24 | const searchParams = useSearchParams(); 25 | const token = searchParams.get('token'); 26 | 27 | const [error, setError] = useState(""); 28 | const [success, setSuccess] = useState(""); 29 | const [isPending, startTransition] = useTransition(); 30 | 31 | const form = useForm>({ 32 | resolver: zodResolver(NewPasswordSchema), 33 | defaultValues: { 34 | password: "", 35 | }, 36 | }); 37 | 38 | const onSubmit = (values: z.infer) => { 39 | if (!token) { 40 | setError("Invalid or missing token."); 41 | return; 42 | } 43 | 44 | setError(""); 45 | setSuccess(""); 46 | 47 | startTransition(() => { 48 | newPassword(values, token) 49 | .then((data) => { 50 | if (data?.error) { 51 | setError(data?.error); 52 | } else { 53 | setSuccess(data?.success); 54 | } 55 | }); 56 | }); 57 | }; 58 | 59 | return ( 60 | 65 |
66 | 67 |
68 | {/* Ensure name="password" is set here */} 69 | ( 73 | 74 | Password 75 | 76 | 80 | 81 | 82 | 83 | )} 84 | /> 85 |
86 | 87 | 88 | 91 | 92 | 93 |
94 | ); 95 | }; 96 | 97 | export default NewPasswordForm; 98 | -------------------------------------------------------------------------------- /actions/login.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | import * as z from "zod" 3 | 4 | 5 | import { signIn } from '../auth' 6 | import { LoginSchema } from "../schemas"; 7 | import { db } from "@/lib/db"; 8 | import { DEFAULT_LOGIN_REDIRECT } from "../routes"; 9 | import { getTwoFactorTokenByEmail } from "../data/two-fator-token"; 10 | import { AuthError } from "next-auth"; 11 | import { generateVerificationToken, generateTwoFactorToken } from "@/lib/tokens"; 12 | import { getUserByEmail } from "../data/user"; 13 | import { sendVerificationEmail, sendTwoFactorTokenEmail } from "@/lib/mail"; 14 | import { getTwoFactorConfirmationByUserId } from "../data/two-factor-confirmation"; 15 | 16 | 17 | export const Login = async (values: z.infer, 18 | callbackUrl: string | null 19 | ) => { 20 | const validatedFields = LoginSchema.safeParse(values); 21 | 22 | 23 | if (!validatedFields.success) { 24 | return { error: "Invalid fields" }; 25 | } 26 | const { email, password, code } = validatedFields.data; 27 | 28 | 29 | const existingUser = await getUserByEmail(email); 30 | 31 | 32 | if (!existingUser || !existingUser.email || !existingUser.password) { 33 | return { error: "Email does not exist!" }; 34 | } 35 | 36 | 37 | if (!existingUser.emailVerified) { 38 | const verificationToken = await generateVerificationToken(existingUser.email) 39 | 40 | 41 | await sendVerificationEmail( 42 | verificationToken.email, 43 | verificationToken.token 44 | ); 45 | 46 | 47 | return { success: "Confirmation email sent!" } 48 | } 49 | 50 | 51 | if (existingUser.isTwoFactorEnabled && existingUser.email) { 52 | if (code) { 53 | // Ensure async retrieval of the token 54 | const twoFactorToken = await getTwoFactorTokenByEmail(existingUser.email); 55 | 56 | 57 | if (!twoFactorToken) { 58 | return { error: "Invalid code!" } 59 | } 60 | 61 | 62 | // Make sure the comparison is correct and case-sensitive 63 | if (twoFactorToken.token !== code.trim()) { 64 | return { error: "Invalid code!" } 65 | } 66 | 67 | 68 | const hasExpired = new Date(twoFactorToken.expires) < new Date(); 69 | 70 | 71 | if (hasExpired) { 72 | return { error: "Code expired!" } 73 | } 74 | 75 | 76 | // Delete the token after use 77 | await db.twoFactorToken.delete({ 78 | where: { id: twoFactorToken.id } 79 | }); 80 | 81 | 82 | // Handle existing 2FA confirmation 83 | const existingConfirmation = await getTwoFactorConfirmationByUserId( 84 | existingUser.id 85 | ); 86 | 87 | 88 | if (existingConfirmation) { 89 | await db.twoFactorConfirmation.delete({ 90 | where: { id: existingConfirmation.id } 91 | }) 92 | } 93 | 94 | 95 | // Create new confirmation entry 96 | await db.twoFactorConfirmation.create({ 97 | data: { 98 | userId: existingUser.id 99 | } 100 | }) 101 | } else { 102 | // If no code was provided, send a new token 103 | const twoFactorToken = await generateTwoFactorToken(existingUser.email) 104 | await sendTwoFactorTokenEmail( 105 | twoFactorToken.email, 106 | twoFactorToken.token 107 | ); 108 | return { twoFactor: true } 109 | } 110 | } 111 | 112 | 113 | try { 114 | await signIn("credentials", { 115 | email, 116 | password, 117 | redirectTo: callbackUrl || DEFAULT_LOGIN_REDIRECT 118 | }) 119 | } catch (error) { 120 | if (error instanceof AuthError) { 121 | switch (error.type) { 122 | case "CredentialsSignin": 123 | return { error: "Invalid credentials" } 124 | default: 125 | return { error: "Something went wrong!" } 126 | } 127 | } 128 | throw error; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/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 { cn } from "@/lib/utils" 6 | import { Cross2Icon } from "@radix-ui/react-icons" 7 | 8 | const Dialog = DialogPrimitive.Root 9 | 10 | const DialogTrigger = DialogPrimitive.Trigger 11 | 12 | const DialogPortal = DialogPrimitive.Portal 13 | 14 | const DialogClose = DialogPrimitive.Close 15 | 16 | const DialogOverlay = React.forwardRef< 17 | React.ElementRef, 18 | React.ComponentPropsWithoutRef 19 | >(({ className, ...props }, ref) => ( 20 | 28 | )) 29 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 30 | 31 | const DialogContent = React.forwardRef< 32 | React.ElementRef, 33 | React.ComponentPropsWithoutRef 34 | >(({ className, children, ...props }, ref) => ( 35 | 36 | 37 | 45 | {children} 46 | 47 | 48 | Close 49 | 50 | 51 | 52 | )) 53 | DialogContent.displayName = DialogPrimitive.Content.displayName 54 | 55 | const DialogHeader = ({ 56 | className, 57 | ...props 58 | }: React.HTMLAttributes) => ( 59 |
66 | ) 67 | DialogHeader.displayName = "DialogHeader" 68 | 69 | const DialogFooter = ({ 70 | className, 71 | ...props 72 | }: React.HTMLAttributes) => ( 73 |
80 | ) 81 | DialogFooter.displayName = "DialogFooter" 82 | 83 | const DialogTitle = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )) 96 | DialogTitle.displayName = DialogPrimitive.Title.displayName 97 | 98 | const DialogDescription = React.forwardRef< 99 | React.ElementRef, 100 | React.ComponentPropsWithoutRef 101 | >(({ className, ...props }, ref) => ( 102 | 107 | )) 108 | DialogDescription.displayName = DialogPrimitive.Description.displayName 109 | 110 | export { 111 | Dialog, 112 | DialogPortal, 113 | DialogOverlay, 114 | DialogTrigger, 115 | DialogClose, 116 | DialogContent, 117 | DialogHeader, 118 | DialogFooter, 119 | DialogTitle, 120 | DialogDescription, 121 | } 122 | -------------------------------------------------------------------------------- /src/components/auth/register-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import * as z from "zod"; 3 | import { useForm } from "react-hook-form"; 4 | import { zodResolver } from "@hookform/resolvers/zod"; 5 | import { CardWrapper } from './card-wrapper'; 6 | import { RegisterSchema } from "../../../schemas"; 7 | import { 8 | Form, 9 | FormControl, 10 | FormField, 11 | FormItem, 12 | FormLabel, 13 | FormMessage, 14 | } from "@/components/ui/form"; 15 | import { Input } from "@/components/ui/input"; 16 | import { Button } from "@/components//ui/button"; 17 | import { FormError } from "@/components/form-error"; 18 | import { FormSuccess } from "@/components/form-success"; 19 | import { register } from "../../../actions/register"; 20 | import { useState, useTransition } from "react"; 21 | 22 | const RegisterForm = () => { 23 | const [error, setError] = useState("") 24 | const [success, setSuccess] = useState("") 25 | const [isPending, startTransition] = useTransition() 26 | 27 | const form = useForm>({ 28 | resolver: zodResolver(RegisterSchema), 29 | defaultValues: { 30 | email: "", 31 | password: "", 32 | name: "", 33 | }, 34 | }); 35 | 36 | const onSubmit = (values: z.infer) => { 37 | setError(""); 38 | setSuccess(""); 39 | 40 | startTransition(() => { 41 | register(values) 42 | .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 | 67 | 68 | 69 | 70 | )} 71 | /> 72 | ( 76 | 77 | Email 78 | 79 | 80 | 81 | 82 | 83 | )} 84 | /> 85 | ( 89 | 90 | Password 91 | 92 | 95 | 96 | 97 | 98 | )} 99 | /> 100 |
101 | 102 | 103 | 108 | 109 | 110 |
111 | ); 112 | }; 113 | 114 | export default RegisterForm; 115 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { Slot } from "@radix-ui/react-slot" 6 | import { 7 | Controller, 8 | ControllerProps, 9 | FieldPath, 10 | FieldValues, 11 | FormProvider, 12 | useFormContext, 13 | } from "react-hook-form" 14 | 15 | import { cn } from "@/lib/utils" 16 | import { Label } from "@/components/ui/label" 17 | 18 | const Form = FormProvider 19 | 20 | type FormFieldContextValue< 21 | TFieldValues extends FieldValues = FieldValues, 22 | TName extends FieldPath = FieldPath 23 | > = { 24 | name: TName 25 | } 26 | 27 | const FormFieldContext = React.createContext( 28 | {} as FormFieldContextValue 29 | ) 30 | 31 | const FormField = < 32 | TFieldValues extends FieldValues = FieldValues, 33 | TName extends FieldPath = FieldPath 34 | >({ 35 | ...props 36 | }: ControllerProps) => { 37 | return ( 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | const useFormField = () => { 45 | const fieldContext = React.useContext(FormFieldContext) 46 | const itemContext = React.useContext(FormItemContext) 47 | const { getFieldState, formState } = useFormContext() 48 | 49 | const fieldState = getFieldState(fieldContext.name, formState) 50 | 51 | if (!fieldContext) { 52 | throw new Error("useFormField should be used within ") 53 | } 54 | 55 | const { id } = itemContext 56 | 57 | return { 58 | id, 59 | name: fieldContext.name, 60 | formItemId: `${id}-form-item`, 61 | formDescriptionId: `${id}-form-item-description`, 62 | formMessageId: `${id}-form-item-message`, 63 | ...fieldState, 64 | } 65 | } 66 | 67 | type FormItemContextValue = { 68 | id: string 69 | } 70 | 71 | const FormItemContext = React.createContext( 72 | {} as FormItemContextValue 73 | ) 74 | 75 | const FormItem = React.forwardRef< 76 | HTMLDivElement, 77 | React.HTMLAttributes 78 | >(({ className, ...props }, ref) => { 79 | const id = React.useId() 80 | 81 | return ( 82 | 83 |
84 | 85 | ) 86 | }) 87 | FormItem.displayName = "FormItem" 88 | 89 | const FormLabel = React.forwardRef< 90 | React.ElementRef, 91 | React.ComponentPropsWithoutRef 92 | >(({ className, ...props }, ref) => { 93 | const { error, formItemId } = useFormField() 94 | 95 | return ( 96 |