From 7f1ebe04d2126e3477f2f91f471bdd2af107c38b Mon Sep 17 00:00:00 2001 From: Presa Date: Sun, 24 Nov 2024 06:58:20 +0100 Subject: [PATCH 1/2] Major application overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - edge-compatible cryptography replacing bcrypt - Magic link authentication flow - Error bubbling pattern across app - Refactored server actions, improved reliability, better error handling - Reorganized codebase structure for better maintainability - Name changes - Stuff(...) 🏗️ Core Improvements: - Centralized error handling - Enhanced type safety - Edge runtime compatibility - Server actions error handling pattern updated - Auth flow modifications - Magic-link feature. - Prisma schema more in line with docs - Auth.js handling more in line with docs - Credentials with CustomVerificationToken. - VerificationToken with magic-link - Ready to start implementing WebAuthn --- .eslintrc.json | 41 ++- .gitignore | 1 + actions/admin.ts | 16 +- actions/login.ts | 251 +++++++++------ actions/logout.ts | 4 +- actions/magic-link.ts | 54 ++++ actions/new-password.ts | 124 +++++--- actions/new-verification.ts | 147 +++++++-- actions/register.ts | 149 ++++++--- actions/reset-password.ts | 112 ++++--- actions/settings.ts | 232 ++++++++++---- app/(auth)/login/magic-link/page.tsx | 5 + app/(auth)/login/page.tsx | 2 +- app/(auth)/loginerror/page.tsx | 2 +- app/(auth)/new-password/page.tsx | 2 +- app/(auth)/new-verification/page.tsx | 2 +- app/(auth)/register/page.tsx | 2 +- app/(auth)/reset-password/page.tsx | 2 +- app/(protected)/admin/page.tsx | 19 +- app/(protected)/client/client-component.tsx | 15 +- app/(protected)/client/page.tsx | 9 + app/(protected)/layout.tsx | 10 +- app/(protected)/server/page.tsx | 9 +- app/(protected)/settings/SettingsForm.tsx | 189 ------------ app/(protected)/settings/page.tsx | 19 +- app/api/auth/[...nextauth]/route.ts | 9 + app/layout.tsx | 3 +- app/page.tsx | 2 +- auth.config.ts | 114 ++++++- auth.ts | 130 ++++---- components/LogoutButton.tsx | 10 - .../AdminActionAndRhTester.tsx} | 8 +- components/{ => access-control}/RoleGate.tsx | 8 +- components/auth/SocialButtons.tsx | 29 -- components/auth/{ => buttons}/BackButton.tsx | 4 +- components/{ => auth/buttons}/LoginButton.tsx | 2 +- components/auth/buttons/SocialButtons.tsx | 39 +++ components/auth/{ => forms}/LoginForm.tsx | 32 +- components/auth/forms/MagicLinkForm.tsx | 87 ++++++ .../auth/{ => forms}/NewPasswordForm.tsx | 14 +- .../auth/{ => forms}/NewVerificationForm.tsx | 14 +- components/auth/{ => forms}/RegisterForm.tsx | 14 +- .../auth/{ => forms}/ResetPasswordForm.tsx | 14 +- .../auth/{ => shared}/AuthFormHeader.tsx | 6 +- components/auth/{ => shared}/CardWrapper.tsx | 16 +- components/auth/{ => shared}/ErrorCard.tsx | 6 +- components/forms/SettingsForm.tsx | 290 ++++++++++++++++++ .../messages}/FormError.tsx | 4 +- .../messages}/FormSuccess.tsx | 4 +- components/{navbar => layout}/Navbar.tsx | 4 +- .../{ => layout}/navbar/NavigationMenu.tsx | 4 +- .../{ => layout}/navbar/UserAvatarMenu.tsx | 12 +- components/ui/CustomSpinner.tsx | 7 + components/ui/avatar.tsx | 2 +- components/ui/badge.tsx | 6 +- components/ui/button.tsx | 5 +- components/ui/dialog.tsx | 2 +- components/ui/dropdown-menu.tsx | 2 +- components/ui/form.tsx | 4 +- components/ui/label.tsx | 2 +- components/ui/select.tsx | 2 +- components/ui/switch.tsx | 2 +- components/user/actions/LogoutButton.tsx | 16 + components/{ => user/profile}/UserInfo.tsx | 9 +- data/account.ts | 13 - data/db/account/helpers.ts | 57 ++++ data/db/tokens/password-reset/create.ts | 33 ++ data/db/tokens/password-reset/delete.ts | 7 + data/db/tokens/password-reset/helpers.ts | 73 +++++ data/db/tokens/two-factor/create.ts | 29 ++ data/db/tokens/two-factor/helpers.ts | 17 + data/db/tokens/verification-email/create.ts | 42 +++ data/db/tokens/verification-email/delete.ts | 7 + data/db/tokens/verification-email/helpers.ts | 8 + .../verification-tokens/magic-link/helpers.ts | 36 +++ data/db/unstable-cache/helpers.ts | 5 + data/db/user/create.ts | 84 +++++ data/db/user/helpers.ts | 30 ++ data/db/user/login.ts | 101 ++++++ data/db/user/reset-password.ts | 71 +++++ data/db/user/settings.ts | 36 +++ data/password-reset-token.ts | 25 -- data/two-factor-confirmation.ts | 13 - data/two-factor-token.ts | 23 -- data/user.ts | 23 -- data/verification-token.ts | 34 -- e2e-tests/credentials-2FA.spec.ts | 5 +- .../credentials-registration-flow.spec.ts | 21 +- e2e-tests/forgot-password.spec.ts | 7 +- e2e-tests/helpers/helper-functions.ts | 8 +- e2e-tests/helpers/mailsac/mailsac.ts | 2 +- hooks/use-current-role.ts | 10 - hooks/use-current-user.ts | 11 - lib/auth-utils.ts | 28 -- lib/auth/auth-utils.ts | 45 +++ lib/auth/hooks.ts | 42 +++ lib/auth/types.d.ts | 71 +++++ lib/constants/errors/errors.ts | 90 ++++++ lib/constants/messages/actions/messages.ts | 115 +++++++ lib/crypto/hash-edge-compatible.ts | 121 ++++++++ lib/db.ts | 9 +- lib/{ => mail}/mail.ts | 12 +- lib/nextjs/headers.ts | 12 + lib/tokens.ts | 80 ----- middleware.ts | 4 +- next-auth.d.ts | 14 - next.config.mjs | 4 +- package-lock.json | 158 +++++++++- package.json | 2 +- playwright.config.ts | 4 +- prisma/schema.prisma | 61 +++- routes.ts | 18 +- schemas/index.tsx | 126 +++++--- 113 files changed, 3067 insertions(+), 1136 deletions(-) create mode 100644 actions/magic-link.ts create mode 100644 app/(auth)/login/magic-link/page.tsx delete mode 100644 app/(protected)/settings/SettingsForm.tsx delete mode 100644 components/LogoutButton.tsx rename components/{admin-only-rh-and-sa.tsx => access-control/AdminActionAndRhTester.tsx} (92%) rename components/{ => access-control}/RoleGate.tsx (63%) delete mode 100644 components/auth/SocialButtons.tsx rename components/auth/{ => buttons}/BackButton.tsx (81%) rename components/{ => auth/buttons}/LoginButton.tsx (92%) create mode 100644 components/auth/buttons/SocialButtons.tsx rename components/auth/{ => forms}/LoginForm.tsx (86%) create mode 100644 components/auth/forms/MagicLinkForm.tsx rename components/auth/{ => forms}/NewPasswordForm.tsx (85%) rename components/auth/{ => forms}/NewVerificationForm.tsx (79%) rename components/auth/{ => forms}/RegisterForm.tsx (89%) rename components/auth/{ => forms}/ResetPasswordForm.tsx (84%) rename components/auth/{ => shared}/AuthFormHeader.tsx (78%) rename components/auth/{ => shared}/CardWrapper.tsx (65%) rename components/auth/{ => shared}/ErrorCard.tsx (77%) create mode 100644 components/forms/SettingsForm.tsx rename components/{form-messages => forms/messages}/FormError.tsx (84%) rename components/{form-messages => forms/messages}/FormSuccess.tsx (82%) rename components/{navbar => layout}/Navbar.tsx (64%) rename components/{ => layout}/navbar/NavigationMenu.tsx (95%) rename components/{ => layout}/navbar/UserAvatarMenu.tsx (83%) create mode 100644 components/ui/CustomSpinner.tsx create mode 100644 components/user/actions/LogoutButton.tsx rename components/{ => user/profile}/UserInfo.tsx (93%) delete mode 100644 data/account.ts create mode 100644 data/db/account/helpers.ts create mode 100644 data/db/tokens/password-reset/create.ts create mode 100644 data/db/tokens/password-reset/delete.ts create mode 100644 data/db/tokens/password-reset/helpers.ts create mode 100644 data/db/tokens/two-factor/create.ts create mode 100644 data/db/tokens/two-factor/helpers.ts create mode 100644 data/db/tokens/verification-email/create.ts create mode 100644 data/db/tokens/verification-email/delete.ts create mode 100644 data/db/tokens/verification-email/helpers.ts create mode 100644 data/db/tokens/verification-tokens/magic-link/helpers.ts create mode 100644 data/db/unstable-cache/helpers.ts create mode 100644 data/db/user/create.ts create mode 100644 data/db/user/helpers.ts create mode 100644 data/db/user/login.ts create mode 100644 data/db/user/reset-password.ts create mode 100644 data/db/user/settings.ts delete mode 100644 data/password-reset-token.ts delete mode 100644 data/two-factor-confirmation.ts delete mode 100644 data/two-factor-token.ts delete mode 100644 data/user.ts delete mode 100644 data/verification-token.ts delete mode 100644 hooks/use-current-role.ts delete mode 100644 hooks/use-current-user.ts delete mode 100644 lib/auth-utils.ts create mode 100644 lib/auth/auth-utils.ts create mode 100644 lib/auth/hooks.ts create mode 100644 lib/auth/types.d.ts create mode 100644 lib/constants/errors/errors.ts create mode 100644 lib/constants/messages/actions/messages.ts create mode 100644 lib/crypto/hash-edge-compatible.ts rename lib/{ => mail}/mail.ts (90%) create mode 100644 lib/nextjs/headers.ts delete mode 100644 lib/tokens.ts delete mode 100644 next-auth.d.ts diff --git a/.eslintrc.json b/.eslintrc.json index cd783fb..cff27b6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,16 +2,41 @@ "extends": ["next/core-web-vitals", "prettier", "next"], "plugins": ["import", "jsx-a11y", "react-hooks", "react"], "rules": { + "react/function-component-definition": [ + "warn", + { + "namedComponents": "arrow-function" + } + ], + "react/jsx-pascal-case": "warn", + "import/no-anonymous-default-export": "warn", "import/no-extraneous-dependencies": "error", - "react/jsx-filename-extension": [1, { "extensions": [".jsx", ".tsx"] }], + "react/jsx-filename-extension": [ + 1, + { + "extensions": [".jsx", ".tsx"] + } + ], "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "jsx-a11y/accessible-emoji": "warn", - "no-console": ["error", { "allow": ["warn", "error"] }], + "no-console": [ + "error", + { + "allow": ["warn", "error"] + } + ], "consistent-return": "error", "import/order": [ "error", - { "groups": ["builtin", "external", "internal", "parent", "sibling", "index"], "newlines-between": "always" } + { + "groups": ["builtin", "external", "internal", "parent", "sibling", "index", "object", "type"], + "newlines-between": "always", + "alphabetize": { + "order": "asc", + "caseInsensitive": true + } + } ], "react/jsx-key": "warn", "react/no-array-index-key": "warn", @@ -29,10 +54,16 @@ }, "overrides": [ { - "files": ["e2e-tests/**/*", "allure-report/**/*"], + "files": ["e2e-tests/**/*", "allure-report/**/*", "app/**/*.{js,jsx,ts,tsx}"], "rules": { "no-console": "off", - "consistent-return": "off" + "consistent-return": "off", + "react/function-component-definition": [ + "warn", + { + "namedComponents": "function-declaration" + } + ] } } ], diff --git a/.gitignore b/.gitignore index 34b82bc..00770b1 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ node_modules/ /tests-examples/ /allure-results/ /allure-report/ +/save diff --git a/actions/admin.ts b/actions/admin.ts index 3294386..5a75d64 100644 --- a/actions/admin.ts +++ b/actions/admin.ts @@ -1,15 +1,13 @@ 'use server'; -import { UserRole } from '@prisma/client'; +import { sessionHasRole } from '@/lib/auth/auth-utils'; +import { messages } from '@/lib/constants/messages/actions/messages'; -import { currentSessionRole } from '@/lib/auth-utils'; - -export const admin = async () => { - const role = await currentSessionRole(); - - if (role === UserRole.ADMIN) { - return { success: 'Allowed Server Action!' }; +export const adminAction = async () => { + const isAdmin = await sessionHasRole('ADMIN'); + if (!isAdmin) { + return { error: messages.admin.errors.FORBIDDEN_SA }; } - return { error: 'Forbidden Server Action!' }; + return { success: messages.admin.success.ALLOWED_SA }; }; diff --git a/actions/login.ts b/actions/login.ts index d98888a..1abf64b 100644 --- a/actions/login.ts +++ b/actions/login.ts @@ -1,129 +1,190 @@ 'use server'; +import { PrismaClientKnownRequestError, PrismaClientInitializationError } from '@prisma/client/runtime/library'; import { AuthError } from 'next-auth'; import * as zod from 'zod'; -import bcrypt from 'bcryptjs'; - -import { getVerificationTokenByEmail } from '@/data/verification-token'; -import { getTwoFactorConfirmationByUserId } from '@/data/two-factor-confirmation'; -import { getTwoFactorTokenByEmail } from '@/data/two-factor-token'; -import { db } from '@/lib/db'; -import { getUserByEmail } from '@/data/user'; -import { sendVerificationEmail, sendTwoFactorTokenEmail } from '@/lib/mail'; -import { generateVerificationToken, generateTwoFactorToken } from '@/lib/tokens'; -import { DEFAULT_LOGIN_REDIRECT } from '@/routes'; -import { LoginSchema } from '@/schemas'; -import { signIn } from '@/auth'; - -export const login = async (values: zod.infer, callbackUrl?: string | null) => { - const validatedFields = LoginSchema.safeParse(values); - - if (!validatedFields.success) { - return { error: 'Invalid fields!' }; - } - const { email, password, code } = validatedFields.data; - - const existingUser = await getUserByEmail(email); - if (!existingUser || !existingUser.email || !existingUser.password) { - return { error: 'Invalid credentials' }; - } - - // Verify password before proceeding with any other checks - const passwordsMatch = await bcrypt.compare(password, existingUser.password); - if (!passwordsMatch) { - return { error: 'Invalid credentials' }; - } +import { signIn } from '@/auth'; +import { generateTwoFactorToken } from '@/data/db/tokens/two-factor/create'; +import { generateCustomVerificationToken } from '@/data/db/tokens/verification-email/create'; +import { deleteCustomVerificationTokenById } from '@/data/db/tokens/verification-email/delete'; +import { consumeTwoFactorToken, getUserLoginAuthData } from '@/data/db/user/login'; +import { CustomLoginAuthError } from '@/lib/constants/errors/errors'; +import { messages } from '@/lib/constants/messages/actions/messages'; +import { verifyPassword } from '@/lib/crypto/hash-edge-compatible'; +import { sendVerificationEmail, sendTwoFactorTokenEmail } from '@/lib/mail/mail'; +import { DEFAULT_LOGIN_REDIRECT } from '@/routes'; +import { CallbackUrlSchema, LoginSchema } from '@/schemas'; + +import type { VerifiedUserForAuth } from '@/lib/auth/types'; + +type LoginActionResult = + | { success: string; error?: never; twoFactor?: never } + | { error: string; success?: never; twoFactor?: never } + | { twoFactor: true; success?: never; error?: never }; + +/** + * Server action to handle user authentication with support for 2FA and email verification + * + * Manages the complete login flow including credentials verification, 2FA handling, + * email verification status, and proper session creation. Supports callback URLs + * and handles various authentication scenarios. + * + * 1. Validate input fields and callback URL + * 2. Fetch user authentication data + * 3. Verify password + * - Check needs update to Reset Password + * 4. Check email verification status + * - Send verification email if needed + * 5. Handle 2FA if enabled + * - Generate and send 2FA token if needed + * - Send user to 2FA form + * - Verify 2FA code if provided + * 6. Create authenticated session with auth.js + * + * @notes + * - Successful login throws NEXT_REDIRECT (normal Auth.js behavior) + * - 2FA tokens are single-use + * - Email verification tokens are reissued if expired + * - Supports custom callback URLs with validation + */ +export const loginAction = async ( + values: zod.infer, + callbackUrl: string | null +): Promise => { + try { + const validatedFields = LoginSchema.safeParse(values); + const validatedCallbackUrl = CallbackUrlSchema.safeParse(callbackUrl); - /** Confirmation email token recently sent? - * if not, generates and send email - */ - if (!existingUser.emailVerified) { - const existingToken = await getVerificationTokenByEmail(email); - if (existingToken) { - const hasExpired = new Date(existingToken.expires) < new Date(); - if (!hasExpired) { - return { error: 'Confirmation email already sent! Check your inbox!' }; - } + if (!validatedFields.success) { + throw new CustomLoginAuthError('InvalidFields'); } - const verificationToken = await generateVerificationToken(email, existingUser.id); + callbackUrl = validatedCallbackUrl.success ? validatedCallbackUrl.data : null; + const { email, password, twoFactorCode } = validatedFields.data; - await sendVerificationEmail(verificationToken.email, verificationToken.token); - - return { success: 'Confirmation email sent!' }; - } + // Get all user auth data in a single query + const { user, activeCustomVerificationToken, activeTwoFactorToken } = await getUserLoginAuthData(email); - /** 2FA code logic - * Currently if current token is unexpired it does not re-send a new one - * Reduce db calls and e-mail sents on this preview - */ - if (existingUser.isTwoFactorEnabled && existingUser.email) { - // If user is already at the 2fa on loginForm - if (code) { - const twoFactorToken = await getTwoFactorTokenByEmail(existingUser.email); - if (!twoFactorToken) { - return { error: 'Invalid two factor token' }; - } + if (!user?.email || !user?.password) { + throw new CustomLoginAuthError('WrongCredentials'); + } - if (twoFactorToken.token !== code) { - return { error: 'Invalid code' }; - } + // Verify password, this handles crypto version changes + const { isPasswordValid, passwordNeedsUpdate } = await verifyPassword(password, user.password); + if (passwordNeedsUpdate) { + throw new CustomLoginAuthError('PasswordNeedUpdate'); + } + if (!isPasswordValid) { + throw new CustomLoginAuthError('WrongCredentials'); + } - const hasExpired = new Date(twoFactorToken.expires) < new Date(); - if (hasExpired) { - return { error: 'Code expired!' }; + // Handle email verification + if (!user.emailVerified) { + if (activeCustomVerificationToken) { + throw new CustomLoginAuthError('ConfirmationEmailAlreadySent'); } - await db.twoFactorToken.delete({ - where: { id: twoFactorToken.id }, + const customVerificationToken = await generateCustomVerificationToken({ + email, + userId: user.id, }); - const existingConfirmation = await getTwoFactorConfirmationByUserId(existingUser.id); - if (existingConfirmation) { - await db.twoFactorConfirmation.delete({ - where: { id: existingConfirmation.id }, - }); + const emailResponse = await sendVerificationEmail(customVerificationToken.email, customVerificationToken.token); + if (emailResponse.error) { + await deleteCustomVerificationTokenById(customVerificationToken.id); + throw new CustomLoginAuthError('ResendEmailError'); } - // consumed by the signIn callback - await db.twoFactorConfirmation.create({ - data: { userId: existingUser.id }, - }); - } else { - // return { twoFactor: true }; sends the user to the 2fa on loginForm - const existingTwoFactorToken = await getTwoFactorTokenByEmail(existingUser.email); - if (existingTwoFactorToken) { - const hasExpired = new Date(existingTwoFactorToken.expires) < new Date(); - if (!hasExpired) { - return { twoFactor: true }; + throw new CustomLoginAuthError('NewConfirmationEmailSent'); + } + + // Handle 2FA + if (user.isTwoFactorEnabled) { + if (twoFactorCode) { + if (!activeTwoFactorToken) { + throw new CustomLoginAuthError('TwoFactorTokenNotExists'); } - } - const twoFactorToken = await generateTwoFactorToken(existingUser.email, existingUser.id); - await sendTwoFactorTokenEmail(existingUser.email, twoFactorToken.token); + if (activeTwoFactorToken.token !== twoFactorCode) { + throw new CustomLoginAuthError('TwoFactorCodeInvalid'); + } - return { twoFactor: true }; + await consumeTwoFactorToken(activeTwoFactorToken.token, user.id); + } else { + // At this point user is logging in and have 2FA Activated + if (!activeTwoFactorToken) { + const twoFactorToken = await generateTwoFactorToken(user.email, user.id); + const emailResponse = await sendTwoFactorTokenEmail(user.email, twoFactorToken.token); + if (emailResponse.error) { + throw new CustomLoginAuthError('ResendEmailError'); + } + } + // We send user to the 2FA Code Form + return { twoFactor: true }; + } } - } - - try { + const verifiedUser: VerifiedUserForAuth = { + id: user.id, + email: user.email, + name: user.name ?? null, + role: user.role, + isTwoFactorEnabled: user.isTwoFactorEnabled, + emailVerified: user.emailVerified, + image: user.image, + isOauth: false, + }; + // Stringify user object since Auth.js credentials only accept strings + // Will be JSON.parsed in the auth callback + // by doing JSON.stringify, is easier to construct the object again. + // Could use formData too await signIn('credentials', { - email, - password, - redirectTo: callbackUrl || DEFAULT_LOGIN_REDIRECT, + user: JSON.stringify(verifiedUser), + redirectTo: callbackUrl ?? DEFAULT_LOGIN_REDIRECT, }); } catch (error) { + if (error instanceof CustomLoginAuthError) { + switch (error.type) { + case 'InvalidFields': + return { error: messages.login.errors.INVALID_FIELDS }; + case 'WrongCredentials': + return { error: messages.login.errors.WRONG_CREDENTIALS }; + case 'ConfirmationEmailAlreadySent': + return { error: messages.login.errors.CONFIRMATION_EMAIL_ALREADY_SENT }; + case 'ResendEmailError': + return { error: messages.login.errors.RESEND_EMAIL_ERROR }; + case 'NewConfirmationEmailSent': + return { error: messages.login.errors.NEW_CONFIRMATION_EMAIL_SENT }; + case 'TwoFactorTokenNotExists': + return { error: messages.login.errors.TWO_FACTOR_TOKEN_NOT_EXISTS }; + case 'TwoFactorCodeInvalid': + return { error: messages.login.errors.TWO_FACTOR_CODE_INVALID }; + case 'PasswordNeedUpdate': + return { error: messages.login.errors.ASK_USER_RESET_PASSWORD }; + default: + return { error: messages.generic.errors.UNKNOWN_ERROR }; + } + } + + if (error instanceof PrismaClientKnownRequestError || error instanceof PrismaClientInitializationError) { + console.error('Database error:', error); + return { error: messages.generic.errors.DB_CONNECTION_ERROR }; + } + if (error instanceof AuthError) { switch (error.type) { case 'CredentialsSignin': - return { error: 'Invalid credentials' }; + return { error: messages.login.errors.AUTH_ERROR }; default: - return { error: 'An error occurred' }; + return { error: messages.login.errors.AUTH_ERROR }; } } - throw error; + if (error instanceof Error && error.message?.includes('NEXT_REDIRECT')) { + throw error; // This is necessary for the redirect to work + } + + return { error: messages.generic.errors.UNEXPECTED_ERROR }; } - return { error: 'Something went wrong!' }; + return { error: messages.generic.errors.NASTY_WEIRD_ERROR }; }; diff --git a/actions/logout.ts b/actions/logout.ts index 0520001..1136ced 100644 --- a/actions/logout.ts +++ b/actions/logout.ts @@ -2,7 +2,7 @@ import { signOut } from '@/auth'; -export const logout = async () => { +export const logoutAction = async () => { // some server stuff - await signOut(); + await signOut({ redirectTo: '/' }); }; diff --git a/actions/magic-link.ts b/actions/magic-link.ts new file mode 100644 index 0000000..1e29c82 --- /dev/null +++ b/actions/magic-link.ts @@ -0,0 +1,54 @@ +'use server'; + +import { AuthError } from 'next-auth'; + +import { signIn } from '@/auth'; +import { CustomMagicLinkError } from '@/lib/constants/errors/errors'; +import { messages } from '@/lib/constants/messages/actions/messages'; + +type MagicLinkActionResult = { success: string; error?: never } | { error: string; success?: never }; + +/** + * Server action to handle magic link authentication requests + * + * Processes email-based authentication by sending a magic link to the user's email. + * Uses the Resend provider from Auth.js to handle email delivery and implements + * rate limiting and security checks. + * + * @note Handles NEXT_REDIRECT errors differently than standard Auth.js flow: + * Instead of redirecting, returns a success message + * + * @securityNotes + * - Uses built-in Resend email normalization + */ +export async function magicLinkAction(formData: FormData): Promise { + try { + // This is a example sending raw formData + await signIn('resend', formData); + return { success: messages.magicLink.success.SENT }; + } catch (error) { + if (error instanceof AuthError) { + if (error.cause?.err instanceof CustomMagicLinkError) { + switch (error.cause.err.errorType) { + case 'IpInvalid': + return { error: messages.magicLink.errors.GENERIC_FAILED }; + case 'IpLimit': + return { error: messages.magicLink.errors.IP_LIMIT }; + case 'TokenExists': + return { error: messages.magicLink.errors.EMAIL_ALREADY_SENT }; + default: + return { error: messages.magicLink.errors.GENERIC_CUSTOMMAGICLINKERROR }; + } + } + return { error: messages.magicLink.errors.GENERIC_AUTHERROR }; + } + + if (error instanceof Error && error.message?.includes('NEXT_REDIRECT')) { + /*throw error;// This is necessary for the redirect to work*/ + // Not redirecting, returning a success instead + return { success: messages.magicLink.success.SENT }; + } + + return { error: messages.generic.errors.UNEXPECTED_ERROR }; + } +} diff --git a/actions/new-password.ts b/actions/new-password.ts index a3e9a80..c86f944 100644 --- a/actions/new-password.ts +++ b/actions/new-password.ts @@ -1,63 +1,91 @@ 'use server'; +import { PrismaClientKnownRequestError, PrismaClientInitializationError } from '@prisma/client/runtime/library'; import * as zod from 'zod'; -import bcrypt from 'bcryptjs'; +import { getValidPasswordResetToken } from '@/data/db/tokens/password-reset/helpers'; +import { CustomNewPasswordError } from '@/lib/constants/errors/errors'; +import { messages } from '@/lib/constants/messages/actions/messages'; +import { hashPassword } from '@/lib/crypto/hash-edge-compatible'; import { db } from '@/lib/db'; -import { getPasswordResetTokenByToken } from '@/data/password-reset-token'; -import { getUserByEmail } from '@/data/user'; -import { NewPasswordSchema } from '@/schemas'; - -export const newPassword = async (values: zod.infer, token?: string | null) => { - if (!token) { - return { - error: 'No token provided!', - }; - } +import { NewPasswordSchema, PasswordResetTokenSchema } from '@/schemas'; - const validatedFields = NewPasswordSchema.safeParse(values); +type NewPasswordActionResult = { success: string; error?: never } | { error: string; success?: never }; - if (!validatedFields.success) { - return { - error: 'Invalid fields!', - }; - } - const { password } = validatedFields.data; +/** + * Server action to handle password reset after user clicks email link + * + * This action is triggered when a user submits the new password form after clicking + * the reset password link from their email. It validates the token from the URL + * + * @note Uses a database transaction to ensure token is invalidated when password is updated + * @note Passwords are hashed before storage + * @note Tokens are single-use and removed + */ +export const newPasswordAction = async ( + values: zod.infer, + token?: string | null +): Promise => { + try { + // Validate inputs + const validatedToken = PasswordResetTokenSchema.safeParse(token); + if (!validatedToken.success || !validatedToken.data) { + throw new CustomNewPasswordError('InvalidToken'); + } - const existingToken = await getPasswordResetTokenByToken(token); + const validatedFields = NewPasswordSchema.safeParse(values); + if (!validatedFields.success || !validatedFields.data) { + throw new CustomNewPasswordError('InvalidFields'); + } - if (!existingToken) { - return { - error: 'Invalid token!', - }; - } + const existingToken = await getValidPasswordResetToken(validatedToken.data); + if (!existingToken) { + throw new CustomNewPasswordError('TokenNotExist'); + } - const hasExpired = new Date(existingToken.expires) < new Date(); + const { password } = validatedFields.data; + // Hash the new password + const hashedPassword = await hashPassword(password); - if (hasExpired) { - return { - error: 'Token has expired!', - }; - } - const existingUser = await getUserByEmail(existingToken.email); - if (!existingUser) { - return { - error: 'Email does not exist!', - }; - } - - const hashedPassword = await bcrypt.hash(password, 10); + // Update password and delete token to prevent token reuse + await db.$transaction(async (tx) => { + // Update user password + await tx.user.update({ + where: { + id: existingToken.userId, + }, + data: { + password: hashedPassword, + }, + }); - await db.user.update({ - where: { id: existingUser.id }, - data: { - password: hashedPassword, - }, - }); + // Delete the used token + await tx.passwordResetToken.delete({ + where: { + id: existingToken.id, + }, + }); + }); - await db.passwordResetToken.delete({ - where: { id: existingToken.id }, - }); + return { success: messages.new_password.success.UPDATE_SUCCESSFUL }; + } catch (error) { + if (error instanceof CustomNewPasswordError) { + switch (error.type) { + case 'InvalidToken': + return { error: messages.new_password.errors.INVALID_TOKEN }; + case 'InvalidFields': + return { error: messages.new_password.errors.INVALID_PASSWORD }; + case 'TokenNotExist': + return { error: messages.new_password.errors.REQUEST_NEW_PASSWORD_RESET }; + default: + return { error: messages.generic.errors.UNKNOWN_ERROR }; + } + } + if (error instanceof PrismaClientKnownRequestError || error instanceof PrismaClientInitializationError) { + console.error('Database error:', error); + return { error: messages.generic.errors.DB_CONNECTION_ERROR }; + } - return { success: 'Password updated!' }; + return { error: messages.generic.errors.UNEXPECTED_ERROR }; + } }; diff --git a/actions/new-verification.ts b/actions/new-verification.ts index 1e531de..f24838a 100644 --- a/actions/new-verification.ts +++ b/actions/new-verification.ts @@ -1,37 +1,130 @@ 'use server'; -import { getUserByEmail } from '@/data/user'; -import { getVerificationTokenByToken } from '@/data/verification-token'; +import { PrismaClientInitializationError, PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; + +import { generateCustomVerificationToken } from '@/data/db/tokens/verification-email/create'; +import { deleteCustomVerificationTokenById } from '@/data/db/tokens/verification-email/delete'; +import { CustomNewVerificationEmailError } from '@/lib/constants/errors/errors'; +import { messages } from '@/lib/constants/messages/actions/messages'; import { db } from '@/lib/db'; +import { sendVerificationEmail } from '@/lib/mail/mail'; +import { NewVerificationEmailTokenSchema } from '@/schemas'; -export const newVerification = async (token: string) => { - const existingToken = await getVerificationTokenByToken(token); - if (!existingToken) { - return { error: 'Token invalid!' }; - } +type NewVerificationActionResult = { success: string; error?: never } | { error: string; success?: never }; - const hasExpired = new Date(existingToken.expires) < new Date(); - if (hasExpired) { - return { error: 'Token has expired!' }; - } +/** + * Server action to handle email verification token processing + * + * Validates and processes email verification tokens, handling various scenarios + * including token expiration, already verified emails, and automatic token renewal. + * Uses transactions to ensure data consistency when updating verification status. + * + * 1. Validate token format + * 2. Find token in database with user data + * 3. Check if email already verified + * 4. Check token expiration + * - If expired, generate and send new token + * 5. Update user verification status + * 6. Delete used token + */ +export const newVerificationAction = async (token: string): Promise => { + try { + const validatedToken = NewVerificationEmailTokenSchema.safeParse(token); + if (!validatedToken.success) { + throw new CustomNewVerificationEmailError('InvalidToken'); + } - const existingUser = await getUserByEmail(existingToken.email); - if (!existingUser) { - return { error: 'Email does not exist!' }; - } + const verificationData = await db.customVerificationToken.findUnique({ + where: { + token: validatedToken.data, + }, + include: { + user: { + select: { + id: true, + email: true, + emailVerified: true, + }, + }, + }, + }); + + if (!verificationData) { + throw new CustomNewVerificationEmailError('InvalidTokenOrVerified'); + } + + // Check if email is already verified + // This should not happen + if (verificationData.user.emailVerified) { + await db.customVerificationToken.delete({ + where: { id: verificationData.id }, + }); + throw new CustomNewVerificationEmailError('EmailAlreadyVerified'); + } - await db.user.update({ - where: { id: existingUser.id }, - data: { - emailVerified: new Date(), - // Reusability for updating the email of user on settings - email: existingToken.email, - }, - }); + // Check if token has expired + const now = new Date(); + if (verificationData.expires <= now) { + const newToken = await generateCustomVerificationToken({ + userId: verificationData.user.id, + email: verificationData.email, + }); - await db.verificationToken.delete({ - where: { id: existingToken.id }, - }); + // Send new verification email + const emailResponse = await sendVerificationEmail(newToken.email, newToken.token); + if (emailResponse.error) { + await deleteCustomVerificationTokenById(newToken.id); + throw new CustomNewVerificationEmailError('ResendEmailError'); + } + throw new CustomNewVerificationEmailError('TokenExpiredSentNewEmail'); + } - return { success: 'Email verified!' }; + // Verify email and delete token + await db.$transaction(async (tx) => { + await tx.user.update({ + where: { + id: verificationData.user.id, + }, + data: { + emailVerified: new Date(), + email: verificationData.email, + }, + }); + + await tx.customVerificationToken.delete({ + where: { + id: verificationData.id, + }, + }); + }); + + return { success: messages.new_verification_email.success.EMAIL_VERIFIED }; + } catch (error) { + if (error instanceof CustomNewVerificationEmailError) { + switch (error.type) { + case 'InvalidToken': + return { error: messages.new_verification_email.errors.INVALID_TOKEN }; + case 'EmailNotFound': + return { error: messages.new_verification_email.errors.EMAIL_NOT_FOUND }; + case 'EmailAlreadyVerified': + return { error: messages.new_verification_email.errors.EMAIL_ALREADY_VERIFIED }; + case 'ResendEmailError': + return { error: messages.new_verification_email.errors.TOKEN_EXPIRED_FAILED_SEND_EMAIL }; + case 'TokenExpiredSentNewEmail': + return { error: messages.new_verification_email.errors.TOKEN_EXPIRED_SENT_NEW }; + case 'InvalidTokenOrVerified': + return { error: messages.new_verification_email.errors.INVALID_TOKEN_OR_VERIFIED }; + default: + return { error: messages.generic.errors.UNKNOWN_ERROR }; + } + } + + if (error instanceof PrismaClientInitializationError || error instanceof PrismaClientKnownRequestError) { + console.error('Database error:', error); + return { error: messages.generic.errors.DB_CONNECTION_ERROR }; + } + + console.error('Verification error:', error); + return { error: messages.generic.errors.UNEXPECTED_ERROR }; + } }; diff --git a/actions/register.ts b/actions/register.ts index 1ecba18..407a329 100644 --- a/actions/register.ts +++ b/actions/register.ts @@ -1,64 +1,123 @@ 'use server'; -import { headers } from 'next/headers'; +import { PrismaClientInitializationError, PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; import * as zod from 'zod'; -import bcrypt from 'bcryptjs'; -import { hashIp } from '@/lib/auth-utils'; -import { getUserByEmail } from '@/data/user'; -import { sendVerificationEmail } from '@/lib/mail'; -import { RegisterSchema } from '@/schemas'; +import { deleteCustomVerificationTokenById } from '@/data/db/tokens/verification-email/delete'; +import { createNewCredentialsUser } from '@/data/db/user/create'; +import { countUserRegistrationsByIp } from '@/data/db/user/helpers'; +import { CustomRegisterCredentialsUserError } from '@/lib/constants/errors/errors'; +import { messages } from '@/lib/constants/messages/actions/messages'; import { db } from '@/lib/db'; -import { generateVerificationToken } from '@/lib/tokens'; - -export const register = async (values: zod.infer) => { - const validatedFields = RegisterSchema.safeParse(values); +import { sendVerificationEmail } from '@/lib/mail/mail'; +import { getHashedUserIpFromHeaders } from '@/lib/nextjs/headers'; +import { RegisterSchema } from '@/schemas'; - if (!validatedFields.success) return { error: 'Invalid fields' }; +type RegisterActionResult = { success: string; error?: never } | { error: string; success?: never }; - const { email, password, name } = validatedFields.data; +/** + * Server action to handle new user registration with email verification + * + * Manages the entire registration process including input validation, IP-based + * rate limiting, user creation, and email verification token generation/sending. + * Implements security measures like IP tracking and account limits. + * + * 1. Validate input fields + * 2. Get and validate IP address + * 3. Check account limits per IP (production only) + * 4. Verify email uniqueness + * 5. Create user and customVerification token + * 6. Send verification email + * 7. Delete the created token when send email fail + * + * @securityFeatures + * - IP-based rate limiting (max 2 accounts per IP in production) + * - Email uniqueness validation + * - IP tracking for registrations + * + */ +export const registerAction = async (values: zod.infer): Promise => { + try { + const validatedFields = RegisterSchema.safeParse(values); + if (!validatedFields.success) { + throw new CustomRegisterCredentialsUserError('InvalidFields'); + } - const headersList = headers(); - const userIp = headersList.get('request-ip'); - const hashedIp = await hashIp(userIp); + const { email, password, name } = validatedFields.data; + const hashedIp = await getHashedUserIpFromHeaders(); - /* If we can not determine the IP of the user, fails to register */ - if (!userIp || hashedIp === 'unknown') { - return { error: 'Sorry! Something went wrong. Could not identify you as a human' }; - } + if (!hashedIp) { + throw new CustomRegisterCredentialsUserError('IpValidation'); + } + // Check account limit per IP + if (process.env.NODE_ENV === 'production') { + const accountCount = await countUserRegistrationsByIp({ + hashedIp, + }); - const existingAccounts = await db.user.count({ - where: { ip: hashedIp }, - }); - if (process.env.NODE_ENV === 'production' && existingAccounts >= 2) { - return { error: 'You are not allowed to register more accounts on this app preview' }; - } + if (accountCount >= 2) { + throw new CustomRegisterCredentialsUserError('AccountLimit'); + } + } - //TODO: Single Query Approach using Prisma Error code or upsert approach - const existingUser = await getUserByEmail(email); - if (existingUser) { - return { error: 'Email already registered!' }; - } + // check existing email + const existingUser = await db.user.findUnique({ + where: { email: email }, + select: { id: true }, + }); - const hashedPassword = await bcrypt.hash(password, 10); + if (existingUser) { + throw new CustomRegisterCredentialsUserError('EmailExists'); + } - const createdUser = await db.user.create({ - data: { + // Create user and verification token + const { emailCustomVerificationToken } = await createNewCredentialsUser({ name, email, - password: hashedPassword, - ip: hashedIp, - }, - select: { - id: true, - email: true, - }, - }); + password, + hashedIp, + }); - if (!createdUser?.id || !createdUser?.email) return { error: 'Something went wrong!' }; + // Send email for email-verification. - const verificationToken = await generateVerificationToken(createdUser.email, createdUser.id); - await sendVerificationEmail(verificationToken.email, verificationToken.token); + const emailResponse = await sendVerificationEmail( + emailCustomVerificationToken.email, + emailCustomVerificationToken.token + ); - return { success: 'Confirmation email sent!' }; + // If it fails we still send success message. Account is Registered at this point! + if (emailResponse.error) { + await deleteCustomVerificationTokenById(emailCustomVerificationToken.id); + return { success: messages.register.success.ACC_CREATED_EMAIL_SEND_FAILED }; + } + + return { success: messages.register.success.REGISTRATION_COMPLETE }; + } catch (error) { + if (error instanceof CustomRegisterCredentialsUserError) { + switch (error.type) { + case 'InvalidFields': + return { error: messages.generic.errors.INVALID_FIELDS }; + case 'IpValidation': + return { error: messages.register.errors.IP_VALIDATION_FAILED }; + case 'AccountLimit': + return { error: messages.register.errors.ACCOUNT_LIMIT }; + case 'EmailExists': + return { error: messages.register.errors.EMAIL_EXISTS }; + default: + return { error: messages.generic.errors.GENERIC_ERROR }; + } + } + + if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') { + return { error: messages.register.errors.EMAIL_EXISTS }; + } + + if (error instanceof PrismaClientInitializationError) { + console.error('Database connection error:', error); + return { error: messages.generic.errors.DB_CONNECTION_ERROR }; + } + + console.error('Unknown registration error:', error); + return { error: messages.generic.errors.GENERIC_ERROR }; + } }; diff --git a/actions/reset-password.ts b/actions/reset-password.ts index ca349f5..65ea2c0 100644 --- a/actions/reset-password.ts +++ b/actions/reset-password.ts @@ -1,53 +1,93 @@ 'use server'; +import { PrismaClientKnownRequestError, PrismaClientInitializationError } from '@prisma/client/runtime/library'; import * as zod from 'zod'; -import { getAccountByUserId } from '@/data/account'; -import { getPasswordResetTokenByEmail } from '@/data/password-reset-token'; -import { sendPasswordResetEmail } from '@/lib/mail'; -import { generatePasswordResetToken } from '@/lib/tokens'; -import { getUserByEmail } from '@/data/user'; +import { generatePasswordResetToken } from '@/data/db/tokens/password-reset/create'; +import { deletePasswordResetTokenById } from '@/data/db/tokens/password-reset/delete'; +import { getUserResetPasswordData } from '@/data/db/user/reset-password'; +import { CustomResetPasswordError } from '@/lib/constants/errors/errors'; +import { messages } from '@/lib/constants/messages/actions/messages'; +import { sendPasswordResetEmail } from '@/lib/mail/mail'; import { ResetPasswordSchema } from '@/schemas'; -export const resetPassword = async (values: zod.infer) => { - const validatedFields = ResetPasswordSchema.safeParse(values); +type ResetPasswordActionResult = { success: string; error?: never } | { error: string; success?: never }; - if (!validatedFields.success) { - return { - error: 'Invalid email!', - }; - } +/** + * Server action to initiate password reset process + * + * Handles the first step of password reset where user requests a reset link. + * Performs validations, generates a reset token, and sends an email + * with the reset link to the user. + * + * 1. Validate email format + * 2. Check if user exists and can reset password + * 3. Check for existing valid reset tokens + * 4. Generate new reset token + * 5. Send reset email + * 6. Clean up token if email fails + * + * @securityNotes + * - Validates email format before processing + * - Prevents multiple active reset tokens + * - Deletes token if email sending fails + * - Prevents OAuth-only accounts from password reset + */ +export const resetPasswordAction = async ( + values: zod.infer +): Promise => { + try { + const validatedFields = ResetPasswordSchema.safeParse(values); + if (!validatedFields.success) { + throw new CustomResetPasswordError('InvalidFields'); + } - const { email } = validatedFields.data; + const { email } = validatedFields.data; - const existingUser = await getUserByEmail(email); + const { userId, canResetPassword, activeResetToken } = await getUserResetPasswordData(email); - if (!existingUser) { - return { - error: 'Email not found!', - }; - } + if (!userId) { + throw new CustomResetPasswordError('EmailNotFound'); + } - const userRegisteredWithOauth = await getAccountByUserId(existingUser.id); - if (!!userRegisteredWithOauth) { - return { - error: 'Email registered with a provider! Login with your Email Provider!', - }; - } + if (!canResetPassword) { + throw new CustomResetPasswordError('NoPasswordToReset'); + } - const existingPasswordResetToken = await getPasswordResetTokenByEmail(email); - if (existingPasswordResetToken) { - const hasExpired = new Date(existingPasswordResetToken.expires) < new Date(); - if (!hasExpired) { - return { error: 'Reset password email already sent! Check your inbox!' }; + if (activeResetToken) { + throw new CustomResetPasswordError('TokenStillValid'); } - } - const passwordResetToken = await generatePasswordResetToken(email, existingUser.id); + const passwordResetToken = await generatePasswordResetToken(email, userId); + const emailResponse = await sendPasswordResetEmail(passwordResetToken.email, passwordResetToken.token); + if (emailResponse.error) { + await deletePasswordResetTokenById(passwordResetToken.id); + throw new CustomResetPasswordError('ResendEmailError'); + } + return { success: messages.reset_password.success.PASSWORD_RESET_EMAIL_SENT }; + } catch (error) { + if (error instanceof CustomResetPasswordError) { + switch (error.type) { + case 'InvalidFields': + return { error: messages.reset_password.errors.INVALID_EMAIL }; + case 'EmailNotFound': + return { error: messages.reset_password.errors.EMAIL_NOT_FOUND }; + case 'NoPasswordToReset': + return { error: messages.reset_password.errors.OAUTH_USER_ONLY }; + case 'TokenStillValid': + return { error: messages.reset_password.errors.TOKEN_STILL_VALID }; + case 'ResendEmailError': + return { error: messages.reset_password.errors.SEND_EMAIL_ERROR }; + default: + return { error: messages.generic.errors.UNKNOWN_ERROR }; + } + } - await sendPasswordResetEmail(passwordResetToken.email, passwordResetToken.token); + if (error instanceof PrismaClientKnownRequestError || error instanceof PrismaClientInitializationError) { + console.error('Database error:', error); + return { error: messages.generic.errors.DB_CONNECTION_ERROR }; + } - return { - success: 'Reset email sent!', - }; + return { error: messages.generic.errors.UNEXPECTED_ERROR }; + } }; diff --git a/actions/settings.ts b/actions/settings.ts index 42c9a94..2b7ef83 100644 --- a/actions/settings.ts +++ b/actions/settings.ts @@ -1,94 +1,192 @@ 'use server'; -import bcrypt from 'bcryptjs'; +import { PrismaClientInitializationError } from '@prisma/client/runtime/library'; import * as zod from 'zod'; -import { getVerificationTokenByEmail, getVerificationTokenByWhoRequested } from '@/data/verification-token'; import { unstable_update } from '@/auth'; -import { getUserByEmail, getUserById } from '@/data/user'; -import { currentSessionUser } from '@/lib/auth-utils'; +import { getUserSettingsData } from '@/data/db/user/settings'; +import { currentSessionUser } from '@/lib/auth/auth-utils'; +import { CustomSettingsError } from '@/lib/constants/errors/errors'; +import { messages } from '@/lib/constants/messages/actions/messages'; +import { hashPassword, verifyPassword } from '@/lib/crypto/hash-edge-compatible'; import { db } from '@/lib/db'; -import { sendVerificationEmail } from '@/lib/mail'; -import { generateVerificationToken } from '@/lib/tokens'; import { SettingsSchema } from '@/schemas'; -export const settings = async (values: zod.infer) => { - const user = await currentSessionUser(); - if (!user?.id) { - return { error: 'Unauthorized!' }; - } - - const dbUser = await getUserById(user.id); - - if (!dbUser?.email) { - return { error: 'Unauthorized!' }; - } - - const validatedFields = SettingsSchema.safeParse(values); - if (!validatedFields.success) { - return { error: 'Invalid fields!' }; - } +type SettingsActionResult = { success: string; error?: never } | { error: string; success?: never }; + +/** + * Server action to handle user settings updates + * + * Manages user profile updates of a logged-in user. + * Handles different update scenarios for OAuth and password-based users, with + * appropriate validations and restrictions. + * + * @specialBehavior + * - OAuth users cannot change password or 2FA settings + * - Password changes require current password verification + * - Returns dynamic success message based on updated fields + * - Updates user session to reflect changes immediately + * + * @helper getValuesWeAreUpdating + * Helper function that: + * - Determines which fields have actually changed + * - Builds update data object for database + * - Tracks changed fields for success message + * - Prevents unnecessary database updates + */ +export const settingsAction = async (values: zod.infer): Promise => { + try { + const authUser = await currentSessionUser(); + if (!authUser?.id || !authUser.email) { + throw new CustomSettingsError('Unauthorized'); + } - /* Fields that users from oauth should not be able to change */ - if (user.isOauth) { - values.email = undefined; - values.password = undefined; - values.newPassword = undefined; - values.isTwoFactorEnabled = undefined; - } + const validatedFields = SettingsSchema.safeParse(values); + if (!validatedFields.success) { + throw new CustomSettingsError('InvalidFields'); + } - /* Email change logic */ - if (values.email && values.email !== user.email) { - const existingUser = await getUserByEmail(values.email); + let { name, password, newPassword, isTwoFactorEnabled, role } = validatedFields.data; + /* Fields that users from oauth should not be able to change */ + if (authUser.isOauth) { + password = undefined; + newPassword = undefined; + isTwoFactorEnabled = undefined; + } - if (existingUser && existingUser.id !== user.id) { - return { error: 'Email already in use!' }; + const userData = await getUserSettingsData(authUser.id); + if (!userData?.email) { + throw new CustomSettingsError('Unauthorized'); } - /* Grace period before user can request new verification token */ - const existingVerificationToken = await getVerificationTokenByEmail(values.email); - if (existingVerificationToken) { - const hasExpired = new Date(existingVerificationToken.expires) < new Date(); - if (!hasExpired) { - return { error: 'Verification email already sent! Confirm your inbox!' }; + /* Password change logic */ + let hashedNewPassword = undefined; + if (password && newPassword && userData.password) { + const { isPasswordValid, passwordNeedsUpdate } = await verifyPassword(password, userData.password); + if (passwordNeedsUpdate) { + throw new CustomSettingsError('PasswordNeedUpdate'); + } + if (!isPasswordValid) { + throw new CustomSettingsError('IncorrectPassword'); + } + if (password === newPassword) { + throw new CustomSettingsError('SamePassword'); } + hashedNewPassword = await hashPassword(newPassword); } - /* Grace period before user can request new email change */ - const verificationTokenByRequest_email_change_by = await getVerificationTokenByWhoRequested(dbUser.email); - if (verificationTokenByRequest_email_change_by) { - const hasExpired = new Date(verificationTokenByRequest_email_change_by.expires) < new Date(); - if (!hasExpired) { - return { error: 'You have already requested to change your email! You need to wait 1hour to change again' }; + const { updateData, updatedFields, hasChanges } = getValuesWeAreUpdating({ + name, + hashedNewPassword, + isTwoFactorEnabled, + role, + userData: { + name: userData.name ?? '', + isTwoFactorEnabled: userData.isTwoFactorEnabled, + role: userData.role, + }, + }); + if (!hasChanges) { + throw new CustomSettingsError('NoChangesToBeMade'); + } + + const updatedUser = await db.user.update({ + where: { id: userData.id }, + data: updateData, + }); + + await unstable_update({ + user: { + id: updatedUser.id, + email: updatedUser.email, + image: updatedUser.image, + isOauth: true, + isTwoFactorEnabled: updatedUser.isTwoFactorEnabled, + role: updatedUser.role, + name: updatedUser.name ?? undefined, + }, + }); + // Create update message + const updatedMessage = + updatedFields.length === 1 + ? `Updated ${updatedFields[0]}` + : `Updated ${updatedFields.slice(0, -1).join(', ')} and ${updatedFields[updatedFields.length - 1]}`; + return { success: updatedMessage }; + } catch (error) { + if (error instanceof CustomSettingsError) { + switch (error.type) { + case 'Unauthorized': + return { error: messages.settings.errors.UNAUTHORIZED }; + case 'InvalidFields': + return { error: messages.settings.errors.INVALID_FIELDS }; + case 'IncorrectPassword': + return { error: messages.settings.errors.INCORRECT_PASSWORD }; + case 'SamePassword': + return { error: messages.settings.errors.SAME_PASSWORD }; + case 'NoChangesToBeMade': + return { error: messages.settings.errors.NO_CHANGES_REQUIRED }; + case 'PasswordNeedUpdate': + return { error: messages.settings.errors.PASSWORD_NEEDS_UPDATE }; + default: + return { error: messages.generic.errors.UNKNOWN_ERROR }; } } - const verificationToken = await generateVerificationToken(values.email, dbUser.id, dbUser.email); - await sendVerificationEmail(verificationToken.email, verificationToken.token); + if (error instanceof PrismaClientInitializationError) { + console.error('Database connection error:', error); + return { error: messages.generic.errors.DB_CONNECTION_ERROR }; + } - return { success: 'Verification email sent!' }; + console.error('Settings update error:', error); + return { error: messages.generic.errors.GENERIC_ERROR }; } +}; - /* Password change logic */ - if (values.password && values.newPassword && dbUser.password) { - const passwordsMatch = await bcrypt.compare(values.password, dbUser.password); - - if (!passwordsMatch) { - return { error: 'Incorrect Password!' }; - } - - const hashedPassword = await bcrypt.hash(values.newPassword, 10); +interface GetUpdateValuesParams { + name?: string; + hashedNewPassword?: string; + isTwoFactorEnabled?: boolean; + role?: string; + userData: { + name: string; + isTwoFactorEnabled: boolean; + role: string; + }; +} + +function getValuesWeAreUpdating({ + name, + hashedNewPassword, + isTwoFactorEnabled, + role, + userData, +}: GetUpdateValuesParams) { + const updateData: Record = {}; + const updatedFields: string[] = []; + + if (name && name !== userData.name) { + updateData.name = name; + updatedFields.push('name'); + } - values.password = hashedPassword; - values.newPassword = undefined; + if (hashedNewPassword) { + updateData.password = hashedNewPassword; + updatedFields.push('password'); } - const updatedUser = await db.user.update({ - where: { id: dbUser.id }, - data: { ...values }, - }); + if (typeof isTwoFactorEnabled === 'boolean' && isTwoFactorEnabled !== userData.isTwoFactorEnabled) { + updateData.isTwoFactorEnabled = isTwoFactorEnabled; + updatedFields.push('2FA'); + } - await unstable_update({ user: { ...updatedUser } }); + if (role && role !== userData.role) { + updateData.role = role; + updatedFields.push('role'); + } - return { success: 'Settings updated!' }; -}; + return { + updateData, + updatedFields, + hasChanges: updatedFields.length > 0, + }; +} diff --git a/app/(auth)/login/magic-link/page.tsx b/app/(auth)/login/magic-link/page.tsx new file mode 100644 index 0000000..102b285 --- /dev/null +++ b/app/(auth)/login/magic-link/page.tsx @@ -0,0 +1,5 @@ +import { MagicLinkForm } from '@/components/auth/forms/MagicLinkForm'; + +export default function MagicLinkPage() { + return ; +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 80d2ce1..5b4416f 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from 'react'; -import { LoginForm } from '@/components/auth/LoginForm'; +import { LoginForm } from '@/components/auth/forms/LoginForm'; export default function LoginPage() { return ( diff --git a/app/(auth)/loginerror/page.tsx b/app/(auth)/loginerror/page.tsx index a15732b..1799b92 100644 --- a/app/(auth)/loginerror/page.tsx +++ b/app/(auth)/loginerror/page.tsx @@ -1,4 +1,4 @@ -import { ErrorCard } from '@/components/auth/ErrorCard'; +import { ErrorCard } from '@/components/auth/shared/ErrorCard'; export default function LoginErrorPage() { return ; diff --git a/app/(auth)/new-password/page.tsx b/app/(auth)/new-password/page.tsx index b56d767..4b50acc 100644 --- a/app/(auth)/new-password/page.tsx +++ b/app/(auth)/new-password/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from 'react'; -import { NewPasswordForm } from '@/components/auth/NewPasswordForm'; +import { NewPasswordForm } from '@/components/auth/forms/NewPasswordForm'; export default function NewPasswordPage() { return ( diff --git a/app/(auth)/new-verification/page.tsx b/app/(auth)/new-verification/page.tsx index 130759e..141d209 100644 --- a/app/(auth)/new-verification/page.tsx +++ b/app/(auth)/new-verification/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from 'react'; -import { NewVerificationForm } from '@/components/auth/NewVerificationForm'; +import { NewVerificationForm } from '@/components/auth/forms/NewVerificationForm'; export default function NewVerificationPage() { return ( diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index f9929fb..37ff10e 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from 'react'; -import { RegisterForm } from '@/components/auth/RegisterForm'; +import { RegisterForm } from '@/components/auth/forms/RegisterForm'; export default function RegisterPage() { return ( diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx index 516d7cd..0b7b7d5 100644 --- a/app/(auth)/reset-password/page.tsx +++ b/app/(auth)/reset-password/page.tsx @@ -1,4 +1,4 @@ -import { ResetPasswordForm } from '@/components/auth/ResetPasswordForm'; +import { ResetPasswordForm } from '@/components/auth/forms/ResetPasswordForm'; export default function ResetPasswordPage() { return ; diff --git a/app/(protected)/admin/page.tsx b/app/(protected)/admin/page.tsx index 84b2167..527c6a9 100644 --- a/app/(protected)/admin/page.tsx +++ b/app/(protected)/admin/page.tsx @@ -1,10 +1,21 @@ import { UserRole } from '@prisma/client'; -import { AdminOnlyRhAndSa } from '@/components/admin-only-rh-and-sa'; -import { RoleGate } from '@/components/RoleGate'; -import { FormSuccess } from '@/components/form-messages/FormSuccess'; +import { AdminActionAndRhTester } from '@/components/access-control/AdminActionAndRhTester'; +import { RoleGate } from '@/components/access-control/RoleGate'; +import { FormSuccess } from '@/components/forms/messages/FormSuccess'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; +/** + * Admin Page example of role-based access. + * + * @description This page showcases how to implement role-based UI components and content visibility + * using RoleGate component. + * + * @notice This page implements multiple levels of protection: + * 1. Role-based content visibility using RoleGate + * 2. Server Actions role-based access + * 3. Route Handler(API) role-based access + **/ export default function AdminPage() { return ( @@ -21,7 +32,7 @@ export default function AdminPage() {

This is a example of secret content

- +
); diff --git a/app/(protected)/client/client-component.tsx b/app/(protected)/client/client-component.tsx index 75c2373..976b1a3 100644 --- a/app/(protected)/client/client-component.tsx +++ b/app/(protected)/client/client-component.tsx @@ -1,10 +1,19 @@ 'use client'; -import { useCurrentUser } from '@/hooks/use-current-user'; -import UserInfo from '@/components/UserInfo'; +import { UserInfo } from '@/components/user/profile/UserInfo'; +import { useCurrentUser } from '@/lib/auth/hooks'; +import type { Session } from 'next-auth'; + +/** + * Example client component demonstrating client-side session access. + * + * @notice This is for demonstration purposes only. + * Prefer fetching user session data in server components using auth() + * for better performance and security. + */ export default function ClientComponent() { - const userSession = useCurrentUser(); + const userSession: Session['user'] | undefined = useCurrentUser(); return (
{/* This userInfo component is what we call a hybrid component, as children of a client component, is a client component */} diff --git a/app/(protected)/client/page.tsx b/app/(protected)/client/page.tsx index da1c752..96e4ac1 100644 --- a/app/(protected)/client/page.tsx +++ b/app/(protected)/client/page.tsx @@ -2,6 +2,15 @@ import { SessionProvider } from 'next-auth/react'; import ClientComponent from '@/app/(protected)/client/client-component'; +/** + * Example page demonstrating client-side authentication setup. + * + * @notice This setup is specifically for demonstrating client-side session handling. + * For most applications, it's recommended to: + * 1. Use server components with auth() to fetch session data + * + * @see https://authjs.dev/getting-started/migrating-to-v5 + */ export default function ClientPage() { return ( diff --git a/app/(protected)/layout.tsx b/app/(protected)/layout.tsx index e258047..76ff5de 100644 --- a/app/(protected)/layout.tsx +++ b/app/(protected)/layout.tsx @@ -1,12 +1,12 @@ -import { NavigationMenu } from '@/components/navbar/NavigationMenu'; -import { UserAvatarMenu } from '@/components/navbar/UserAvatarMenu'; -import { Navbar } from '@/components/navbar/Navbar'; +import { Navbar } from '@/components/layout/Navbar'; +import { NavigationMenu } from '@/components/layout/navbar/NavigationMenu'; +import { UserAvatarMenu } from '@/components/layout/navbar/UserAvatarMenu'; export default function ProtectedLayout(props: { children: React.ReactNode }) { return (
diff --git a/app/(protected)/server/page.tsx b/app/(protected)/server/page.tsx index 7a1f397..4149b13 100644 --- a/app/(protected)/server/page.tsx +++ b/app/(protected)/server/page.tsx @@ -1,11 +1,12 @@ -import UserInfo from '@/components/UserInfo'; -import { currentSessionUser } from '@/lib/auth-utils'; +import { UserInfo } from '@/components/user/profile/UserInfo'; +import { currentSessionUser } from '@/lib/auth/auth-utils'; export default async function ServerPage() { - const userSession = await currentSessionUser(); + const user = await currentSessionUser(); + return (
- +
); } diff --git a/app/(protected)/settings/SettingsForm.tsx b/app/(protected)/settings/SettingsForm.tsx deleted file mode 100644 index a735378..0000000 --- a/app/(protected)/settings/SettingsForm.tsx +++ /dev/null @@ -1,189 +0,0 @@ -'use client'; -import * as zod from 'zod'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { UserRole } from '@prisma/client'; -import { useSession } from 'next-auth/react'; -import { useEffect, useState, useTransition } from 'react'; - -import type { ExtendedUser } from '@/next-auth'; -import { settings } from '@/actions/settings'; -import { FormError } from '@/components/form-messages/FormError'; -import { FormSuccess } from '@/components/form-messages/FormSuccess'; -import { Button } from '@/components/ui/button'; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Switch } from '@/components/ui/switch'; -import { SettingsSchema } from '@/schemas'; - -export function SettingsForm({ user }: { user: ExtendedUser }) { - const { update } = useSession(); - - const [error, setError] = useState(); - const [success, setSuccess] = useState(); - const [isPending, startTransition] = useTransition(); - - const defaultValues = { - name: user?.name || '', - email: user?.email || '', - password: undefined, - newPassword: undefined, - role: user?.role || UserRole.USER, - isTwoFactorEnabled: user?.isTwoFactorEnabled || undefined, - }; - - const form = useForm>({ - resolver: zodResolver(SettingsSchema), - defaultValues, - }); - - useEffect(() => { - if (user) { - form.reset(defaultValues); - } - }, [user?.name, user?.email, user?.id, user?.image, user?.isOauth, user?.isTwoFactorEnabled, user?.role, form.reset]); - - const onSubmit = (values: zod.infer) => { - setError(''); - setSuccess(''); - startTransition(() => { - settings(values) - .then((data) => { - if (data.error) { - setError(data.error); - } - if (data.success) { - // updates client side session - update(); - setSuccess(data.success); - } - }) - .catch(() => setError('An error occurred!')); - }); - }; - return ( - <> -
- -
- {/* Name */} - ( - - Name - - - - - - )} - /> - {/* Content not shown to oauth users */} - {/* Email and Password */} - {user?.isOauth === false && ( - <> - ( - - Email - - - - - - )} - /> - ( - - Password - - - - - - )} - /> - ( - - New Password - - - - - - )} - /> - - )} - {/* Role */} - ( - - Role - - - - )} - /> - {/* Two Factor Authentication not shown to oauth users */} - {user?.isOauth === false && ( - ( - -
- Two Factor Authentication - Enable Two Factor Authentication for your account -
- - - -
- )} - /> - )} -
- - - - - - - ); -} diff --git a/app/(protected)/settings/page.tsx b/app/(protected)/settings/page.tsx index 17f2601..43c04a3 100644 --- a/app/(protected)/settings/page.tsx +++ b/app/(protected)/settings/page.tsx @@ -1,25 +1,16 @@ -import { SessionProvider } from 'next-auth/react'; - -import { SettingsForm } from '@/app/(protected)/settings/SettingsForm'; -import { auth } from '@/auth'; +import { SettingsForm } from '@/components/forms/SettingsForm'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { currentSessionUser } from '@/lib/auth/auth-utils'; export default async function SettingsPage() { - const session = await auth(); - const user = session?.user; + const user = await currentSessionUser(); return ( - +

Settings

- - {user && ( - - - - )} - + {user && }
); } diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index fae4907..c0ed1fb 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1 +1,10 @@ +/** + * Auth.js API Route Handler + * + * @notice + * This file serves as the API endpoint that Auth.js needs to operate. + * + * @path /api/auth/[...nextauth] + * This catches all routes under /api/auth/ and forwards them to Auth.js + */ export { GET, POST } from '@/auth'; diff --git a/app/layout.tsx b/app/layout.tsx index 7397047..5c993ea 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,8 +1,9 @@ -import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import { Toaster } from '@/components/ui/sonner'; +import type { Metadata } from 'next'; + import './globals.css'; const inter = Inter({ subsets: ['latin'] }); diff --git a/app/page.tsx b/app/page.tsx index 6965465..a75c5b0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,8 +1,8 @@ import { Poppins } from 'next/font/google'; +import { LoginButton } from '@/components/auth/buttons/LoginButton'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; -import { LoginButton } from '@/components/LoginButton'; const font = Poppins({ subsets: ['latin'], diff --git a/auth.config.ts b/auth.config.ts index 290a0bd..15d7cbb 100644 --- a/auth.config.ts +++ b/auth.config.ts @@ -1,37 +1,121 @@ -import Google from 'next-auth/providers/google'; -import Github from 'next-auth/providers/github'; -import bcrypt from 'bcryptjs'; +import 'server-only'; +import { PrismaAdapter } from '@auth/prisma-adapter'; +import { type NextAuthConfig } from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; -import type { NextAuthConfig } from 'next-auth'; +import Github from 'next-auth/providers/github'; +import Google from 'next-auth/providers/google'; +import Resend from 'next-auth/providers/resend'; + +import { db } from '@/lib/db'; +import { getHashedUserIpFromHeaders } from '@/lib/nextjs/headers'; +import { VerifiedCredentialsUserSchema } from '@/schemas'; + +import type { AdapterUser, VerificationToken } from '@auth/core/adapters'; + +/** + * Custom Auth.js adapter extending PrismaAdapter + * 1. CreateVerificationToken with IP tracking, rate limit + * 2. Custom user creation logic with default name + */ +const adapter = { + ...PrismaAdapter(db), + /** + * Creates a verification token with IP tracking + * Used when sending magic links + * + * @note If it fails, the email will still be sent, a bug, or intended? Did not bother much. + * @note Remove id and hashedIp from the return object to match PrismaAdapter original patterns + */ + async createVerificationToken( + data: VerificationToken + ): Promise<{ identifier: string; expires: Date; token: string }> { + const hashedIp = await getHashedUserIpFromHeaders(); + + const token = await db.verificationToken.create({ + data: { + identifier: data.identifier, + token: data.token, + expires: data.expires, + hashedIp: hashedIp ?? 'nobueno', + }, + }); + + if ('id' in token) { + delete (token as any).id; + } + if ('hashedIp' in token) { + delete (token as any).hashedIp; + } + + return token; + }, + // Follow PrismaAdapter pattern of removing id + createUser: ({ id, ...data }: AdapterUser) => { + const userData = { + ...data, + name: data.name || 'your pretty fake name', + }; + + return db.user.create({ + data: userData, + }); + }, +}; -import { LoginSchema } from '@/schemas'; -import { getUserByEmail } from '@/data/user'; +/** + * Auth.js (NextAuth.js) Configuration + * + * @description Defines authentication providers and their configurations. + * This setup includes OAuth providers (Google, Github, magic-link with Resend) and credentials-based authentication. + * + * Credentials Provider Authorization + * + * @description all validation logic is done on login server action. + */ export default { + adapter, + session: { + strategy: 'jwt', + maxAge: 2592000, + updateAge: 86400, + }, + secret: process.env.AUTH_SECRET, providers: [ Google({ clientId: process.env.AUTH_GOOGLE_CLIENT_ID, clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET, + allowDangerousEmailAccountLinking: true, }), Github({ clientId: process.env.AUTH_GITHUB_CLIENT_ID, clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET, }), + Resend({ + apiKey: process.env.RESEND_API_KEY, + from: 'noreply@fpresa.org', + }), + Credentials({ + credentials: { + user: {}, + callbackUrl: {}, + }, async authorize(credentials) { - const validatedFields = LoginSchema.safeParse(credentials); - if (validatedFields.success) { - const { email, password } = validatedFields.data; - - const user = await getUserByEmail(email); - if (!user || !user.password) return null; + try { + const userStr = credentials?.user; + if (typeof userStr !== 'string') return null; - const passwordsMatch = await bcrypt.compare(password, user.password); - if (passwordsMatch) return user; + const user = JSON.parse(userStr); + const validatedFields = VerifiedCredentialsUserSchema.safeParse(user); + return validatedFields.success ? validatedFields.data : null; + } catch (error) { + console.error('Error parsing credentials:', error); + return null; } - return null; }, }), ], + debug: false, trustHost: true, } satisfies NextAuthConfig; diff --git a/auth.ts b/auth.ts index 34075d2..aeaba4c 100644 --- a/auth.ts +++ b/auth.ts @@ -1,13 +1,26 @@ import { UserRole } from '@prisma/client'; -import NextAuth from 'next-auth'; -import { PrismaAdapter } from '@auth/prisma-adapter'; +import NextAuth, { type Session } from 'next-auth'; -import { getAccountByUserId } from '@/data/account'; -import { getTwoFactorConfirmationByUserId } from '@/data/two-factor-confirmation'; import authConfig from '@/auth.config'; +import { + cleanupExpiredVerificationTokens, + validateMagicLinkRequest, +} from '@/data/db/tokens/verification-tokens/magic-link/helpers'; +import { CustomMagicLinkError } from '@/lib/constants/errors/errors'; import { db } from '@/lib/db'; -import { getUserById } from '@/data/user'; +import { getHashedUserIpFromHeaders } from '@/lib/nextjs/headers'; +/** + * Auth.js (NextAuth.js) Main Configuration + * + * @description Primary authentication configuration that extends auth.config.ts. + * + * @notice This configuration: + * Uses JWT strategy for session handling + * Implements custom types, @/lib/auth/types.d.ts + * Manages user role and session data through JWT tokens + * + */ export const { handlers: { GET, POST }, auth, @@ -20,75 +33,82 @@ export const { error: '/loginerror', }, events: { + // Runs AFTER an account is linked/OAuth sign in async linkAccount({ user }) { await db.user.update({ where: { id: user.id }, data: { emailVerified: new Date() }, }); }, + async signIn({ user, account, profile, isNewUser }) { + if (isNewUser && account?.provider === 'resend' && !user.name) { + // do stuff + } + if (isNewUser && account?.provider !== 'credentials') { + // TODO: send welcome email? + } + }, }, - callbacks: { - async signIn({ user, account }) { - // allow OAuth without email verification - if (account?.provider !== 'credentials') return true; - if (!user.id) return false; - - const existingUser = await getUserById(user.id); - // prevent sign in without email verification - if (!existingUser?.emailVerified) return false; - - if (existingUser.isTwoFactorEnabled) { - const twoFactorConfirmation = await getTwoFactorConfirmationByUserId(existingUser.id); - if (!twoFactorConfirmation) return false; + callbacks: { + async signIn({ user, account, email }) { + // Magic Link request + if (email?.verificationRequest === true) { + /*if(!user) { + // Block non current users from magic link + throw new CustomMagicLinkError('NoUserExists') + }*/ + const hashedIp = await getHashedUserIpFromHeaders(); + if (!hashedIp) { + throw new CustomMagicLinkError('IpInvalid'); + } + if (!account?.providerAccountId) { + throw new CustomMagicLinkError('InvalidEmail'); + } + // Take this opportunity to clean expired tokens + await cleanupExpiredVerificationTokens(); - // Forcing two factor on every login - await db.twoFactorConfirmation.delete({ - where: { id: twoFactorConfirmation.id }, - }); + // Check ip limit, check existing token + await validateMagicLinkRequest(account.providerAccountId, hashedIp); } - + // Example: Only allow sign in for users with email addresses ending with "yourdomain.com" + // return profile?.email?.endsWith("@yourdomain.com") return true; }, async session({ token, session }) { - if (token.sub && session.user) { - session.user.id = token.sub; - } - - if (token.role && session.user) { - session.user.role = token.role as UserRole; - } - - if (session.user) { - session.user.isTwoFactorEnabled = token.isTwoFactorEnabled as boolean; + if (!token.sub) return session; + return { + ...session, + user: { + ...session.user, + id: token.sub ?? session.user.id, + role: (token.role as UserRole) ?? UserRole.USER, + isTwoFactorEnabled: Boolean(token.isTwoFactorEnabled), + name: token.name ?? session.user.name ?? null, + email: token.email ?? session.user.email ?? null, + isOauth: Boolean(token.isOauth), + }, + } as Session; + }, + async jwt({ token, trigger, user, account, session }) { + if ((trigger === 'signIn' || trigger === 'signUp') && user) { + token.email = user.email; + token.isOauth = account?.provider !== 'credentials'; + token.name = user.name; + token.role = user.role; + token.isTwoFactorEnabled = user.isTwoFactorEnabled; + return token; } - if (session.user && token.name && token.email) { - session.user.name = token.name; - session.user.email = token.email; - session.user.isOauth = token.isOauth as boolean; + if (trigger === 'update' && session) { + token.name = session.user.name; + token.role = session.user.role; + token.isTwoFactorEnabled = session.user.isTwoFactorEnabled; + return token; } - return session; - }, - async jwt({ token }) { - if (!token.sub) return token; - const existingUser = await getUserById(token.sub); - - if (!existingUser) return token; - - const existingAccount = await getAccountByUserId(existingUser.id); - - token.isOauth = !!existingAccount; - token.name = existingUser.name; - token.email = existingUser.email; - token.role = existingUser.role; - token.isTwoFactorEnabled = existingUser.isTwoFactorEnabled; - return token; }, }, - adapter: PrismaAdapter(db), - session: { strategy: 'jwt' }, ...authConfig, }); diff --git a/components/LogoutButton.tsx b/components/LogoutButton.tsx deleted file mode 100644 index 676180f..0000000 --- a/components/LogoutButton.tsx +++ /dev/null @@ -1,10 +0,0 @@ -'use client'; -import { signOut } from 'next-auth/react'; - -export function LogoutButton(props: { children: React.ReactNode }) { - return ( - signOut()}> - {props.children} - - ); -} diff --git a/components/admin-only-rh-and-sa.tsx b/components/access-control/AdminActionAndRhTester.tsx similarity index 92% rename from components/admin-only-rh-and-sa.tsx rename to components/access-control/AdminActionAndRhTester.tsx index 8f6e1b2..0ed63c5 100644 --- a/components/admin-only-rh-and-sa.tsx +++ b/components/access-control/AdminActionAndRhTester.tsx @@ -1,10 +1,10 @@ 'use client'; import { toast } from 'sonner'; -import { admin } from '@/actions/admin'; +import { adminAction } from '@/actions/admin'; import { Button } from '@/components/ui/button'; -export function AdminOnlyRhAndSa() { +export const AdminActionAndRhTester = () => { const onRouteHandlerClick = () => { fetch('/api/admin') .then(async (response) => { @@ -22,7 +22,7 @@ export function AdminOnlyRhAndSa() { }; const onServerActionClick = () => { - admin() + adminAction() .then((data) => { if (data.error) { toast.error(data.error); @@ -48,4 +48,4 @@ export function AdminOnlyRhAndSa() {
); -} +}; diff --git a/components/RoleGate.tsx b/components/access-control/RoleGate.tsx similarity index 63% rename from components/RoleGate.tsx rename to components/access-control/RoleGate.tsx index 1430477..464950d 100644 --- a/components/RoleGate.tsx +++ b/components/access-control/RoleGate.tsx @@ -1,18 +1,18 @@ import { UserRole } from '@prisma/client'; -import { currentSessionRole } from '@/lib/auth-utils'; -import { FormError } from '@/components/form-messages/FormError'; +import { FormError } from '@/components/forms/messages/FormError'; +import { currentSessionRole } from '@/lib/auth/auth-utils'; interface RoleGateProps { children: React.ReactNode; allowedRole: UserRole; } -export async function RoleGate({ children, allowedRole }: RoleGateProps) { +export const RoleGate = async ({ children, allowedRole }: RoleGateProps) => { const currentUserRole = await currentSessionRole(); if (!currentUserRole || currentUserRole !== allowedRole) { return ; } return <>{children}; -} +}; diff --git a/components/auth/SocialButtons.tsx b/components/auth/SocialButtons.tsx deleted file mode 100644 index ea564b3..0000000 --- a/components/auth/SocialButtons.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client'; - -import { useSearchParams } from 'next/navigation'; -import { FaGithub } from 'react-icons/fa'; -import { FcGoogle } from 'react-icons/fc'; -import { signIn } from 'next-auth/react'; - -import { DEFAULT_LOGIN_REDIRECT } from '@/routes'; -import { Button } from '@/components/ui/button'; - -export function SocialButtons() { - const searchParams = useSearchParams(); - const callbackUrl = searchParams.get('callbackUrl'); - const onClick = (provider: 'google' | 'github') => { - signIn(provider, { - redirectTo: callbackUrl || DEFAULT_LOGIN_REDIRECT, - }); - }; - return ( -
- - -
- ); -} diff --git a/components/auth/BackButton.tsx b/components/auth/buttons/BackButton.tsx similarity index 81% rename from components/auth/BackButton.tsx rename to components/auth/buttons/BackButton.tsx index e6ddc81..8876498 100644 --- a/components/auth/BackButton.tsx +++ b/components/auth/buttons/BackButton.tsx @@ -9,10 +9,10 @@ interface BackButtonProps { label: string; } -export function BackButton({ href, label }: BackButtonProps) { +export const BackButton = ({ href, label }: BackButtonProps) => { return ( ); -} +}; diff --git a/components/LoginButton.tsx b/components/auth/buttons/LoginButton.tsx similarity index 92% rename from components/LoginButton.tsx rename to components/auth/buttons/LoginButton.tsx index 7d85733..1c5c8e1 100644 --- a/components/LoginButton.tsx +++ b/components/auth/buttons/LoginButton.tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/navigation'; -import { LoginForm } from '@/components/auth/LoginForm'; +import { LoginForm } from '@/components/auth/forms/LoginForm'; import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'; interface LoginButtonProps { diff --git a/components/auth/buttons/SocialButtons.tsx b/components/auth/buttons/SocialButtons.tsx new file mode 100644 index 0000000..6688b3c --- /dev/null +++ b/components/auth/buttons/SocialButtons.tsx @@ -0,0 +1,39 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { signIn } from 'next-auth/react'; +import { FaGithub } from 'react-icons/fa'; +import { FcGoogle } from 'react-icons/fc'; +import { ImMail4 } from 'react-icons/im'; + +import { Button } from '@/components/ui/button'; +import { DEFAULT_LOGIN_REDIRECT } from '@/routes'; + +export const SocialButtons = () => { + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get('callbackUrl'); + const onClick = (provider: 'google' | 'github') => { + signIn(provider, { + redirectTo: callbackUrl || DEFAULT_LOGIN_REDIRECT, + }); + }; + return ( +
+
+ + +
+ + + +
+ ); +}; diff --git a/components/auth/LoginForm.tsx b/components/auth/forms/LoginForm.tsx similarity index 86% rename from components/auth/LoginForm.tsx rename to components/auth/forms/LoginForm.tsx index 5eaaba8..b06059c 100644 --- a/components/auth/LoginForm.tsx +++ b/components/auth/forms/LoginForm.tsx @@ -1,21 +1,21 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; import { useState, useTransition } from 'react'; import { useForm } from 'react-hook-form'; import * as zod from 'zod'; -import { useSearchParams } from 'next/navigation'; -import { login } from '@/actions/login'; -import { CardWrapper } from '@/components/auth/CardWrapper'; -import { FormError } from '@/components/form-messages/FormError'; -import { FormSuccess } from '@/components/form-messages/FormSuccess'; +import { loginAction } from '@/actions/login'; +import { CardWrapper } from '@/components/auth/shared/CardWrapper'; +import { FormError } from '@/components/forms/messages/FormError'; +import { FormSuccess } from '@/components/forms/messages/FormSuccess'; import { Button } from '@/components/ui/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { LoginSchema } from '@/schemas'; -export function LoginForm() { +export const LoginForm = () => { const searchParams = useSearchParams(); const callbackUrl = searchParams.get('callbackUrl'); const urlError = @@ -38,7 +38,7 @@ export function LoginForm() { setError(''); setSuccess(''); startTransition(() => { - login(values, callbackUrl) + loginAction(values, callbackUrl) .then((data) => { if (data?.error) { setError(data.error); @@ -48,20 +48,26 @@ export function LoginForm() { form.reset(); setSuccess(data.success); } - + // send user to 2FA if (data?.twoFactor) { setShowTwoFactor(true); } }) - .catch(() => setError('Something went wrong')); + .catch((error) => { + if (error?.digest?.includes('NEXT_REDIRECT')) { + return; + } + + setError('Something went wrong'); + }); }); }; return (
@@ -69,7 +75,7 @@ export function LoginForm() { {showTwoFactor && ( ( Two Factor Code @@ -124,4 +130,4 @@ export function LoginForm() {
); -} +}; diff --git a/components/auth/forms/MagicLinkForm.tsx b/components/auth/forms/MagicLinkForm.tsx new file mode 100644 index 0000000..60e2006 --- /dev/null +++ b/components/auth/forms/MagicLinkForm.tsx @@ -0,0 +1,87 @@ +'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useRef, useState, useTransition } from 'react'; +import { useForm } from 'react-hook-form'; +import * as zod from 'zod'; + +import { magicLinkAction } from '@/actions/magic-link'; +import { CardWrapper } from '@/components/auth/shared/CardWrapper'; +import { FormError } from '@/components/forms/messages/FormError'; +import { FormSuccess } from '@/components/forms/messages/FormSuccess'; +import { Button } from '@/components/ui/button'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { MagicLinkSchema } from '@/schemas'; + +export const MagicLinkForm = () => { + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [isPending, startTransition] = useTransition(); + const formRef = useRef(null); + + const form = useForm>({ + resolver: zodResolver(MagicLinkSchema), + defaultValues: { email: '' }, + }); + + const onSubmit = () => { + setError(''); + setSuccess(''); + + if (!formRef.current) return; + + startTransition(() => { + // This lives here as an example of handling as FormData + // and passing it to Server Action + const formData = new FormData(formRef.current!); + + magicLinkAction(formData) + .then((data) => { + if (data?.error) { + setError(data.error); + } + + if (data?.success) { + form.reset(); + setSuccess(data.success); + } + }) + .catch(() => { + setError('Something went wrong'); + }); + }); + }; + + return ( + +
+ +
+ ( + + Email + + + + + + )} + /> +
+ + + + + +
+ ); +}; diff --git a/components/auth/NewPasswordForm.tsx b/components/auth/forms/NewPasswordForm.tsx similarity index 85% rename from components/auth/NewPasswordForm.tsx rename to components/auth/forms/NewPasswordForm.tsx index 25ad1e1..1e67688 100644 --- a/components/auth/NewPasswordForm.tsx +++ b/components/auth/forms/NewPasswordForm.tsx @@ -5,16 +5,16 @@ import { useState, useTransition } from 'react'; import { useForm } from 'react-hook-form'; import * as zod from 'zod'; -import { newPassword } from '@/actions/new-password'; -import { CardWrapper } from '@/components/auth/CardWrapper'; -import { FormError } from '@/components/form-messages/FormError'; -import { FormSuccess } from '@/components/form-messages/FormSuccess'; +import { newPasswordAction } from '@/actions/new-password'; +import { CardWrapper } from '@/components/auth/shared/CardWrapper'; +import { FormError } from '@/components/forms/messages/FormError'; +import { FormSuccess } from '@/components/forms/messages/FormSuccess'; import { Button } from '@/components/ui/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { NewPasswordSchema } from '@/schemas'; -export function NewPasswordForm() { +export const NewPasswordForm = () => { const searchParams = useSearchParams(); const token = searchParams.get('token'); @@ -33,7 +33,7 @@ export function NewPasswordForm() { setError(''); setSuccess(''); startTransition(() => { - newPassword(values, token).then((data) => { + newPasswordAction(values, token).then((data) => { setError(data?.error); setSuccess(data?.success); }); @@ -67,4 +67,4 @@ export function NewPasswordForm() { ); -} +}; diff --git a/components/auth/NewVerificationForm.tsx b/components/auth/forms/NewVerificationForm.tsx similarity index 79% rename from components/auth/NewVerificationForm.tsx rename to components/auth/forms/NewVerificationForm.tsx index fc5e413..14ae904 100644 --- a/components/auth/NewVerificationForm.tsx +++ b/components/auth/forms/NewVerificationForm.tsx @@ -4,12 +4,12 @@ import { useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useRef, useState } from 'react'; import { BeatLoader } from 'react-spinners'; -import { newVerification } from '@/actions/new-verification'; -import { CardWrapper } from '@/components/auth/CardWrapper'; -import { FormError } from '@/components/form-messages/FormError'; -import { FormSuccess } from '@/components/form-messages/FormSuccess'; +import { newVerificationAction } from '@/actions/new-verification'; +import { CardWrapper } from '@/components/auth/shared/CardWrapper'; +import { FormError } from '@/components/forms/messages/FormError'; +import { FormSuccess } from '@/components/forms/messages/FormSuccess'; -export function NewVerificationForm() { +export const NewVerificationForm = () => { const [error, setError] = useState(); const [success, setSuccess] = useState(); const verificationRequested = useRef(false); @@ -26,7 +26,7 @@ export function NewVerificationForm() { return; } - newVerification(token) + newVerificationAction(token) .then((data) => { setSuccess(data.success); setError(data.error); @@ -51,4 +51,4 @@ export function NewVerificationForm() {
); -} +}; diff --git a/components/auth/RegisterForm.tsx b/components/auth/forms/RegisterForm.tsx similarity index 89% rename from components/auth/RegisterForm.tsx rename to components/auth/forms/RegisterForm.tsx index 2a5d11c..bde3586 100644 --- a/components/auth/RegisterForm.tsx +++ b/components/auth/forms/RegisterForm.tsx @@ -4,16 +4,16 @@ import { useState, useTransition } from 'react'; import { useForm } from 'react-hook-form'; import * as zod from 'zod'; -import { register } from '@/actions/register'; -import { CardWrapper } from '@/components/auth/CardWrapper'; -import { FormError } from '@/components/form-messages/FormError'; -import { FormSuccess } from '@/components/form-messages/FormSuccess'; +import { registerAction } from '@/actions/register'; +import { CardWrapper } from '@/components/auth/shared/CardWrapper'; +import { FormError } from '@/components/forms/messages/FormError'; +import { FormSuccess } from '@/components/forms/messages/FormSuccess'; import { Button } from '@/components/ui/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { RegisterSchema } from '@/schemas'; -export function RegisterForm() { +export const RegisterForm = () => { const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [isPending, startTransition] = useTransition(); @@ -31,7 +31,7 @@ export function RegisterForm() { setError(''); setSuccess(''); startTransition(() => { - register(values).then((data) => { + registerAction(values).then((data) => { setError(data.error); setSuccess(data.success); }); @@ -96,4 +96,4 @@ export function RegisterForm() { ); -} +}; diff --git a/components/auth/ResetPasswordForm.tsx b/components/auth/forms/ResetPasswordForm.tsx similarity index 84% rename from components/auth/ResetPasswordForm.tsx rename to components/auth/forms/ResetPasswordForm.tsx index 0815bee..48e36e0 100644 --- a/components/auth/ResetPasswordForm.tsx +++ b/components/auth/forms/ResetPasswordForm.tsx @@ -4,16 +4,16 @@ import { useState, useTransition } from 'react'; import { useForm } from 'react-hook-form'; import * as zod from 'zod'; -import { resetPassword } from '@/actions/reset-password'; -import { CardWrapper } from '@/components/auth/CardWrapper'; -import { FormError } from '@/components/form-messages/FormError'; -import { FormSuccess } from '@/components/form-messages/FormSuccess'; +import { resetPasswordAction } from '@/actions/reset-password'; +import { CardWrapper } from '@/components/auth/shared/CardWrapper'; +import { FormError } from '@/components/forms/messages/FormError'; +import { FormSuccess } from '@/components/forms/messages/FormSuccess'; import { Button } from '@/components/ui/button'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { ResetPasswordSchema } from '@/schemas'; -export function ResetPasswordForm() { +export const ResetPasswordForm = () => { const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [isPending, startTransition] = useTransition(); @@ -29,7 +29,7 @@ export function ResetPasswordForm() { setError(''); setSuccess(''); startTransition(() => { - resetPassword(values).then((data) => { + resetPasswordAction(values).then((data) => { setError(data?.error); setSuccess(data?.success); }); @@ -63,4 +63,4 @@ export function ResetPasswordForm() { ); -} +}; diff --git a/components/auth/AuthFormHeader.tsx b/components/auth/shared/AuthFormHeader.tsx similarity index 78% rename from components/auth/AuthFormHeader.tsx rename to components/auth/shared/AuthFormHeader.tsx index f1fae9e..69dd527 100644 --- a/components/auth/AuthFormHeader.tsx +++ b/components/auth/shared/AuthFormHeader.tsx @@ -8,7 +8,7 @@ interface CardHeaderProps { label: string; } -export function AuthFormHeader({ label }: CardHeaderProps) { +export const AuthFormHeader = ({ label }: CardHeaderProps) => { return (

@@ -17,7 +17,7 @@ export function AuthFormHeader({ label }: CardHeaderProps) { {' '} Auth

-

{label}

+

{label}

); -} +}; diff --git a/components/auth/CardWrapper.tsx b/components/auth/shared/CardWrapper.tsx similarity index 65% rename from components/auth/CardWrapper.tsx rename to components/auth/shared/CardWrapper.tsx index 1de6532..82a5fa1 100644 --- a/components/auth/CardWrapper.tsx +++ b/components/auth/shared/CardWrapper.tsx @@ -1,8 +1,8 @@ 'use client'; -import { BackButton } from '@/components/auth/BackButton'; -import { AuthFormHeader } from '@/components/auth/AuthFormHeader'; -import { SocialButtons } from '@/components/auth/SocialButtons'; +import { BackButton } from '@/components/auth/buttons/BackButton'; +import { SocialButtons } from '@/components/auth/buttons/SocialButtons'; +import { AuthFormHeader } from '@/components/auth/shared/AuthFormHeader'; import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'; interface CardWrapperProps { @@ -13,7 +13,13 @@ interface CardWrapperProps { showSocial?: boolean; } -export function CardWrapper({ children, headerLabel, backButtonLabel, backButtonHref, showSocial }: CardWrapperProps) { +export const CardWrapper = ({ + children, + headerLabel, + backButtonLabel, + backButtonHref, + showSocial, +}: CardWrapperProps) => { return ( @@ -30,4 +36,4 @@ export function CardWrapper({ children, headerLabel, backButtonLabel, backButton ); -} +}; diff --git a/components/auth/ErrorCard.tsx b/components/auth/shared/ErrorCard.tsx similarity index 77% rename from components/auth/ErrorCard.tsx rename to components/auth/shared/ErrorCard.tsx index f8a32c2..6c1b74e 100644 --- a/components/auth/ErrorCard.tsx +++ b/components/auth/shared/ErrorCard.tsx @@ -1,8 +1,8 @@ import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; -import { CardWrapper } from '@/components/auth/CardWrapper'; +import { CardWrapper } from '@/components/auth/shared/CardWrapper'; -export function ErrorCard() { +export const ErrorCard = () => { return (
@@ -10,4 +10,4 @@ export function ErrorCard() {
); -} +}; diff --git a/components/forms/SettingsForm.tsx b/components/forms/SettingsForm.tsx new file mode 100644 index 0000000..5dee061 --- /dev/null +++ b/components/forms/SettingsForm.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { UserRole } from '@prisma/client'; +import { useRouter } from 'next/navigation'; +import { type Session } from 'next-auth'; +import { useState, useTransition } from 'react'; +import { useForm, UseFormReturn } from 'react-hook-form'; +import * as zod from 'zod'; + +import { settingsAction } from '@/actions/settings'; +import { FormError } from '@/components/forms/messages/FormError'; +import { FormSuccess } from '@/components/forms/messages/FormSuccess'; +import { Button } from '@/components/ui/button'; +import { CustomSpinner } from '@/components/ui/CustomSpinner'; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; +import { SettingsSchema } from '@/schemas'; + +export const SettingsForm = ({ user }: { user: Session['user'] }) => { + const [error, setError] = useState(); + const [success, setSuccess] = useState(); + const [isPending, startTransition] = useTransition(); + const router = useRouter(); + const defaultValues = { + name: user?.name || '', + email: user?.email || '', + password: undefined, + newPassword: undefined, + role: user?.role || UserRole.USER, + isTwoFactorEnabled: user?.isTwoFactorEnabled || undefined, + }; + + const form = useForm>({ + resolver: zodResolver(SettingsSchema), + defaultValues, + }); + + const onSubmit = (values: zod.infer) => { + setError(''); + setSuccess(''); + startTransition(() => { + settingsAction(values) + .then((data) => { + if (data.error) { + setError(data.error); + } + if (data.success) { + setSuccess(data.success); + router.refresh(); + } + }) + .catch(() => setError('An error occurred!')); + }); + }; + + return ( + <> +
+ +
+ + + + + + +
+ + + + + + + ); +}; + +const EmailField = ({ + form, + disabled = true, +}: { + form: UseFormReturn>; + disabled?: boolean; +}) => { + return ( + ( + + Email + + + + + + )} + /> + ); +}; + +const NameField = ({ + form, + disabled = true, +}: { + form: UseFormReturn>; + disabled?: boolean; +}) => { + return ( + ( + + Name + + + + + + )} + /> + ); +}; + +const PasswordField = ({ + form, + isOauthAccount, + disabled = true, +}: { + form: UseFormReturn>; + isOauthAccount: boolean; + disabled?: boolean; +}) => { + return ( + ( + + Password + {isOauthAccount && ( + + Password cannot be changed when using OAuth + + )} + + { + const value = e.target.value; + const newValue = value === '' ? undefined : value; + field.onChange(newValue); + if (!newValue) { + form.setValue('newPassword', undefined); + } + }} + placeholder='123456' + type='password' + /> + + + + )} + /> + ); +}; + +const NewPasswordField = ({ + form, + isOauthAccount, + disabled = true, +}: { + form: UseFormReturn>; + isOauthAccount: boolean; + disabled?: boolean; +}) => { + const passwordFieldValue = form.watch('password'); + return ( + ( + + New Password + + { + const value = e.target.value; + field.onChange(value === '' ? undefined : value); + }} + placeholder='123456' + type='password' + value={!passwordFieldValue ? '' : field.value} + /> + + + + )} + /> + ); +}; + +const RoleField = ({ + form, + disabled = true, + defaultValue, +}: { + form: UseFormReturn>; + disabled?: boolean; + defaultValue: UserRole; +}) => { + return ( + ( + + Role + + + + )} + /> + ); +}; + +const TwoFactorField = ({ + form, + isOauthAccount, + disabled = true, +}: { + form: UseFormReturn>; + isOauthAccount: boolean; + disabled?: boolean; +}) => { + return ( + ( + + Two Factor Authentication + + + + {isOauthAccount && ( + + Not implemented for OAuthAccount + + )} + + )} + /> + ); +}; diff --git a/components/form-messages/FormError.tsx b/components/forms/messages/FormError.tsx similarity index 84% rename from components/form-messages/FormError.tsx rename to components/forms/messages/FormError.tsx index 200922a..43775c9 100644 --- a/components/form-messages/FormError.tsx +++ b/components/forms/messages/FormError.tsx @@ -4,7 +4,7 @@ interface FormErrorProps { message?: string; } -export function FormError({ message }: FormErrorProps) { +export const FormError = ({ message }: FormErrorProps) => { if (!message) return null; return (
@@ -12,4 +12,4 @@ export function FormError({ message }: FormErrorProps) {

{message}

); -} +}; diff --git a/components/form-messages/FormSuccess.tsx b/components/forms/messages/FormSuccess.tsx similarity index 82% rename from components/form-messages/FormSuccess.tsx rename to components/forms/messages/FormSuccess.tsx index e04b012..8b78817 100644 --- a/components/form-messages/FormSuccess.tsx +++ b/components/forms/messages/FormSuccess.tsx @@ -4,7 +4,7 @@ interface FormSuccessProps { message?: string; } -export function FormSuccess({ message }: FormSuccessProps) { +export const FormSuccess = ({ message }: FormSuccessProps) => { if (!message) return null; return (
@@ -12,4 +12,4 @@ export function FormSuccess({ message }: FormSuccessProps) {

{message}

); -} +}; diff --git a/components/navbar/Navbar.tsx b/components/layout/Navbar.tsx similarity index 64% rename from components/navbar/Navbar.tsx rename to components/layout/Navbar.tsx index 8776e06..57c38fb 100644 --- a/components/navbar/Navbar.tsx +++ b/components/layout/Navbar.tsx @@ -1,5 +1,5 @@ -export function Navbar({ children }: { children: React.ReactNode }) { +export const Navbar = ({ children }: { children: React.ReactNode }) => { return ( ); -} +}; diff --git a/components/navbar/NavigationMenu.tsx b/components/layout/navbar/NavigationMenu.tsx similarity index 95% rename from components/navbar/NavigationMenu.tsx rename to components/layout/navbar/NavigationMenu.tsx index c1b97c1..7c24e49 100644 --- a/components/navbar/NavigationMenu.tsx +++ b/components/layout/navbar/NavigationMenu.tsx @@ -4,7 +4,7 @@ import { usePathname } from 'next/navigation'; import { Button } from '@/components/ui/button'; -export function NavigationMenu() { +export const NavigationMenu = () => { const pathname = usePathname(); return (
@@ -30,4 +30,4 @@ export function NavigationMenu() {
); -} +}; diff --git a/components/navbar/UserAvatarMenu.tsx b/components/layout/navbar/UserAvatarMenu.tsx similarity index 83% rename from components/navbar/UserAvatarMenu.tsx rename to components/layout/navbar/UserAvatarMenu.tsx index b5e0dad..7ce96b1 100644 --- a/components/navbar/UserAvatarMenu.tsx +++ b/components/layout/navbar/UserAvatarMenu.tsx @@ -1,19 +1,19 @@ import 'server-only'; -import { FaUser } from 'react-icons/fa'; import { ExitIcon } from '@radix-ui/react-icons'; +import { FaUser } from 'react-icons/fa'; -import { currentSessionUser } from '@/lib/auth-utils'; -import { LogoutButton } from '@/components/LogoutButton'; +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { LogoutButton } from '@/components/user/actions/LogoutButton'; +import { currentSessionUser } from '@/lib/auth/auth-utils'; -export async function UserAvatarMenu() { +export const UserAvatarMenu = async () => { const user = await currentSessionUser(); return ( @@ -35,4 +35,4 @@ export async function UserAvatarMenu() { ); -} +}; diff --git a/components/ui/CustomSpinner.tsx b/components/ui/CustomSpinner.tsx new file mode 100644 index 0000000..c9127bd --- /dev/null +++ b/components/ui/CustomSpinner.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +export const CustomSpinner = () => ( +
+
+
+); diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx index ffad528..df15bfd 100644 --- a/components/ui/avatar.tsx +++ b/components/ui/avatar.tsx @@ -1,7 +1,7 @@ 'use client'; -import * as React from 'react'; import * as AvatarPrimitive from '@radix-ui/react-avatar'; +import * as React from 'react'; import { cn } from '@/lib/utils'; diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx index 0d54d4f..b673974 100644 --- a/components/ui/badge.tsx +++ b/components/ui/badge.tsx @@ -1,5 +1,5 @@ -import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; import { cn } from '@/lib/utils'; @@ -23,8 +23,8 @@ const badgeVariants = cva( export interface BadgeProps extends React.HTMLAttributes, VariantProps {} -function Badge({ className, variant, ...props }: BadgeProps) { +const Badge = ({ className, variant, ...props }: BadgeProps) => { return
; -} +}; export { Badge, badgeVariants }; diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 7b03fc1..c2e21fd 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -1,11 +1,11 @@ -import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; import { cn } from '@/lib/utils'; const buttonVariants = cva( - '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', + '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', { variants: { variant: { @@ -13,6 +13,7 @@ const buttonVariants = cva( destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + magicLink: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', link: 'text-primary underline-offset-4 hover:underline', }, diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index e92dd36..6c3c8dd 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -1,8 +1,8 @@ 'use client'; -import * as React from 'react'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import { Cross2Icon } from '@radix-ui/react-icons'; +import * as React from 'react'; import { cn } from '@/lib/utils'; diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx index fbdd42b..e8421ff 100644 --- a/components/ui/dropdown-menu.tsx +++ b/components/ui/dropdown-menu.tsx @@ -1,8 +1,8 @@ 'use client'; -import * as React from 'react'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import { CheckIcon, ChevronRightIcon, DotFilledIcon } from '@radix-ui/react-icons'; +import * as React from 'react'; import { cn } from '@/lib/utils'; diff --git a/components/ui/form.tsx b/components/ui/form.tsx index d89eda2..ab2a9fc 100644 --- a/components/ui/form.tsx +++ b/components/ui/form.tsx @@ -1,10 +1,10 @@ -import * as React from 'react'; import * as LabelPrimitive from '@radix-ui/react-label'; import { Slot } from '@radix-ui/react-slot'; +import * as React from 'react'; import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form'; -import { cn } from '@/lib/utils'; import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; const Form = FormProvider; diff --git a/components/ui/label.tsx b/components/ui/label.tsx index 7c525f2..4387ed9 100644 --- a/components/ui/label.tsx +++ b/components/ui/label.tsx @@ -1,8 +1,8 @@ 'use client'; -import * as React from 'react'; import * as LabelPrimitive from '@radix-ui/react-label'; import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; import { cn } from '@/lib/utils'; diff --git a/components/ui/select.tsx b/components/ui/select.tsx index 3aa1190..f88eb00 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -1,8 +1,8 @@ 'use client'; -import * as React from 'react'; import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; import * as SelectPrimitive from '@radix-ui/react-select'; +import * as React from 'react'; import { cn } from '@/lib/utils'; diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx index 9c51976..6da84d4 100644 --- a/components/ui/switch.tsx +++ b/components/ui/switch.tsx @@ -1,7 +1,7 @@ 'use client'; -import * as React from 'react'; import * as SwitchPrimitives from '@radix-ui/react-switch'; +import * as React from 'react'; import { cn } from '@/lib/utils'; diff --git a/components/user/actions/LogoutButton.tsx b/components/user/actions/LogoutButton.tsx new file mode 100644 index 0000000..c046058 --- /dev/null +++ b/components/user/actions/LogoutButton.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { logoutAction } from '@/actions/logout'; + +export const LogoutButton = (props: { children: React.ReactNode }) => { + return ( + { + await logoutAction(); + }} + > + {props.children} + + ); +}; diff --git a/components/UserInfo.tsx b/components/user/profile/UserInfo.tsx similarity index 93% rename from components/UserInfo.tsx rename to components/user/profile/UserInfo.tsx index 65be80e..de81b6f 100644 --- a/components/UserInfo.tsx +++ b/components/user/profile/UserInfo.tsx @@ -1,13 +1,14 @@ import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { type ExtendedUser } from '@/next-auth'; + +import type { Session } from 'next-auth'; interface UserInfoProps { - user?: ExtendedUser; + user?: Session['user']; label: string; } -export default function UserInfo({ user, label }: UserInfoProps) { +export const UserInfo = ({ user, label }: UserInfoProps) => { return ( @@ -44,4 +45,4 @@ export default function UserInfo({ user, label }: UserInfoProps) { ); -} +}; diff --git a/data/account.ts b/data/account.ts deleted file mode 100644 index 4812da3..0000000 --- a/data/account.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { db } from '@/lib/db'; - -export const getAccountByUserId = async (userId: string) => { - try { - const account = await db.account.findFirst({ - where: { userId }, - }); - - return account; - } catch { - return null; - } -}; diff --git a/data/db/account/helpers.ts b/data/db/account/helpers.ts new file mode 100644 index 0000000..7e26047 --- /dev/null +++ b/data/db/account/helpers.ts @@ -0,0 +1,57 @@ +import { unstable_cache } from 'next/cache'; + +import { db } from '@/lib/db'; + +export const getAccountByUserId = async (userId: string) => { + const account = await db.account.findFirst({ + where: { userId }, + }); + + return account; +}; + +// For a possible 'database' session strategy database; +export async function getCachedInfoForJwtByUserId(userId: string) { + const tags = [`jwt-info-tag-${userId}`]; + const cacheKey = [`jwt-info-key-${userId}`]; + + return unstable_cache( + async () => { + console.log('🔍 Fetching user and account info from DB...'); + const userWithAccount = await db.user.findUnique({ + where: { id: userId }, + select: { + id: true, + name: true, + email: true, + role: true, + isTwoFactorEnabled: true, + _count: { + select: { + accounts: true, + }, + }, + }, + }); + console.log('✅ DB fetch completed', userWithAccount ? 'Data found' : 'No data found'); + + return { + user: userWithAccount + ? { + id: userWithAccount.id, + name: userWithAccount.name, + email: userWithAccount.email, + role: userWithAccount.role, + isTwoFactorEnabled: userWithAccount.isTwoFactorEnabled, + } + : null, + hasOAuthAccount: (userWithAccount?._count?.accounts ?? 0) > 0, + }; + }, + cacheKey, + { + tags, + revalidate: 3600, + } + )(); +} diff --git a/data/db/tokens/password-reset/create.ts b/data/db/tokens/password-reset/create.ts new file mode 100644 index 0000000..df68615 --- /dev/null +++ b/data/db/tokens/password-reset/create.ts @@ -0,0 +1,33 @@ +import { PasswordResetToken } from '@prisma/client'; +import { v4 as uuidv4 } from 'uuid'; + +import { db } from '@/lib/db'; + +export const generatePasswordResetToken = async (email: string, userId: string): Promise => { + const token = uuidv4(); + const expires = new Date(new Date().getTime() + 3600 * 1000); // 1hr + + // Clean up any existing tokens for this email + await db.passwordResetToken.deleteMany({ + where: { email }, + }); + + const passwordResetToken = await db.passwordResetToken.upsert({ + where: { + email, + }, + update: { + token, + expires, + userId, + }, + create: { + email, + token, + expires, + userId, + }, + }); + + return passwordResetToken; +}; diff --git a/data/db/tokens/password-reset/delete.ts b/data/db/tokens/password-reset/delete.ts new file mode 100644 index 0000000..122149c --- /dev/null +++ b/data/db/tokens/password-reset/delete.ts @@ -0,0 +1,7 @@ +import { db } from '@/lib/db'; + +export const deletePasswordResetTokenById = async (tokenId: string): Promise => { + await db.passwordResetToken.delete({ + where: { id: tokenId }, + }); +}; diff --git a/data/db/tokens/password-reset/helpers.ts b/data/db/tokens/password-reset/helpers.ts new file mode 100644 index 0000000..fc53ab8 --- /dev/null +++ b/data/db/tokens/password-reset/helpers.ts @@ -0,0 +1,73 @@ +import { PasswordResetToken, Prisma } from '@prisma/client'; + +import { db } from '@/lib/db'; + +export const getPasswordResetTokenByToken = async (token: string): Promise => { + const passwordToken = await db.passwordResetToken.findUnique({ + where: { token }, + }); + + return passwordToken; +}; + +export const getPasswordResetTokenByEmail = async (email: string): Promise => { + const passwordResetToken = await db.passwordResetToken.findUnique({ + where: { email }, + }); + + return passwordResetToken; +}; + +type TokenWithUser = Prisma.PasswordResetTokenGetPayload<{ + include: { + user: { + select: { + id: true; + email: true; + }; + }; + }; +}>; + +/** + * Retrieves a password reset token and its associated user information in a single query + * @param {string} token - The password reset token string to look up + * @returns {Promise} The token with user data if found, null otherwise + * + * @throws Will throw an error if the database query fails + */ +export const getPasswordResetTokenWithUserByTokenId = async (token: string): Promise => { + return db.passwordResetToken.findUnique({ + where: { + token, + }, + include: { + user: { + select: { + id: true, + email: true, + }, + }, + }, + }); +}; + +/** + * Retrieves a valid (non-expired) password reset token + * @param {string} token - The password reset token string to look up + * @returns {Promise} The token if found and valid, null otherwise + * + * @throws - Will throw an error if the database query fails + */ +export const getValidPasswordResetToken = async (token: string): Promise => { + const now = new Date(); + + return db.passwordResetToken.findFirst({ + where: { + token, + expires: { + gt: now, + }, + }, + }); +}; diff --git a/data/db/tokens/two-factor/create.ts b/data/db/tokens/two-factor/create.ts new file mode 100644 index 0000000..5566d47 --- /dev/null +++ b/data/db/tokens/two-factor/create.ts @@ -0,0 +1,29 @@ +import crypto from 'crypto'; + +import { TwoFactorToken } from '@prisma/client'; + +import { getTwoFactorTokenByEmail } from '@/data/db/tokens/two-factor/helpers'; +import { db } from '@/lib/db'; + +export const generateTwoFactorToken = async (email: string, userId: string): Promise => { + const token = crypto.randomInt(100_000, 1_000_000).toString(); + const expires = new Date(new Date().getTime() + 3600 * 1000); // 1hr + + const existingToken = await getTwoFactorTokenByEmail(email); + if (existingToken) { + await db.twoFactorToken.delete({ + where: { id: existingToken.id }, + }); + } + + const twoFactorToken = await db.twoFactorToken.create({ + data: { + email, + token, + expires, + userId, + }, + }); + + return twoFactorToken; +}; diff --git a/data/db/tokens/two-factor/helpers.ts b/data/db/tokens/two-factor/helpers.ts new file mode 100644 index 0000000..0dbb253 --- /dev/null +++ b/data/db/tokens/two-factor/helpers.ts @@ -0,0 +1,17 @@ +import { TwoFactorToken } from '@prisma/client'; + +import { db } from '@/lib/db'; + +export const getTwoFactorTokenByToken = async (token: string): Promise => { + const twoFactorToken = await db.twoFactorToken.findUnique({ + where: { token }, + }); + return twoFactorToken; +}; + +export const getTwoFactorTokenByEmail = async (email: string): Promise => { + const twoFactorToken = await db.twoFactorToken.findFirst({ + where: { email }, + }); + return twoFactorToken; +}; diff --git a/data/db/tokens/verification-email/create.ts b/data/db/tokens/verification-email/create.ts new file mode 100644 index 0000000..01f0be4 --- /dev/null +++ b/data/db/tokens/verification-email/create.ts @@ -0,0 +1,42 @@ +import { CustomVerificationToken, Prisma } from '@prisma/client'; +import { v4 as uuidv4 } from 'uuid'; + +import { db } from '@/lib/db'; + +/** + * Generates a verification token for a user + * Uses upsert with compound unique constraint + * + * @param {Object} params - The parameters for token generation + * @param {string} params.userId - User's ID + * @param {string} params.email - User's email + * @param {Prisma.TransactionClient} [params.prisma=db] - Optional transaction client defaults to db from @/lib/db + * @returns {Promise} The created verification token + */ +export const generateCustomVerificationToken = async ({ + userId, + email, + prisma = db, +}: { + userId: string; + email: string; + prisma?: typeof db | Prisma.TransactionClient; +}): Promise => { + const token = uuidv4(); + const expires = new Date(new Date().getTime() + 3600 * 1000); // 1hr + + // First, delete any existing tokens for this email + await prisma.customVerificationToken.deleteMany({ + where: { email }, + }); + + // Then create a new token + return prisma.customVerificationToken.create({ + data: { + email, + token, + expires, + userId, + }, + }); +}; diff --git a/data/db/tokens/verification-email/delete.ts b/data/db/tokens/verification-email/delete.ts new file mode 100644 index 0000000..68579a6 --- /dev/null +++ b/data/db/tokens/verification-email/delete.ts @@ -0,0 +1,7 @@ +import { db } from '@/lib/db'; + +export const deleteCustomVerificationTokenById = async (tokenId: string): Promise => { + await db.customVerificationToken.delete({ + where: { id: tokenId }, + }); +}; diff --git a/data/db/tokens/verification-email/helpers.ts b/data/db/tokens/verification-email/helpers.ts new file mode 100644 index 0000000..874e79b --- /dev/null +++ b/data/db/tokens/verification-email/helpers.ts @@ -0,0 +1,8 @@ +import { db } from '@/lib/db'; + +export const getCustomVerificationTokenByToken = async (token: string) => { + const customVerificationToken = await db.customVerificationToken.findUnique({ + where: { token }, + }); + return customVerificationToken; +}; diff --git a/data/db/tokens/verification-tokens/magic-link/helpers.ts b/data/db/tokens/verification-tokens/magic-link/helpers.ts new file mode 100644 index 0000000..988ede3 --- /dev/null +++ b/data/db/tokens/verification-tokens/magic-link/helpers.ts @@ -0,0 +1,36 @@ +import { CustomMagicLinkError } from '@/lib/constants/errors/errors'; +import { db } from '@/lib/db'; + +export async function validateMagicLinkRequest(email: string, hashedIp: string) { + // Check IP limit + const activeTokensCountSameIp = await db.verificationToken.count({ + where: { + hashedIp, + expires: { gt: new Date() }, + }, + }); + + if (activeTokensCountSameIp >= 2) { + throw new CustomMagicLinkError('IpLimit'); + } + + // Check for existing token + const existingToken = await db.verificationToken.findFirst({ + where: { + identifier: email, + expires: { gt: new Date() }, + }, + }); + + if (existingToken) { + throw new CustomMagicLinkError('TokenExists'); + } +} + +export async function cleanupExpiredVerificationTokens() { + await db.verificationToken.deleteMany({ + where: { + expires: { lt: new Date() }, + }, + }); +} diff --git a/data/db/unstable-cache/helpers.ts b/data/db/unstable-cache/helpers.ts new file mode 100644 index 0000000..5afef84 --- /dev/null +++ b/data/db/unstable-cache/helpers.ts @@ -0,0 +1,5 @@ +import { revalidateTag } from 'next/cache'; + +export const clearUnstableCachedInfoForJwtByUserId = async (userId: string) => { + revalidateTag(`jwt-info-tag-${userId}`); +}; diff --git a/data/db/user/create.ts b/data/db/user/create.ts new file mode 100644 index 0000000..d694cad --- /dev/null +++ b/data/db/user/create.ts @@ -0,0 +1,84 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { hashPassword } from '@/lib/crypto/hash-edge-compatible'; +import { db } from '@/lib/db'; + +type CreateCredentialsUserData = { + name: string; + email: string; + password: string; + hashedIp: string; +}; + +type CreateCredentialsUserResponse = { + emailCustomVerificationToken: { + id: string; + token: string; + email: string; + }; +}; + +/** + * Creates a new credentials-based user with email verification token + * + * Creates a user account in the database with a hashed password and generates + * an email verification token. Uses a single transaction. + * + * @param {CreateCredentialsUserData} params - name, email, password, hashedIp + * @returns {Promise} CustomVerificationToken data of created user + * + * @throws {PrismaClientKnownRequestError} + * - P2002: Email already exists + * - Other Prisma errors for database operation failures + * + * @note + * - Verification token expires in 1 hour + * - Password is automatically hashed + * - Uses UUID v4 for token generation + * - Returns the necessary token data for email sending + */ +export const createNewCredentialsUser = async ({ + name, + email, + password, + hashedIp, +}: CreateCredentialsUserData): Promise => { + const token = uuidv4(); + + // Hash the password + const hashedPassword = await hashPassword(password); + + // Create user and verification token + const newUser = await db.user.create({ + data: { + name: name, + email: email, + password: hashedPassword, + ip: hashedIp, + customVerificationTokens: { + create: { + email: email, + token: token, + expires: new Date(new Date().getTime() + 3600 * 1000), // 1hr + }, + }, + }, + select: { + customVerificationTokens: { + where: { + token: token, + }, + select: { + id: true, + token: true, + email: true, + }, + }, + }, + }); + const [verificationToken] = newUser.customVerificationTokens; + + return { + emailCustomVerificationToken: verificationToken, + }; +}; diff --git a/data/db/user/helpers.ts b/data/db/user/helpers.ts new file mode 100644 index 0000000..9063d8b --- /dev/null +++ b/data/db/user/helpers.ts @@ -0,0 +1,30 @@ +import { type User } from '@prisma/client'; + +import { db } from '@/lib/db'; + +/** + * Gets the number of accounts registered with a given IP address + * @param data - Configuration options + * @param data.hashedIp - User's hashed IP address + * @throws {Error} When database operation fails + * @returns {Promise} Number of accounts registered with the IP + */ +export const countUserRegistrationsByIp = async ({ hashedIp }: { hashedIp: string }): Promise => { + const existingAccounts = await db.user.count({ + where: { ip: hashedIp }, + }); + + return existingAccounts; +}; + +export const getUserByEmail = async (email: string): Promise => { + const user = await db.user.findUnique({ where: { email } }); + + return user; +}; + +export const getUserById = async (id: string): Promise => { + const user = await db.user.findUnique({ where: { id } }); + + return user; +}; diff --git a/data/db/user/login.ts b/data/db/user/login.ts new file mode 100644 index 0000000..1d3e85d --- /dev/null +++ b/data/db/user/login.ts @@ -0,0 +1,101 @@ +import { db } from '@/lib/db'; + +import type { TwoFactorConfirmation, TwoFactorToken, User, CustomVerificationToken } from '@prisma/client'; + +//Complete user data with authentication-related associations +interface UserLoginAuthData { + user: CompleteUserWithAuth | null; + activeCustomVerificationToken: CustomVerificationToken | null; + activeTwoFactorToken: TwoFactorToken | null; +} + +// Combined user authentication data +type CompleteUserWithAuth = User & { + customVerificationTokens: CustomVerificationToken[]; + twoFactorTokens: TwoFactorToken[]; + twoFactorConfirmation: TwoFactorConfirmation | null; +}; + +/** + * Retrieves all authentication-related data for a user + * + * Fetches user data along with their active verification tokens and 2FA status. + * Only returns one non-expired tokens, ordered by expiration date. + * + * @note + * - Tokens are filtered by expiration date + * - Returns only the most recent tokens (ordered by expiration) + * - Returns null values if user not found + */ +export const getUserLoginAuthData = async (email: string): Promise => { + const now = new Date(Date.now()); + const userData = await db.user.findUnique({ + where: { email }, + include: { + customVerificationTokens: { + where: { + expires: { gt: now }, + }, + orderBy: { + expires: 'desc', + }, + }, + twoFactorTokens: { + where: { + expires: { gt: now }, + }, + orderBy: { + expires: 'desc', + }, + }, + twoFactorConfirmation: true, + }, + }); + + if (!userData) { + return { + user: null, + activeCustomVerificationToken: null, + activeTwoFactorToken: null, + }; + } + + const [customVerificationToken] = userData.customVerificationTokens; + const [twoFactorToken] = userData.twoFactorTokens; + return { + user: userData, + activeCustomVerificationToken: customVerificationToken || null, + activeTwoFactorToken: twoFactorToken || null, + }; +}; + +/** + * Processes a successful 2FA verification + * + * Handles the complete 2FA verification flow in a transaction: + * 1. Deletes the used 2FA token + * 2. Removes any existing 2FA confirmations + * 3. Creates a new 2FA confirmation + * + * @throws {PrismaClientKnownRequestError} + * - If token doesn't exist + * - If user doesn't exist + * - If database transaction fails + */ +export const consumeTwoFactorToken = async (token: string, userId: string): Promise => { + await db.$transaction(async (tx) => { + // Delete the used token + await tx.twoFactorToken.delete({ + where: { token: token }, + }); + + await tx.twoFactorConfirmation.deleteMany({ + where: { userId }, + }); + + // Create new confirmation + await tx.twoFactorConfirmation.create({ + data: { userId }, + }); + }); +}; diff --git a/data/db/user/reset-password.ts b/data/db/user/reset-password.ts new file mode 100644 index 0000000..9e5e66f --- /dev/null +++ b/data/db/user/reset-password.ts @@ -0,0 +1,71 @@ +import { db } from '@/lib/db'; + +import type { PasswordResetToken, User } from '@prisma/client'; + +type UserResetPasswordData = { + userId: User['id'] | null; + canResetPassword: boolean; + activeResetToken: PasswordResetToken | null; +}; + +/** + * Retrieves user data for password reset flow + * + * Fetches necessary user information to determine if and how a password reset + * can proceed. Checks for existing valid reset tokens and whether the user + * is eligible for password reset. + * + * @param {string} email - User's email address + * + * @returns {Promise} Object containing: + * - userId: User's ID if found + * - canResetPassword: Whether user can reset password (has password auth) + * - activeResetToken: Currently active reset token, if any + * + * @Notes + * - Only returns one active (non-expired) reset tokens + * - Orders tokens by expiration to get most recent + * - Checks if user has password-based authentication + * + * @returns {Promise<{ + * userId: string | null; + * canResetPassword: boolean; + * activeResetToken: PasswordResetToken | null; + * }>} + */ +export const getUserResetPasswordData = async (email: string): Promise => { + const now = new Date(); + + const userData = await db.user.findUnique({ + where: { email }, + select: { + id: true, + password: true, + passwordResetTokens: { + where: { + expires: { gt: now }, + }, + orderBy: { + expires: 'desc', + }, + take: 1, + }, + }, + }); + + if (!userData) { + return { + userId: null, + canResetPassword: false, + activeResetToken: null, + }; + } + const canResetPassword = userData.password !== null; + const [passwordResetToken] = userData.passwordResetTokens; + + return { + userId: userData.id, + canResetPassword, + activeResetToken: passwordResetToken || null, + }; +}; diff --git a/data/db/user/settings.ts b/data/db/user/settings.ts new file mode 100644 index 0000000..26fb9ef --- /dev/null +++ b/data/db/user/settings.ts @@ -0,0 +1,36 @@ +import { UserRole } from '@prisma/client'; + +import { db } from '@/lib/db'; + +type UserSettingsDataReturn = { + name: string | null; + id: string; + email: string; + emailVerified: Date | null; + password: string | null; + role: UserRole; + isTwoFactorEnabled: boolean; +}; + +/** + * Retrieves user settings data with specific field selection + * + * Fetches essential user data needed for settings management, including + * authentication status, role, and security preferences. + * + * @param {string} userId - The user's ID + */ +export const getUserSettingsData = async (userId: string): Promise => { + return db.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + emailVerified: true, + password: true, + name: true, + role: true, + isTwoFactorEnabled: true, + }, + }); +}; diff --git a/data/password-reset-token.ts b/data/password-reset-token.ts deleted file mode 100644 index 242bf2e..0000000 --- a/data/password-reset-token.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { db } from '@/lib/db'; - -export const getPasswordResetTokenByToken = async (token: string) => { - try { - const passwordToken = await db.passwordResetToken.findUnique({ - where: { token }, - }); - - return passwordToken; - } catch { - return null; - } -}; - -export const getPasswordResetTokenByEmail = async (email: string) => { - try { - const passwordResetToken = await db.passwordResetToken.findFirst({ - where: { email }, - }); - - return passwordResetToken; - } catch { - return null; - } -}; diff --git a/data/two-factor-confirmation.ts b/data/two-factor-confirmation.ts deleted file mode 100644 index 4d656c1..0000000 --- a/data/two-factor-confirmation.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { db } from '@/lib/db'; - -export const getTwoFactorConfirmationByUserId = async (userId: string) => { - try { - const twoFactorConfirmation = await db.twoFactorConfirmation.findUnique({ - where: { userId }, - }); - - return twoFactorConfirmation; - } catch { - return null; - } -}; diff --git a/data/two-factor-token.ts b/data/two-factor-token.ts deleted file mode 100644 index b4e24b6..0000000 --- a/data/two-factor-token.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { db } from '@/lib/db'; - -export const getTwoFactorTokenByToken = async (token: string) => { - try { - const twoFactorToken = await db.twoFactorToken.findUnique({ - where: { token }, - }); - return twoFactorToken; - } catch { - return null; - } -}; - -export const getTwoFactorTokenByEmail = async (email: string) => { - try { - const twoFactorToken = await db.twoFactorToken.findFirst({ - where: { email }, - }); - return twoFactorToken; - } catch { - return null; - } -}; diff --git a/data/user.ts b/data/user.ts deleted file mode 100644 index 608ebdd..0000000 --- a/data/user.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { User } from '@prisma/client'; - -import { db } from '@/lib/db'; - -export const getUserByEmail = async (email: string): Promise => { - try { - const user = await db.user.findUnique({ where: { email } }); - - return user; - } catch { - return null; - } -}; - -export const getUserById = async (id: string): Promise => { - try { - const user = await db.user.findUnique({ where: { id } }); - - return user; - } catch { - return null; - } -}; diff --git a/data/verification-token.ts b/data/verification-token.ts deleted file mode 100644 index 7b08861..0000000 --- a/data/verification-token.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { db } from '@/lib/db'; - -export const getVerificationTokenByToken = async (token: string) => { - try { - const verificationToken = await db.verificationToken.findUnique({ - where: { token }, - }); - return verificationToken; - } catch { - return null; - } -}; - -export const getVerificationTokenByEmail = async (email: string) => { - try { - const verificationToken = await db.verificationToken.findFirst({ - where: { email }, - }); - return verificationToken; - } catch { - return null; - } -}; - -export const getVerificationTokenByWhoRequested = async (request_email_change_by: string) => { - try { - const verificationToken = await db.verificationToken.findFirst({ - where: { request_email_change_by }, - }); - return verificationToken; - } catch { - return null; - } -}; diff --git a/e2e-tests/credentials-2FA.spec.ts b/e2e-tests/credentials-2FA.spec.ts index eb606fc..ed1985e 100644 --- a/e2e-tests/credentials-2FA.spec.ts +++ b/e2e-tests/credentials-2FA.spec.ts @@ -4,6 +4,7 @@ import { TEST_CONFIG } from '@/e2e-tests/config/test-config'; import { cleanupTestUserFromDB, createCredentialsTestUser } from '@/e2e-tests/helpers/helper-functions'; import { cleanupMailsacInbox, getEmailContent } from '@/e2e-tests/helpers/mailsac/mailsac'; import { fillLoginForm } from '@/e2e-tests/helpers/tests'; +import { messages } from '@/lib/constants/messages/actions/messages'; test.describe('2FA Authentication Flow', () => { const { MAILSAC_API_KEY, TEST_EMAIL, TEST_PASSWORD, TEST_NAME } = TEST_CONFIG; @@ -47,7 +48,7 @@ test.describe('2FA Authentication Flow', () => { } async function submitTwoFactorCode(page: Page, code: string) { - await page.locator('input[name="code"]').fill(code); + await page.locator('input[name="twoFactorCode"]').fill(code); await page.locator('button[type="submit"]').click(); } @@ -78,7 +79,7 @@ test.describe('2FA Authentication Flow', () => { await test.step('Attempt login with invalid 2FA code', async () => { await initiateLogin(page); await submitTwoFactorCode(page, '000000'); - await expect(page.getByText('Invalid code')).toBeVisible(); + await expect(page.getByText(messages.login.errors.TWO_FACTOR_CODE_INVALID)).toBeVisible(); }); }); }); diff --git a/e2e-tests/credentials-registration-flow.spec.ts b/e2e-tests/credentials-registration-flow.spec.ts index d0b320b..43e4f73 100644 --- a/e2e-tests/credentials-registration-flow.spec.ts +++ b/e2e-tests/credentials-registration-flow.spec.ts @@ -1,9 +1,14 @@ import { test, expect, type Page } from '@playwright/test'; import { TEST_CONFIG } from '@/e2e-tests/config/test-config'; -import { cleanupMailsacInbox, extractVerificationToken, getEmailContent } from '@/e2e-tests/helpers/mailsac/mailsac'; -import { fillLoginForm, fillRegistrationForm } from '@/e2e-tests/helpers/tests'; import { cleanupLocalhostTestAccounts, cleanupTestUserFromDB } from '@/e2e-tests/helpers/helper-functions'; +import { + cleanupMailsacInbox, + extractCustomVerificationToken, + getEmailContent, +} from '@/e2e-tests/helpers/mailsac/mailsac'; +import { fillLoginForm, fillRegistrationForm } from '@/e2e-tests/helpers/tests'; +import { messages } from '@/lib/constants/messages/actions/messages'; test.describe('User Registration and Email Verification Flow', () => { const { MAILSAC_API_KEY, TEST_EMAIL, TEST_PASSWORD, TEST_NAME } = TEST_CONFIG; @@ -35,7 +40,7 @@ test.describe('User Registration and Email Verification Flow', () => { }); await test.step('Verify success message', async () => { - const expectedMessage = 'Confirmation email sent!'; + const expectedMessage = messages.register.success.REGISTRATION_COMPLETE; await expect(page.getByText(expectedMessage, { exact: false })).toBeVisible({ timeout: 5000 }); }); } @@ -52,7 +57,7 @@ test.describe('User Registration and Email Verification Flow', () => { }); await test.step('Verify confirmation requirement message', async () => { - await expect(page.getByText('Confirmation email already sent! Check your inbox!', { exact: true })).toBeVisible({ + await expect(page.getByText(messages.login.errors.CONFIRMATION_EMAIL_ALREADY_SENT, { exact: true })).toBeVisible({ timeout: 5000, }); }); @@ -63,11 +68,11 @@ test.describe('User Registration and Email Verification Flow', () => { const emailContent = await getEmailContent(TEST_EMAIL, MAILSAC_API_KEY, 'Please confirm your email'); expect(emailContent).toBeTruthy(); - const verificationToken = await extractVerificationToken(emailContent); - expect(verificationToken).toBeTruthy(); + const customVerificationToken = await extractCustomVerificationToken(emailContent); + expect(customVerificationToken).toBeTruthy(); - await page.goto(`/new-verification?token=${verificationToken}`); - await expect(page.getByText('Email verified!')).toBeVisible(); + await page.goto(`/new-verification?token=${customVerificationToken}`); + await expect(page.getByText(messages.new_verification_email.success.EMAIL_VERIFIED)).toBeVisible(); }); await test.step('Navigate to login', async () => { diff --git a/e2e-tests/forgot-password.spec.ts b/e2e-tests/forgot-password.spec.ts index 0964259..5d0a366 100644 --- a/e2e-tests/forgot-password.spec.ts +++ b/e2e-tests/forgot-password.spec.ts @@ -4,6 +4,7 @@ import { TEST_CONFIG } from '@/e2e-tests/config/test-config'; import { cleanupTestUserFromDB, createCredentialsTestUser } from '@/e2e-tests/helpers/helper-functions'; import { cleanupMailsacInbox, getEmailContent } from '@/e2e-tests/helpers/mailsac/mailsac'; import { fillLoginForm } from '@/e2e-tests/helpers/tests'; +import { messages } from '@/lib/constants/messages/actions/messages'; test.describe('Password Reset Flow', () => { const { MAILSAC_API_KEY, TEST_EMAIL, TEST_PASSWORD, TEST_NAME } = TEST_CONFIG; @@ -34,7 +35,7 @@ test.describe('Password Reset Flow', () => { expect(response.status()).toBe(200); - await expect(page.getByText('Reset email sent!')).toBeVisible(); + await expect(page.getByText(messages.reset_password.success.PASSWORD_RESET_EMAIL_SENT)).toBeVisible(); } async function getResetToken(): Promise { @@ -91,7 +92,7 @@ test.describe('Password Reset Flow', () => { await test.step('Process reset token and change password', async () => { const resetToken = await getResetToken(); await resetPassword(page, resetToken); - await expect(page.getByText('Password updated!')).toBeVisible(); + await expect(page.getByText(messages.new_password.success.UPDATE_SUCCESSFUL)).toBeVisible(); }); await test.step('Should be able to login with new password', async () => { @@ -102,6 +103,6 @@ test.describe('Password Reset Flow', () => { test('Should show error for invalid reset token', async ({ page }) => { await resetPassword(page, 'invalid-token'); - await expect(page.getByText('Invalid token!')).toBeVisible(); + await expect(page.getByText(messages.new_password.errors.INVALID_TOKEN)).toBeVisible(); }); }); diff --git a/e2e-tests/helpers/helper-functions.ts b/e2e-tests/helpers/helper-functions.ts index 2121461..e515760 100644 --- a/e2e-tests/helpers/helper-functions.ts +++ b/e2e-tests/helpers/helper-functions.ts @@ -1,8 +1,6 @@ -import crypto from 'crypto'; - import { UserRole, Prisma } from '@prisma/client'; -import bcrypt from 'bcryptjs'; +import { hashIp, hashPassword } from '@/lib/crypto/hash-edge-compatible'; import { db } from '@/lib/db'; /** @@ -59,7 +57,7 @@ export async function createCredentialsTestUser( await cleanupTestUserFromDB(email); } - const hashedPassword = await bcrypt.hash(password, 10); + const hashedPassword = await hashPassword(password); const userData: Prisma.UserCreateInput = { name, @@ -87,7 +85,7 @@ export async function createCredentialsTestUser( */ export async function cleanupLocalhostTestAccounts(): Promise { try { - const hashedLocalhost = crypto.createHash('sha256').update('127.0.0.1').digest('hex'); + const hashedLocalhost = await hashIp('127.0.0.1'); await db.user.deleteMany({ where: { diff --git a/e2e-tests/helpers/mailsac/mailsac.ts b/e2e-tests/helpers/mailsac/mailsac.ts index 6ca15ef..cfbfd2c 100644 --- a/e2e-tests/helpers/mailsac/mailsac.ts +++ b/e2e-tests/helpers/mailsac/mailsac.ts @@ -78,7 +78,7 @@ export async function getEmailContent( * @param emailContent The raw email content * @returns The verification token */ -export async function extractVerificationToken(emailContent: string): Promise { +export async function extractCustomVerificationToken(emailContent: string): Promise { // Look for the token in the verification URL const tokenMatch = emailContent.match(/token=([0-9a-f-]+)/i); diff --git a/hooks/use-current-role.ts b/hooks/use-current-role.ts deleted file mode 100644 index 1c4dfdb..0000000 --- a/hooks/use-current-role.ts +++ /dev/null @@ -1,10 +0,0 @@ -'use client'; -import { useSession } from 'next-auth/react'; - -{ - /* To be used on client components */ -} -export const useCurrentRole = () => { - const session = useSession(); - return session?.data?.user?.role; -}; diff --git a/hooks/use-current-user.ts b/hooks/use-current-user.ts deleted file mode 100644 index ab6876a..0000000 --- a/hooks/use-current-user.ts +++ /dev/null @@ -1,11 +0,0 @@ -'use client'; -import { useSession } from 'next-auth/react'; - -{ - /* To be used on client components */ -} -export const useCurrentUser = () => { - const session = useSession(); - - return session.data?.user; -}; diff --git a/lib/auth-utils.ts b/lib/auth-utils.ts deleted file mode 100644 index 522432d..0000000 --- a/lib/auth-utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import 'server-only'; -import crypto from 'crypto'; - -import type { UserRole } from '@prisma/client'; - -import type { ExtendedUser } from '@/next-auth'; -import { auth } from '@/auth'; - -/** To be used in server components */ -export const currentSessionUser = async (): Promise => { - const session = await auth(); - - return session?.user; -}; - -/** To be used in server components */ -export const currentSessionRole = async (): Promise => { - const session = await auth(); - - return session?.user?.role; -}; - -export const hashIp = async (ipAddress: string | null) => { - if (ipAddress) { - return crypto.createHash('sha256').update(ipAddress).digest('hex'); - } - return 'unknown'; -}; diff --git a/lib/auth/auth-utils.ts b/lib/auth/auth-utils.ts new file mode 100644 index 0000000..5b79bc1 --- /dev/null +++ b/lib/auth/auth-utils.ts @@ -0,0 +1,45 @@ +import 'server-only'; + +import { auth } from '@/auth'; + +import type { Session } from 'next-auth'; + +/** + * Retrieves the current user from session in server components + * + * @serverOnly This function can only be used in server components or server-side code + * + * @returns {Promise} Current user object from session or undefined if not authenticated + */ +export const currentSessionUser = async (): Promise => { + const session = await auth(); + + return session?.user; +}; + +/** + * Retrieves the current user's role from session in server components + * + * @serverOnly This function can only be used in server components or server-side code + * + * @returns {Promise} User's role or undefined if not authenticated + */ +export const currentSessionRole = async (): Promise => { + const session = await auth(); + + return session?.user?.role; +}; + +/** + * Checks if current user's session has a specific role + * + * @serverOnly This function can only be used in server components or server-side code + * + * @param {string} role - The role to check against (e.g., 'ADMIN', 'USER') + * @returns {Promise} True if user has the specified role, false otherwise + */ +export const sessionHasRole = async (role: string): Promise => { + const session = await auth(); + + return session?.user?.role === role; +}; diff --git a/lib/auth/hooks.ts b/lib/auth/hooks.ts new file mode 100644 index 0000000..903c242 --- /dev/null +++ b/lib/auth/hooks.ts @@ -0,0 +1,42 @@ +'use client'; + +import { useSession } from 'next-auth/react'; + +import type { Session } from 'next-auth'; + +/** + * Custom hook to access the current user session on client components. + * + * @deprecated Consider using server components with auth() instead. + * This hook should only be used when client-side session access is absolutely necessary. + * Server components provide better performance and security by fetching session data on the server. + * + * @example + * // Client Component usage (not recommended) + * const user = useCurrentUser(); + * + * @returns {Session['user'] | undefined} The current user data from the session + */ +export const useCurrentUser = (): Session['user'] | undefined => { + const session = useSession(); + + return session.data?.user; +}; + +/** + * Custom hook to access the current user role on client components. + * + * @deprecated Consider using server components with auth() instead. + * This hook should only be used when client-side session access is absolutely necessary. + * Server components provide better performance and security by fetching session data on the server. + * + * @example + * // Client Component usage (not recommended) + * const userRole = useCurrentRole(); + * + * @returns {Session['user'] | undefined} The current user data from the session + */ +export const useCurrentRole = (): Session['user']['role'] | undefined => { + const session = useSession(); + return session?.data?.user?.role; +}; diff --git a/lib/auth/types.d.ts b/lib/auth/types.d.ts new file mode 100644 index 0000000..dfabffd --- /dev/null +++ b/lib/auth/types.d.ts @@ -0,0 +1,71 @@ +import { UserRole } from '@prisma/client'; +import NextAuth, { type DefaultSession } from 'next-auth'; + +declare module 'next-auth' { + /** + * Returned by `useSession`, `auth`, contains information about the active session. + */ + interface Session { + user: { + id: string; + role: UserRole; + isTwoFactorEnabled: boolean; + isOauth: boolean; + } & DefaultSession['user']; // This adds name, email, image + } + + /** + * The shape of the user object returned in the OAuth providers' `profile` callback, + * or the second parameter of the `session` callback, when using a database. + */ + interface User { + id: string; + name: string | null; + email: string; + emailVerified: Date | null; + image: string | null; + role: UserRole; + isTwoFactorEnabled: boolean; + password?: string | null; + ip?: string | null; + } + + interface VerificationToken { + id: string; + identifier: string; + expires: Date; + token: string; + hashIp: string; + } +} + +// Custom type for the verified user we pass to signIn Credentials +export interface VerifiedUserForAuth { + id: string; + email: string; + name: string | null; + role: UserRole; + isTwoFactorEnabled: boolean; + isOauth: boolean; + emailVerified: Date | null; + image: string | null; +} + +declare module 'next-auth/jwt' { + interface JWT { + // Default + name?: string | null; + email?: string | null; + picture?: string | null; + sub?: string; + // standard + iat?: number; + exp?: number; + jti?: string; + // custom + id?: string; + role?: UserRole; + isTwoFactorEnabled?: boolean; + isOauth?: boolean; + } +} diff --git a/lib/constants/errors/errors.ts b/lib/constants/errors/errors.ts new file mode 100644 index 0000000..d29ad2d --- /dev/null +++ b/lib/constants/errors/errors.ts @@ -0,0 +1,90 @@ +import { AuthError } from 'next-auth'; + +type CustomLoginErrorType = + | 'InvalidFields' + | 'WrongCredentials' + | 'ConfirmationEmailAlreadySent' + | 'ResendEmailError' + | 'NewConfirmationEmailSent' + | 'TwoFactorTokenNotExists' + | 'TwoFactorCodeInvalid' + | 'PasswordNeedUpdate'; + +export class CustomLoginAuthError extends Error { + constructor(public type: CustomLoginErrorType) { + super(); + this.name = 'CustomLoginAuthError'; + } +} + +type CustomNewPasswordErrorType = 'InvalidToken' | 'InvalidFields' | 'TokenNotExist'; + +export class CustomNewPasswordError extends Error { + constructor(public type: CustomNewPasswordErrorType) { + super(); + this.name = 'CustomNewPasswordError'; + } +} + +type CustomResetPasswordErrorType = + | 'InvalidFields' + | 'EmailNotFound' + | 'NoPasswordToReset' + | 'TokenStillValid' + | 'ResendEmailError'; + +export class CustomResetPasswordError extends Error { + constructor(public type: CustomResetPasswordErrorType) { + super(); + this.name = 'CustomResetPasswordError'; + } +} + +type CustomNewVerificationEmailErrorType = + | 'InvalidToken' + | 'TokenExpired' + | 'EmailNotFound' + | 'EmailAlreadyVerified' + | 'ResendEmailError' + | 'TokenExpiredSentNewEmail' + | 'InvalidTokenOrVerified'; + +export class CustomNewVerificationEmailError extends Error { + constructor(public type: CustomNewVerificationEmailErrorType) { + super(); + this.name = 'CustomNewVerificationEmailError'; + } +} + +type CustomSettingsErrorType = + | 'Unauthorized' + | 'InvalidFields' + | 'IncorrectPassword' + | 'SamePassword' + | 'NoChangesToBeMade' + | 'PasswordNeedUpdate'; + +export class CustomSettingsError extends Error { + constructor(public type: CustomSettingsErrorType) { + super(); + this.name = 'CustomSettingsError'; + } +} + +type CustomRegisterCredentialsUserErrorType = 'InvalidFields' | 'IpValidation' | 'AccountLimit' | 'EmailExists'; + +export class CustomRegisterCredentialsUserError extends Error { + constructor(public type: CustomRegisterCredentialsUserErrorType) { + super(); + this.name = 'CustomRegisterCredentialsUserError'; + } +} + +type CustomMagicLinkErrorType = 'IpLimit' | 'IpInvalid' | 'TokenExists' | 'InvalidEmail' | 'NoUserExists'; + +export class CustomMagicLinkError extends AuthError { + constructor(public errorType: CustomMagicLinkErrorType) { + super(); + this.name = 'CustomMagicLinkError'; + } +} diff --git a/lib/constants/messages/actions/messages.ts b/lib/constants/messages/actions/messages.ts new file mode 100644 index 0000000..36158fe --- /dev/null +++ b/lib/constants/messages/actions/messages.ts @@ -0,0 +1,115 @@ +export const messages = { + generic: { + errors: { + GENERIC_ERROR: 'Something went wrong!', + DB_CONNECTION_ERROR: 'Unable to connect to the database. Please try again later.', + INVALID_FIELDS: 'Invalid fields', + UNEXPECTED_ERROR: 'An unexpected error occurred. Try again!', + NASTY_WEIRD_ERROR: 'Something weird went wrong', // wtf just happened + UNKNOWN_ERROR: 'Unknown Error. Try Again!', + }, + }, + admin: { + success: { + ALLOWED_SA: 'Allowed Server Action!', + }, + errors: { + FORBIDDEN_SA: 'Forbidden Server Action!', + }, + }, + register: { + errors: { + EMAIL_EXISTS: 'Email already registered!', + ACCOUNT_LIMIT: 'You are not allowed to register more accounts on this app preview', + IP_VALIDATION_FAILED: 'Sorry! Something went wrong. Could not identify you as a human', + }, + success: { + REGISTRATION_COMPLETE: 'Success! Check your inbox to verify your account', + ACC_CREATED_EMAIL_SEND_FAILED: 'Account created but Failed to send your email for email verification.', + }, + }, + new_verification_email: { + success: { + EMAIL_VERIFIED: 'Email verified successfully! You can now login', + }, + errors: { + EMAIL_NOT_FOUND: 'Error - try again', + EMAIL_ALREADY_VERIFIED: 'Your email is already verified', + INVALID_TOKEN: 'Error - Can not complete verification', + TOKEN_EXPIRED_FAILED_SEND_EMAIL: 'Expired', + TOKEN_EXPIRED_SENT_NEW: 'Expired - Check your inbox for a new link to confirm your email', + INVALID_TOKEN_OR_VERIFIED: 'Invalid request or email already verified', + }, + }, + reset_password: { + success: { + PASSWORD_RESET_EMAIL_SENT: 'Reset email sent!', + }, + errors: { + INVALID_EMAIL: 'Invalid email!', + EMAIL_NOT_FOUND: 'Invalid email!', + OAUTH_USER_ONLY: 'Email registered with a provider! Login with your Email Provider!', + TOKEN_STILL_VALID: 'Reset password email already sent! Check your inbox!', + SEND_EMAIL_ERROR: 'Error - Could not send you a email to reset your password', + }, + }, + new_password: { + success: { + UPDATE_SUCCESSFUL: 'Password updated successfully', + }, + errors: { + REQUEST_NEW_PASSWORD_RESET: 'Expired! Please request a new Password Reset!', + INVALID_PASSWORD: 'Invalid password format', + INVALID_TOKEN: 'Error - Please request a new Password Reset!', + }, + }, + login: { + errors: { + WRONG_CREDENTIALS: 'Invalid credentials', + CONFIRMATION_EMAIL_ALREADY_SENT: 'Confirmation email already sent! Check your inbox!', + NEW_CONFIRMATION_EMAIL_SENT: 'Sent new confirmation email! Check your inbox!', + INVALID_FIELDS: 'Invalid fields!', + RESEND_EMAIL_ERROR: 'Something went wrong while sending your email! Try again!', + TWO_FACTOR_TOKEN_NOT_EXISTS: 'Two-factor authentication code required', + TWO_FACTOR_CODE_INVALID: 'Invalid authentication code', + GENERIC_ERROR: 'Something went wrong!', + AUTH_ERROR: 'An authentication error occurred', + ASK_USER_RESET_PASSWORD: 'You need to reset your password. Please use the password reset option.', + }, + success: { + LOGIN_COMPLETE: 'Successfully logged in', + }, + }, + settings: { + success: { + SETTINGS_UPDATED: 'Settings updated!', + VERIFICATION_EMAIL_SENT: 'Verification email sent!', + }, + errors: { + UNAUTHORIZED: 'Unauthorized!', + INVALID_FIELDS: 'Invalid fields!', + EMAIL_IN_USE: 'Email already in use!', + VERIFICATION_EMAIL_ALREADY_SENT: 'Verification email already sent! Confirm your inbox!', + EMAIL_CHANGE_REQUEST_EXISTS: + 'You have already requested to change your email! You need to wait 1hour to change again', + INCORRECT_PASSWORD: 'Incorrect Password!', + SAME_PASSWORD: 'Your new password is equal to your old password', + NO_CHANGES_REQUIRED: 'No changes required! Your settings are already perfect? ☜(ˆ▽ˆ)', + PASSWORD_NEEDS_UPDATE: 'You are currently in need of a password reset. Please proceed, and do a password reset.', + }, + }, + magicLink: { + errors: { + IP_LIMIT: 'Too many attempts. Please try again later.', + GENERIC_FAILED: 'Failed to send your link. Try again later!', + GENERIC_AUTHERROR: 'Failed to send your link. Try again later!', + GENERIC_CUSTOMMAGICLINKERROR: 'Failed to send your link. Try again later!', + INVALID_EMAIL: 'Invalid email address!', + INVALID_IP: 'Can not process more requests! Try again later!', + EMAIL_ALREADY_SENT: 'Email already sent! Check your inbox!', + }, + success: { + SENT: 'Magic link sent! Click the link send to your email.', + }, + }, +} as const; diff --git a/lib/crypto/hash-edge-compatible.ts b/lib/crypto/hash-edge-compatible.ts new file mode 100644 index 0000000..baac2f1 --- /dev/null +++ b/lib/crypto/hash-edge-compatible.ts @@ -0,0 +1,121 @@ +// This is an example with web crypto api +// The hashIp cryptography is not viable for passwords +export { hashIp, hashPassword, verifyPassword }; + +const CURRENT_VERSION = 'v1'; +const ITERATIONS = 310000; +const HASH_LENGTH = 256; // bits +const SALT_LENGTH = 16; // bytes + +/** + * Creates a deterministic SHA-256 hash of an IP address + * Using Web Crypto API compatible with edge + * Used for storing IPs in the database + */ +const hashIp = async (ipAddress: string): Promise => { + const encoder = new TextEncoder(); + const data = encoder.encode(ipAddress); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return bufferToHex(hashBuffer); +}; + +/** + * Hashes a password using PBKDF2 + * Compatible with edge + * @param password - The password to hash + * @returns A versioned, salted hash string + */ +const hashPassword = async (password: string): Promise => { + const salt = generateSalt(); + const hash = await generateHash(password, salt); + return formatHash(salt, hash); +}; + +/** + * Verifies a password against a stored hash and checks if the hash needs upgrading + * @param password - The password to verify + * @param storedHash - The stored hash to verify against + * @returns An object containing: + * - isPasswordValid: boolean - True if password matches + * - passwordNeedsUpdate: boolean - True if the hash should be upgraded to current version + */ +const verifyPassword = async ( + password: string, + storedHash: string +): Promise<{ + isPasswordValid: boolean; + passwordNeedsUpdate: boolean; +}> => { + if (storedHash.startsWith('$2')) { + return { + isPasswordValid: false, + passwordNeedsUpdate: true, + }; + } + try { + const { version, salt, hash } = parseHash(storedHash); + const newHash = await generateHash(password, salt); + return { + isPasswordValid: newHash === hash, + passwordNeedsUpdate: version !== CURRENT_VERSION, + }; + } catch (error) { + // likely an invalid or corrupted hash + return { isPasswordValid: false, passwordNeedsUpdate: true }; + } +}; + +// Helper functions (not exported) +function generateSalt(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); +} + +async function generateHash(password: string, salt: Uint8Array): Promise { + const encoder = new TextEncoder(); + const passwordData = encoder.encode(password); + const keyMaterial = await crypto.subtle.importKey('raw', passwordData, 'PBKDF2', false, ['deriveBits']); + const hashBuffer = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt, + iterations: ITERATIONS, + hash: 'SHA-256', + }, + keyMaterial, + HASH_LENGTH + ); + return bufferToHex(hashBuffer); +} + +function formatHash(salt: Uint8Array, hash: string): string { + const saltHex = bufferToHex(salt); + return `${CURRENT_VERSION}.${saltHex}.${hash}`; +} + +/** + * Parses a stored hash string into its components + * @throws Error if the hash format is invalid + */ +function parseHash(storedHash: string): { + version: string; + salt: Uint8Array; + hash: string; +} { + const [version, saltHex, hash] = storedHash.split('.'); + if (!version || !saltHex || !hash) { + throw new Error('Invalid hash format'); + } + const salt = hexToBuffer(saltHex); + return { version, salt, hash }; +} + +function bufferToHex(buffer: ArrayBuffer | Uint8Array): string { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +function hexToBuffer(hex: string): Uint8Array { + const pairs = hex.match(/.{1,2}/g) || []; + return new Uint8Array(pairs.map((byte) => parseInt(byte, 16))); +} diff --git a/lib/db.ts b/lib/db.ts index 214a962..f53a523 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,10 +1,7 @@ import { PrismaClient } from '@prisma/client'; -declare global { - var prisma: PrismaClient | undefined; -} - -export const db = globalThis.prisma || new PrismaClient(); +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; +export const db = globalForPrisma.prisma || new PrismaClient(); // For prisma to not be affected by hot reload from nextjs -if (process.env.NODE_ENV !== 'production') globalThis.prisma = db; +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db; diff --git a/lib/mail.ts b/lib/mail/mail.ts similarity index 90% rename from lib/mail.ts rename to lib/mail/mail.ts index bb1adae..53fc760 100644 --- a/lib/mail.ts +++ b/lib/mail/mail.ts @@ -1,4 +1,4 @@ -import { Resend } from 'resend'; +import { CreateEmailResponse, Resend } from 'resend'; const resend = new Resend(process.env.RESEND_API_KEY); const domain = process.env.NEXT_PUBLIC_APP_URL; @@ -12,7 +12,7 @@ export const sendTwoFactorTokenEmail = async (email: string, token: string) => {

This code will expire in 1h. Please enter it promptly.

`; - await resend.emails.send({ + return await resend.emails.send({ from: 'noreply@fpresa.org', to: email, subject: 'Next-auth Example || 2FA Code', @@ -20,7 +20,7 @@ export const sendTwoFactorTokenEmail = async (email: string, token: string) => { }); }; -export const sendPasswordResetEmail = async (email: string, token: string) => { +export const sendPasswordResetEmail = async (email: string, token: string): Promise => { const resetLink = `${domain}/new-password?token=${token}`; const htmlContent = `
@@ -31,7 +31,7 @@ export const sendPasswordResetEmail = async (email: string, token: string) => {

If you did not request a password reset, please ignore this email.

`; - await resend.emails.send({ + return await resend.emails.send({ from: 'noreply@fpresa.org', to: email, subject: 'Next-auth Example || Reset your password', @@ -39,7 +39,7 @@ export const sendPasswordResetEmail = async (email: string, token: string) => { }); }; -export const sendVerificationEmail = async (email: string, token: string) => { +export const sendVerificationEmail = async (email: string, token: string): Promise => { const confirmLink = `${domain}/new-verification?token=${token}`; const htmlContent = `
@@ -50,7 +50,7 @@ export const sendVerificationEmail = async (email: string, token: string) => {

If you did not request this verification, please ignore this email.

`; - await resend.emails.send({ + return await resend.emails.send({ from: 'noreply@fpresa.org', to: email, subject: 'Next-auth Example || Please confirm your email', diff --git a/lib/nextjs/headers.ts b/lib/nextjs/headers.ts new file mode 100644 index 0000000..cb6c57e --- /dev/null +++ b/lib/nextjs/headers.ts @@ -0,0 +1,12 @@ +import { headers } from 'next/headers'; + +import { hashIp } from '@/lib/crypto/hash-edge-compatible'; + +/** + * Retrieves and hashes the user IP from Next.js request headers + * @returns Promise resolving to hashedIp or null if IP is not present + */ +export const getHashedUserIpFromHeaders = async (): Promise => { + const userIp = headers().get('request-ip'); + return userIp ? await hashIp(userIp) : null; +}; diff --git a/lib/tokens.ts b/lib/tokens.ts deleted file mode 100644 index 7e97082..0000000 --- a/lib/tokens.ts +++ /dev/null @@ -1,80 +0,0 @@ -import crypto from 'crypto'; - -import { v4 as uuidv4 } from 'uuid'; - -import { getTwoFactorTokenByEmail } from '@/data/two-factor-token'; -import { getVerificationTokenByEmail } from '@/data/verification-token'; -import { db } from '@/lib/db'; -import { getPasswordResetTokenByEmail } from '@/data/password-reset-token'; - -export const generateTwoFactorToken = async (email: string, userId: string) => { - const token = crypto.randomInt(100_000, 1_000_000).toString(); - const expires = new Date(new Date().getTime() + 3600 * 1000); // 1hr - - const existingToken = await getTwoFactorTokenByEmail(email); - if (existingToken) { - await db.twoFactorToken.delete({ - where: { id: existingToken.id }, - }); - } - - const twoFactorToken = await db.twoFactorToken.create({ - data: { - email, - token, - expires, - userId, - }, - }); - - return twoFactorToken; -}; - -export const generatePasswordResetToken = async (email: string, userId: string) => { - const token = uuidv4(); - const expires = new Date(new Date().getTime() + 3600 * 1000); // 1hr - - const existingToken = await getPasswordResetTokenByEmail(email); - - if (existingToken) { - await db.passwordResetToken.delete({ - where: { id: existingToken.id }, - }); - } - - const passwordResetToken = await db.passwordResetToken.create({ - data: { - email, - token, - expires, - userId, - }, - }); - - return passwordResetToken; -}; - -export const generateVerificationToken = async (email: string, userId: string, request_email_change_by?: string) => { - const token = uuidv4(); - const expires = new Date(new Date().getTime() + 3600 * 1000); // 1hr - - const existingToken = await getVerificationTokenByEmail(email); - - if (existingToken) { - await db.verificationToken.delete({ - where: { id: existingToken.id }, - }); - } - - const verificationToken = await db.verificationToken.create({ - data: { - email, - token, - expires, - userId, - request_email_change_by, - }, - }); - - return verificationToken; -}; diff --git a/middleware.ts b/middleware.ts index 8420df4..b36928a 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,8 +1,8 @@ -import NextAuth from 'next-auth'; import { NextResponse } from 'next/server'; +import NextAuth from 'next-auth'; -import { authRoutes, DEFAULT_LOGIN_REDIRECT, publicRoutes } from '@/routes'; import authConfig from '@/auth.config'; +import { authRoutes, DEFAULT_LOGIN_REDIRECT, publicRoutes } from '@/routes'; const { auth } = NextAuth(authConfig); diff --git a/next-auth.d.ts b/next-auth.d.ts deleted file mode 100644 index 783ac85..0000000 --- a/next-auth.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { UserRole } from '@prisma/client'; -import NextAuth, { type DefaultSession } from 'next-auth'; - -export type ExtendedUser = DefaultSession['user'] & { - role: UserRole; - isTwoFactorEnabled: boolean; - isOauth: boolean; -}; - -declare module 'next-auth' { - interface Session { - user: ExtendedUser; - } -} diff --git a/next.config.mjs b/next.config.mjs index 0cc443f..c89a22c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,10 +1,9 @@ -/** @type {import('next').NextConfig} */ const cspHeader = ` default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self'; style-src 'self' 'unsafe-inline'; - img-src 'self'; + img-src 'self' data: https://*.googleusercontent.com; font-src 'self'; object-src 'none'; base-uri 'self'; @@ -19,6 +18,7 @@ const cspHeader = ` .replace(/\s{2,}/g, ' ') // Collapse multiple spaces into one. .trim(); +/** @type {import('next').NextConfig} */ const nextConfig = { async headers() { return [ diff --git a/package-lock.json b/package-lock.json index add419f..673ab49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,6 @@ "@radix-ui/react-switch": "1.0.3", "@upstash/ratelimit": "2.0.3", "@vercel/kv": "3.0.0", - "bcryptjs": "2.4.3", "class-variance-authority": "0.7.0", "clsx": "2.1.1", "dotenv": "^16.4.5", @@ -64,6 +63,7 @@ "prettier-plugin-tailwindcss": "0.5.13", "prisma": "5.20.0", "tailwindcss": "3.4.13", + "ts-node": "^10.9.2", "typescript": "5.6.2" } }, @@ -139,6 +139,28 @@ "node": ">=6.9.0" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -1448,6 +1470,30 @@ "tslib": "^2.4.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "devOptional": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true + }, "node_modules/@types/bcrypt": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", @@ -1478,7 +1524,7 @@ "version": "22.7.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz", "integrity": "sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==", - "dev": true, + "devOptional": true, "dependencies": { "undici-types": "~6.19.2" } @@ -1697,7 +1743,7 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "dev": true, + "devOptional": true, "bin": { "acorn": "bin/acorn" }, @@ -1714,6 +1760,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "devOptional": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2087,11 +2145,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, - "node_modules/bcryptjs": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2360,6 +2413,12 @@ "node": ">= 0.6" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -2545,6 +2604,15 @@ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "devOptional": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -4516,6 +4584,12 @@ "node": "14 || >=16.14" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "devOptional": true + }, "node_modules/md5": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", @@ -6331,6 +6405,55 @@ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -6449,7 +6572,7 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6477,7 +6600,7 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true + "devOptional": true }, "node_modules/update-browserslist-db": { "version": "1.1.1", @@ -6576,6 +6699,12 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6778,6 +6907,15 @@ "node": ">= 14" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 3765a83..584e30d 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "@radix-ui/react-switch": "1.0.3", "@upstash/ratelimit": "2.0.3", "@vercel/kv": "3.0.0", - "bcryptjs": "2.4.3", "class-variance-authority": "0.7.0", "clsx": "2.1.1", "dotenv": "^16.4.5", @@ -74,6 +73,7 @@ "prettier-plugin-tailwindcss": "0.5.13", "prisma": "5.20.0", "tailwindcss": "3.4.13", + "ts-node": "^10.9.2", "typescript": "5.6.2" } } diff --git a/playwright.config.ts b/playwright.config.ts index 7560c57..c3c5e72 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -71,10 +71,10 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - { + /*{ name: 'firefox', use: { ...devices['Desktop Firefox'] }, - }, + },*/ /* { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ba85538..1d090aa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,4 +1,3 @@ -// prisma/schema.prisma datasource db { provider = "postgresql" url = env("DATABASE_URL") @@ -16,60 +15,96 @@ enum UserRole { model User { id String @id @default(cuid()) name String? - email String? @unique + email String @unique emailVerified DateTime? image String? password String? role UserRole @default(USER) accounts Account[] + sessions Session[] + Authenticator Authenticator[] isTwoFactorEnabled Boolean @default(false) twoFactorConfirmation TwoFactorConfirmation? ip String? passwordResetTokens PasswordResetToken[] - verificationTokens VerificationToken[] + customVerificationTokens CustomVerificationToken[] twoFactorTokens TwoFactorToken[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @default(now()) } model Account { - id String @id @default(cuid()) userId String type String provider String providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text + refresh_token String? + access_token String? expires_at Int? token_type String? scope String? - id_token String? @db.Text + id_token String? session_state String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@id([provider, providerAccountId]) +} + +model Session { + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@unique([provider, providerAccountId]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } -model VerificationToken { +model CustomVerificationToken { id String @id @default(cuid()) email String token String @unique expires DateTime - request_email_change_by String? userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([email, token]) } +model VerificationToken { + identifier String + token String + expires DateTime + hashedIp String + + @@id([identifier, token]) +} + +// WebAuthn support maybe later +model Authenticator { + credentialID String @unique + userId String + providerAccountId String + credentialPublicKey String + counter Int + credentialDeviceType String + credentialBackedUp Boolean + transports String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([userId, credentialID]) +} + model PasswordResetToken { id String @id @default(cuid()) - email String + email String @unique token String @unique expires DateTime userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([email, token]) } model TwoFactorToken { diff --git a/routes.ts b/routes.ts index b444241..95b9b58 100644 --- a/routes.ts +++ b/routes.ts @@ -5,11 +5,19 @@ export const publicRoutes = ['/new-verification']; /** - * These routes are used for authentication + * These routes are used for authentication, * redirect logged-in users to /settings * @type {string[]} * */ -export const authRoutes = ['/', '/login', '/register', '/loginerror', '/reset-password', '/new-password']; +export const authRoutes = [ + '/', + '/login', + '/register', + '/loginerror', + '/reset-password', + '/new-password', + '/login/magic-link', +]; /** * The prefix for API authentication routes @@ -24,3 +32,9 @@ export const apiAuthPrefix = '/api/auth'; * @type {string} * */ export const DEFAULT_LOGIN_REDIRECT = '/settings'; + +/** + * Default Allowed Redirects from callbackUrl searchParams + * @type {string} + * */ +export const ALLOWED_REDIRECTS = ['/server', '/admin', '/client', '/settings']; diff --git a/schemas/index.tsx b/schemas/index.tsx index 5cc939a..aeccfc3 100644 --- a/schemas/index.tsx +++ b/schemas/index.tsx @@ -1,64 +1,120 @@ import { UserRole } from '@prisma/client'; import * as zod from 'zod'; +import { ALLOWED_REDIRECTS } from '@/routes'; + +const baseEmailSchema = zod + .string() + .min(1, { message: 'Email cannot be empty' }) + .trim() + .toLowerCase() + .email({ message: 'Invalid email address' }) + .max(62, { message: 'Email is too long' }); // can go to 255 db limit? but, no + +const basePasswordSchema = zod + .string() + .min(1, { message: 'Password cannot be empty' }) + .trim() + .min(6, { message: 'Password must be at least 6 characters' }) + .max(62, { message: 'Password is too long' }) // can go to 256 with crypto web api but, no + .regex(/\S/, { message: 'Password cannot be only whitespaces' }) + .transform((value) => value.trim()); + +const baseNameSchema = zod + .string() + .min(1, { message: 'Empty' }) + .max(62, { message: 'Name is too long' }) + .trim() + .regex(/\S/, { message: 'Name cannot be only whitespaces' }) // remove all whitespaces or tabs + .transform((value) => value.trim().replace(/\s+/g, ' ')); // remove double spaces between names + +export const MagicLinkSchema = zod.object({ + email: baseEmailSchema, +}); + export const LoginSchema = zod.object({ - email: zod.string().email({ - message: 'Email is required', - }), - password: zod.string().min(1, { message: 'Password is required' }), - code: zod.optional(zod.string()), + email: baseEmailSchema, + password: basePasswordSchema, + twoFactorCode: zod + .string() + .trim() + .regex(/^\d{6}$/, { message: 'Code must be 6 digits' }) + .optional(), }); +export const CallbackUrlSchema = zod + .string() + .nullish() + .transform((url) => { + if (!url) return null; + + try { + const decodedUrl = decodeURIComponent(url); + return ALLOWED_REDIRECTS.includes(decodedUrl) ? decodedUrl : null; + } catch { + return null; + } + }); + export const RegisterSchema = zod.object({ - email: zod.string().email({ - message: 'Email is required', - }), - password: zod.string().min(6, { message: 'Password is required' }), - name: zod.string().min(1, { message: 'Name is required' }), + email: baseEmailSchema, + password: basePasswordSchema, + name: baseNameSchema, }); export const ResetPasswordSchema = zod.object({ - email: zod.string().email({ - message: 'Email is required', - }), + email: baseEmailSchema, }); export const NewPasswordSchema = zod.object({ - password: zod.string().min(6, { message: 'Minimum of 6 characters required' }), + password: basePasswordSchema, }); +export const PasswordResetTokenSchema = zod.string().uuid().nullish(); + +export const NewVerificationEmailTokenSchema = zod.string().uuid(); + export const SettingsSchema = zod .object({ - name: zod.optional(zod.string().min(1)), + name: baseNameSchema.optional(), isTwoFactorEnabled: zod.optional(zod.boolean()), role: zod.enum([UserRole.ADMIN, UserRole.USER]), - email: zod.optional(zod.string().email()), - password: zod.optional(zod.string().min(6)), - newPassword: zod.optional(zod.string().min(6)), + email: baseEmailSchema.optional(), + password: basePasswordSchema.optional(), + newPassword: basePasswordSchema.optional(), + }) + .refine((data) => !data.password || data.newPassword, { + message: 'New password is required when changing password', + path: ['newPassword'], + }) + .refine((data) => !data.newPassword || data.password, { + message: 'Current password is required when setting new password', + path: ['password'], }) .refine( (data) => { - if (data.password && !data.newPassword) { - return false; + if (data.password && data.newPassword) { + return data.password.trim() !== data.newPassword.trim(); } - return true; }, { - message: 'New Password is required!', + message: 'New password must be different from current password', path: ['newPassword'], } - ) - .refine( - (data) => { - if (data.newPassword && !data.password) { - return false; - } - - return true; - }, - { - message: 'Password is required!', - path: ['password'], - } ); + +export const VerifiedCredentialsUserSchema = zod.object({ + id: zod.string().min(1), + email: zod.string().email(), + name: zod.string().nullable().optional(), + role: zod.nativeEnum(UserRole), + isTwoFactorEnabled: zod.boolean(), + emailVerified: zod + .string() + .nullable() + .optional() + .transform((str) => (str ? new Date(str) : null)), + image: zod.string().nullable().optional(), + isOauth: zod.boolean(), +}); From 3fe6268e076f1bb77ab87227d6d031d1e83d84f6 Mon Sep 17 00:00:00 2001 From: Presa Date: Sun, 24 Nov 2024 07:06:58 +0100 Subject: [PATCH 2/2] firefox tests --- playwright.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index c3c5e72..7560c57 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -71,10 +71,10 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - /*{ + { name: 'firefox', use: { ...devices['Desktop Firefox'] }, - },*/ + }, /* {