├── .env.example ├── .github └── workflows │ └── playwright.yml ├── .gitignore ├── .prettierrc.json ├── README.md ├── actions ├── admin.ts ├── login.ts ├── logout.ts ├── magic-link.ts ├── new-password.ts ├── new-verification.ts ├── register.ts ├── reset-password.ts └── settings.ts ├── app ├── (auth) │ ├── layout.tsx │ ├── login │ │ ├── magic-link │ │ │ └── page.tsx │ │ └── page.tsx │ ├── loginerror │ │ └── page.tsx │ ├── new-password │ │ └── page.tsx │ ├── new-verification │ │ └── page.tsx │ ├── register │ │ └── page.tsx │ └── reset-password │ │ └── page.tsx ├── (protected) │ ├── admin │ │ └── page.tsx │ ├── client │ │ ├── client-component.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── server │ │ └── page.tsx │ └── settings │ │ └── page.tsx ├── api │ ├── admin │ │ └── route.ts │ └── auth │ │ └── [...nextauth] │ │ └── route.ts ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── auth.config.ts ├── auth.ts ├── components.json ├── components ├── access-control │ ├── AdminActionAndRhTester.tsx │ └── RoleGate.tsx ├── auth │ ├── buttons │ │ ├── BackButton.tsx │ │ ├── LoginButton.tsx │ │ └── SocialButtons.tsx │ ├── forms │ │ ├── LoginForm.tsx │ │ ├── MagicLinkForm.tsx │ │ ├── NewPasswordForm.tsx │ │ ├── NewVerificationForm.tsx │ │ ├── RegisterForm.tsx │ │ └── ResetPasswordForm.tsx │ └── shared │ │ ├── AuthFormHeader.tsx │ │ ├── CardWrapper.tsx │ │ └── ErrorCard.tsx ├── forms │ ├── SettingsForm.tsx │ └── messages │ │ ├── FormError.tsx │ │ └── FormSuccess.tsx ├── layout │ ├── Navbar.tsx │ └── navbar │ │ ├── NavigationMenu.tsx │ │ └── UserAvatarMenu.tsx ├── ui │ ├── CustomSpinner.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── select.tsx │ ├── sonner.tsx │ └── switch.tsx └── user │ ├── actions │ └── LogoutButton.tsx │ └── profile │ └── UserInfo.tsx ├── data └── db │ ├── account │ └── helpers.ts │ ├── tokens │ ├── password-reset │ │ ├── create.ts │ │ ├── delete.ts │ │ └── helpers.ts │ ├── two-factor │ │ ├── create.ts │ │ └── helpers.ts │ ├── verification-email │ │ ├── create.ts │ │ ├── delete.ts │ │ └── helpers.ts │ └── verification-tokens │ │ └── magic-link │ │ └── helpers.ts │ ├── unstable-cache │ └── helpers.ts │ └── user │ ├── create.ts │ ├── helpers.ts │ ├── login.ts │ ├── reset-password.ts │ └── settings.ts ├── e2e-tests ├── config │ └── test-config.ts ├── credentials-2FA.spec.ts ├── credentials-registration-flow.spec.ts ├── forgot-password.spec.ts ├── helpers │ ├── helper-functions.ts │ ├── mailsac │ │ └── mailsac.ts │ └── tests.ts ├── navigation.spec.ts └── rolegate.spec.ts ├── eslint.config.mjs ├── lib ├── auth │ ├── auth-utils.ts │ ├── hooks.ts │ └── types.d.ts ├── constants │ ├── errors │ │ └── errors.ts │ └── messages │ │ └── actions │ │ └── messages.ts ├── crypto │ └── hash-edge-compatible.ts ├── db.ts ├── mail │ └── mail.ts ├── nextjs │ └── headers.ts └── utils.ts ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── playwright.config.ts ├── postcss.config.js ├── prisma └── schema.prisma ├── public ├── next.svg └── vercel.svg ├── routes.ts ├── schemas └── index.tsx ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | AUTH_SECRET= 3 | 4 | AUTH_GITHUB_CLIENT_SECRET= 5 | AUTH_GITHUB_CLIENT_ID= 6 | 7 | AUTH_GOOGLE_CLIENT_SECRET= 8 | AUTH_GOOGLE_CLIENT_ID= 9 | 10 | RESEND_API_KEY= 11 | 12 | NEXT_PUBLIC_APP_URL= 13 | 14 | MAILSAC_API_KEY= -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: E2E Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | jobs: 15 | test: 16 | name: Run E2E Tests 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Setup Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: '20' 26 | cache: 'npm' 27 | 28 | - name: Install dependencies 29 | run: npm ci 30 | 31 | - name: Install Playwright browsers 32 | run: npx playwright install --with-deps 33 | 34 | - name: Install Allure Commandline 35 | run: npm install -g allure-commandline 36 | 37 | - name: Run Playwright tests 38 | env: 39 | DATABASE_URL: ${{ secrets.DATABASE_URL }} 40 | AUTH_SECRET: ${{ secrets.AUTH_SECRET }} 41 | AUTH_GITHUB_CLIENT_SECRET: ${{ secrets.AUTH_GITHUB_CLIENT_SECRET }} 42 | AUTH_GITHUB_CLIENT_ID: ${{ secrets.AUTH_GITHUB_CLIENT_ID }} 43 | AUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.AUTH_GOOGLE_CLIENT_SECRET }} 44 | AUTH_GOOGLE_CLIENT_ID: ${{ secrets.AUTH_GOOGLE_CLIENT_ID }} 45 | RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} 46 | MAILSAC_API_KEY: ${{ secrets.MAILSAC_API_KEY }} 47 | NEXT_PUBLIC_APP_URL: http://localhost:3000 48 | KV_REST_API_READ_ONLY_TOKEN: ${{ secrets.KV_REST_API_READ_ONLY_TOKEN }} 49 | KV_REST_API_TOKEN: ${{ secrets.KV_REST_API_TOKEN }} 50 | KV_REST_API_URL: ${{ secrets.KV_REST_API_URL }} 51 | KV_URL: ${{ secrets.KV_URL }} 52 | run: npx playwright test 53 | continue-on-error: true 54 | 55 | - name: Generate Allure Report 56 | if: always() 57 | run: | 58 | allure generate allure-results -o allure-report --clean 59 | 60 | # Optional: Upload allure-results as artifact for debugging 61 | - name: Upload Allure Results 62 | if: always() 63 | uses: actions/upload-artifact@v4 64 | with: 65 | name: allure-results 66 | path: allure-results/ 67 | retention-days: 30 68 | 69 | # Setup Pages 70 | - name: Setup Pages 71 | if: always() 72 | uses: actions/configure-pages@v4 73 | 74 | # Upload to GitHub Pages 75 | - name: Upload Pages artifact 76 | if: always() 77 | uses: actions/upload-pages-artifact@v3 78 | with: 79 | path: allure-report 80 | 81 | # Deploy job 82 | deploy: 83 | needs: test # Wait for test job to complete 84 | runs-on: ubuntu-latest 85 | if: github.ref == 'refs/heads/main' # Only deploy on main branch 86 | 87 | environment: 88 | name: github-pages 89 | url: ${{ steps.deployment.outputs.page_url }} 90 | 91 | steps: 92 | - name: Deploy to GitHub Pages 93 | id: deployment 94 | uses: actions/deploy-pages@v4 95 | -------------------------------------------------------------------------------- /.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 | /.idea/ 39 | node_modules/ 40 | /test-results/ 41 | /playwright-report/ 42 | /blob-report/ 43 | /playwright/.cache/ 44 | /scripts/ 45 | /tests-examples/ 46 | /allure-results/ 47 | /allure-report/ 48 | /save 49 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": true, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "jsxSingleQuote": true, 7 | "plugins": ["prettier-plugin-tailwindcss"], 8 | "printWidth": 120, 9 | "arrowParens": "always", 10 | "bracketSpacing": true 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Next.js](https://img.shields.io/badge/Next.js%2014-black?style=flat-square&logo=next.js)](https://zenwai.github.io/nextjs14-next-authv5-app-router/) 2 | [![NextAuth v5](https://img.shields.io/badge/NextAuth%20v5-black?style=flat-square&logo=auth0)](https://zenwai.github.io/nextjs14-next-authv5-app-router/) 3 | 4 | [![Tests](https://img.shields.io/badge/tests-20%20passed-success?style=flat-square)](https://zenwai.github.io/nextjs14-next-authv5-app-router/) 5 | [View Test Report](https://zenwai.github.io/nextjs14-next-authv5-app-router/) 6 | 7 | Key Features: 8 | - 🔐 Next-auth v5 (Auth.js) 9 | - 🚀 Next.js 14 with server actions 10 | - 🔑 Credentials Provider 11 | - 🪄 Magic-Link Authentication 12 | - 🌐 OAuth Provider (Social login with Google & GitHub) 13 | - 🛑 Registration Restriction, maximum of 2 accounts per user 14 | - 🔒 Forgot password functionality 15 | - ✉️ Email verification 16 | - 📱 Two factor verification (2FA) 17 | - 👥 User roles 18 | - 🔓 Login component 19 | - 📝 Register component 20 | - 🤔 Forgot password component 21 | - ✅ Verification component 22 | - ⚠️ Error component 23 | - 🚧 Role Gate 24 | - 👑 Render content for admins using RoleGate component 25 | - 📈 next-auth session 26 | - 🔄 next-auth callbacks 27 | - 💎 CustomAdapter extends PrismaAdapter 28 | - 🖥️ Example with server component 29 | - 💻 Example with client component 30 | - 🛡️ Protect API Routes for admins only 31 | - 🔐 Protect Server Actions for admins only 32 | - 📧 Change email with new verification in Settings page 33 | - 🔑 Change password with old password confirmation in Settings page 34 | - 🔔 Enable/disable two-factor auth in Settings page 35 | - 🔄 Direct Change user role in Settings page (for dev&testing purposes) 36 | -------------------------------------------------------------------------------- /actions/admin.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { sessionHasRole } from '@/lib/auth/auth-utils'; 4 | import { messages } from '@/lib/constants/messages/actions/messages'; 5 | 6 | export const adminAction = async () => { 7 | const isAdmin = await sessionHasRole('ADMIN'); 8 | if (!isAdmin) { 9 | return { error: messages.admin.errors.FORBIDDEN_SA }; 10 | } 11 | 12 | return { success: messages.admin.success.ALLOWED_SA }; 13 | }; 14 | -------------------------------------------------------------------------------- /actions/logout.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { signOut } from '@/auth'; 4 | 5 | export const logoutAction = async () => { 6 | // some server stuff 7 | await signOut({ redirectTo: '/' }); 8 | }; 9 | -------------------------------------------------------------------------------- /actions/magic-link.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { AuthError } from 'next-auth'; 4 | 5 | import { signIn } from '@/auth'; 6 | import { CustomMagicLinkError } from '@/lib/constants/errors/errors'; 7 | import { messages } from '@/lib/constants/messages/actions/messages'; 8 | 9 | type MagicLinkActionResult = { success: string; error?: never } | { error: string; success?: never }; 10 | 11 | /** 12 | * Server action to handle magic link authentication requests 13 | * 14 | * Processes email-based authentication by sending a magic link to the user's email. 15 | * Uses the Resend provider from Auth.js to handle email delivery and implements 16 | * rate limiting and security checks. 17 | * 18 | * @note Handles NEXT_REDIRECT errors differently than standard Auth.js flow: 19 | * Instead of redirecting, returns a success message 20 | * 21 | * @securityNotes 22 | * - Uses built-in Resend email normalization 23 | */ 24 | export async function magicLinkAction(formData: FormData): Promise { 25 | try { 26 | // This is a example sending raw formData 27 | await signIn('resend', formData); 28 | return { success: messages.magicLink.success.SENT }; 29 | } catch (error) { 30 | if (error instanceof AuthError) { 31 | if (error.cause?.err instanceof CustomMagicLinkError) { 32 | switch (error.cause.err.errorType) { 33 | case 'IpInvalid': 34 | return { error: messages.magicLink.errors.GENERIC_FAILED }; 35 | case 'IpLimit': 36 | return { error: messages.magicLink.errors.IP_LIMIT }; 37 | case 'TokenExists': 38 | return { error: messages.magicLink.errors.EMAIL_ALREADY_SENT }; 39 | default: 40 | return { error: messages.magicLink.errors.GENERIC_CUSTOMMAGICLINKERROR }; 41 | } 42 | } 43 | return { error: messages.magicLink.errors.GENERIC_AUTHERROR }; 44 | } 45 | 46 | if (error instanceof Error && error.message?.includes('NEXT_REDIRECT')) { 47 | /*throw error;// This is necessary for the redirect to work*/ 48 | // Not redirecting, returning a success instead 49 | return { success: messages.magicLink.success.SENT }; 50 | } 51 | 52 | return { error: messages.generic.errors.UNEXPECTED_ERROR }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /actions/new-password.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { PrismaClientKnownRequestError, PrismaClientInitializationError } from '@prisma/client/runtime/library'; 4 | import * as zod from 'zod'; 5 | 6 | import { getValidPasswordResetToken } from '@/data/db/tokens/password-reset/helpers'; 7 | import { CustomNewPasswordError } from '@/lib/constants/errors/errors'; 8 | import { messages } from '@/lib/constants/messages/actions/messages'; 9 | import { hashPassword } from '@/lib/crypto/hash-edge-compatible'; 10 | import { db } from '@/lib/db'; 11 | import { NewPasswordSchema, PasswordResetTokenSchema } from '@/schemas'; 12 | 13 | type NewPasswordActionResult = { success: string; error?: never } | { error: string; success?: never }; 14 | 15 | /** 16 | * Server action to handle password reset after user clicks email link 17 | * 18 | * This action is triggered when a user submits the new password form after clicking 19 | * the reset password link from their email. It validates the token from the URL 20 | * 21 | * @note Uses a database transaction to ensure token is invalidated when password is updated 22 | * @note Passwords are hashed before storage 23 | * @note Tokens are single-use and removed 24 | */ 25 | export const newPasswordAction = async ( 26 | values: zod.infer, 27 | token?: string | null 28 | ): Promise => { 29 | try { 30 | // Validate inputs 31 | const validatedToken = PasswordResetTokenSchema.safeParse(token); 32 | if (!validatedToken.success || !validatedToken.data) { 33 | throw new CustomNewPasswordError('InvalidToken'); 34 | } 35 | 36 | const validatedFields = NewPasswordSchema.safeParse(values); 37 | if (!validatedFields.success || !validatedFields.data) { 38 | throw new CustomNewPasswordError('InvalidFields'); 39 | } 40 | 41 | const existingToken = await getValidPasswordResetToken(validatedToken.data); 42 | if (!existingToken) { 43 | throw new CustomNewPasswordError('TokenNotExist'); 44 | } 45 | 46 | const { password } = validatedFields.data; 47 | // Hash the new password 48 | const hashedPassword = await hashPassword(password); 49 | 50 | // Update password and delete token to prevent token reuse 51 | await db.$transaction(async (tx) => { 52 | // Update user password 53 | await tx.user.update({ 54 | where: { 55 | id: existingToken.userId, 56 | }, 57 | data: { 58 | password: hashedPassword, 59 | }, 60 | }); 61 | 62 | // Delete the used token 63 | await tx.passwordResetToken.delete({ 64 | where: { 65 | id: existingToken.id, 66 | }, 67 | }); 68 | }); 69 | 70 | return { success: messages.new_password.success.UPDATE_SUCCESSFUL }; 71 | } catch (error) { 72 | if (error instanceof CustomNewPasswordError) { 73 | switch (error.type) { 74 | case 'InvalidToken': 75 | return { error: messages.new_password.errors.INVALID_TOKEN }; 76 | case 'InvalidFields': 77 | return { error: messages.new_password.errors.INVALID_PASSWORD }; 78 | case 'TokenNotExist': 79 | return { error: messages.new_password.errors.REQUEST_NEW_PASSWORD_RESET }; 80 | default: 81 | return { error: messages.generic.errors.UNKNOWN_ERROR }; 82 | } 83 | } 84 | if (error instanceof PrismaClientKnownRequestError || error instanceof PrismaClientInitializationError) { 85 | console.error('Database error:', error); 86 | return { error: messages.generic.errors.DB_CONNECTION_ERROR }; 87 | } 88 | 89 | return { error: messages.generic.errors.UNEXPECTED_ERROR }; 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /actions/new-verification.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { PrismaClientInitializationError, PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; 4 | 5 | import { generateCustomVerificationToken } from '@/data/db/tokens/verification-email/create'; 6 | import { deleteCustomVerificationTokenById } from '@/data/db/tokens/verification-email/delete'; 7 | import { CustomNewVerificationEmailError } from '@/lib/constants/errors/errors'; 8 | import { messages } from '@/lib/constants/messages/actions/messages'; 9 | import { db } from '@/lib/db'; 10 | import { sendVerificationEmail } from '@/lib/mail/mail'; 11 | import { NewVerificationEmailTokenSchema } from '@/schemas'; 12 | 13 | type NewVerificationActionResult = { success: string; error?: never } | { error: string; success?: never }; 14 | 15 | /** 16 | * Server action to handle email verification token processing 17 | * 18 | * Validates and processes email verification tokens, handling various scenarios 19 | * including token expiration, already verified emails, and automatic token renewal. 20 | * Uses transactions to ensure data consistency when updating verification status. 21 | * 22 | * 1. Validate token format 23 | * 2. Find token in database with user data 24 | * 3. Check if email already verified 25 | * 4. Check token expiration 26 | * - If expired, generate and send new token 27 | * 5. Update user verification status 28 | * 6. Delete used token 29 | */ 30 | export const newVerificationAction = async (token: string): Promise => { 31 | try { 32 | const validatedToken = NewVerificationEmailTokenSchema.safeParse(token); 33 | if (!validatedToken.success) { 34 | throw new CustomNewVerificationEmailError('InvalidToken'); 35 | } 36 | 37 | const verificationData = await db.customVerificationToken.findUnique({ 38 | where: { 39 | token: validatedToken.data, 40 | }, 41 | include: { 42 | user: { 43 | select: { 44 | id: true, 45 | email: true, 46 | emailVerified: true, 47 | }, 48 | }, 49 | }, 50 | }); 51 | 52 | if (!verificationData) { 53 | throw new CustomNewVerificationEmailError('InvalidTokenOrVerified'); 54 | } 55 | 56 | // Check if email is already verified 57 | // This should not happen 58 | if (verificationData.user.emailVerified) { 59 | await db.customVerificationToken.delete({ 60 | where: { id: verificationData.id }, 61 | }); 62 | throw new CustomNewVerificationEmailError('EmailAlreadyVerified'); 63 | } 64 | 65 | // Check if token has expired 66 | const now = new Date(); 67 | if (verificationData.expires <= now) { 68 | const newToken = await generateCustomVerificationToken({ 69 | userId: verificationData.user.id, 70 | email: verificationData.email, 71 | }); 72 | 73 | // Send new verification email 74 | const emailResponse = await sendVerificationEmail(newToken.email, newToken.token); 75 | if (emailResponse.error) { 76 | await deleteCustomVerificationTokenById(newToken.id); 77 | throw new CustomNewVerificationEmailError('ResendEmailError'); 78 | } 79 | throw new CustomNewVerificationEmailError('TokenExpiredSentNewEmail'); 80 | } 81 | 82 | // Verify email and delete token 83 | await db.$transaction(async (tx) => { 84 | await tx.user.update({ 85 | where: { 86 | id: verificationData.user.id, 87 | }, 88 | data: { 89 | emailVerified: new Date(), 90 | email: verificationData.email, 91 | }, 92 | }); 93 | 94 | await tx.customVerificationToken.delete({ 95 | where: { 96 | id: verificationData.id, 97 | }, 98 | }); 99 | }); 100 | 101 | return { success: messages.new_verification_email.success.EMAIL_VERIFIED }; 102 | } catch (error) { 103 | if (error instanceof CustomNewVerificationEmailError) { 104 | switch (error.type) { 105 | case 'InvalidToken': 106 | return { error: messages.new_verification_email.errors.INVALID_TOKEN }; 107 | case 'EmailNotFound': 108 | return { error: messages.new_verification_email.errors.EMAIL_NOT_FOUND }; 109 | case 'EmailAlreadyVerified': 110 | return { error: messages.new_verification_email.errors.EMAIL_ALREADY_VERIFIED }; 111 | case 'ResendEmailError': 112 | return { error: messages.new_verification_email.errors.TOKEN_EXPIRED_FAILED_SEND_EMAIL }; 113 | case 'TokenExpiredSentNewEmail': 114 | return { error: messages.new_verification_email.errors.TOKEN_EXPIRED_SENT_NEW }; 115 | case 'InvalidTokenOrVerified': 116 | return { error: messages.new_verification_email.errors.INVALID_TOKEN_OR_VERIFIED }; 117 | default: 118 | return { error: messages.generic.errors.UNKNOWN_ERROR }; 119 | } 120 | } 121 | 122 | if (error instanceof PrismaClientInitializationError || error instanceof PrismaClientKnownRequestError) { 123 | console.error('Database error:', error); 124 | return { error: messages.generic.errors.DB_CONNECTION_ERROR }; 125 | } 126 | 127 | console.error('Verification error:', error); 128 | return { error: messages.generic.errors.UNEXPECTED_ERROR }; 129 | } 130 | }; 131 | -------------------------------------------------------------------------------- /actions/register.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { PrismaClientInitializationError, PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; 4 | import * as zod from 'zod'; 5 | 6 | import { deleteCustomVerificationTokenById } from '@/data/db/tokens/verification-email/delete'; 7 | import { createNewCredentialsUser } from '@/data/db/user/create'; 8 | import { countUserRegistrationsByIp } from '@/data/db/user/helpers'; 9 | import { CustomRegisterCredentialsUserError } from '@/lib/constants/errors/errors'; 10 | import { messages } from '@/lib/constants/messages/actions/messages'; 11 | import { db } from '@/lib/db'; 12 | import { sendVerificationEmail } from '@/lib/mail/mail'; 13 | import { getHashedUserIpFromHeaders } from '@/lib/nextjs/headers'; 14 | import { RegisterSchema } from '@/schemas'; 15 | 16 | type RegisterActionResult = { success: string; error?: never } | { error: string; success?: never }; 17 | 18 | /** 19 | * Server action to handle new user registration with email verification 20 | * 21 | * Manages the entire registration process including input validation, IP-based 22 | * rate limiting, user creation, and email verification token generation/sending. 23 | * Implements security measures like IP tracking and account limits. 24 | * 25 | * 1. Validate input fields 26 | * 2. Get and validate IP address 27 | * 3. Check account limits per IP (production only) 28 | * 4. Verify email uniqueness 29 | * 5. Create user and customVerification token 30 | * 6. Send verification email 31 | * 7. Delete the created token when send email fail 32 | * 33 | * @securityFeatures 34 | * - IP-based rate limiting (max 2 accounts per IP in production) 35 | * - Email uniqueness validation 36 | * - IP tracking for registrations 37 | * 38 | */ 39 | export const registerAction = async (values: zod.infer): Promise => { 40 | try { 41 | const validatedFields = RegisterSchema.safeParse(values); 42 | if (!validatedFields.success) { 43 | throw new CustomRegisterCredentialsUserError('InvalidFields'); 44 | } 45 | 46 | const { email, password, name } = validatedFields.data; 47 | const hashedIp = await getHashedUserIpFromHeaders(); 48 | 49 | if (!hashedIp) { 50 | throw new CustomRegisterCredentialsUserError('IpValidation'); 51 | } 52 | // Check account limit per IP 53 | if (process.env.NODE_ENV === 'production') { 54 | const accountCount = await countUserRegistrationsByIp({ 55 | hashedIp, 56 | }); 57 | 58 | if (accountCount >= 2) { 59 | throw new CustomRegisterCredentialsUserError('AccountLimit'); 60 | } 61 | } 62 | 63 | // check existing email 64 | const existingUser = await db.user.findUnique({ 65 | where: { email: email }, 66 | select: { id: true }, 67 | }); 68 | 69 | if (existingUser) { 70 | throw new CustomRegisterCredentialsUserError('EmailExists'); 71 | } 72 | 73 | // Create user and verification token 74 | const { emailCustomVerificationToken } = await createNewCredentialsUser({ 75 | name, 76 | email, 77 | password, 78 | hashedIp, 79 | }); 80 | 81 | // Send email for email-verification. 82 | 83 | const emailResponse = await sendVerificationEmail( 84 | emailCustomVerificationToken.email, 85 | emailCustomVerificationToken.token 86 | ); 87 | 88 | // If it fails we still send success message. Account is Registered at this point! 89 | if (emailResponse.error) { 90 | await deleteCustomVerificationTokenById(emailCustomVerificationToken.id); 91 | return { success: messages.register.success.ACC_CREATED_EMAIL_SEND_FAILED }; 92 | } 93 | 94 | return { success: messages.register.success.REGISTRATION_COMPLETE }; 95 | } catch (error) { 96 | if (error instanceof CustomRegisterCredentialsUserError) { 97 | switch (error.type) { 98 | case 'InvalidFields': 99 | return { error: messages.generic.errors.INVALID_FIELDS }; 100 | case 'IpValidation': 101 | return { error: messages.register.errors.IP_VALIDATION_FAILED }; 102 | case 'AccountLimit': 103 | return { error: messages.register.errors.ACCOUNT_LIMIT }; 104 | case 'EmailExists': 105 | return { error: messages.register.errors.EMAIL_EXISTS }; 106 | default: 107 | return { error: messages.generic.errors.GENERIC_ERROR }; 108 | } 109 | } 110 | 111 | if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') { 112 | return { error: messages.register.errors.EMAIL_EXISTS }; 113 | } 114 | 115 | if (error instanceof PrismaClientInitializationError) { 116 | console.error('Database connection error:', error); 117 | return { error: messages.generic.errors.DB_CONNECTION_ERROR }; 118 | } 119 | 120 | console.error('Unknown registration error:', error); 121 | return { error: messages.generic.errors.GENERIC_ERROR }; 122 | } 123 | }; 124 | -------------------------------------------------------------------------------- /actions/reset-password.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { PrismaClientKnownRequestError, PrismaClientInitializationError } from '@prisma/client/runtime/library'; 4 | import * as zod from 'zod'; 5 | 6 | import { generatePasswordResetToken } from '@/data/db/tokens/password-reset/create'; 7 | import { deletePasswordResetTokenById } from '@/data/db/tokens/password-reset/delete'; 8 | import { getUserResetPasswordData } from '@/data/db/user/reset-password'; 9 | import { CustomResetPasswordError } from '@/lib/constants/errors/errors'; 10 | import { messages } from '@/lib/constants/messages/actions/messages'; 11 | import { sendPasswordResetEmail } from '@/lib/mail/mail'; 12 | import { ResetPasswordSchema } from '@/schemas'; 13 | 14 | type ResetPasswordActionResult = { success: string; error?: never } | { error: string; success?: never }; 15 | 16 | /** 17 | * Server action to initiate password reset process 18 | * 19 | * Handles the first step of password reset where user requests a reset link. 20 | * Performs validations, generates a reset token, and sends an email 21 | * with the reset link to the user. 22 | * 23 | * 1. Validate email format 24 | * 2. Check if user exists and can reset password 25 | * 3. Check for existing valid reset tokens 26 | * 4. Generate new reset token 27 | * 5. Send reset email 28 | * 6. Clean up token if email fails 29 | * 30 | * @securityNotes 31 | * - Validates email format before processing 32 | * - Prevents multiple active reset tokens 33 | * - Deletes token if email sending fails 34 | * - Prevents OAuth-only accounts from password reset 35 | */ 36 | export const resetPasswordAction = async ( 37 | values: zod.infer 38 | ): Promise => { 39 | try { 40 | const validatedFields = ResetPasswordSchema.safeParse(values); 41 | if (!validatedFields.success) { 42 | throw new CustomResetPasswordError('InvalidFields'); 43 | } 44 | 45 | const { email } = validatedFields.data; 46 | 47 | const { userId, canResetPassword, activeResetToken } = await getUserResetPasswordData(email); 48 | 49 | if (!userId) { 50 | throw new CustomResetPasswordError('EmailNotFound'); 51 | } 52 | 53 | if (!canResetPassword) { 54 | throw new CustomResetPasswordError('NoPasswordToReset'); 55 | } 56 | 57 | if (activeResetToken) { 58 | throw new CustomResetPasswordError('TokenStillValid'); 59 | } 60 | 61 | const passwordResetToken = await generatePasswordResetToken(email, userId); 62 | const emailResponse = await sendPasswordResetEmail(passwordResetToken.email, passwordResetToken.token); 63 | if (emailResponse.error) { 64 | await deletePasswordResetTokenById(passwordResetToken.id); 65 | throw new CustomResetPasswordError('ResendEmailError'); 66 | } 67 | return { success: messages.reset_password.success.PASSWORD_RESET_EMAIL_SENT }; 68 | } catch (error) { 69 | if (error instanceof CustomResetPasswordError) { 70 | switch (error.type) { 71 | case 'InvalidFields': 72 | return { error: messages.reset_password.errors.INVALID_EMAIL }; 73 | case 'EmailNotFound': 74 | return { error: messages.reset_password.errors.EMAIL_NOT_FOUND }; 75 | case 'NoPasswordToReset': 76 | return { error: messages.reset_password.errors.OAUTH_USER_ONLY }; 77 | case 'TokenStillValid': 78 | return { error: messages.reset_password.errors.TOKEN_STILL_VALID }; 79 | case 'ResendEmailError': 80 | return { error: messages.reset_password.errors.SEND_EMAIL_ERROR }; 81 | default: 82 | return { error: messages.generic.errors.UNKNOWN_ERROR }; 83 | } 84 | } 85 | 86 | if (error instanceof PrismaClientKnownRequestError || error instanceof PrismaClientInitializationError) { 87 | console.error('Database error:', error); 88 | return { error: messages.generic.errors.DB_CONNECTION_ERROR }; 89 | } 90 | 91 | return { error: messages.generic.errors.UNEXPECTED_ERROR }; 92 | } 93 | }; 94 | -------------------------------------------------------------------------------- /actions/settings.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { PrismaClientInitializationError } from '@prisma/client/runtime/library'; 4 | import * as zod from 'zod'; 5 | 6 | import { unstable_update } from '@/auth'; 7 | import { getUserSettingsData } from '@/data/db/user/settings'; 8 | import { currentSessionUser } from '@/lib/auth/auth-utils'; 9 | import { CustomSettingsError } from '@/lib/constants/errors/errors'; 10 | import { messages } from '@/lib/constants/messages/actions/messages'; 11 | import { hashPassword, verifyPassword } from '@/lib/crypto/hash-edge-compatible'; 12 | import { db } from '@/lib/db'; 13 | import { SettingsSchema } from '@/schemas'; 14 | 15 | type SettingsActionResult = { success: string; error?: never } | { error: string; success?: never }; 16 | 17 | /** 18 | * Server action to handle user settings updates 19 | * 20 | * Manages user profile updates of a logged-in user. 21 | * Handles different update scenarios for OAuth and password-based users, with 22 | * appropriate validations and restrictions. 23 | * 24 | * @specialBehavior 25 | * - OAuth users cannot change password or 2FA settings 26 | * - Password changes require current password verification 27 | * - Returns dynamic success message based on updated fields 28 | * - Updates user session to reflect changes immediately 29 | * 30 | * @helper getValuesWeAreUpdating 31 | * Helper function that: 32 | * - Determines which fields have actually changed 33 | * - Builds update data object for database 34 | * - Tracks changed fields for success message 35 | * - Prevents unnecessary database updates 36 | */ 37 | export const settingsAction = async (values: zod.infer): Promise => { 38 | try { 39 | const authUser = await currentSessionUser(); 40 | if (!authUser?.id || !authUser.email) { 41 | throw new CustomSettingsError('Unauthorized'); 42 | } 43 | 44 | const validatedFields = SettingsSchema.safeParse(values); 45 | if (!validatedFields.success) { 46 | throw new CustomSettingsError('InvalidFields'); 47 | } 48 | 49 | let { name, password, newPassword, isTwoFactorEnabled, role } = validatedFields.data; 50 | /* Fields that users from oauth should not be able to change */ 51 | if (authUser.isOauth) { 52 | password = undefined; 53 | newPassword = undefined; 54 | isTwoFactorEnabled = undefined; 55 | } 56 | 57 | const userData = await getUserSettingsData(authUser.id); 58 | if (!userData?.email) { 59 | throw new CustomSettingsError('Unauthorized'); 60 | } 61 | 62 | /* Password change logic */ 63 | let hashedNewPassword = undefined; 64 | if (password && newPassword && userData.password) { 65 | const { isPasswordValid, passwordNeedsUpdate } = await verifyPassword(password, userData.password); 66 | if (passwordNeedsUpdate) { 67 | throw new CustomSettingsError('PasswordNeedUpdate'); 68 | } 69 | if (!isPasswordValid) { 70 | throw new CustomSettingsError('IncorrectPassword'); 71 | } 72 | if (password === newPassword) { 73 | throw new CustomSettingsError('SamePassword'); 74 | } 75 | hashedNewPassword = await hashPassword(newPassword); 76 | } 77 | 78 | const { updateData, updatedFields, hasChanges } = getValuesWeAreUpdating({ 79 | name, 80 | hashedNewPassword, 81 | isTwoFactorEnabled, 82 | role, 83 | userData: { 84 | name: userData.name ?? '', 85 | isTwoFactorEnabled: userData.isTwoFactorEnabled, 86 | role: userData.role, 87 | }, 88 | }); 89 | if (!hasChanges) { 90 | throw new CustomSettingsError('NoChangesToBeMade'); 91 | } 92 | 93 | const updatedUser = await db.user.update({ 94 | where: { id: userData.id }, 95 | data: updateData, 96 | }); 97 | 98 | await unstable_update({ 99 | user: { 100 | id: updatedUser.id, 101 | email: updatedUser.email, 102 | image: updatedUser.image, 103 | isOauth: true, 104 | isTwoFactorEnabled: updatedUser.isTwoFactorEnabled, 105 | role: updatedUser.role, 106 | name: updatedUser.name ?? undefined, 107 | }, 108 | }); 109 | // Create update message 110 | const updatedMessage = 111 | updatedFields.length === 1 112 | ? `Updated ${updatedFields[0]}` 113 | : `Updated ${updatedFields.slice(0, -1).join(', ')} and ${updatedFields[updatedFields.length - 1]}`; 114 | return { success: updatedMessage }; 115 | } catch (error) { 116 | if (error instanceof CustomSettingsError) { 117 | switch (error.type) { 118 | case 'Unauthorized': 119 | return { error: messages.settings.errors.UNAUTHORIZED }; 120 | case 'InvalidFields': 121 | return { error: messages.settings.errors.INVALID_FIELDS }; 122 | case 'IncorrectPassword': 123 | return { error: messages.settings.errors.INCORRECT_PASSWORD }; 124 | case 'SamePassword': 125 | return { error: messages.settings.errors.SAME_PASSWORD }; 126 | case 'NoChangesToBeMade': 127 | return { error: messages.settings.errors.NO_CHANGES_REQUIRED }; 128 | case 'PasswordNeedUpdate': 129 | return { error: messages.settings.errors.PASSWORD_NEEDS_UPDATE }; 130 | default: 131 | return { error: messages.generic.errors.UNKNOWN_ERROR }; 132 | } 133 | } 134 | 135 | if (error instanceof PrismaClientInitializationError) { 136 | console.error('Database connection error:', error); 137 | return { error: messages.generic.errors.DB_CONNECTION_ERROR }; 138 | } 139 | 140 | console.error('Settings update error:', error); 141 | return { error: messages.generic.errors.GENERIC_ERROR }; 142 | } 143 | }; 144 | 145 | interface GetUpdateValuesParams { 146 | name?: string; 147 | hashedNewPassword?: string; 148 | isTwoFactorEnabled?: boolean; 149 | role?: string; 150 | userData: { 151 | name: string; 152 | isTwoFactorEnabled: boolean; 153 | role: string; 154 | }; 155 | } 156 | 157 | function getValuesWeAreUpdating({ 158 | name, 159 | hashedNewPassword, 160 | isTwoFactorEnabled, 161 | role, 162 | userData, 163 | }: GetUpdateValuesParams) { 164 | const updateData: Record = {}; 165 | const updatedFields: string[] = []; 166 | 167 | if (name && name !== userData.name) { 168 | updateData.name = name; 169 | updatedFields.push('name'); 170 | } 171 | 172 | if (hashedNewPassword) { 173 | updateData.password = hashedNewPassword; 174 | updatedFields.push('password'); 175 | } 176 | 177 | if (typeof isTwoFactorEnabled === 'boolean' && isTwoFactorEnabled !== userData.isTwoFactorEnabled) { 178 | updateData.isTwoFactorEnabled = isTwoFactorEnabled; 179 | updatedFields.push('2FA'); 180 | } 181 | 182 | if (role && role !== userData.role) { 183 | updateData.role = role; 184 | updatedFields.push('role'); 185 | } 186 | 187 | return { 188 | updateData, 189 | updatedFields, 190 | hasChanges: updatedFields.length > 0, 191 | }; 192 | } 193 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | export default function AuthLayout({ children }: { children: ReactNode }) { 4 | return ( 5 | <> 6 |
7 | {children} 8 |
9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/(auth)/login/magic-link/page.tsx: -------------------------------------------------------------------------------- 1 | import { MagicLinkForm } from '@/components/auth/forms/MagicLinkForm'; 2 | 3 | export default function MagicLinkPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | 3 | import { LoginForm } from '@/components/auth/forms/LoginForm'; 4 | 5 | export default function LoginPage() { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/(auth)/loginerror/page.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorCard } from '@/components/auth/shared/ErrorCard'; 2 | 3 | export default function LoginErrorPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(auth)/new-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | 3 | import { NewPasswordForm } from '@/components/auth/forms/NewPasswordForm'; 4 | 5 | export default function NewPasswordPage() { 6 | return ( 7 |
8 | 9 | 10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/(auth)/new-verification/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | 3 | import { NewVerificationForm } from '@/components/auth/forms/NewVerificationForm'; 4 | 5 | export default function NewVerificationPage() { 6 | return ( 7 |
8 | 9 | 10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | 3 | import { RegisterForm } from '@/components/auth/forms/RegisterForm'; 4 | 5 | export default function RegisterPage() { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/(auth)/reset-password/page.tsx: -------------------------------------------------------------------------------- 1 | import { ResetPasswordForm } from '@/components/auth/forms/ResetPasswordForm'; 2 | 3 | export default function ResetPasswordPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(protected)/admin/page.tsx: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@prisma/client'; 2 | 3 | import { AdminActionAndRhTester } from '@/components/access-control/AdminActionAndRhTester'; 4 | import { RoleGate } from '@/components/access-control/RoleGate'; 5 | import { FormSuccess } from '@/components/forms/messages/FormSuccess'; 6 | import { Card, CardContent, CardHeader } from '@/components/ui/card'; 7 | 8 | /** 9 | * Admin Page example of role-based access. 10 | * 11 | * @description This page showcases how to implement role-based UI components and content visibility 12 | * using RoleGate component. 13 | * 14 | * @notice This page implements multiple levels of protection: 15 | * 1. Role-based content visibility using RoleGate 16 | * 2. Server Actions role-based access 17 | * 3. Route Handler(API) role-based access 18 | **/ 19 | export default function AdminPage() { 20 | return ( 21 | 22 | 23 |

24 | 25 | 🔑 26 | 27 | Admin 28 |

29 |
30 | 31 | 32 | 33 |

This is a example of secret content

34 |
35 | 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/(protected)/client/client-component.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { UserInfo } from '@/components/user/profile/UserInfo'; 4 | import { useCurrentUser } from '@/lib/auth/hooks'; 5 | 6 | import type { Session } from 'next-auth'; 7 | 8 | /** 9 | * Example client component demonstrating client-side session access. 10 | * 11 | * @notice This is for demonstration purposes only. 12 | * Prefer fetching user session data in server components using auth() 13 | * for better performance and security. 14 | */ 15 | export default function ClientComponent() { 16 | const userSession: Session['user'] | undefined = useCurrentUser(); 17 | return ( 18 |
19 | {/* This userInfo component is what we call a hybrid component, 20 | as children of a client component, it is a client component */} 21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/(protected)/client/page.tsx: -------------------------------------------------------------------------------- 1 | import { SessionProvider } from 'next-auth/react'; 2 | 3 | import ClientComponent from '@/app/(protected)/client/client-component'; 4 | 5 | /** 6 | * Example page demonstrating client-side authentication setup. 7 | * 8 | * @notice This setup is specifically for demonstrating client-side session handling. 9 | * For most applications, it's recommended to: 10 | * 1. Use server components with auth() to fetch session data 11 | * 12 | * @see https://authjs.dev/getting-started/migrating-to-v5 13 | */ 14 | export default function ClientPage() { 15 | return ( 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/(protected)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from '@/components/layout/Navbar'; 2 | import { NavigationMenu } from '@/components/layout/navbar/NavigationMenu'; 3 | import { UserAvatarMenu } from '@/components/layout/navbar/UserAvatarMenu'; 4 | 5 | import type { ReactNode } from 'react'; 6 | 7 | export default function ProtectedLayout(props: { children: ReactNode }) { 8 | return ( 9 |
10 | 11 | 12 | 13 | 14 | {props.children} 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/(protected)/server/page.tsx: -------------------------------------------------------------------------------- 1 | import { UserInfo } from '@/components/user/profile/UserInfo'; 2 | import { currentSessionUser } from '@/lib/auth/auth-utils'; 3 | 4 | export default async function ServerPage() { 5 | const user = await currentSessionUser(); 6 | 7 | return ( 8 |
9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/(protected)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { SettingsForm } from '@/components/forms/SettingsForm'; 2 | import { Card, CardContent, CardHeader } from '@/components/ui/card'; 3 | import { currentSessionUser } from '@/lib/auth/auth-utils'; 4 | 5 | export default async function SettingsPage() { 6 | const user = await currentSessionUser(); 7 | 8 | return ( 9 | 10 | 11 |

Settings

12 |
13 | {user && } 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /app/api/admin/route.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@prisma/client'; 2 | import { NextResponse } from 'next/server'; 3 | 4 | import { auth } from '@/auth'; 5 | 6 | export const GET = auth(function GET(req) { 7 | if (!req.auth) { 8 | return NextResponse.json({ message: 'Not authenticated' }, { status: 401 }); 9 | } 10 | 11 | const role = req.auth.user.role; 12 | if (role === UserRole.ADMIN) { 13 | return NextResponse.json({ message: 'Allowed RH call' }, { status: 200 }); 14 | } else { 15 | return NextResponse.json({ message: 'Forbidden RH call' }, { status: 403 }); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Auth.js API Route Handler 3 | * 4 | * @notice 5 | * This file serves as the API endpoint that Auth.js needs to operate. 6 | * 7 | * @path /api/auth/[...nextauth] 8 | * This catches all routes under /api/auth/ and forwards them to Auth.js 9 | */ 10 | export { GET, POST } from '@/auth'; 11 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zenWai/nextjs14-next-authv5-app-router/d7cde4798dde89cea14a91fca2cc4905254e5b1f/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root { 8 | height: 100%; 9 | } 10 | @layer base { 11 | :root { 12 | --background: 0 0% 100%; 13 | --foreground: 222.2 84% 4.9%; 14 | 15 | --card: 0 0% 100%; 16 | --card-foreground: 222.2 84% 4.9%; 17 | 18 | --popover: 0 0% 100%; 19 | --popover-foreground: 222.2 84% 4.9%; 20 | 21 | --primary: 222.2 47.4% 11.2%; 22 | --primary-foreground: 210 40% 98%; 23 | 24 | --secondary: 210 40% 96.1%; 25 | --secondary-foreground: 222.2 47.4% 11.2%; 26 | 27 | --muted: 210 40% 96.1%; 28 | --muted-foreground: 215.4 16.3% 46.9%; 29 | 30 | --accent: 210 40% 96.1%; 31 | --accent-foreground: 222.2 47.4% 11.2%; 32 | 33 | --destructive: 0 84.2% 60.2%; 34 | --destructive-foreground: 210 40% 98%; 35 | 36 | --border: 214.3 31.8% 91.4%; 37 | --input: 214.3 31.8% 91.4%; 38 | --ring: 222.2 84% 4.9%; 39 | 40 | --radius: 0.5rem; 41 | } 42 | 43 | .dark { 44 | --background: 222.2 84% 4.9%; 45 | --foreground: 210 40% 98%; 46 | 47 | --card: 222.2 84% 4.9%; 48 | --card-foreground: 210 40% 98%; 49 | 50 | --popover: 222.2 84% 4.9%; 51 | --popover-foreground: 210 40% 98%; 52 | 53 | --primary: 210 40% 98%; 54 | --primary-foreground: 222.2 47.4% 11.2%; 55 | 56 | --secondary: 217.2 32.6% 17.5%; 57 | --secondary-foreground: 210 40% 98%; 58 | 59 | --muted: 217.2 32.6% 17.5%; 60 | --muted-foreground: 215 20.2% 65.1%; 61 | 62 | --accent: 217.2 32.6% 17.5%; 63 | --accent-foreground: 210 40% 98%; 64 | 65 | --destructive: 0 62.8% 30.6%; 66 | --destructive-foreground: 210 40% 98%; 67 | 68 | --border: 217.2 32.6% 17.5%; 69 | --input: 217.2 32.6% 17.5%; 70 | --ring: 212.7 26.8% 83.9%; 71 | } 72 | } 73 | 74 | @layer base { 75 | * { 76 | @apply border-border; 77 | } 78 | body { 79 | @apply bg-background text-foreground; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from 'next/font/google'; 2 | 3 | import { Toaster } from '@/components/ui/sonner'; 4 | 5 | import type { Metadata } from 'next'; 6 | import type { ReactNode } from 'react'; 7 | 8 | import './globals.css'; 9 | 10 | const inter = Inter({ subsets: ['latin'] }); 11 | 12 | export const metadata: Metadata = { 13 | title: 'Create Next App', 14 | description: 'Generated by create next app', 15 | }; 16 | 17 | export default async function RootLayout({ 18 | children, 19 | }: Readonly<{ 20 | children: ReactNode; 21 | }>) { 22 | return ( 23 | 24 | 25 | 26 | {children} 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Poppins } from 'next/font/google'; 2 | 3 | import { LoginButton } from '@/components/auth/buttons/LoginButton'; 4 | import { Button } from '@/components/ui/button'; 5 | import { cn } from '@/lib/utils'; 6 | 7 | const font = Poppins({ 8 | subsets: ['latin'], 9 | weight: ['600'], 10 | }); 11 | export default function Home() { 12 | return ( 13 |
14 |
15 |

16 | 17 | 🔐 18 | {' '} 19 | Auth 20 |

21 |

Authentication

22 |
23 | 24 | 27 | 28 |
29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | import { PrismaAdapter } from '@auth/prisma-adapter'; 3 | import { type NextAuthConfig } from 'next-auth'; 4 | import Credentials from 'next-auth/providers/credentials'; 5 | import Github from 'next-auth/providers/github'; 6 | import Google from 'next-auth/providers/google'; 7 | import Resend from 'next-auth/providers/resend'; 8 | 9 | import { db } from '@/lib/db'; 10 | import { getHashedUserIpFromHeaders } from '@/lib/nextjs/headers'; 11 | import { VerifiedCredentialsUserSchema } from '@/schemas'; 12 | 13 | import type { AdapterUser, VerificationToken } from '@auth/core/adapters'; 14 | 15 | /** 16 | * Custom Auth.js adapter extending PrismaAdapter 17 | * 1. CreateVerificationToken with IP tracking, rate limit 18 | * 2. Custom user creation logic with default name 19 | */ 20 | const adapter = { 21 | ...PrismaAdapter(db), 22 | /** 23 | * Creates a verification token with IP tracking 24 | * Used when sending magic links 25 | * 26 | * @note If it fails, the email will still be sent, a bug, or intended? Did not bother much. 27 | * @note Remove id from the return object to match PrismaAdapter original patterns, we do not have id tho, currently. 28 | */ 29 | async createVerificationToken(data: VerificationToken): Promise { 30 | const hashedIp = await getHashedUserIpFromHeaders(); 31 | 32 | const token = await db.verificationToken.create({ 33 | data: { 34 | identifier: data.identifier, 35 | token: data.token, 36 | expires: data.expires, 37 | hashedIp: hashedIp ?? 'nobueno', 38 | }, 39 | }); 40 | 41 | if ('id' in token) { 42 | delete (token as any).id; 43 | } 44 | 45 | return token; 46 | }, 47 | // Follow PrismaAdapter pattern of removing id 48 | createUser: (data: AdapterUser) => { 49 | const userData = { 50 | ...data, 51 | name: data.name || 'your pretty fake name', 52 | }; 53 | if ('id' in userData) { 54 | delete (userData as any).id; 55 | } 56 | 57 | return db.user.create({ 58 | data: userData, 59 | }); 60 | }, 61 | }; 62 | 63 | /** 64 | * Auth.js (NextAuth.js) Configuration 65 | * 66 | * @description Defines authentication providers and their configurations. 67 | * This setup includes OAuth providers (Google, Github, magic-link with Resend) and credentials-based authentication. 68 | * 69 | * Credentials Provider Authorization 70 | * 71 | * @description all validation logic is done on login server action. 72 | */ 73 | 74 | export default { 75 | adapter, 76 | session: { 77 | strategy: 'jwt', 78 | maxAge: 2592000, 79 | updateAge: 86400, 80 | }, 81 | secret: process.env.AUTH_SECRET, 82 | providers: [ 83 | Google({ 84 | clientId: process.env.AUTH_GOOGLE_CLIENT_ID, 85 | clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET, 86 | allowDangerousEmailAccountLinking: true, 87 | }), 88 | Github({ 89 | clientId: process.env.AUTH_GITHUB_CLIENT_ID, 90 | clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET, 91 | }), 92 | Resend({ 93 | apiKey: process.env.RESEND_API_KEY, 94 | from: 'noreply@fpresa.org', 95 | }), 96 | 97 | Credentials({ 98 | credentials: { 99 | user: {}, 100 | callbackUrl: {}, 101 | }, 102 | async authorize(credentials) { 103 | try { 104 | const userStr = credentials?.user; 105 | if (typeof userStr !== 'string') return null; 106 | 107 | const user = JSON.parse(userStr); 108 | const validatedFields = VerifiedCredentialsUserSchema.safeParse(user); 109 | return validatedFields.success ? validatedFields.data : null; 110 | } catch (error) { 111 | console.error('Error parsing credentials:', error); 112 | return null; 113 | } 114 | }, 115 | }), 116 | ], 117 | debug: false, 118 | trustHost: true, 119 | } satisfies NextAuthConfig; 120 | -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@prisma/client'; 2 | import NextAuth, { type Session } from 'next-auth'; 3 | 4 | import authConfig from '@/auth.config'; 5 | import { 6 | cleanupExpiredVerificationTokens, 7 | validateMagicLinkRequest, 8 | } from '@/data/db/tokens/verification-tokens/magic-link/helpers'; 9 | import { CustomMagicLinkError } from '@/lib/constants/errors/errors'; 10 | import { db } from '@/lib/db'; 11 | import { getHashedUserIpFromHeaders } from '@/lib/nextjs/headers'; 12 | 13 | /** 14 | * Auth.js (NextAuth.js) Main Configuration 15 | * 16 | * @description Primary authentication configuration that extends auth.config.ts. 17 | * 18 | * @notice This configuration: 19 | * Uses JWT strategy for session handling 20 | * Implements custom types, @/lib/auth/types.d.ts 21 | * Manages user role and session data through JWT tokens 22 | * 23 | */ 24 | export const { 25 | handlers: { GET, POST }, 26 | auth, 27 | signIn, 28 | signOut, 29 | unstable_update, 30 | } = NextAuth({ 31 | pages: { 32 | signIn: '/login', 33 | error: '/loginerror', 34 | }, 35 | events: { 36 | // Runs AFTER an account is linked/OAuth sign in 37 | async linkAccount({ user }) { 38 | await db.user.update({ 39 | where: { id: user.id }, 40 | data: { emailVerified: new Date() }, 41 | }); 42 | }, 43 | async signIn({ user, account, profile, isNewUser }) { 44 | if (isNewUser && account?.provider === 'resend' && !user.name) { 45 | // do stuff 46 | } 47 | if (isNewUser && account?.provider !== 'credentials') { 48 | // TODO: send welcome email? 49 | } 50 | }, 51 | }, 52 | 53 | callbacks: { 54 | async signIn({ user, account, email }) { 55 | // Magic Link request 56 | if (email?.verificationRequest === true) { 57 | /*if(!user) { 58 | // Block non current users from magic link 59 | throw new CustomMagicLinkError('NoUserExists') 60 | }*/ 61 | const hashedIp = await getHashedUserIpFromHeaders(); 62 | if (!hashedIp) { 63 | throw new CustomMagicLinkError('IpInvalid'); 64 | } 65 | if (!account?.providerAccountId) { 66 | throw new CustomMagicLinkError('InvalidEmail'); 67 | } 68 | // Take this opportunity to clean expired tokens 69 | await cleanupExpiredVerificationTokens(); 70 | 71 | // Check ip limit, check existing token 72 | await validateMagicLinkRequest(account.providerAccountId, hashedIp); 73 | } 74 | // Example: Only allow sign in for users with email addresses ending with "yourdomain.com" 75 | // return profile?.email?.endsWith("@yourdomain.com") 76 | return true; 77 | }, 78 | async session({ token, session }) { 79 | if (!token.sub) return session; 80 | return { 81 | ...session, 82 | user: { 83 | ...session.user, 84 | id: token.sub ?? session.user.id, 85 | role: (token.role as UserRole) ?? UserRole.USER, 86 | isTwoFactorEnabled: Boolean(token.isTwoFactorEnabled), 87 | name: token.name ?? session.user.name ?? null, 88 | email: token.email ?? session.user.email ?? null, 89 | isOauth: Boolean(token.isOauth), 90 | }, 91 | } as Session; 92 | }, 93 | async jwt({ token, trigger, user, account, session }) { 94 | if ((trigger === 'signIn' || trigger === 'signUp') && user) { 95 | token.email = user.email; 96 | token.isOauth = account?.provider !== 'credentials'; 97 | token.name = user.name; 98 | token.role = user.role; 99 | token.isTwoFactorEnabled = user.isTwoFactorEnabled; 100 | return token; 101 | } 102 | 103 | if (trigger === 'update' && session) { 104 | token.name = session.user.name; 105 | token.role = session.user.role; 106 | token.isTwoFactorEnabled = session.user.isTwoFactorEnabled; 107 | return token; 108 | } 109 | 110 | return token; 111 | }, 112 | }, 113 | ...authConfig, 114 | }); 115 | -------------------------------------------------------------------------------- /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 | } 18 | -------------------------------------------------------------------------------- /components/access-control/AdminActionAndRhTester.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { toast } from 'sonner'; 3 | 4 | import { adminAction } from '@/actions/admin'; 5 | import { Button } from '@/components/ui/button'; 6 | 7 | export const AdminActionAndRhTester = () => { 8 | const onRouteHandlerClick = () => { 9 | fetch('/api/admin') 10 | .then(async (response) => { 11 | const { message } = await response.json(); 12 | if (response.ok) { 13 | toast.success(message); 14 | } else { 15 | toast.error(message); 16 | } 17 | }) 18 | .catch(async (error) => { 19 | const { message } = await error.json(); 20 | toast.error(message); 21 | }); 22 | }; 23 | 24 | const onServerActionClick = () => { 25 | adminAction() 26 | .then((data) => { 27 | if (data.error) { 28 | toast.error(data.error); 29 | } 30 | 31 | if (data.success) { 32 | toast.success(data.success); 33 | } 34 | }) 35 | .catch(() => { 36 | toast.error('Failed to execute server action'); 37 | }); 38 | }; 39 | return ( 40 | <> 41 |
42 |

Admin-only Route Handler

43 | 44 |
45 |
46 |

Admin-only Server Action

47 | 48 |
49 | 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /components/access-control/RoleGate.tsx: -------------------------------------------------------------------------------- 1 | import { UserRole } from '@prisma/client'; 2 | 3 | import { FormError } from '@/components/forms/messages/FormError'; 4 | import { currentSessionRole } from '@/lib/auth/auth-utils'; 5 | 6 | import type { ReactNode } from 'react'; 7 | 8 | interface RoleGateProps { 9 | children: ReactNode; 10 | allowedRole: UserRole; 11 | } 12 | 13 | export const RoleGate = async ({ children, allowedRole }: RoleGateProps) => { 14 | const currentUserRole = await currentSessionRole(); 15 | if (!currentUserRole || currentUserRole !== allowedRole) { 16 | return ; 17 | } 18 | 19 | return <>{children}; 20 | }; 21 | -------------------------------------------------------------------------------- /components/auth/buttons/BackButton.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 | -------------------------------------------------------------------------------- /components/auth/buttons/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRouter } from 'next/navigation'; 4 | 5 | import { LoginForm } from '@/components/auth/forms/LoginForm'; 6 | import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'; 7 | 8 | import type { ReactNode } from 'react'; 9 | 10 | interface LoginButtonProps { 11 | children: ReactNode; 12 | mode?: 'modal' | 'redirect'; 13 | asChild?: boolean; 14 | } 15 | 16 | export const LoginButton = ({ children, mode = 'redirect', asChild }: LoginButtonProps) => { 17 | const router = useRouter(); 18 | 19 | const onClick = () => { 20 | router.push('/login'); 21 | }; 22 | 23 | if (mode === 'modal') { 24 | return ( 25 | 26 | {children} 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | return ( 34 | 35 | {children} 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /components/auth/buttons/SocialButtons.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import { useSearchParams } from 'next/navigation'; 5 | import { signIn } from 'next-auth/react'; 6 | import { FaGithub } from 'react-icons/fa'; 7 | import { FcGoogle } from 'react-icons/fc'; 8 | import { ImMail4 } from 'react-icons/im'; 9 | 10 | import { Button } from '@/components/ui/button'; 11 | import { DEFAULT_LOGIN_REDIRECT } from '@/routes'; 12 | 13 | export const SocialButtons = () => { 14 | const searchParams = useSearchParams(); 15 | const callbackUrl = searchParams.get('callbackUrl'); 16 | const onClick = (provider: 'google' | 'github') => { 17 | signIn(provider, { 18 | redirectTo: callbackUrl || DEFAULT_LOGIN_REDIRECT, 19 | }); 20 | }; 21 | return ( 22 |
23 |
24 | 27 | 30 |
31 | 32 | 36 | 37 |
38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /components/auth/forms/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { zodResolver } from '@hookform/resolvers/zod'; 3 | import Link from 'next/link'; 4 | import { useSearchParams } from 'next/navigation'; 5 | import { useState, useTransition } from 'react'; 6 | import { useForm } from 'react-hook-form'; 7 | import * as zod from 'zod'; 8 | 9 | import { loginAction } from '@/actions/login'; 10 | import { CardWrapper } from '@/components/auth/shared/CardWrapper'; 11 | import { FormError } from '@/components/forms/messages/FormError'; 12 | import { FormSuccess } from '@/components/forms/messages/FormSuccess'; 13 | import { Button } from '@/components/ui/button'; 14 | import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; 15 | import { Input } from '@/components/ui/input'; 16 | import { LoginSchema } from '@/schemas'; 17 | 18 | export const LoginForm = () => { 19 | const searchParams = useSearchParams(); 20 | const callbackUrl = searchParams.get('callbackUrl'); 21 | const urlError = 22 | searchParams.get('error') === 'OAuthAccountNotLinked' ? 'Email already in use with different provider!' : ''; 23 | 24 | const [showTwoFactor, setShowTwoFactor] = useState(false); 25 | const [error, setError] = useState(''); 26 | const [success, setSuccess] = useState(''); 27 | const [isPending, startTransition] = useTransition(); 28 | 29 | const form = useForm>({ 30 | resolver: zodResolver(LoginSchema), 31 | defaultValues: { 32 | email: '', 33 | password: '', 34 | }, 35 | }); 36 | 37 | const onSubmit = (values: zod.infer) => { 38 | setError(''); 39 | setSuccess(''); 40 | startTransition(() => { 41 | loginAction(values, callbackUrl) 42 | .then((data) => { 43 | if (data?.error) { 44 | setError(data.error); 45 | } 46 | 47 | if (data?.success) { 48 | form.reset(); 49 | setSuccess(data.success); 50 | } 51 | // send user to 2FA 52 | if (data?.twoFactor) { 53 | setShowTwoFactor(true); 54 | } 55 | }) 56 | .catch((error) => { 57 | if (error?.digest?.includes('NEXT_REDIRECT')) { 58 | return; 59 | } 60 | 61 | setError('Something went wrong'); 62 | }); 63 | }); 64 | }; 65 | return ( 66 | 72 |
73 | 74 |
75 | {showTwoFactor && ( 76 | ( 80 | 81 | Two Factor Code 82 | 83 | 84 | 85 | 86 | 87 | )} 88 | /> 89 | )} 90 | {!showTwoFactor && ( 91 | <> 92 | ( 96 | 97 | Email 98 | 99 | 100 | 101 | 102 | 103 | )} 104 | /> 105 | ( 109 | 110 | password 111 | 112 | 113 | 114 | 117 | 118 | 119 | )} 120 | /> 121 | 122 | )} 123 |
124 | 125 | 126 | 129 | 130 | 131 |
132 | ); 133 | }; 134 | -------------------------------------------------------------------------------- /components/auth/forms/MagicLinkForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { zodResolver } from '@hookform/resolvers/zod'; 3 | import { useRef, useState, useTransition } from 'react'; 4 | import { useForm } from 'react-hook-form'; 5 | import * as zod from 'zod'; 6 | 7 | import { magicLinkAction } from '@/actions/magic-link'; 8 | import { CardWrapper } from '@/components/auth/shared/CardWrapper'; 9 | import { FormError } from '@/components/forms/messages/FormError'; 10 | import { FormSuccess } from '@/components/forms/messages/FormSuccess'; 11 | import { Button } from '@/components/ui/button'; 12 | import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; 13 | import { Input } from '@/components/ui/input'; 14 | import { MagicLinkSchema } from '@/schemas'; 15 | 16 | export const MagicLinkForm = () => { 17 | const [error, setError] = useState(''); 18 | const [success, setSuccess] = useState(''); 19 | const [isPending, startTransition] = useTransition(); 20 | const formRef = useRef(null); 21 | 22 | const form = useForm>({ 23 | resolver: zodResolver(MagicLinkSchema), 24 | defaultValues: { email: '' }, 25 | }); 26 | 27 | const onSubmit = () => { 28 | setError(''); 29 | setSuccess(''); 30 | 31 | if (!formRef.current) return; 32 | 33 | startTransition(() => { 34 | // This lives here as an example of handling as FormData 35 | // and passing it to Server Action 36 | const formData = new FormData(formRef.current!); 37 | 38 | magicLinkAction(formData) 39 | .then((data) => { 40 | if (data?.error) { 41 | setError(data.error); 42 | } 43 | 44 | if (data?.success) { 45 | form.reset(); 46 | setSuccess(data.success); 47 | } 48 | }) 49 | .catch(() => { 50 | setError('Something went wrong'); 51 | }); 52 | }); 53 | }; 54 | 55 | return ( 56 | 61 |
62 | 63 |
64 | ( 68 | 69 | Email 70 | 71 | 72 | 73 | 74 | 75 | )} 76 | /> 77 |
78 | 79 | 80 | 83 | 84 | 85 |
86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /components/auth/forms/NewPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { zodResolver } from '@hookform/resolvers/zod'; 3 | import { useSearchParams } from 'next/navigation'; 4 | import { useState, useTransition } from 'react'; 5 | import { useForm } from 'react-hook-form'; 6 | import * as zod from 'zod'; 7 | 8 | import { newPasswordAction } from '@/actions/new-password'; 9 | import { CardWrapper } from '@/components/auth/shared/CardWrapper'; 10 | import { FormError } from '@/components/forms/messages/FormError'; 11 | import { FormSuccess } from '@/components/forms/messages/FormSuccess'; 12 | import { Button } from '@/components/ui/button'; 13 | import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; 14 | import { Input } from '@/components/ui/input'; 15 | import { NewPasswordSchema } from '@/schemas'; 16 | 17 | export const NewPasswordForm = () => { 18 | const searchParams = useSearchParams(); 19 | const token = searchParams.get('token'); 20 | 21 | const [error, setError] = useState(''); 22 | const [success, setSuccess] = useState(''); 23 | const [isPending, startTransition] = useTransition(); 24 | 25 | const form = useForm>({ 26 | resolver: zodResolver(NewPasswordSchema), 27 | defaultValues: { 28 | password: '', 29 | }, 30 | }); 31 | 32 | const onSubmit = (values: zod.infer) => { 33 | setError(''); 34 | setSuccess(''); 35 | startTransition(() => { 36 | newPasswordAction(values, token).then((data) => { 37 | setError(data?.error); 38 | setSuccess(data?.success); 39 | }); 40 | }); 41 | }; 42 | return ( 43 | 44 |
45 | 46 |
47 | ( 51 | 52 | Password 53 | 54 | 55 | 56 | 57 | 58 | )} 59 | /> 60 |
61 | 62 | 63 | 66 | 67 | 68 |
69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /components/auth/forms/NewVerificationForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useSearchParams } from 'next/navigation'; 4 | import { useCallback, useEffect, useRef, useState } from 'react'; 5 | import { BeatLoader } from 'react-spinners'; 6 | 7 | import { newVerificationAction } from '@/actions/new-verification'; 8 | import { CardWrapper } from '@/components/auth/shared/CardWrapper'; 9 | import { FormError } from '@/components/forms/messages/FormError'; 10 | import { FormSuccess } from '@/components/forms/messages/FormSuccess'; 11 | 12 | export const NewVerificationForm = () => { 13 | const [error, setError] = useState(); 14 | const [success, setSuccess] = useState(); 15 | const verificationRequested = useRef(false); 16 | 17 | const searchParams = useSearchParams(); 18 | const token = searchParams.get('token'); 19 | 20 | const onSubmit = useCallback(() => { 21 | if (verificationRequested.current || success || error) return; 22 | verificationRequested.current = true; 23 | 24 | if (!token) { 25 | setError('Invalid token!'); 26 | return; 27 | } 28 | 29 | newVerificationAction(token) 30 | .then((data) => { 31 | setSuccess(data.success); 32 | setError(data.error); 33 | verificationRequested.current = false; 34 | }) 35 | .catch(() => { 36 | setError('Something went wrong while verifying token!'); 37 | verificationRequested.current = false; 38 | }); 39 | }, [token, success, error]); 40 | 41 | useEffect(() => { 42 | onSubmit(); 43 | }); 44 | 45 | return ( 46 | 47 |
48 | {!success && !error && } 49 | 50 | 51 |
52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /components/auth/forms/RegisterForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { zodResolver } from '@hookform/resolvers/zod'; 3 | import { useState, useTransition } from 'react'; 4 | import { useForm } from 'react-hook-form'; 5 | import * as zod from 'zod'; 6 | 7 | import { registerAction } from '@/actions/register'; 8 | import { CardWrapper } from '@/components/auth/shared/CardWrapper'; 9 | import { FormError } from '@/components/forms/messages/FormError'; 10 | import { FormSuccess } from '@/components/forms/messages/FormSuccess'; 11 | import { Button } from '@/components/ui/button'; 12 | import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; 13 | import { Input } from '@/components/ui/input'; 14 | import { RegisterSchema } from '@/schemas'; 15 | 16 | export const RegisterForm = () => { 17 | const [error, setError] = useState(''); 18 | const [success, setSuccess] = useState(''); 19 | const [isPending, startTransition] = useTransition(); 20 | 21 | const form = useForm>({ 22 | resolver: zodResolver(RegisterSchema), 23 | defaultValues: { 24 | email: '', 25 | password: '', 26 | name: '', 27 | }, 28 | }); 29 | 30 | const onSubmit = (values: zod.infer) => { 31 | setError(''); 32 | setSuccess(''); 33 | startTransition(() => { 34 | registerAction(values).then((data) => { 35 | setError(data.error); 36 | setSuccess(data.success); 37 | }); 38 | }); 39 | }; 40 | return ( 41 | 47 |
48 | 49 |
50 | ( 54 | 55 | Email 56 | 57 | 58 | 59 | 60 | 61 | )} 62 | /> 63 | ( 67 | 68 | password 69 | 70 | 71 | 72 | 73 | 74 | )} 75 | /> 76 | ( 80 | 81 | Name 82 | 83 | 84 | 85 | 86 | 87 | )} 88 | /> 89 |
90 | 91 | 92 | 95 | 96 | 97 |
98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /components/auth/forms/ResetPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { zodResolver } from '@hookform/resolvers/zod'; 3 | import { useState, useTransition } from 'react'; 4 | import { useForm } from 'react-hook-form'; 5 | import * as zod from 'zod'; 6 | 7 | import { resetPasswordAction } from '@/actions/reset-password'; 8 | import { CardWrapper } from '@/components/auth/shared/CardWrapper'; 9 | import { FormError } from '@/components/forms/messages/FormError'; 10 | import { FormSuccess } from '@/components/forms/messages/FormSuccess'; 11 | import { Button } from '@/components/ui/button'; 12 | import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; 13 | import { Input } from '@/components/ui/input'; 14 | import { ResetPasswordSchema } from '@/schemas'; 15 | 16 | export const ResetPasswordForm = () => { 17 | const [error, setError] = useState(''); 18 | const [success, setSuccess] = useState(''); 19 | const [isPending, startTransition] = useTransition(); 20 | 21 | const form = useForm>({ 22 | resolver: zodResolver(ResetPasswordSchema), 23 | defaultValues: { 24 | email: '', 25 | }, 26 | }); 27 | 28 | const onSubmit = (values: zod.infer) => { 29 | setError(''); 30 | setSuccess(''); 31 | startTransition(() => { 32 | resetPasswordAction(values).then((data) => { 33 | setError(data?.error); 34 | setSuccess(data?.success); 35 | }); 36 | }); 37 | }; 38 | return ( 39 | 40 |
41 | 42 |
43 | ( 47 | 48 | Email 49 | 50 | 51 | 52 | 53 | 54 | )} 55 | /> 56 |
57 | 58 | 59 | 62 | 63 | 64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /components/auth/shared/AuthFormHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Poppins } from 'next/font/google'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | const font = Poppins({ subsets: ['latin'], weight: '600' }); 6 | 7 | interface CardHeaderProps { 8 | label: string; 9 | } 10 | 11 | export const AuthFormHeader = ({ label }: CardHeaderProps) => { 12 | return ( 13 |
14 |

15 | 16 | 🔐 17 | {' '} 18 | Auth 19 |

20 |

{label}

21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /components/auth/shared/CardWrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { BackButton } from '@/components/auth/buttons/BackButton'; 4 | import { SocialButtons } from '@/components/auth/buttons/SocialButtons'; 5 | import { AuthFormHeader } from '@/components/auth/shared/AuthFormHeader'; 6 | import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'; 7 | 8 | import type { ReactNode } from 'react'; 9 | 10 | interface CardWrapperProps { 11 | children: ReactNode; 12 | headerLabel: string; 13 | backButtonLabel: string; 14 | backButtonHref: string; 15 | showSocial?: boolean; 16 | } 17 | 18 | export const CardWrapper = ({ 19 | children, 20 | headerLabel, 21 | backButtonLabel, 22 | backButtonHref, 23 | showSocial, 24 | }: CardWrapperProps) => { 25 | return ( 26 | 27 | 28 | 29 | 30 | {children} 31 | {showSocial && ( 32 | 33 | 34 | 35 | )} 36 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /components/auth/shared/ErrorCard.tsx: -------------------------------------------------------------------------------- 1 | import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; 2 | 3 | import { CardWrapper } from '@/components/auth/shared/CardWrapper'; 4 | 5 | export const ErrorCard = () => { 6 | return ( 7 | 8 |
9 | 10 |
11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /components/forms/messages/FormError.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 | return ( 10 |
11 | 12 |

{message}

13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /components/forms/messages/FormSuccess.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 | return ( 10 |
11 | 12 |

{message}

13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /components/layout/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | export const Navbar = ({ children }: { children: ReactNode }) => { 4 | return ( 5 | 6 | ); 7 | }; 8 | -------------------------------------------------------------------------------- /components/layout/navbar/NavigationMenu.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import Link from 'next/link'; 3 | import { usePathname } from 'next/navigation'; 4 | 5 | import { Button } from '@/components/ui/button'; 6 | 7 | export const NavigationMenu = () => { 8 | const pathname = usePathname(); 9 | return ( 10 |
11 | 16 | 21 | 26 | 31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /components/layout/navbar/UserAvatarMenu.tsx: -------------------------------------------------------------------------------- 1 | import 'server-only'; 2 | 3 | import { ExitIcon } from '@radix-ui/react-icons'; 4 | import { FaUser } from 'react-icons/fa'; 5 | 6 | import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from '@/components/ui/dropdown-menu'; 13 | import { LogoutButton } from '@/components/user/actions/LogoutButton'; 14 | import { currentSessionUser } from '@/lib/auth/auth-utils'; 15 | 16 | export const UserAvatarMenu = async () => { 17 | const user = await currentSessionUser(); 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | Logout 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /components/ui/CustomSpinner.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export const CustomSpinner = () => ( 4 |
5 |
6 |
7 | ); 8 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as AvatarPrimitive from '@radix-ui/react-avatar'; 4 | import * as React from 'react'; 5 | 6 | import { cn } from '@/lib/utils'; 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 17 | )); 18 | Avatar.displayName = AvatarPrimitive.Root.displayName; 19 | 20 | const AvatarImage = React.forwardRef< 21 | React.ElementRef, 22 | React.ComponentPropsWithoutRef 23 | >(({ className, ...props }, ref) => ( 24 | 25 | )); 26 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 27 | 28 | const AvatarFallback = React.forwardRef< 29 | React.ElementRef, 30 | React.ComponentPropsWithoutRef 31 | >(({ className, ...props }, ref) => ( 32 | 37 | )); 38 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 39 | 40 | export { Avatar, AvatarImage, AvatarFallback }; 41 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { cva, type VariantProps } from 'class-variance-authority'; 2 | import * as React from 'react'; 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: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', 12 | secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 13 | destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', 14 | outline: 'text-foreground', 15 | success: 'border-transparent bg-emerald-500 text-primary-foreground', 16 | }, 17 | }, 18 | defaultVariants: { 19 | variant: 'default', 20 | }, 21 | } 22 | ); 23 | 24 | export interface BadgeProps extends React.HTMLAttributes, VariantProps {} 25 | 26 | const Badge = ({ className, variant, ...props }: BadgeProps) => { 27 | return
; 28 | }; 29 | 30 | export { Badge, badgeVariants }; 31 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from '@radix-ui/react-slot'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | import * as React from 'react'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | '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 inline-flex gap-2 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', 13 | destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 14 | outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 15 | secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 16 | magicLink: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 17 | ghost: 'hover:bg-accent hover:text-accent-foreground', 18 | link: 'text-primary underline-offset-4 hover:underline', 19 | }, 20 | size: { 21 | default: 'h-9 px-4 py-2', 22 | sm: 'h-8 rounded-md px-3 text-xs', 23 | lg: 'h-10 rounded-md px-8', 24 | icon: 'h-9 w-9', 25 | }, 26 | }, 27 | defaultVariants: { 28 | variant: 'default', 29 | size: 'default', 30 | }, 31 | } 32 | ); 33 | 34 | export interface ButtonProps 35 | extends React.ButtonHTMLAttributes, 36 | VariantProps { 37 | asChild?: boolean; 38 | } 39 | 40 | const Button = React.forwardRef( 41 | ({ className, variant, size, asChild = false, ...props }, ref) => { 42 | const Comp = asChild ? Slot : 'button'; 43 | return ; 44 | } 45 | ); 46 | Button.displayName = 'Button'; 47 | 48 | export { Button, buttonVariants }; 49 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | const Card = React.forwardRef>(({ className, ...props }, ref) => ( 6 |
7 | )); 8 | Card.displayName = 'Card'; 9 | 10 | const CardHeader = React.forwardRef>( 11 | ({ className, ...props }, ref) => ( 12 |
13 | ) 14 | ); 15 | CardHeader.displayName = 'CardHeader'; 16 | 17 | const CardTitle = React.forwardRef>( 18 | ({ className, ...props }, ref) => ( 19 |

20 | ) 21 | ); 22 | CardTitle.displayName = 'CardTitle'; 23 | 24 | const CardDescription = React.forwardRef>( 25 | ({ className, ...props }, ref) => ( 26 |

27 | ) 28 | ); 29 | CardDescription.displayName = 'CardDescription'; 30 | 31 | const CardContent = React.forwardRef>( 32 | ({ className, ...props }, ref) =>

33 | ); 34 | CardContent.displayName = 'CardContent'; 35 | 36 | const CardFooter = React.forwardRef>( 37 | ({ className, ...props }, ref) =>
38 | ); 39 | CardFooter.displayName = 'CardFooter'; 40 | 41 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; 42 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as DialogPrimitive from '@radix-ui/react-dialog'; 4 | import { Cross2Icon } from '@radix-ui/react-icons'; 5 | import * as React from 'react'; 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 = ({ className, ...props }: React.HTMLAttributes) => ( 57 |
58 | ); 59 | DialogHeader.displayName = 'DialogHeader'; 60 | 61 | const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( 62 |
63 | ); 64 | DialogFooter.displayName = 'DialogFooter'; 65 | 66 | const DialogTitle = React.forwardRef< 67 | React.ElementRef, 68 | React.ComponentPropsWithoutRef 69 | >(({ className, ...props }, ref) => ( 70 | 75 | )); 76 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 77 | 78 | const DialogDescription = React.forwardRef< 79 | React.ElementRef, 80 | React.ComponentPropsWithoutRef 81 | >(({ className, ...props }, ref) => ( 82 | 83 | )); 84 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 85 | 86 | export { 87 | Dialog, 88 | DialogPortal, 89 | DialogOverlay, 90 | DialogTrigger, 91 | DialogClose, 92 | DialogContent, 93 | DialogHeader, 94 | DialogFooter, 95 | DialogTitle, 96 | DialogDescription, 97 | }; 98 | -------------------------------------------------------------------------------- /components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from '@radix-ui/react-label'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | import * as React from 'react'; 4 | import { Controller, FormProvider, useFormContext } from 'react-hook-form'; 5 | 6 | import { Label } from '@/components/ui/label'; 7 | import { cn } from '@/lib/utils'; 8 | 9 | import type { ControllerProps, FieldPath, FieldValues } from 'react-hook-form'; 10 | 11 | const Form = FormProvider; 12 | 13 | type FormFieldContextValue< 14 | TFieldValues extends FieldValues = FieldValues, 15 | TName extends FieldPath = FieldPath, 16 | > = { 17 | name: TName; 18 | }; 19 | 20 | const FormFieldContext = React.createContext({} as FormFieldContextValue); 21 | 22 | const FormField = < 23 | TFieldValues extends FieldValues = FieldValues, 24 | TName extends FieldPath = FieldPath, 25 | >({ 26 | ...props 27 | }: ControllerProps) => { 28 | return ( 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | const useFormField = () => { 36 | const fieldContext = React.useContext(FormFieldContext); 37 | const itemContext = React.useContext(FormItemContext); 38 | const { getFieldState, formState } = useFormContext(); 39 | 40 | const fieldState = getFieldState(fieldContext.name, formState); 41 | 42 | if (!fieldContext) { 43 | throw new Error('useFormField should be used within '); 44 | } 45 | 46 | const { id } = itemContext; 47 | 48 | return { 49 | id, 50 | name: fieldContext.name, 51 | formItemId: `${id}-form-item`, 52 | formDescriptionId: `${id}-form-item-description`, 53 | formMessageId: `${id}-form-item-message`, 54 | ...fieldState, 55 | }; 56 | }; 57 | 58 | type FormItemContextValue = { 59 | id: string; 60 | }; 61 | 62 | const FormItemContext = React.createContext({} as FormItemContextValue); 63 | 64 | const FormItem = React.forwardRef>( 65 | ({ className, ...props }, ref) => { 66 | const id = React.useId(); 67 | 68 | return ( 69 | 70 |
71 | 72 | ); 73 | } 74 | ); 75 | FormItem.displayName = 'FormItem'; 76 | 77 | const FormLabel = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef 80 | >(({ className, ...props }, ref) => { 81 | const { error, formItemId } = useFormField(); 82 | 83 | return