Skip to content

Conversation

@Divkix
Copy link

@Divkix Divkix commented Dec 19, 2025

Summary

This PR adds complete email-password authentication alongside the existing OAuth (Google, GitHub) and magic link providers, making useSend a more complete Resend self-hosted clone.

Features

  • Email/Password Signup - Users can create accounts with email and password
  • Email/Password Login - Toggle between password and magic link modes on login page
  • Password Reset Flow - Complete forgot password → email → reset password flow
  • Account Settings - New /settings/account page to change/set password and view linked OAuth providers
  • OAuth + Password Linking - OAuth-only users can add passwords to their accounts
  • Rate Limiting - Security protection for login (5 attempts/min) and password reset (3 requests/hour)
  • Strong Password Validation - Minimum 8 characters with uppercase, lowercase, and number requirements

Technical Changes

Database

  • Added passwordHash field to User model (nullable for OAuth-only users)
  • Added PasswordResetToken model for secure password reset flow
  • Migration: 20251219093000_add_password_auth

Authentication

  • Switched from database sessions to JWT sessions (required for CredentialsProvider)
  • Added NextAuth CredentialsProvider for email/password login
  • Updated session callbacks for JWT token handling

New Files

File Purpose
src/server/password-utils.ts Zod schemas for password validation
src/app/api/auth/signup/route.ts Signup API endpoint
src/app/api/auth/forgot-password/route.ts Forgot password API
src/app/api/auth/reset-password/route.ts Reset password API
src/app/signup/signup-page.tsx Password signup form
src/app/forgot-password/page.tsx Forgot password page
src/app/reset-password/page.tsx Reset password page
src/server/email-templates/PasswordResetEmail.tsx Password reset email template
src/server/api/routers/user.ts tRPC router for password management
src/app/(dashboard)/settings/account/page.tsx Account settings page

Modified Files

File Changes
prisma/schema.prisma Added passwordHash, PasswordResetToken
src/server/auth.ts JWT sessions, CredentialsProvider
src/app/login/login-page.tsx Password/Magic Link toggle UI
src/server/mailer.ts Password reset email function
src/app/api/auth/[...nextauth]/route.ts Rate limiting for credentials

Security Considerations

  • Passwords hashed using scrypt (existing createSecureHash utility)
  • Rate limiting prevents brute force attacks
  • Password reset always returns success to prevent email enumeration
  • Reset tokens expire after 1 hour
  • Strong password requirements enforced via Zod validation

Breaking Changes

  • Session Strategy: Migrated from database sessions to JWT. Existing logged-in users will need to re-login once after deployment. All user data is preserved.

Test plan

  • New user signup with email/password
  • Login with email/password
  • Login with OAuth (existing flow still works)
  • Toggle between Password and Magic Link modes
  • Forgot password → receive email → reset password
  • OAuth user adds password in settings
  • Change password (existing password user)
  • Rate limiting triggers after 5 failed logins
  • Weak password rejection
  • Existing OAuth users can still login after JWT migration

Summary by cubic

Adds full email/password authentication alongside OAuth and magic link, including signup, login, and password reset. Switches NextAuth to JWT sessions and hardens security with rate limits and timing-safe password checks.

  • New Features

    • Email/password auth: signup, password login toggle, forgot → email → reset flow
    • Account settings: set/change password and view linked OAuth; OAuth-only users can add a password
    • Security: rate limiting (5 login attempts/min, 3 reset requests/hour), timing-safe credential verification, transactional password resets, and strong password rules
    • Auth internals: NextAuth CredentialsProvider with JWT session strategy
  • Migration

    • Run database migrations (adds passwordHash and PasswordResetToken)
    • Users will be signed out once; they must log in again after the JWT session change

Written for commit 48e480c. Summary will update automatically on new commits.

Summary by CodeRabbit

  • New Features
    • Password-based signup and credential login added alongside OAuth and magic-link
    • Password reset flow with time-limited email links and email template
    • Account Settings page: view profile, linked providers, change/set password
    • Client pages for Forgot Password and Reset Password flows
    • Server APIs and utilities for password validation, password management, and sending reset emails
    • Rate limiting applied to login and reset endpoints for security

✏️ Tip: You can customize this high-level summary in your review settings.

- Add passwordHash field to User model and PasswordResetToken table
- Configure NextAuth with JWT sessions and CredentialsProvider
- Create password validation utilities with Zod schemas
- Add signup API route with OAuth user password linking support
- Update login page with password/magic link toggle
- Create dedicated signup page with password registration
- Implement complete password reset flow:
  - Forgot password API and page
  - Reset password API and page
  - Password reset email template
- Add sendPasswordResetEmail to mailer

This enables users to sign up and login with email/password
alongside existing OAuth (Google, GitHub) authentication.
- Create user tRPC router with password management procedures:
  - hasPassword: check if user has password set
  - getLinkedAccounts: list OAuth providers linked
  - changePassword: change existing password
  - setPassword: add password for OAuth-only users
  - getProfile: get user profile info
- Create account settings page at /settings/account:
  - Profile info display
  - Linked OAuth accounts section
  - Change/set password forms
- Add Account tab to settings navigation
- Add rate limiting for credentials login (5 attempts/minute)
- Add rate limiting for password reset (3 requests/hour)
- Use existing Redis and IP detection patterns
Copilot AI review requested due to automatic review settings December 19, 2025 04:09
@vercel
Copy link

vercel bot commented Dec 19, 2025

@Divkix is attempting to deploy a commit to the kmkoushik's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 19, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds password-based authentication: DB changes (User.passwordHash, PasswordResetToken table and indexes); NextAuth credentials provider with JWT sessions; credential signin rate limiting; TRPC user router (hasPassword, getLinkedAccounts, changePassword, setPassword, getProfile); API routes for signup, forgot-password, reset-password; client pages for login, signup, forgot-password, reset-password, and account settings (password forms); password validation utilities; password-reset email template and mailer function.

Possibly related PRs

Suggested labels

codex

Suggested reviewers

  • KMKoushik

Pre-merge checks

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.03% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Add email-password authentication' directly and accurately summarizes the main change—introducing email/password authentication to the system, which is the primary focus of all file modifications and new features.

📜 Recent review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4901e30 and 48e480c.

📒 Files selected for processing (6)
  • apps/web/src/app/api/auth/reset-password/route.ts (1 hunks)
  • apps/web/src/app/api/auth/signup/route.ts (1 hunks)
  • apps/web/src/app/forgot-password/page.tsx (1 hunks)
  • apps/web/src/app/login/login-page.tsx (6 hunks)
  • apps/web/src/app/reset-password/page.tsx (1 hunks)
  • apps/web/src/server/auth.ts (3 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{tsx,ts,jsx,js}

📄 CodeRabbit inference engine (.cursor/rules/general.mdc)

Include all required imports and ensure proper naming of key components in React/NextJS code

Files:

  • apps/web/src/app/login/login-page.tsx
  • apps/web/src/app/forgot-password/page.tsx
  • apps/web/src/app/reset-password/page.tsx
  • apps/web/src/app/api/auth/reset-password/route.ts
  • apps/web/src/server/auth.ts
  • apps/web/src/app/api/auth/signup/route.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs

Files:

  • apps/web/src/app/login/login-page.tsx
  • apps/web/src/app/forgot-password/page.tsx
  • apps/web/src/app/reset-password/page.tsx
  • apps/web/src/app/api/auth/reset-password/route.ts
  • apps/web/src/server/auth.ts
  • apps/web/src/app/api/auth/signup/route.ts
**/*.{tsx,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

React components must use PascalCase naming convention (e.g., AppSideBar.tsx)

Files:

  • apps/web/src/app/login/login-page.tsx
  • apps/web/src/app/forgot-password/page.tsx
  • apps/web/src/app/reset-password/page.tsx
apps/web/**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use alias ~/ for src imports in apps/web (e.g., import { x } from "~/utils/x")

Files:

  • apps/web/src/app/login/login-page.tsx
  • apps/web/src/app/forgot-password/page.tsx
  • apps/web/src/app/reset-password/page.tsx
  • apps/web/src/app/api/auth/reset-password/route.ts
  • apps/web/src/server/auth.ts
  • apps/web/src/app/api/auth/signup/route.ts
apps/web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web

Files:

  • apps/web/src/app/login/login-page.tsx
  • apps/web/src/app/forgot-password/page.tsx
  • apps/web/src/app/reset-password/page.tsx
  • apps/web/src/app/api/auth/reset-password/route.ts
  • apps/web/src/server/auth.ts
  • apps/web/src/app/api/auth/signup/route.ts
**/*.{ts,tsx,md}

📄 CodeRabbit inference engine (AGENTS.md)

Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files

Files:

  • apps/web/src/app/login/login-page.tsx
  • apps/web/src/app/forgot-password/page.tsx
  • apps/web/src/app/reset-password/page.tsx
  • apps/web/src/app/api/auth/reset-password/route.ts
  • apps/web/src/server/auth.ts
  • apps/web/src/app/api/auth/signup/route.ts
🧬 Code graph analysis (4)
apps/web/src/app/login/login-page.tsx (4)
apps/web/src/server/password-utils.ts (1)
  • passwordLoginSchema (40-43)
packages/ui/src/button.tsx (1)
  • Button (80-80)
packages/ui/src/form.tsx (6)
  • Form (170-170)
  • FormField (176-176)
  • FormItem (171-171)
  • FormControl (173-173)
  • FormMessage (175-175)
  • FormDescription (174-174)
packages/ui/src/input.tsx (1)
  • Input (25-25)
apps/web/src/app/forgot-password/page.tsx (4)
apps/web/src/server/public-api/schemas/email-schema.ts (1)
  • emailSchema (6-44)
packages/ui/src/button.tsx (1)
  • Button (80-80)
packages/ui/src/form.tsx (5)
  • Form (170-170)
  • FormField (176-176)
  • FormItem (171-171)
  • FormControl (173-173)
  • FormMessage (175-175)
packages/ui/src/input.tsx (1)
  • Input (25-25)
apps/web/src/app/api/auth/reset-password/route.ts (5)
apps/web/src/app/api/auth/signup/route.ts (1)
  • POST (7-74)
apps/web/src/app/api/auth/forgot-password/route.ts (1)
  • POST (53-119)
apps/web/src/server/password-utils.ts (1)
  • resetPasswordSchema (63-66)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/crypto.ts (1)
  • createSecureHash (3-10)
apps/web/src/server/auth.ts (3)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/crypto.ts (1)
  • verifySecureHash (12-19)
apps/web/src/env.js (2)
  • env (5-145)
  • env (5-145)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: cubic · AI code reviewer
🔇 Additional comments (12)
apps/web/src/app/api/auth/signup/route.ts (6)

1-6: LGTM: Imports follow project conventions.

All imports are correctly structured using the ~/ alias for source imports as required by the coding guidelines.


7-17: LGTM: Proper validation with Zod.

The validation logic correctly uses safeParse to handle invalid input gracefully and returns appropriate error responses.


19-32: LGTM: Proper email normalization and existence check.

The email normalization (lowercase + trim) prevents duplicate accounts with case variations, and the existence check provides clear user feedback. The code path for adding passwords to existing OAuth accounts (mentioned in past reviews) has been removed, resolving those security concerns.

Note: While the error message at line 29 technically enables email enumeration, this is generally acceptable for signup endpoints as it helps users understand why signup failed.


34-51: LGTM: Secure password hashing and logical beta/waitlist determination.

The password hashing uses createSecureHash (scrypt-based per PR summary), and the beta/waitlist logic correctly handles different environment configurations and team invite scenarios.


63-66: LGTM: Clear success response.

The response provides appropriate feedback to guide the user to the next step (signing in).


67-73: LGTM: Proper error handling.

The error handling logs details for debugging while returning a generic message to the user, avoiding exposure of internal implementation details.

apps/web/src/app/login/login-page.tsx (3)

76-81: LGTM! State and URL handling is well-structured.

The state management for auth mode toggling and error handling is clean. The SSR-safe window check for callbackUrl is appropriate for a client component.


91-141: LGTM! Password authentication flow is well-implemented.

The password form setup, submission handler, and error handling follow best practices. The loading state prevents duplicate submissions, and the invite-aware redirect logic ensures users land in the correct location.


242-303: LGTM! Password form UI is well-structured and accessible.

The form implementation follows React Hook Form patterns correctly, includes proper form validation, shows loading states, and provides a good user experience with the "Forgot password?" link.

apps/web/src/app/reset-password/page.tsx (1)

32-216: LGTM! Reset password form is well-implemented.

The component correctly handles all states (idle, sending, success, error), validates the token presence, provides clear user feedback, and integrates cleanly with the API endpoint.

apps/web/src/app/forgot-password/page.tsx (1)

35-49: Excellent security practice: preventing email enumeration.

Always returning success (lines 43-47) regardless of whether the user exists prevents attackers from enumerating valid email addresses. This aligns with the security-conscious approach seen throughout the password reset flow.

apps/web/src/server/auth.ts (1)

148-171: LGTM! JWT session configuration is properly implemented.

The JWT strategy with 30-day sessions aligns with the PR's breaking change from database sessions. The callbacks correctly populate the token on sign-in and build the session from token data. The admin determination via env.ADMIN_EMAIL is consistent across both the credentials provider and JWT callback.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 20 files

Prompt for AI agents (all 6 issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="apps/web/src/app/reset-password/page.tsx">

<violation number="1" location="apps/web/src/app/reset-password/page.tsx:21">
P2: Duplicate password validation schema. Import `passwordSchema` from `~/server/password-utils` instead of defining it locally. This ensures consistency across the codebase and reduces maintenance burden if password requirements change.</violation>
</file>

<file name="apps/web/src/app/api/auth/signup/route.ts">

<violation number="1" location="apps/web/src/app/api/auth/signup/route.ts:36">
P0: Critical account takeover vulnerability: Adding a password to an existing OAuth user&#39;s account without any ownership verification. An attacker who knows an OAuth user&#39;s email can add a password and gain full access to their account. The user should receive a verification email or be required to authenticate via their existing OAuth provider before a password can be added.</violation>
</file>

<file name="apps/web/src/app/login/login-page.tsx">

<violation number="1" location="apps/web/src/app/login/login-page.tsx:224">
P2: The `loginError` state is not cleared when switching between auth modes. If a user gets a password login error and then switches to &quot;Magic Link&quot; mode, the stale error message will still be displayed, which is confusing.</violation>
</file>

<file name="apps/web/src/app/api/auth/reset-password/route.ts">

<violation number="1" location="apps/web/src/app/api/auth/reset-password/route.ts:42">
P1: Password update and token deletion should be wrapped in a database transaction to prevent race conditions. Without atomicity, concurrent requests could reuse the same reset token, and partial failures could leave tokens valid after password updates.</violation>
</file>

<file name="apps/web/src/app/forgot-password/page.tsx">

<violation number="1" location="apps/web/src/app/forgot-password/page.tsx:28">
P2: Missing `defaultValues` in `useForm` hook. Without default values, the email input starts as `undefined`, causing React&#39;s controlled/uncontrolled component warning when the user types.</violation>
</file>

<file name="apps/web/src/server/auth.ts">

<violation number="1" location="apps/web/src/server/auth.ts:112">
P1: Timing attack vulnerability enables email enumeration. The early return when user doesn&#39;t exist (fast) vs. running `verifySecureHash` when user exists (slow scrypt computation) creates a measurable timing difference. Consider always running the hash verification against a dummy hash when user is not found to normalize response timing.</violation>
</file>

Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Ask questions if you need clarification on any suggestion

Reply to cubic to teach it or ask questions. Re-run a review with @cubic-dev-ai review this PR

import Link from "next/link";
import { useSearchParams } from "next/navigation";

const passwordSchema = z
Copy link

@cubic-dev-ai cubic-dev-ai bot Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Duplicate password validation schema. Import passwordSchema from ~/server/password-utils instead of defining it locally. This ensures consistency across the codebase and reduces maintenance burden if password requirements change.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/app/reset-password/page.tsx, line 21:

<comment>Duplicate password validation schema. Import `passwordSchema` from `~/server/password-utils` instead of defining it locally. This ensures consistency across the codebase and reduces maintenance burden if password requirements change.</comment>

<file context>
@@ -0,0 +1,244 @@
+import Link from &quot;next/link&quot;;
+import { useSearchParams } from &quot;next/navigation&quot;;
+
+const passwordSchema = z
+  .object({
+    password: z
</file context>

✅ Addressed in 48e480c

);
}

// User exists via OAuth, allow adding password
Copy link

@cubic-dev-ai cubic-dev-ai bot Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Critical account takeover vulnerability: Adding a password to an existing OAuth user's account without any ownership verification. An attacker who knows an OAuth user's email can add a password and gain full access to their account. The user should receive a verification email or be required to authenticate via their existing OAuth provider before a password can be added.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/app/api/auth/signup/route.ts, line 36:

<comment>Critical account takeover vulnerability: Adding a password to an existing OAuth user&#39;s account without any ownership verification. An attacker who knows an OAuth user&#39;s email can add a password and gain full access to their account. The user should receive a verification email or be required to authenticate via their existing OAuth provider before a password can be added.</comment>

<file context>
@@ -0,0 +1,90 @@
+        );
+      }
+
+      // User exists via OAuth, allow adding password
+      const passwordHash = await createSecureHash(password);
+      await db.user.update({
</file context>

✅ Addressed in 48e480c

type="button"
variant={authMode === "password" ? "default" : "outline"}
className="flex-1"
onClick={() => setAuthMode("password")}
Copy link

@cubic-dev-ai cubic-dev-ai bot Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The loginError state is not cleared when switching between auth modes. If a user gets a password login error and then switches to "Magic Link" mode, the stale error message will still be displayed, which is confusing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/app/login/login-page.tsx, line 224:

<comment>The `loginError` state is not cleared when switching between auth modes. If a user gets a password login error and then switches to &quot;Magic Link&quot; mode, the stale error message will still be displayed, which is confusing.</comment>

<file context>
@@ -178,7 +214,96 @@ export default function LoginPage({
+                  type=&quot;button&quot;
+                  variant={authMode === &quot;password&quot; ? &quot;default&quot; : &quot;outline&quot;}
+                  className=&quot;flex-1&quot;
+                  onClick={() =&gt; setAuthMode(&quot;password&quot;)}
+                &gt;
+                  Password
</file context>

✅ Addressed in 48e480c


const passwordHash = await createSecureHash(password);

await db.user.update({
Copy link

@cubic-dev-ai cubic-dev-ai bot Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Password update and token deletion should be wrapped in a database transaction to prevent race conditions. Without atomicity, concurrent requests could reuse the same reset token, and partial failures could leave tokens valid after password updates.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/app/api/auth/reset-password/route.ts, line 42:

<comment>Password update and token deletion should be wrapped in a database transaction to prevent race conditions. Without atomicity, concurrent requests could reuse the same reset token, and partial failures could leave tokens valid after password updates.</comment>

<file context>
@@ -0,0 +1,61 @@
+
+    const passwordHash = await createSecureHash(password);
+
+    await db.user.update({
+      where: { email: resetToken.email },
+      data: {
</file context>

✅ Addressed in 48e480c

export default function ForgotPasswordPage() {
const [status, setStatus] = useState<"idle" | "sending" | "success">("idle");

const form = useForm<z.infer<typeof emailSchema>>({
Copy link

@cubic-dev-ai cubic-dev-ai bot Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Missing defaultValues in useForm hook. Without default values, the email input starts as undefined, causing React's controlled/uncontrolled component warning when the user types.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/app/forgot-password/page.tsx, line 28:

<comment>Missing `defaultValues` in `useForm` hook. Without default values, the email input starts as `undefined`, causing React&#39;s controlled/uncontrolled component warning when the user types.</comment>

<file context>
@@ -0,0 +1,139 @@
+export default function ForgotPasswordPage() {
+  const [status, setStatus] = useState&lt;&quot;idle&quot; | &quot;sending&quot; | &quot;success&quot;&gt;(&quot;idle&quot;);
+
+  const form = useForm&lt;z.infer&lt;typeof emailSchema&gt;&gt;({
+    resolver: zodResolver(emailSchema),
+  });
</file context>

✅ Addressed in 48e480c


const user = await db.user.findUnique({ where: { email } });

if (!user || !user.passwordHash) {
Copy link

@cubic-dev-ai cubic-dev-ai bot Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Timing attack vulnerability enables email enumeration. The early return when user doesn't exist (fast) vs. running verifySecureHash when user exists (slow scrypt computation) creates a measurable timing difference. Consider always running the hash verification against a dummy hash when user is not found to normalize response timing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/server/auth.ts, line 112:

<comment>Timing attack vulnerability enables email enumeration. The early return when user doesn&#39;t exist (fast) vs. running `verifySecureHash` when user exists (slow scrypt computation) creates a measurable timing difference. Consider always running the hash verification against a dummy hash when user is not found to normalize response timing.</comment>

<file context>
@@ -88,6 +90,47 @@ function getProviders() {
+
+        const user = await db.user.findUnique({ where: { email } });
+
+        if (!user || !user.passwordHash) {
+          return null;
+        }
</file context>

✅ Addressed in 48e480c

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (17)
apps/web/prisma/schema.prisma (1)

96-105: Consider cleanup strategy for expired password reset tokens.

The PasswordResetToken model is well-structured with appropriate indexes. However, consider these points:

  1. The composite unique constraint @@unique([email, token]) on Line 103 is redundant since token already has a @unique constraint on Line 99.
  2. No relation to the User model means orphaned tokens could accumulate if users are deleted.
Recommended improvements

Option 1: Remove redundant composite unique constraint

  @@unique([email, token])

Since token is already unique globally, this composite constraint provides no additional enforcement.

Option 2: Add a background job to clean up expired tokens
Consider implementing a periodic cleanup task to delete expired tokens:

// Example cleanup job
await db.passwordResetToken.deleteMany({
  where: {
    expires: {
      lt: new Date()
    }
  }
});
apps/web/src/app/api/auth/[...nextauth]/route.ts (1)

87-116: Rate limiting implementation is solid.

The credentials rate limiting follows the established pattern from email signin rate limiting and uses appropriate limits (5 attempts/60 seconds).

Consider extracting the rate limiting logic into a reusable helper to reduce duplication:

Optional refactoring

The credentials rate limit uses a hardcoded value of 5 (Line 98), while email rate limiting uses env.AUTH_EMAIL_RATE_LIMIT. For consistency, consider:

  1. Adding an environment variable for credentials rate limiting
  2. Extracting common rate-limit logic into a helper function
async function checkRateLimit(
  req: Request,
  key: string,
  limit: number,
  ttl: number,
  errorMessage: string
): Promise<Response | null> {
  const ip = getClientIp(req);
  if (!ip) {
    logger.warn(`Rate limit skipped for ${key}: missing client IP`);
    return null;
  }
  
  const redis = getRedis();
  const rateLimitKey = `${key}:${ip}`;
  const count = await redis.incr(rateLimitKey);
  if (count === 1) await redis.expire(rateLimitKey, ttl);
  
  if (count > limit) {
    logger.warn({ ip }, `Rate limit exceeded for ${key}`);
    return Response.json(
      { error: { code: "RATE_LIMITED", message: errorMessage } },
      { status: 429 }
    );
  }
  
  return null;
}
apps/web/src/app/api/auth/reset-password/route.ts (2)

54-55: Use logger instead of console.error for consistency.

The codebase uses a structured logger (imported in other files). Use it here for consistent logging and proper log levels.

Recommended change
+ import { logger } from "~/server/logger/log";

  } catch (error) {
-   console.error("Reset password error:", error);
+   logger.error({ err: error }, "Reset password error");
    return NextResponse.json(

42-51: Consider wrapping database operations in a transaction.

The user update (Lines 42-48) and token deletion (Line 51) are separate operations. If the user update succeeds but token deletion fails, the token could be reused.

Optional transaction wrapper
  const passwordHash = await createSecureHash(password);

- await db.user.update({
-   where: { email: resetToken.email },
-   data: {
-     passwordHash,
-     emailVerified: new Date(),
-   },
- });
-
- // Delete used token
- await db.passwordResetToken.delete({ where: { token } });
+ await db.$transaction([
+   db.user.update({
+     where: { email: resetToken.email },
+     data: {
+       passwordHash,
+       emailVerified: new Date(),
+     },
+   }),
+   db.passwordResetToken.delete({ where: { token } }),
+ ]);

This ensures both operations succeed or fail together.

apps/web/src/app/api/auth/forgot-password/route.ts (3)

62-63: Consider atomic rate limiting to avoid orphan keys.

The incr then conditional expire pattern has a small race: if the process terminates after incr but before expire (when count === 1), the key persists indefinitely. While unlikely, this could leave orphan keys in Redis.

A more robust approach uses a Lua script or the SET NX EX pattern with INCR:

🔎 Suggested fix using MULTI/EXEC
-      const count = await redis.incr(key);
-      if (count === 1) await redis.expire(key, ttl);
+      const results = await redis
+        .multi()
+        .incr(key)
+        .expire(key, ttl)
+        .exec();
+      const count = results?.[0]?.[1] as number;

115-117: Use logger.error for consistent logging.

The file imports and uses logger elsewhere (lines 65, 70, 73), but the catch block uses console.error. This inconsistency may affect log aggregation and structured logging.

🔎 Suggested fix
   } catch (error) {
-    console.error("Forgot password error:", error);
+    logger.error({ err: error }, "Forgot password error");
     return NextResponse.json({ success: true });
   }

13-51: Consider extracting getClientIp to a shared utility.

This IP extraction logic may be duplicated elsewhere (e.g., rate limiting in the auth route). Extracting it to a shared utility (e.g., ~/server/utils/ip.ts) would improve maintainability and ensure consistent behavior across rate-limited endpoints.

apps/web/src/app/api/auth/signup/route.ts (2)

7-89: Consider adding rate limiting to prevent mass account creation.

The forgot-password route implements IP-based rate limiting, but signup does not. Without rate limiting, an attacker could create accounts in bulk, potentially exhausting database resources or facilitating spam/abuse.

Consider reusing the Redis-based rate limiting pattern from forgot-password/route.ts.


83-85: Use a structured logger instead of console.error.

For consistency with other auth routes (e.g., forgot-password), use a structured logger for better observability.

🔎 Suggested fix
+import { logger } from "~/server/logger/log";
+
 // ... in catch block:
   } catch (error) {
-    console.error("Signup error:", error);
+    logger.error({ err: error }, "Signup error");
     return NextResponse.json(
apps/web/prisma/migrations/20251219093000_add_password_auth/migration.sql (1)

21-22: Redundant composite unique index.

Since token already has a unique constraint (line 16), the composite unique index on (email, token) provides no additional uniqueness guarantee—if token is globally unique, any (email, token) pair is inherently unique.

If this index exists for query optimization (e.g., queries filtering by both email and token), it's unnecessary since the application looks up tokens by token alone. Consider removing it to reduce storage and index maintenance overhead.

🔎 Suggested fix
 -- CreateIndex
 CREATE INDEX "PasswordResetToken_email_idx" ON "PasswordResetToken"("email");
-
--- CreateIndex
-CREATE UNIQUE INDEX "PasswordResetToken_email_token_key" ON "PasswordResetToken"("email", "token");

Note: Also update the Prisma schema to remove @@unique([email, token]) if present.

apps/web/src/app/forgot-password/page.tsx (1)

28-30: Add defaultValues to prevent controlled input warnings.

Without defaultValues, field.value may be undefined on initial render, which can cause React warnings about switching between controlled and uncontrolled inputs.

🔎 Suggested fix
   const form = useForm<z.infer<typeof emailSchema>>({
     resolver: zodResolver(emailSchema),
+    defaultValues: {
+      email: "",
+    },
   });
apps/web/src/app/reset-password/page.tsx (1)

21-35: Consider reusing the shared password schema.

This schema duplicates the validation rules from ~/server/password-utils.ts. For consistency and maintainability, consider importing the base passwordSchema and extending it here:

+import { passwordSchema as basePasswordSchema } from "~/server/password-utils";

-const passwordSchema = z
+const resetPasswordSchema = z
   .object({
-    password: z
-      .string()
-      .min(8, "Password must be at least 8 characters")
-      .max(128, "Password must be less than 128 characters")
-      .regex(/[a-z]/, "Password must contain at least one lowercase letter")
-      .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
-      .regex(/[0-9]/, "Password must contain at least one number"),
+    password: basePasswordSchema,
     confirmPassword: z.string(),
   })

This ensures password requirements stay synchronized across all forms.

apps/web/src/server/api/routers/user.ts (2)

40-43: Consider selecting only required fields.

The query fetches the entire user record but only needs passwordHash for validation.

🔎 Proposed fix
       const user = await ctx.db.user.findUnique({
         where: { id: ctx.session.user.id },
+        select: { id: true, passwordHash: true },
       });

86-89: Same optimization applies here.

For consistency with changePassword, consider selecting only required fields.

🔎 Proposed fix
       const user = await ctx.db.user.findUnique({
         where: { id: ctx.session.user.id },
+        select: { id: true, passwordHash: true },
       });
apps/web/src/server/auth.ts (2)

93-132: Credentials provider implementation looks solid, but consider timing attack mitigation.

The authorize function correctly normalizes email and verifies the password hash. However, when a user doesn't exist (!user), the function returns immediately without performing any hash verification, which could allow timing-based user enumeration attacks.

Consider performing a dummy hash verification when the user doesn't exist to equalize response times:

🔎 Proposed timing-safe improvement
       const user = await db.user.findUnique({ where: { email } });

       if (!user || !user.passwordHash) {
+        // Perform dummy verification to prevent timing attacks
+        await verifySecureHash(password, "dummy$dummyhashvalue");
         return null;
       }

       const isValid = await verifySecureHash(password, user.passwordHash);
       if (!isValid) {
         return null;
       }

161-170: Type assertions on token fields could be fragile.

The as casts assume the token always contains these fields with correct types. If the token is corrupted or tampered with, this could lead to unexpected behavior. Consider adding runtime validation or using a more defensive approach.

🔎 Proposed defensive typing
     session: ({ session, token }) => ({
       ...session,
       user: {
         ...session.user,
-        id: token.id as number,
-        isBetaUser: token.isBetaUser as boolean,
-        isAdmin: token.isAdmin as boolean,
-        isWaitlisted: token.isWaitlisted as boolean,
+        id: typeof token.id === "number" ? token.id : 0,
+        isBetaUser: Boolean(token.isBetaUser),
+        isAdmin: Boolean(token.isAdmin),
+        isWaitlisted: Boolean(token.isWaitlisted),
       },
     }),
apps/web/src/server/password-utils.ts (1)

31-35: Error message for optional name field is misleading.

The name field is marked as .optional(), but the .min(1) error says "Name is required". This is confusing since the field can be omitted. If provided, it must be non-empty, so consider a clearer message:

🔎 Proposed fix
 export const signupSchema = z.object({
   email: z.string().email("Invalid email address"),
   password: passwordSchema,
-  name: z.string().min(1, "Name is required").optional(),
+  name: z.string().min(1, "Name cannot be empty").optional(),
 });
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 95dfa6b and 4901e30.

📒 Files selected for processing (20)
  • apps/web/prisma/migrations/20251219093000_add_password_auth/migration.sql (1 hunks)
  • apps/web/prisma/schema.prisma (2 hunks)
  • apps/web/src/app/(dashboard)/settings/account/page.tsx (1 hunks)
  • apps/web/src/app/(dashboard)/settings/layout.tsx (1 hunks)
  • apps/web/src/app/api/auth/[...nextauth]/route.ts (2 hunks)
  • apps/web/src/app/api/auth/forgot-password/route.ts (1 hunks)
  • apps/web/src/app/api/auth/reset-password/route.ts (1 hunks)
  • apps/web/src/app/api/auth/signup/route.ts (1 hunks)
  • apps/web/src/app/forgot-password/page.tsx (1 hunks)
  • apps/web/src/app/login/login-page.tsx (6 hunks)
  • apps/web/src/app/reset-password/page.tsx (1 hunks)
  • apps/web/src/app/signup/page.tsx (2 hunks)
  • apps/web/src/app/signup/signup-page.tsx (1 hunks)
  • apps/web/src/server/api/root.ts (2 hunks)
  • apps/web/src/server/api/routers/user.ts (1 hunks)
  • apps/web/src/server/auth.ts (3 hunks)
  • apps/web/src/server/email-templates/PasswordResetEmail.tsx (1 hunks)
  • apps/web/src/server/email-templates/index.ts (1 hunks)
  • apps/web/src/server/mailer.ts (3 hunks)
  • apps/web/src/server/password-utils.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{tsx,ts,jsx,js}

📄 CodeRabbit inference engine (.cursor/rules/general.mdc)

Include all required imports and ensure proper naming of key components in React/NextJS code

Files:

  • apps/web/src/app/signup/page.tsx
  • apps/web/src/app/api/auth/signup/route.ts
  • apps/web/src/app/api/auth/reset-password/route.ts
  • apps/web/src/app/(dashboard)/settings/layout.tsx
  • apps/web/src/app/login/login-page.tsx
  • apps/web/src/server/api/root.ts
  • apps/web/src/server/email-templates/PasswordResetEmail.tsx
  • apps/web/src/app/signup/signup-page.tsx
  • apps/web/src/app/forgot-password/page.tsx
  • apps/web/src/server/mailer.ts
  • apps/web/src/server/api/routers/user.ts
  • apps/web/src/app/api/auth/forgot-password/route.ts
  • apps/web/src/server/email-templates/index.ts
  • apps/web/src/app/(dashboard)/settings/account/page.tsx
  • apps/web/src/app/reset-password/page.tsx
  • apps/web/src/app/api/auth/[...nextauth]/route.ts
  • apps/web/src/server/password-utils.ts
  • apps/web/src/server/auth.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: Use TypeScript-first approach with 2-space indent and semicolons enabled by Prettier in apps/web (Next.js), apps/marketing, apps/smtp-server, and all packages
Never use dynamic imports; always import on the top level
Run ESLint via @usesend/eslint-config and ensure no warnings remain before submitting PRs

Files:

  • apps/web/src/app/signup/page.tsx
  • apps/web/src/app/api/auth/signup/route.ts
  • apps/web/src/app/api/auth/reset-password/route.ts
  • apps/web/src/app/(dashboard)/settings/layout.tsx
  • apps/web/src/app/login/login-page.tsx
  • apps/web/src/server/api/root.ts
  • apps/web/src/server/email-templates/PasswordResetEmail.tsx
  • apps/web/src/app/signup/signup-page.tsx
  • apps/web/src/app/forgot-password/page.tsx
  • apps/web/src/server/mailer.ts
  • apps/web/src/server/api/routers/user.ts
  • apps/web/src/app/api/auth/forgot-password/route.ts
  • apps/web/src/server/email-templates/index.ts
  • apps/web/src/app/(dashboard)/settings/account/page.tsx
  • apps/web/src/app/reset-password/page.tsx
  • apps/web/src/app/api/auth/[...nextauth]/route.ts
  • apps/web/src/server/password-utils.ts
  • apps/web/src/server/auth.ts
**/*.{tsx,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

React components must use PascalCase naming convention (e.g., AppSideBar.tsx)

Files:

  • apps/web/src/app/signup/page.tsx
  • apps/web/src/app/(dashboard)/settings/layout.tsx
  • apps/web/src/app/login/login-page.tsx
  • apps/web/src/server/email-templates/PasswordResetEmail.tsx
  • apps/web/src/app/signup/signup-page.tsx
  • apps/web/src/app/forgot-password/page.tsx
  • apps/web/src/app/(dashboard)/settings/account/page.tsx
  • apps/web/src/app/reset-password/page.tsx
apps/web/**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use alias ~/ for src imports in apps/web (e.g., import { x } from "~/utils/x")

Files:

  • apps/web/src/app/signup/page.tsx
  • apps/web/src/app/api/auth/signup/route.ts
  • apps/web/src/app/api/auth/reset-password/route.ts
  • apps/web/src/app/(dashboard)/settings/layout.tsx
  • apps/web/src/app/login/login-page.tsx
  • apps/web/src/server/api/root.ts
  • apps/web/src/server/email-templates/PasswordResetEmail.tsx
  • apps/web/src/app/signup/signup-page.tsx
  • apps/web/src/app/forgot-password/page.tsx
  • apps/web/src/server/mailer.ts
  • apps/web/src/server/api/routers/user.ts
  • apps/web/src/app/api/auth/forgot-password/route.ts
  • apps/web/src/server/email-templates/index.ts
  • apps/web/src/app/(dashboard)/settings/account/page.tsx
  • apps/web/src/app/reset-password/page.tsx
  • apps/web/src/app/api/auth/[...nextauth]/route.ts
  • apps/web/src/server/password-utils.ts
  • apps/web/src/server/auth.ts
apps/web/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

apps/web/**/*.{ts,tsx}: Prefer to use TRPC for client-server communication unless explicitly asked otherwise in apps/web
Use Prisma for database access in apps/web

Files:

  • apps/web/src/app/signup/page.tsx
  • apps/web/src/app/api/auth/signup/route.ts
  • apps/web/src/app/api/auth/reset-password/route.ts
  • apps/web/src/app/(dashboard)/settings/layout.tsx
  • apps/web/src/app/login/login-page.tsx
  • apps/web/src/server/api/root.ts
  • apps/web/src/server/email-templates/PasswordResetEmail.tsx
  • apps/web/src/app/signup/signup-page.tsx
  • apps/web/src/app/forgot-password/page.tsx
  • apps/web/src/server/mailer.ts
  • apps/web/src/server/api/routers/user.ts
  • apps/web/src/app/api/auth/forgot-password/route.ts
  • apps/web/src/server/email-templates/index.ts
  • apps/web/src/app/(dashboard)/settings/account/page.tsx
  • apps/web/src/app/reset-password/page.tsx
  • apps/web/src/app/api/auth/[...nextauth]/route.ts
  • apps/web/src/server/password-utils.ts
  • apps/web/src/server/auth.ts
**/*.{ts,tsx,md}

📄 CodeRabbit inference engine (AGENTS.md)

Run Prettier 3 for code formatting on TypeScript, TSX, and Markdown files

Files:

  • apps/web/src/app/signup/page.tsx
  • apps/web/src/app/api/auth/signup/route.ts
  • apps/web/src/app/api/auth/reset-password/route.ts
  • apps/web/src/app/(dashboard)/settings/layout.tsx
  • apps/web/src/app/login/login-page.tsx
  • apps/web/src/server/api/root.ts
  • apps/web/src/server/email-templates/PasswordResetEmail.tsx
  • apps/web/src/app/signup/signup-page.tsx
  • apps/web/src/app/forgot-password/page.tsx
  • apps/web/src/server/mailer.ts
  • apps/web/src/server/api/routers/user.ts
  • apps/web/src/app/api/auth/forgot-password/route.ts
  • apps/web/src/server/email-templates/index.ts
  • apps/web/src/app/(dashboard)/settings/account/page.tsx
  • apps/web/src/app/reset-password/page.tsx
  • apps/web/src/app/api/auth/[...nextauth]/route.ts
  • apps/web/src/server/password-utils.ts
  • apps/web/src/server/auth.ts
🧬 Code graph analysis (11)
apps/web/src/app/signup/page.tsx (1)
apps/web/src/app/signup/signup-page.tsx (1)
  • SignupPage (57-320)
apps/web/src/app/api/auth/reset-password/route.ts (3)
apps/web/src/server/password-utils.ts (1)
  • resetPasswordSchema (63-66)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/crypto.ts (1)
  • createSecureHash (3-10)
apps/web/src/app/(dashboard)/settings/layout.tsx (1)
apps/web/src/app/(dashboard)/dev-settings/settings-nav-button.tsx (1)
  • SettingsNavButton (7-39)
apps/web/src/server/api/root.ts (1)
apps/web/src/server/api/routers/user.ts (1)
  • userRouter (7-133)
apps/web/src/app/signup/signup-page.tsx (5)
apps/web/src/server/password-utils.ts (1)
  • signupSchema (31-35)
packages/ui/src/button.tsx (1)
  • Button (80-80)
packages/ui/src/spinner.tsx (1)
  • Spinner (4-51)
packages/ui/src/form.tsx (7)
  • Form (170-170)
  • FormField (176-176)
  • FormItem (171-171)
  • FormLabel (172-172)
  • FormControl (173-173)
  • FormMessage (175-175)
  • FormDescription (174-174)
packages/ui/src/input.tsx (1)
  • Input (25-25)
apps/web/src/server/mailer.ts (2)
apps/web/src/server/email-templates/PasswordResetEmail.tsx (1)
  • renderPasswordResetEmail (83-87)
apps/web/src/server/email-templates/index.ts (1)
  • renderPasswordResetEmail (13-13)
apps/web/src/app/api/auth/forgot-password/route.ts (4)
apps/web/src/app/api/auth/reset-password/route.ts (1)
  • POST (6-61)
apps/web/src/server/redis.ts (1)
  • getRedis (6-13)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/mailer.ts (1)
  • sendPasswordResetEmail (76-95)
apps/web/src/app/(dashboard)/settings/account/page.tsx (1)
apps/web/src/server/password-utils.ts (1)
  • passwordSchema (11-17)
apps/web/src/app/reset-password/page.tsx (4)
apps/web/src/server/password-utils.ts (1)
  • passwordSchema (11-17)
packages/ui/src/button.tsx (1)
  • Button (80-80)
packages/ui/src/form.tsx (6)
  • Form (170-170)
  • FormField (176-176)
  • FormItem (171-171)
  • FormLabel (172-172)
  • FormControl (173-173)
  • FormMessage (175-175)
packages/ui/src/input.tsx (1)
  • Input (25-25)
apps/web/src/app/api/auth/[...nextauth]/route.ts (2)
apps/web/src/server/logger/log.ts (1)
  • logger (31-63)
apps/web/src/server/redis.ts (1)
  • getRedis (6-13)
apps/web/src/server/auth.ts (3)
apps/web/src/server/db.ts (1)
  • db (20-20)
apps/web/src/server/crypto.ts (1)
  • verifySecureHash (12-19)
apps/web/src/env.js (2)
  • env (5-145)
  • env (5-145)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Agent
  • GitHub Check: cubic · AI code reviewer
🔇 Additional comments (29)
apps/web/src/server/api/root.ts (1)

17-17: LGTM! User router properly integrated.

The new user router follows the established pattern and is correctly imported and mounted at the top level.

Also applies to: 40-40

apps/web/src/app/signup/page.tsx (1)

3-3: LGTM! Clean refactor separating signup and login pages.

The component properly uses the new dedicated SignupPage component and follows naming conventions.

Also applies to: 6-6, 15-15

apps/web/src/app/(dashboard)/settings/layout.tsx (1)

29-29: LGTM! Account navigation properly added.

The new Account settings link is unconditionally rendered for all users, which is appropriate for personal account management.

apps/web/src/server/email-templates/index.ts (1)

11-14: LGTM! Password reset email template properly exported.

The export follows the established pattern for email templates in this module.

apps/web/prisma/schema.prisma (1)

87-87: LGTM! Nullable password hash field correctly supports OAuth-only users.

The nullable passwordHash field appropriately accommodates users who authenticate via OAuth without setting a password.

apps/web/src/app/api/auth/[...nextauth]/route.ts (1)

53-53: Good refactor moving URL construction to shared location.

This avoids duplication between the rate limiting blocks.

apps/web/src/app/api/auth/reset-password/route.ts (1)

46-46: Verify email on password reset is a good security practice.

Setting emailVerified when resetting a password is correct because the user proved email ownership by receiving and using the reset token.

apps/web/src/server/mailer.ts (1)

76-95: LGTM! Password reset email function follows established patterns.

The implementation mirrors existing email functions (sendSignUpEmail, sendTeamInviteEmail) and correctly handles dev/production environments. The token expiration time mentioned in the email text ("1 hour") matches the actual expiration set when creating the reset token (60 * 60 * 1000 milliseconds in forgot-password/route.ts).

apps/web/src/server/email-templates/PasswordResetEmail.tsx (1)

1-87: LGTM!

The email template is well-structured with clear messaging, consistent styling, and proper type definitions. The security guidance (1-hour expiry notice and "safe to ignore" message) follows email best practices.

apps/web/prisma/migrations/20251219093000_add_password_auth/migration.sql (1)

1-19: Schema additions look good.

The passwordHash as nullable TEXT appropriately supports optional password authentication alongside OAuth. The PasswordResetToken table structure with token uniqueness and email index aligns well with the password reset flow requirements.

apps/web/src/app/forgot-password/page.tsx (1)

32-46: Good security practice: enumeration prevention.

Always transitioning to success state regardless of the fetch outcome correctly prevents email enumeration attacks. The matching behavior on the server side (always returning success) completes this security pattern.

apps/web/src/app/login/login-page.tsx (2)

121-142: LGTM!

The password submit handler properly manages loading state, handles errors gracefully with a user-friendly message, and redirects correctly while respecting invite-based paths.


219-236: LGTM!

The auth mode toggle provides a clean UX for switching between password and magic link authentication methods.

apps/web/src/app/reset-password/page.tsx (2)

54-84: LGTM!

The form submission logic handles API errors gracefully with appropriate user feedback. The defensive token check at line 55 is redundant since the component already guards against missing tokens at line 86, but it's harmless.


223-244: LGTM!

Properly wraps the form component in Suspense as required by Next.js App Router when using useSearchParams.

apps/web/src/app/signup/signup-page.tsx (2)

83-128: LGTM!

The signup flow with auto-login after successful account creation provides excellent UX. The fallback redirect to the login page ensures users aren't stuck if auto-login fails.


206-315: LGTM!

The form implementation is well-structured with proper validation feedback, password requirement hints, and loading states.

apps/web/src/app/(dashboard)/settings/account/page.tsx (5)

33-54: LGTM!

The password schemas are well-defined with proper refinement for confirmation matching. Reusing passwordSchema from password-utils ensures consistent validation rules.


84-184: LGTM!

The ChangePasswordForm properly handles the mutation lifecycle with loading states, success toast, form reset, and error handling.


186-268: LGTM!

Good use of query invalidation (utils.user.hasPassword.invalidate()) to ensure the UI updates immediately after setting a password.


307-334: LGTM!

The ProfileSection handles edge cases gracefully with proper fallbacks for missing name/email/image.


336-385: LGTM!

The loading state aggregation and skeleton UI provide a polished loading experience. The page correctly waits for all queries before rendering content.

apps/web/src/server/api/routers/user.ts (2)

11-17: LGTM!

The hasPassword query efficiently checks for password existence without exposing the hash.


115-132: LGTM!

The getProfile query properly selects only the necessary fields and handles the not-found case.

apps/web/src/server/auth.ts (2)

146-172: Session and adapter configuration looks correct for mixed provider setup.

Using JWT strategy with the PrismaAdapter is the right approach here. The adapter handles OAuth/magic-link users while credentials-based users are managed separately via the signup API route. Note that the createUser event (lines 176-203) won't trigger for email/password signups since they don't go through the adapter.


11-17: Imports are correctly structured.

The new imports follow the project's coding guidelines using the ~/ alias for src imports and are all top-level as required.

apps/web/src/server/password-utils.ts (3)

11-17: Password validation schema is well-designed.

The password requirements (8-128 chars, mixed case, number) strike a good balance between security and usability. The explicit error messages for each constraint will provide clear feedback to users.


40-66: Login and password management schemas are well-structured.

Good design choices:

  • passwordLoginSchema correctly uses simple presence validation (not strength) since the password is verified against the stored hash
  • changePasswordSchema validates the new password but not the current one (correct, since current is verified server-side)
  • resetPasswordSchema includes token validation alongside password requirements

24-26: Helper function provides clean API for password validation.

The validatePassword wrapper is useful for getting detailed error information via safeParse when you need to handle validation results programmatically.

Comment on lines +68 to +77
await db.user.create({
data: {
email: normalizedEmail,
name: name ?? null,
passwordHash,
isBetaUser,
isWaitlisted,
emailVerified: new Date(), // Mark as verified since they're signing up directly
},
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Security: emailVerified is set without actual email verification.

Setting emailVerified: new Date() at signup allows users to claim any email address without proving ownership. This enables:

  1. Account squatting with emails they don't own
  2. The legitimate email owner being blocked from signing up later
  3. Potential bypassing of email-gated features

Either remove emailVerified from signup (leave it null) and implement an email verification flow, or acknowledge this as an intentional trade-off with appropriate downstream protections.

🔎 Suggested fix - remove premature verification
     await db.user.create({
       data: {
         email: normalizedEmail,
         name: name ?? null,
         passwordHash,
         isBetaUser,
         isWaitlisted,
-        emailVerified: new Date(), // Mark as verified since they're signing up directly
       },
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await db.user.create({
data: {
email: normalizedEmail,
name: name ?? null,
passwordHash,
isBetaUser,
isWaitlisted,
emailVerified: new Date(), // Mark as verified since they're signing up directly
},
});
await db.user.create({
data: {
email: normalizedEmail,
name: name ?? null,
passwordHash,
isBetaUser,
isWaitlisted,
},
});
🤖 Prompt for AI Agents
In apps/web/src/app/api/auth/signup/route.ts around lines 68 to 77, the signup
flow is setting emailVerified: new Date() which marks emails as verified without
ownership proof; remove the emailVerified assignment so new users have a
null/unset emailVerified value and implement an email verification flow (send
verification token/email after account creation and set emailVerified only after
the token is validated) or, if intentionally allowing auto-verified emails, add
a clear code comment and downstream protections (feature gating, re-verification
for sensitive actions) and record an audit flag to indicate the trade-off.

Comment on lines +57 to +64
export default function SignupPage({
providers,
}: {
providers?: ClientSafeProvider[];
}) {
const router = useRouter();
const searchParams = useNextSearchParams();
const inviteId = searchParams.get("inviteId");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing Suspense boundary for useSearchParams.

This component uses useSearchParams (line 63) but isn't wrapped in a Suspense boundary. In Next.js App Router, this can cause the entire page to opt into client-side rendering during static generation. Consider wrapping the component similar to how reset-password/page.tsx handles it:

🔎 Proposed fix
+import { Suspense } from "react";

// ... existing code ...

-export default function SignupPage({
+function SignupPageContent({
   providers,
 }: {
   providers?: ClientSafeProvider[];
 }) {
   // ... component body ...
 }
+
+export default function SignupPage({
+  providers,
+}: {
+  providers?: ClientSafeProvider[];
+}) {
+  return (
+    <Suspense fallback={<div className="min-h-screen flex justify-center items-center">Loading...</div>}>
+      <SignupPageContent providers={providers} />
+    </Suspense>
+  );
+}

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds comprehensive email-password authentication to useSend, complementing the existing OAuth (Google, GitHub) and magic link providers. The implementation includes user signup, login, password reset flows, and account management capabilities. A significant architectural change migrates from database sessions to JWT sessions (required for NextAuth CredentialsProvider), which will require existing users to re-login after deployment.

Key Changes

  • Complete email/password authentication flow with signup, login, and password reset capabilities
  • JWT session strategy migration from database sessions to support CredentialsProvider
  • Rate limiting for login (5 attempts/min) and password reset (3 requests/hour) endpoints
  • New account settings page for password management and viewing linked OAuth providers
  • Strong password validation requirements (8+ chars, uppercase, lowercase, number)

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
apps/web/src/server/password-utils.ts Centralized Zod schemas for password validation and form schemas
apps/web/src/server/mailer.ts Added sendPasswordResetEmail function for password reset flow
apps/web/src/server/email-templates/index.ts Exported PasswordResetEmail template
apps/web/src/server/email-templates/PasswordResetEmail.tsx Password reset email template with 1-hour expiry notice
apps/web/src/server/auth.ts Added CredentialsProvider and migrated to JWT session strategy
apps/web/src/server/api/routers/user.ts New tRPC router for password management (change, set, check) and linked accounts
apps/web/src/server/api/root.ts Registered user router in main app router
apps/web/src/app/signup/signup-page.tsx New signup page with email/password form and OAuth options
apps/web/src/app/signup/page.tsx Signup route using dedicated SignupPage component
apps/web/src/app/reset-password/page.tsx Password reset page with token validation
apps/web/src/app/login/login-page.tsx Added password/magic link toggle and password login form
apps/web/src/app/forgot-password/page.tsx Forgot password page with email submission
apps/web/src/app/api/auth/signup/route.ts Signup API with support for new users and OAuth users adding passwords
apps/web/src/app/api/auth/reset-password/route.ts Password reset API with token validation and expiry handling
apps/web/src/app/api/auth/forgot-password/route.ts Forgot password API with rate limiting and email enumeration prevention
apps/web/src/app/api/auth/[...nextauth]/route.ts Added rate limiting for credentials authentication
apps/web/src/app/(dashboard)/settings/layout.tsx Added Account settings navigation link
apps/web/src/app/(dashboard)/settings/account/page.tsx Account settings page for password management and linked accounts display
apps/web/prisma/schema.prisma Added passwordHash field to User and PasswordResetToken model
apps/web/prisma/migrations/20251219093000_add_password_auth/migration.sql Database migration for password authentication schema changes

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 38 to 42
await db.user.update({
where: { id: existingUser.id },
data: { passwordHash },
});

Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a potential race condition for OAuth users trying to add a password through the signup endpoint. Between checking if a user has a password (line 28) and updating the user (line 38-41), another concurrent request could also check and update, potentially causing one update to overwrite the other. While both would set a password, they would be different passwords, leading to user confusion.

Consider using a conditional update with a where clause that includes passwordHash: null to ensure only one request succeeds, or use optimistic locking.

Suggested change
await db.user.update({
where: { id: existingUser.id },
data: { passwordHash },
});
const updateResult = await db.user.updateMany({
where: { id: existingUser.id, passwordHash: null },
data: { passwordHash },
});
if (updateResult.count === 0) {
// Another request may have already set the password
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 400 }
);
}

Copilot uses AI. Check for mistakes.
Comment on lines 219 to 236
<div className="flex gap-2 w-full">
<Button
type="button"
variant={authMode === "password" ? "default" : "outline"}
className="flex-1"
onClick={() => setAuthMode("password")}
>
Password
</Button>
<Button
type="button"
variant={authMode === "otp" ? "default" : "outline"}
className="flex-1"
onClick={() => setAuthMode("otp")}
>
Magic Link
</Button>
</div>
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When users switch between "Password" and "Magic Link" authentication modes, the loginError state is not cleared. This means a password login error will persist and display even after switching to magic link mode, which could confuse users.

Clear the loginError state when the auth mode changes by adding setLoginError(null) in the onClick handlers.

Copilot uses AI. Check for mistakes.
Comment on lines 20 to 51
const resetToken = await db.passwordResetToken.findUnique({
where: { token },
});

if (!resetToken) {
return NextResponse.json(
{ error: "Invalid or expired reset link" },
{ status: 400 }
);
}

if (resetToken.expires < new Date()) {
// Clean up expired token
await db.passwordResetToken.delete({ where: { token } });
return NextResponse.json(
{ error: "This reset link has expired. Please request a new one." },
{ status: 400 }
);
}

const passwordHash = await createSecureHash(password);

await db.user.update({
where: { email: resetToken.email },
data: {
passwordHash,
emailVerified: new Date(), // Verify email on password reset
},
});

// Delete used token
await db.passwordResetToken.delete({ where: { token } });
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a potential race condition where the same reset token could be used twice. Between checking the token validity (line 20-38) and deleting it (line 51), another concurrent request could also validate and use the same token. This could allow a token to reset the password twice.

Consider using a database transaction to ensure atomic token consumption, or delete the token before updating the password, or add a "used" flag to prevent reuse.

Copilot uses AI. Check for mistakes.
Comment on lines 7 to 90
export async function POST(request: Request) {
try {
const body = await request.json();
const result = signupSchema.safeParse(body);

if (!result.success) {
return NextResponse.json(
{ error: result.error.errors[0]?.message ?? "Invalid input" },
{ status: 400 }
);
}

const { email, password, name } = result.data;
const normalizedEmail = email.toLowerCase().trim();

// Check if user exists
const existingUser = await db.user.findUnique({
where: { email: normalizedEmail },
});

if (existingUser) {
if (existingUser.passwordHash) {
// User already has password authentication
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 400 }
);
}

// User exists via OAuth, allow adding password
const passwordHash = await createSecureHash(password);
await db.user.update({
where: { id: existingUser.id },
data: { passwordHash },
});

return NextResponse.json({
success: true,
message:
"Password added to your existing account. You can now sign in with email and password.",
});
}

// Create new user with password
const passwordHash = await createSecureHash(password);

// Check for pending team invites
const pendingInvites = await db.teamInvite.findMany({
where: { email: normalizedEmail },
});

// Determine beta/waitlist status based on environment and invites
const isBetaUser =
!env.NEXT_PUBLIC_IS_CLOUD ||
env.NODE_ENV === "development" ||
pendingInvites.length > 0;
const isWaitlisted =
env.NEXT_PUBLIC_IS_CLOUD &&
env.NODE_ENV !== "development" &&
pendingInvites.length === 0;

await db.user.create({
data: {
email: normalizedEmail,
name: name ?? null,
passwordHash,
isBetaUser,
isWaitlisted,
emailVerified: new Date(), // Mark as verified since they're signing up directly
},
});

return NextResponse.json({
success: true,
message: "Account created successfully. You can now sign in.",
});
} catch (error) {
console.error("Signup error:", error);
return NextResponse.json(
{ error: "Something went wrong. Please try again." },
{ status: 500 }
);
}
}
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The signup endpoint lacks rate limiting, which makes it vulnerable to abuse. An attacker could create many accounts rapidly, potentially for spam, resource exhaustion, or email bombing. This is especially concerning since the endpoint creates database records and may trigger email verifications.

Add rate limiting similar to the password reset endpoint (e.g., 5 signups per hour per IP) to prevent abuse while still allowing legitimate use.

Copilot uses AI. Check for mistakes.
expires DateTime
createdAt DateTime @default(now())
@@unique([email, token])
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PasswordResetToken model is missing an index on the expires field. This field is frequently queried for validation (checking if tokens have expired) and would benefit from an index to optimize these lookups. Additionally, a periodic cleanup job that deletes expired tokens would also benefit from this index.

Consider adding @@index([expires]) to improve query performance for expiration checks and cleanup operations.

Suggested change
@@unique([email, token])
@@unique([email, token])
@@index([expires])

Copilot uses AI. Check for mistakes.
Comment on lines 21 to 35
const passwordSchema = z
.object({
password: z
.string()
.min(8, "Password must be at least 8 characters")
.max(128, "Password must be less than 128 characters")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[0-9]/, "Password must contain at least one number"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The password validation schema is duplicated here instead of being imported from the centralized password-utils module. This violates DRY principles and creates a maintenance burden - if password requirements change, this schema would need to be updated separately.

Import and use the passwordSchema from ~/server/password-utils instead, similar to how it's done in the account settings page and login page.

Copilot uses AI. Check for mistakes.
Comment on lines 116 to 119
const isValid = await verifySecureHash(password, user.passwordHash);
if (!isValid) {
return null;
}
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hash comparison uses simple string equality (===) which is vulnerable to timing attacks. An attacker could potentially use timing differences to determine the hash character-by-character. While scrypt provides some inherent protection, the final comparison should use a constant-time comparison to prevent timing side-channel attacks.

Consider using crypto.timingSafeEqual for the final hash comparison to ensure constant-time equality checking.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +52

const schema = z.object({
email: z.string().email(),
});

function getClientIp(req: Request): string | null {
const h = req.headers;
const direct =
h.get("x-forwarded-for") ??
h.get("x-real-ip") ??
h.get("cf-connecting-ip") ??
h.get("x-client-ip") ??
h.get("true-client-ip") ??
h.get("fastly-client-ip") ??
h.get("x-cluster-client-ip") ??
null;

let ip = direct?.split(",")[0]?.trim() ?? "";

if (!ip) {
const fwd = h.get("forwarded");
if (fwd) {
const first = fwd.split(",")[0];
const match = first?.match(/for=([^;]+)/i);
if (match && match[1]) {
const raw = match[1].trim().replace(/^"|"$/g, "");
if (raw.startsWith("[")) {
const end = raw.indexOf("]");
ip = end !== -1 ? raw.slice(1, end) : raw;
} else {
const parts = raw.split(":");
if (parts.length > 0 && parts[0]) {
ip =
parts.length === 2 && /^\d+(?:\.\d+){3}$/.test(parts[0])
? parts[0]
: raw;
}
}
}
}
}

return ip || null;
}

Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getClientIp function is duplicated across two files (here and in [...nextauth]/route.ts). This identical 40-line function should be extracted into a shared utility module to maintain DRY principles and ensure consistency in IP extraction logic across the application.

Consider moving this to a shared utility file like ~/server/utils/ip.ts.

Suggested change
const schema = z.object({
email: z.string().email(),
});
function getClientIp(req: Request): string | null {
const h = req.headers;
const direct =
h.get("x-forwarded-for") ??
h.get("x-real-ip") ??
h.get("cf-connecting-ip") ??
h.get("x-client-ip") ??
h.get("true-client-ip") ??
h.get("fastly-client-ip") ??
h.get("x-cluster-client-ip") ??
null;
let ip = direct?.split(",")[0]?.trim() ?? "";
if (!ip) {
const fwd = h.get("forwarded");
if (fwd) {
const first = fwd.split(",")[0];
const match = first?.match(/for=([^;]+)/i);
if (match && match[1]) {
const raw = match[1].trim().replace(/^"|"$/g, "");
if (raw.startsWith("[")) {
const end = raw.indexOf("]");
ip = end !== -1 ? raw.slice(1, end) : raw;
} else {
const parts = raw.split(":");
if (parts.length > 0 && parts[0]) {
ip =
parts.length === 2 && /^\d+(?:\.\d+){3}$/.test(parts[0])
? parts[0]
: raw;
}
}
}
}
}
return ip || null;
}
import { getClientIp } from "~/server/utils/ip";
const schema = z.object({
email: z.string().email(),
});

Copilot uses AI. Check for mistakes.
router.push(callbackUrl);
} else {
// If auto-login fails, redirect to login page
router.push("/login?message=Account created successfully. Please sign in.");
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the auto-login after signup fails (line 121-122), the user is redirected with a URL query parameter "message" that is never read or displayed to the user. This provides a suboptimal user experience - the message parameter is ignored by the login page implementation, so users won't know their account was created successfully.

Either ensure the login page reads and displays this message parameter, or change the redirect to use a more robust notification mechanism like session storage or a toast notification.

Suggested change
router.push("/login?message=Account created successfully. Please sign in.");
router.push("/login");

Copilot uses AI. Check for mistakes.
P0: Remove account takeover vulnerability in signup route
- Removed OAuth password-linking from /api/auth/signup endpoint
- Attackers could previously add passwords to OAuth accounts without auth
- OAuth users must now use authenticated settings page to add passwords

P1: Fix timing attack vulnerability in credentials provider
- Always run hash verification regardless of user existence
- Use dummy hash for non-existent users to normalize response time
- Prevents email enumeration via response timing analysis

P1: Fix race condition in password reset flow
- Wrap password update and token deletion in db.$transaction()
- Add user existence verification before password update
- Prevents concurrent token reuse and partial update failures

P2: Code quality improvements
- Import passwordSchema from shared utils (reset-password/page.tsx)
- Clear loginError when switching auth modes (login-page.tsx)
- Remove debug console.log statement (login-page.tsx)
- Add defaultValues to useForm (forgot-password/page.tsx)
@Divkix Divkix closed this by deleting the head repository Jan 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant