Skip to content

(SP: 3) [Backend][Frontend][UI] Implement secure authentication lifecycle with email verification and password recovery#137

Merged
ViktorSvertoka merged 2 commits into
developfrom
feat/signup-email-verification
Jan 13, 2026
Merged

(SP: 3) [Backend][Frontend][UI] Implement secure authentication lifecycle with email verification and password recovery#137
ViktorSvertoka merged 2 commits into
developfrom
feat/signup-email-verification

Conversation

@kryvosheyin
Copy link
Copy Markdown
Collaborator

@kryvosheyin kryvosheyin commented Jan 13, 2026

This pull request introduces a comprehensive overhaul of the authentication-related UI flows, focusing on improved user experience for login, signup, password reset, and error handling. The most important changes include the addition of dedicated pages for password reset and forgot password, enhanced feedback and error messaging in login and signup forms, and improved navigation and accessibility features.

New authentication flows and pages:

Login flow improvements:

Signup flow improvements:

Navigation and redirect updates:

  • Updated OAuth login callback to redirect users to /dashboard instead of the homepage for a more consistent post-login experience.

These changes collectively streamline the authentication experience, providing users with clearer guidance and feedback throughout the login, signup, and password reset processes.## Description

Database Changes (if applicable)

  • Schema migration required
  • Seed data updated
  • Breaking changes to existing queries
  • Transaction-safe migration
  • Migration tested locally on Neon

How Has This Been Tested?

  • Tested locally
  • Verified in development environment
  • Checked responsive layout (if UI-related)
  • Tested accessibility (keyboard / screen reader)

Screenshots (if applicable)


Checklist

Before submitting

  • Code has been self-reviewed
  • No TypeScript or console errors
  • Code follows project conventions
  • Scope is limited to this feature/fix
  • No unrelated refactors included
  • English used in code, commits, and docs
  • New dependencies discussed with team
  • Database migration tested locally (if applicable)
  • GitHub Projects card moved to In Review

Reviewers

Summary by CodeRabbit

Release Notes

  • New Features

    • Added forgotten password recovery flow with email-based reset link.
    • Implemented email verification requirement for new signups.
    • Added password visibility toggle on login and signup pages.
    • Added resend verification email option on login.
  • Improvements

    • Enhanced error messages and validation feedback during authentication.
    • Improved login and signup navigation with return URL support.

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

@netlify
Copy link
Copy Markdown

netlify Bot commented Jan 13, 2026

Deploy Preview for develop-devlovers ready!

Name Link
🔨 Latest commit 7b2920e
🔍 Latest deploy log https://app.netlify.com/projects/develop-devlovers/deploys/6966c672fd557e0008c5d333
😎 Deploy Preview https://deploy-preview-137--develop-devlovers.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 13, 2026

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

📝 Walkthrough

Walkthrough

This PR implements email verification and password reset flows end-to-end, adding database tables for verification and reset tokens, creating email templates and transporter configuration, introducing API routes for signup, login, password reset, and email verification confirmation, and updating frontend authentication pages with proper UI states and error handling.

Changes

Cohort / File(s) Summary
Frontend Authentication Pages
frontend/app/[locale]/signup/page.tsx, frontend/app/[locale]/login/page.tsx, frontend/app/[locale]/forgot-password/page.tsx, frontend/app/[locale]/reset-password/page.tsx
Added/updated signup and login forms with email verification flow, password visibility toggle, error messaging, and resend verification UI. New forgot-password page with email submission and reset confirmation. New reset-password page with token validation and password update form.
Email Infrastructure
frontend/lib/email/transporter.ts, frontend/lib/email/sendVerificationEmail.ts, frontend/lib/email/sendPasswordResetEmail.ts, frontend/lib/email/templates/base-layout.ts, frontend/lib/email/templates/verify-email.ts, frontend/lib/email/templates/reset-password.ts
Nodemailer Gmail transport configuration, email sender utilities for verification and password reset, reusable HTML email base layout, and template generators for verification and reset flows.
Authentication API Routes
frontend/app/api/auth/signup/route.ts, frontend/app/api/auth/login/route.ts, frontend/app/api/auth/password-reset/route.ts, frontend/app/api/auth/password-reset/confirm/route.ts, frontend/app/api/auth/verify-email/route.ts, frontend/app/api/auth/resend-verification/route.ts, frontend/app/api/auth/github/callback/route.ts
Enhanced signup with email verification requirement, login with email verification checks and credential validation, password reset token generation and email sending, password reset confirmation with token validation and password hashing, email verification via token, resend verification email flow, and corrected GitHub OAuth redirect path.
Token Generation Helpers
frontend/lib/auth/email-verification.ts, frontend/lib/auth/password-reset.ts
UUID token generation and persistence for email verification (24-hour expiry) and password reset (1-hour expiry) flows with database insertion.
Database Schema & Tables
frontend/db/schema/emailVerificationTokens.ts, frontend/db/schema/passwordResetTokens.ts, frontend/db/schema/users.ts, frontend/db/schema/index.ts
New tables for email verification and password reset tokens with userId, token, expiresAt, and createdAt columns plus userId indexes. Updated users schema exports. Removed unused integer import.
Database Migrations
frontend/drizzle/0012_inventory_moves_product_fk_restrict.sql, frontend/drizzle/0013_low_roughhouse.sql, frontend/drizzle/0014_dapper_kang.sql, frontend/drizzle/0015_glamorous_eternity.sql
Conditional constraint handling for inventory moves, email verification tokens table with index, password reset tokens table with index, and additional email verification schema variant.
Drizzle Snapshots & Journal
frontend/drizzle/meta/0013_snapshot.json, frontend/drizzle/meta/0015_snapshot.json, frontend/drizzle/meta/_journal.json
Schema snapshots capturing new email verification and password reset token tables, user_id indexes, and migration journal entries with timestamps and conflict markers.
Database Configuration
frontend/db/index.ts
Bifurcated database initialization for local PostgreSQL (pg Pool + drizzle) vs. production (neon-http), replacing previous single resolver with environment-based configuration and explicit error handling.
Build Configuration
frontend/package.json, frontend/scripts/guard-non-preview.ts
Added nodemailer and @types/nodemailer dependencies, simplified seed and migrate scripts by removing Netlify preview guard logic, deleted guard-non-preview.ts script.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Frontend as Signup Page
    participant SignupAPI as /api/auth/signup
    participant Database
    participant Mailer as Email Service
    
    User->>Frontend: Enter email & password
    User->>Frontend: Click Sign Up
    Frontend->>SignupAPI: POST {email, password}
    SignupAPI->>Database: Check if user exists
    alt User already exists
        SignupAPI-->>Frontend: Return error
        Frontend->>User: Show error message
    else New user
        SignupAPI->>Database: Hash password
        SignupAPI->>Database: Insert user (emailVerified: null)
        Database-->>SignupAPI: User created
        SignupAPI->>Database: Create verification token
        Database-->>SignupAPI: Token generated
        SignupAPI->>Mailer: Send verification email with token
        Mailer-->>SignupAPI: Email sent
        SignupAPI-->>Frontend: Return 201 {verificationRequired: true}
        Frontend->>User: Show verification notice<br/>(Check email for link)
        User->>Mailer: Click verification link
        Mailer->>Frontend: Redirect to /verify-email?token=...
        Frontend->>VerifyAPI: GET /api/auth/verify-email?token=...
        VerifyAPI->>Database: Lookup & validate token
        alt Token valid
            Database->>Database: Set emailVerified = now()
            Database->>Database: Delete token
            VerifyAPI->>Frontend: Redirect to /login?verified=1
            Frontend->>User: Account verified!
        else Token expired/invalid
            VerifyAPI->>Frontend: Redirect to /login?error=...
            Frontend->>User: Show error
        end
    end
Loading
sequenceDiagram
    participant User
    participant Frontend as Forgot Password<br/>Page
    participant ResetAPI as /api/auth/<br/>password-reset
    participant Database
    participant Mailer as Email Service
    participant ResetPage as Reset Password<br/>Page
    
    User->>Frontend: Enter email
    User->>Frontend: Click Send Reset Link
    Frontend->>ResetAPI: POST {email}
    ResetAPI->>Database: Lookup user by email
    alt User exists & uses credentials
        ResetAPI->>Database: Clear old reset tokens
        ResetAPI->>Database: Create reset token (1hr expiry)
        Database-->>ResetAPI: Token created
        ResetAPI->>Mailer: Send reset email with token link
        Mailer-->>ResetAPI: Email sent
    end
    ResetAPI-->>Frontend: Return {success: true}
    Frontend->>User: Show confirmation message
    User->>Mailer: Click reset link in email
    Mailer->>ResetPage: Redirect to /reset-password?token=...
    ResetPage->>User: Show password form with token pre-filled
    User->>ResetPage: Enter new password & submit
    ResetPage->>ConfirmAPI: POST {token, password} to<br/>/api/auth/password-reset/confirm
    ConfirmAPI->>Database: Validate token & expiry
    alt Valid token
        Database->>Database: Hash new password
        Database->>Database: Update user passwordHash
        Database->>Database: Delete used token
        ConfirmAPI-->>ResetPage: Return {success: true}
        ResetPage->>User: Show success, link to login
        User->>Frontend: Click login link
        Frontend->>User: Back to login form
    else Invalid/expired token
        ConfirmAPI-->>ResetPage: Return error
        ResetPage->>User: Show error message
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • AM1007
  • ViktorSvertoka

🐰 Token tokens, emails glow bright,
Passwords reset in the moon's soft light,
Verification hops through the database deep,
Authentication secrets safe we keep!
*~ The DevLovers Rabbit* 🌙✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 clearly and specifically describes the main changes: implementing secure authentication with email verification and password recovery features across backend, frontend, and UI.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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
Copy Markdown
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: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/drizzle/meta/_journal.json (1)

99-128: Unresolved merge conflict markers break this JSON file.

This file contains unresolved git merge conflict markers (<<<<<<<, =======, >>>>>>>), making it invalid JSON. This will break Drizzle migration tooling.

Additionally, after resolving the conflict, note that:

  • There are two entries claiming idx: 13 (lines 97 and 103)
  • There are two entries claiming idx: 14 (lines 108 and 123)

The journal entries need to be reconciled to have sequential, non-duplicate indices.

To fix:

  1. Resolve the merge conflict by choosing the correct migration sequence
  2. Ensure indices are sequential and unique
  3. Verify the chosen migrations exist in frontend/drizzle/ directory
🤖 Fix all issues with AI agents
In @frontend/app/[locale]/forgot-password/page.tsx:
- Around line 16-35: Wrap the fetch in onSubmit with a try-catch-finally:
perform the POST inside try, set setSubmitted(true) when res.ok, and on non-ok
set setError as before; in catch set setError to a user-friendly network error
message and ensure setLoading(false) is always called in finally so loading
doesn't remain true; reference onSubmit, fetch("/api/auth/password-reset"), and
the state setters setLoading, setError, setSubmitted (and the email variable)
when making the changes.

In @frontend/app/[locale]/reset-password/page.tsx:
- Around line 26-42: The fetch to "/api/auth/password-reset/confirm" in the
reset handler can throw on network failures and currently has no try/catch,
which can leave loading state stuck; wrap the fetch and response handling in a
try/catch and use finally to ensure setLoading(false) always runs, set a
user-friendly error via setError on catch (e.g., "Network error, please try
again"), and keep the existing res.ok check to set the "Invalid or expired reset
link." message when the request completes but returns a non-OK response; update
the code surrounding the token/password POST (the fetch call and the calls to
setLoading and setError) accordingly.
- Around line 8-11: The ResetPasswordPage component uses useSearchParams() which
requires a Suspense boundary in Next.js 15+ when the route is statically
prerendered; to fix, either wrap the parent/server component that renders
ResetPasswordPage in a React.Suspense boundary, or convert this route to dynamic
by adding export const dynamic = 'force-dynamic' in the module, or stop calling
useSearchParams in ResetPasswordPage and instead accept searchParams/token as a
prop passed from the parent Server Component; update the code accordingly by
modifying the parent or the ResetPasswordPage signature (referencing
ResetPasswordPage and useSearchParams) to implement one of these fixes.

In @frontend/app/[locale]/signup/page.tsx:
- Around line 23-57: The onSubmit handler can throw if fetch fails, leaving
loading true and later code referencing res; wrap the fetch and subsequent
res.json() calls in a try/catch/finally inside onSubmit so network errors are
caught, call setError with a user-friendly message in the catch, and ensure
setLoading(false) runs in finally; also guard the res.ok check to only run if
res is defined (or handle absence by setting an error) and
setVerificationRequired when appropriate.
- Around line 55-56: The assignment to window.location.href uses the unvalidated
returnTo parameter (returnTo) which can enable open redirect attacks; before
setting window.location.href in the signup flow, validate returnTo by ensuring
it is a safe relative path (e.g., starts with '/' but not '//' and optionally
begins with `/${locale}/`) or by parsing it and confirming its origin matches
the current origin, and only use returnTo when it passes validation otherwise
fall back to `/${locale}/dashboard`; update the code around the
window.location.href assignment where returnTo is read to perform this check.

In @frontend/app/api/auth/password-reset/route.ts:
- Around line 51-56: The code uses origin = (await headers()).get("origin") to
build resetUrl passed to sendPasswordResetEmail, but origin may be null; update
the route to fall back to a safe base URL (e.g., an environment variable like
process.env.NEXT_PUBLIC_APP_URL or NEXT_PUBLIC_BASE_URL) when origin is falsy
before constructing `${origin}/reset-password?token=${token}` so the reset link
never becomes "null/..."; ensure the fallback is used for resetUrl and keep the
rest of the sendPasswordResetEmail call unchanged.

In @frontend/app/api/auth/resend-verification/route.ts:
- Around line 57-62: The origin header can be null causing malformed verifyUrl
("null/..."); update the URL construction in resend-verification route (and the
same pattern in signup/route.ts and password-reset/route.ts) to use a safe
fallback: compute const origin = (await headers()).get("origin") ||
process.env.NEXT_PUBLIC_APP_URL ||
`${req.protocol}://${req.headers.get('host')}` (or equivalent request-derived
base) and normalize trailing slashes before building verifyUrl, then call
sendVerificationEmail({ to: email, verifyUrl:
`${origin}/api/auth/verify-email?token=${token}` }); ensure the same fallback
logic is applied where verifyUrl (or reset URL) is built so token links are
never prefixed with "null".

In @frontend/app/api/auth/signup/route.ts:
- Line 22: Remove the leftover debug statement console.log('signup handler hit')
from the signup handler in route.ts; delete that console.log call (and any other
console.* debugging statements in the same handler) so no debug logs are left in
production code, leaving the handler logic unchanged.
- Around line 66-71: The origin header fetched via headers().get("origin") can
be null, so update the signup route to fall back to a canonical app URL when
building the verifyUrl: compute a baseUrl = origin ??
process.env.NEXT_PUBLIC_APP_URL ?? process.env.APP_URL (and normalize to remove
any trailing slash), then call sendVerificationEmail({ to: normalizedEmail,
verifyUrl: `${baseUrl}/api/auth/verify-email?token=${token}` }) so verifyUrl is
never built with "null" and always uses a valid absolute URL.

In @frontend/db/schema/emailVerificationTokens.ts:
- Around line 3-17: Add a foreign key constraint from
emailVerificationTokens.userId to the users table: update the
emailVerificationTokens pgTable definition to call .references(() => users.id, {
onDelete: "cascade" }) (or equivalent reference to your users primary key) on
the userId column so it enforces referential integrity and cascades deletes;
mirror the pattern used in other schemas (e.g., points, shop, quiz) and ensure
the symbol names match your users table export and primary key field.

In @frontend/drizzle/0015_glamorous_eternity.sql:
- Around line 1-16: The migration email_verification_tokens in
0015_glamorous_eternity.sql conflicts with 0013_low_roughhouse.sql (0013 uses
token as PK and user_id, 0015 uses id uuid PK and email); consolidate into a
single consistent schema for the email_verification_tokens table by choosing the
canonical shape (either keep token as PRIMARY KEY with user_id or keep id uuid
PRIMARY KEY with email), update the CREATE TABLE statement in the migration to
match that chosen schema, remove or replace the unsafe IF NOT EXISTS behavior
(use explicit create/drop/alter so the schema is deterministic), ensure the
index email_verification_tokens_token_idx matches the token column existence and
uniqueness, and adjust any dependent code/schema files to the chosen column
names (token, id, user_id/email) so all migrations and runtime code agree.

In @frontend/lib/auth/email-verification.ts:
- Around line 5-9: The addHours function currently ignores its date parameter
and always uses new Date(); modify addHours(date: Date, hours: number) to
operate on a clone of the passed-in date (e.g., new Date(date.getTime()) or new
Date(date)) so you don't mutate the original, then add the hours via
setHours/getHours and return that cloned date; keep the function name addHours
and ensure it uses the input date rather than creating a fresh Date().

In @frontend/lib/auth/password-reset.ts:
- Around line 5-9: The addHours function ignores its date parameter by creating
a new Date() instead of using the passed-in date; update addHours (in
frontend/lib/auth/password-reset.ts) to use the provided date argument and
return a new Date based on it (or better, replace the implementation to use
date-fns addHours for consistency with email-verification.ts), ensuring all call
sites (e.g., addHours(new Date(), 1)) behave correctly and import addHours from
date-fns if you switch to that approach.
🧹 Nitpick comments (15)
frontend/app/api/auth/github/callback/route.ts (1)

175-175: LGTM! Redirect to dashboard after OAuth login.

The change correctly redirects authenticated users to /dashboard instead of the root path, aligning with the PR objectives.

Optional enhancement: Consider preserving the user's original destination URL (e.g., via state parameter or a stored redirect path) to improve UX when users are redirected to login from a protected route. This would allow them to land on their intended page after authentication rather than always going to /dashboard.

frontend/lib/email/transporter.ts (1)

6-8: Consider lazy initialization to avoid startup crashes.

Throwing at module load time will crash the entire application if these environment variables are missing, even for code paths that don't require email functionality. This can be problematic in development environments or services that don't send emails.

Consider deferring validation to when mailer is first used, or exporting a factory function.

♻️ Example: Lazy initialization approach
-if (!user || !pass) {
-    throw new Error("Missing Gmail SMTP credentials");
-}
-
-export const mailer = nodemailer.createTransport({
+let _mailer: nodemailer.Transporter | null = null;
+
+export function getMailer(): nodemailer.Transporter {
+    if (!_mailer) {
+        if (!user || !pass) {
+            throw new Error("Missing Gmail SMTP credentials");
+        }
+        _mailer = nodemailer.createTransport({
             service: "gmail",
             auth: {
                 user,
                 pass,
             },
-});
+        });
+    }
+    return _mailer;
+}
frontend/app/[locale]/forgot-password/page.tsx (1)

74-81: Add accessible label for the email input.

Using only placeholder as the label is not accessible for screen reader users. Consider adding a visually hidden label or using an explicit <label> element.

♿ Suggested improvement
+                    <label htmlFor="email" className="sr-only">
+                        Email address
+                    </label>
                     <input
+                        id="email"
                         type="email"
                         required
                         placeholder="Email"
                         value={email}
                         onChange={e => setEmail(e.target.value)}
                         className="w-full rounded border px-3 py-2"
                     />
frontend/app/[locale]/reset-password/page.tsx (1)

84-92: Consider adding password visibility toggle for consistency.

The PR summary mentions that LoginPage and SignupPage have show/hide password toggles. For consistency, consider adding the same functionality here.

frontend/app/api/auth/login/route.ts (3)

52-52: Missing semicolon.

Add a semicolon after the assignment for consistency with the rest of the codebase.

-  const user = result[0]
+  const user = result[0];

54-56: Consider graceful handling instead of throwing.

Throwing an unhandled Error returns a 500 status to the client, potentially leaking internal implementation details. Since the users.provider column has a default of "credentials" and is NOT NULL, this condition should only occur with data corruption. Consider returning a generic 401 error or logging this as a critical issue while returning a user-friendly response.

💡 Suggested alternative
   if (!user.provider) {
-    throw new Error("User record missing provider");
+    console.error("Data integrity issue: user record missing provider", { userId: user.id });
+    return NextResponse.json(
+      { error: "Invalid email or password" },
+      { status: 401 }
+    );
   }

75-83: Use user variable consistently.

The user variable was introduced at line 52 but lines 75-83 still reference result[0]. Use user throughout for consistency and clarity.

♻️ Suggested fix
   const token = signAuthToken({
-    userId: result[0].id,
-    role: result[0].role as "user" | "admin",
+    userId: user.id,
+    role: user.role as "user" | "admin",
     email: normalizedEmail,
   });
 
   await setAuthCookie(token);
   revalidatePath('/[locale]', 'layout');
-  return NextResponse.json({ success: true, userId: result[0].id });
+  return NextResponse.json({ success: true, userId: user.id });
 }
frontend/db/schema/index.ts (1)

7-8: Minor: Inconsistent quote style.

Line 7 uses single quotes while line 8 uses double quotes. For consistency with the rest of the file, use single quotes throughout.

 export * from './emailVerificationTokens';
-export * from "./passwordResetTokens";
+export * from './passwordResetTokens';
frontend/lib/email/sendPasswordResetEmail.ts (1)

12-17: Consider: Extract shared EMAIL_FROM validation.

Both sendVerificationEmail and sendPasswordResetEmail duplicate the EMAIL_FROM environment check. You could optionally extract this to a shared helper or constant in the transporter module for DRYer code.

// In transporter.ts
export const emailFrom = (() => {
  const from = process.env.EMAIL_FROM;
  if (!from) throw new Error("EMAIL_FROM is not configured");
  return from;
})();

This is a minor optional improvement—the current implementation is clear and functional.

frontend/app/api/auth/password-reset/confirm/route.ts (1)

10-13: Consider adding password complexity validation.

The current schema only enforces a minimum length of 8 characters. Depending on security requirements, you may want to add additional password complexity rules (e.g., requiring mixed case, numbers, or special characters) or document that this is intentionally delegated to frontend validation.

frontend/drizzle/0014_dapper_kang.sql (1)

1-8: Consider adding a foreign key constraint on user_id.

The user_id column references the users table but lacks a foreign key constraint. While tokens naturally expire, orphaned tokens could accumulate if users are deleted. Adding REFERENCES users(id) ON DELETE CASCADE would automatically clean up tokens when users are removed.

This is a minor concern since:

  1. Tokens have short expiration times
  2. User deletion may be rare
  3. It matches the pattern used for email_verification_tokens
frontend/app/api/auth/verify-email/route.ts (1)

2-2: Remove unused import lt.

The lt operator is imported but never used in this file.

Proposed fix
-import { eq, and, lt } from "drizzle-orm";
+import { eq } from "drizzle-orm";
frontend/app/[locale]/login/page.tsx (1)

107-119: Consider adding error handling and loading state to resendVerification.

The function silently ignores fetch failures and doesn't indicate to the user that a request is in progress. This could lead to confusion if the request fails or if users click multiple times.

Proposed improvement
+  const [resending, setResending] = useState(false);
+
   async function resendVerification() {
     if (!email) return;
 
-    await fetch("/api/auth/resend-verification", {
-      method: "POST",
-      headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ email }),
-    });
+    setResending(true);
+    try {
+      await fetch("/api/auth/resend-verification", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ email }),
+      });
 
-    setVerificationSent(true);
-    setErrorCode(null);
-    setErrorMessage(null);
+      setVerificationSent(true);
+      setErrorCode(null);
+      setErrorMessage(null);
+    } catch {
+      setErrorMessage("Failed to resend verification email. Please try again.");
+    } finally {
+      setResending(false);
+    }
   }

Then update the button to show loading state:

<button
  type="button"
  onClick={resendVerification}
  disabled={resending}
  className="mt-2 underline"
>
  {resending ? "Sending..." : "Resend verification email"}
</button>
frontend/app/api/auth/signup/route.ts (2)

52-76: Consider wrapping user creation and token generation in a transaction.

If createEmailVerificationToken or sendVerificationEmail fails after the user is inserted, the user exists but may not receive a verification email. While the user can request a resend, wrapping in a transaction ensures atomicity.

♻️ Optional transaction wrapper
+    await db.transaction(async (tx) => {
-    const [user] = await db
+      const [user] = await tx
         .insert(users)
         .values({
           name,
           email: normalizedEmail,
           passwordHash,
           provider: "credentials",
           emailVerified: null,
           role: "user",
         })
         .returning();

-    const token = await createEmailVerificationToken(user.id)
+      // Note: createEmailVerificationToken would need to accept tx as parameter
+      const token = await createEmailVerificationToken(user.id, tx);

-    const origin = (await headers()).get("origin")
+      const origin = (await headers()).get("origin") || process.env.NEXT_PUBLIC_APP_URL;

-    await sendVerificationEmail({
+      await sendVerificationEmail({
         to: normalizedEmail,
         verifyUrl: `${origin}/api/auth/verify-email?token=${token}`
-    })
+      });
+    });

This requires modifying createEmailVerificationToken to accept a transaction parameter.


10-11: Minor: Remove extra blank line.

 import { sendVerificationEmail } from "@/lib/email/sendVerificationEmail";
-

 
 export const runtime = "nodejs";
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7d152a3 and 77d48ec.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (33)
  • frontend/app/[locale]/forgot-password/page.tsx
  • frontend/app/[locale]/login/page.tsx
  • frontend/app/[locale]/reset-password/page.tsx
  • frontend/app/[locale]/signup/page.tsx
  • frontend/app/api/auth/github/callback/route.ts
  • frontend/app/api/auth/login/route.ts
  • frontend/app/api/auth/password-reset/confirm/route.ts
  • frontend/app/api/auth/password-reset/route.ts
  • frontend/app/api/auth/resend-verification/route.ts
  • frontend/app/api/auth/signup/route.ts
  • frontend/app/api/auth/verify-email/route.ts
  • frontend/db/index.ts
  • frontend/db/schema/emailVerificationTokens.ts
  • frontend/db/schema/index.ts
  • frontend/db/schema/passwordResetTokens.ts
  • frontend/db/schema/users.ts
  • frontend/drizzle/0012_inventory_moves_product_fk_restrict.sql
  • frontend/drizzle/0013_low_roughhouse.sql
  • frontend/drizzle/0014_dapper_kang.sql
  • frontend/drizzle/0015_glamorous_eternity.sql
  • frontend/drizzle/meta/0013_snapshot.json
  • frontend/drizzle/meta/0015_snapshot.json
  • frontend/drizzle/meta/_journal.json
  • frontend/lib/auth/email-verification.ts
  • frontend/lib/auth/password-reset.ts
  • frontend/lib/email/sendPasswordResetEmail.ts
  • frontend/lib/email/sendVerificationEmail.ts
  • frontend/lib/email/templates/base-layout.ts
  • frontend/lib/email/templates/reset-password.ts
  • frontend/lib/email/templates/verify-email.ts
  • frontend/lib/email/transporter.ts
  • frontend/package.json
  • frontend/scripts/guard-non-preview.ts
💤 Files with no reviewable changes (2)
  • frontend/db/schema/users.ts
  • frontend/scripts/guard-non-preview.ts
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-30T16:01:02.523Z
Learnt from: kryvosheyin
Repo: DevLoversTeam/devlovers.net PR: 88
File: frontend/drizzle/schema.ts:153-153
Timestamp: 2025-12-30T16:01:02.523Z
Learning: The file frontend/drizzle/schema.ts is a legacy file not used by Drizzle or the runtime. The canonical schemas live in frontend/db/schema/* as configured in drizzle-config.ts, and that's what migrations and runtime use.

Applied to files:

  • frontend/db/schema/index.ts
  • frontend/db/schema/passwordResetTokens.ts
  • frontend/drizzle/meta/0015_snapshot.json
  • frontend/db/schema/emailVerificationTokens.ts
  • frontend/db/index.ts
🧬 Code graph analysis (14)
frontend/lib/email/sendPasswordResetEmail.ts (2)
frontend/lib/email/transporter.ts (1)
  • mailer (10-16)
frontend/lib/email/templates/reset-password.ts (1)
  • resetPasswordTemplate (3-47)
frontend/app/api/auth/resend-verification/route.ts (6)
frontend/app/api/auth/password-reset/route.ts (1)
  • POST (16-59)
frontend/app/api/auth/signup/route.ts (1)
  • POST (21-87)
frontend/db/schema/users.ts (1)
  • users (10-41)
frontend/db/schema/emailVerificationTokens.ts (1)
  • emailVerificationTokens (3-18)
frontend/lib/auth/email-verification.ts (1)
  • createEmailVerificationToken (11-21)
frontend/lib/email/sendVerificationEmail.ts (1)
  • sendVerificationEmail (9-25)
frontend/lib/email/templates/reset-password.ts (1)
frontend/lib/email/templates/base-layout.ts (1)
  • baseEmailLayout (6-103)
frontend/lib/auth/email-verification.ts (2)
frontend/db/index.ts (1)
  • db (54-54)
frontend/db/schema/emailVerificationTokens.ts (1)
  • emailVerificationTokens (3-18)
frontend/lib/email/sendVerificationEmail.ts (2)
frontend/lib/email/transporter.ts (1)
  • mailer (10-16)
frontend/lib/email/templates/verify-email.ts (1)
  • verifyEmailTemplate (3-47)
frontend/app/[locale]/signup/page.tsx (1)
frontend/components/ui/button.tsx (1)
  • Button (42-42)
frontend/app/api/auth/verify-email/route.ts (3)
frontend/db/index.ts (1)
  • db (54-54)
frontend/db/schema/emailVerificationTokens.ts (1)
  • emailVerificationTokens (3-18)
frontend/db/schema/users.ts (1)
  • users (10-41)
frontend/app/api/auth/login/route.ts (1)
frontend/db/schema/users.ts (1)
  • users (10-41)
frontend/app/[locale]/login/page.tsx (1)
frontend/lib/quiz/guest-quiz.ts (2)
  • getPendingQuizResult (27-49)
  • clearPendingQuizResult (51-54)
frontend/lib/auth/password-reset.ts (2)
frontend/db/index.ts (1)
  • db (54-54)
frontend/db/schema/passwordResetTokens.ts (1)
  • passwordResetTokens (3-18)
frontend/lib/email/templates/verify-email.ts (1)
frontend/lib/email/templates/base-layout.ts (1)
  • baseEmailLayout (6-103)
frontend/app/api/auth/signup/route.ts (3)
frontend/db/schema/users.ts (1)
  • users (10-41)
frontend/lib/auth/email-verification.ts (1)
  • createEmailVerificationToken (11-21)
frontend/lib/email/sendVerificationEmail.ts (1)
  • sendVerificationEmail (9-25)
frontend/app/api/auth/password-reset/route.ts (4)
frontend/db/schema/users.ts (1)
  • users (10-41)
frontend/db/schema/passwordResetTokens.ts (1)
  • passwordResetTokens (3-18)
frontend/lib/auth/password-reset.ts (1)
  • createPasswordResetToken (11-23)
frontend/lib/email/sendPasswordResetEmail.ts (1)
  • sendPasswordResetEmail (9-26)
frontend/app/api/auth/password-reset/confirm/route.ts (3)
frontend/app/api/auth/password-reset/route.ts (1)
  • POST (16-59)
frontend/db/schema/passwordResetTokens.ts (1)
  • passwordResetTokens (3-18)
frontend/db/schema/users.ts (1)
  • users (10-41)
🪛 Biome (2.1.2)
frontend/drizzle/meta/_journal.json

[error] 99-99: unexpected character <

(parse)


[error] 99-99: unexpected character <

(parse)


[error] 99-99: unexpected character <

(parse)


[error] 99-99: unexpected character <

(parse)


[error] 99-99: unexpected character <

(parse)


[error] 99-99: unexpected character <

(parse)


[error] 99-99: expected , but instead found HEAD

Remove HEAD

(parse)


[error] 100-100: expected , but instead found "when"

Remove "when"

(parse)


[error] 100-100: expected , but instead found :

Remove :

(parse)


[error] 100-100: expected , but instead found 1768162667947

Remove 1768162667947

(parse)


[error] 101-101: expected , but instead found :

Remove :

(parse)


[error] 101-101: expected , but instead found "0013_brown_gamora"

Remove "0013_brown_gamora"

(parse)


[error] 101-102: unexpected character =

(parse)


[error] 102-102: unexpected character =

(parse)


[error] 102-102: unexpected character =

(parse)


[error] 102-102: unexpected character =

(parse)


[error] 102-102: unexpected character =

(parse)


[error] 102-102: unexpected character =

(parse)


[error] 102-102: unexpected character =

(parse)


[error] 103-103: expected , but instead found "when"

Remove "when"

(parse)


[error] 103-103: expected , but instead found :

Remove :

(parse)


[error] 103-103: expected , but instead found 1767697281389

Remove 1767697281389

(parse)


[error] 104-104: expected , but instead found :

Remove :

(parse)


[error] 104-104: expected , but instead found "0013_low_roughhouse"

Remove "0013_low_roughhouse"

(parse)


[error] 105-105: expected , but instead found :

Remove :

(parse)


[error] 105-105: expected , but instead found true

Remove true

(parse)


[error] 106-106: expected , but instead found }

Remove }

(parse)


[error] 106-106: End of file expected

Use an array for a sequence of values: [1, 2]

(parse)


[error] 107-113: End of file expected

Use an array for a sequence of values: [1, 2]

(parse)


[error] 113-113: End of file expected

Use an array for a sequence of values: [1, 2]

(parse)


[error] 114-118: End of file expected

Use an array for a sequence of values: [1, 2]

(parse)


[error] 118-119: unexpected character >

(parse)


[error] 119-119: unexpected character >

(parse)


[error] 119-119: unexpected character >

(parse)


[error] 119-119: unexpected character >

(parse)


[error] 119-119: unexpected character >

(parse)


[error] 119-119: unexpected character >

(parse)


[error] 119-119: unexpected character >

(parse)


[error] 119-119: String values must be double quoted.

(parse)


[error] 119-119: unexpected character (

(parse)


[error] 119-119: String values must be double quoted.

(parse)


[error] 119-119: unexpected character (

(parse)


[error] 119-119: String values must be double quoted.

(parse)


[error] 119-119: unexpected character )

(parse)


[error] 119-119: End of file expected

Use an array for a sequence of values: [1, 2]

(parse)


[error] 119-119: unexpected character )

(parse)

🔇 Additional comments (27)
frontend/package.json (3)

63-63: LGTM!

TypeScript types for nodemailer are correctly added as a dev dependency with matching major version.


10-15: This review comment appears to be based on incorrect assumptions about the codebase.

The guard-non-preview.ts script does not exist in the repository, and there is no scripts/ directory. The current scripts in package.json (lines 10-15) do not have any guard prefix, and no evidence suggests a guard was removed as part of this change.

If you intended to flag that these seed scripts lack environment safeguards, that concern is separate. The scripts currently only check for DATABASE_URL existence but do not prevent execution in production environments.

Likely an incorrect or invalid review comment.


45-45: Appropriate dependency for email functionality.

Adding nodemailer (version 7.0.12) is the correct choice for implementing email verification and password reset features. The version specified is the latest stable release on npm, and the caret range allows for compatible patch and minor updates.

frontend/drizzle/0012_inventory_moves_product_fk_restrict.sql (1)

1-18: LGTM! Safe conditional migration.

The guarded DO $$ ... END $$ block correctly checks for table existence before modifying constraints, preventing errors in environments where inventory_moves may not exist. The ON DELETE RESTRICT constraint appropriately protects referential integrity.

frontend/app/[locale]/forgot-password/page.tsx (1)

37-99: Good privacy practice in the success message.

The confirmation message appropriately uses "If an account for ... exists" rather than confirming account existence, which prevents email enumeration attacks.

frontend/drizzle/0013_low_roughhouse.sql (1)

1-8: This migration has conflicting table definitions with 0015_glamorous_eternity.sql.

Migration 0013 creates email_verification_tokens with token as PRIMARY KEY and user_id column, while 0015 defines the same table with id as PRIMARY KEY and email column instead. Although 0015 uses IF NOT EXISTS to prevent a runtime error (0013 will execute first and 0015 will skip), this schema mismatch indicates a design issue.

Verify whether 0013 or 0015 represents the intended schema and remove or consolidate the conflicting migration definition.

frontend/app/api/auth/login/route.ts (1)

58-63: LGTM!

Good implementation of email verification check that correctly distinguishes between credential-based and OAuth users. The EMAIL_NOT_VERIFIED code enables the frontend to show appropriate contextual feedback.

frontend/db/schema/passwordResetTokens.ts (1)

3-17: LGTM!

The schema structure is consistent with emailVerificationTokens, with appropriate columns for token-based flows. The same consideration about foreign key constraints applies here as noted in the email verification tokens review.

frontend/db/index.ts (1)

1-54: LGTM! Clean environment-based database configuration.

The conditional setup correctly branches between local PostgreSQL (via pg Pool) and production Neon HTTP. Error handling for missing environment variables is appropriate, and test-mode logging suppression is a nice touch.

frontend/lib/email/sendVerificationEmail.ts (1)

1-25: LGTM! Clean email verification utility.

The function correctly validates the required EMAIL_FROM environment variable, provides both plain text and HTML content for email clients, and uses consistent branding in the subject line.

frontend/lib/email/templates/reset-password.ts (1)

1-47: LGTM! Well-structured password reset email template.

The template follows the established pattern from verifyEmailTemplate, provides clear user instructions, and includes appropriate expiry and ignore notices. The direct URL interpolation is safe assuming resetUrl is always server-generated with controlled tokens.

frontend/drizzle/meta/0013_snapshot.json (1)

2261-2314: Verify: Missing foreign key constraint on user_id.

The email_verification_tokens table has user_id without a foreign key constraint to users.id. Other user-related tables (e.g., quiz_attempts, point_transactions) define foreign keys with ON DELETE CASCADE. Without an FK, orphaned tokens may persist if a user is deleted.

If this is intentional (e.g., to avoid cascade complexity during signup flow before user is fully committed), please confirm. Otherwise, consider adding:

FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
frontend/lib/email/sendPasswordResetEmail.ts (1)

1-26: LGTM! Consistent implementation with sendVerificationEmail.

The function correctly follows the same pattern as sendVerificationEmail with proper environment validation, plain text fallback, and branded subject line.

frontend/lib/email/templates/verify-email.ts (1)

1-47: LGTM!

The email template is well-structured with clear messaging, proper use of the base layout, and consistent styling. The 24-hour expiration notice correctly matches the token validity period defined in the verification token creation logic.

frontend/app/api/auth/password-reset/route.ts (1)

16-24: Good security practice: Silent success on validation failure.

Returning { success: true } regardless of validation outcome prevents attackers from discovering valid email addresses through error responses. This is a solid security pattern for password reset flows.

frontend/app/api/auth/password-reset/confirm/route.ts (1)

57-70: LGTM! Proper use of transaction for atomic password update.

The transaction correctly ensures that both the password update and token deletion succeed together or fail together, preventing scenarios where a token could be reused or a password update is lost.

frontend/lib/auth/email-verification.ts (1)

11-21: No action needed. Token cleanup is already properly handled in the resend-verification route (lines 51–53) which deletes existing tokens before calling createEmailVerificationToken. The signup route creates a new user with no pre-existing tokens, so deletion is unnecessary there. The current architecture—with cleanup at the call site rather than inside the utility function—is cleaner and avoids redundant operations.

Likely an incorrect or invalid review comment.

frontend/app/api/auth/resend-verification/route.ts (1)

16-55: LGTM on the security-conscious implementation.

The handler correctly:

  • Returns { success: true } for all non-happy-path cases to prevent user enumeration
  • Validates provider type before sending verification
  • Skips already-verified accounts
  • Cleans up existing tokens before creating new ones
frontend/app/api/auth/verify-email/route.ts (1)

8-59: LGTM on the verification flow.

The handler correctly:

  • Validates token presence and existence
  • Checks expiration and cleans up expired tokens
  • Uses a transaction to atomically update the user and delete the token
  • Provides appropriate redirect URLs with query parameters for UI feedback
frontend/lib/auth/password-reset.ts (1)

11-23: LGTM on the token creation logic.

The function correctly generates a cryptographically random UUID and persists it with the user ID and expiration time.

frontend/app/[locale]/login/page.tsx (3)

143-160: Good implementation of the password visibility toggle.

The toggle correctly uses aria-label for accessibility, and the button type is properly set to "button" to prevent form submission.


162-173: Good preservation of returnTo parameter across navigation links.

Both the "Forgot password?" and "Sign up" links correctly preserve the returnTo query parameter, ensuring a consistent user flow.

Also applies to: 204-213


83-92: The quiz_just_saved key is consistently used across the codebase.

The key is set in frontend/app/[locale]/login/page.tsx and properly consumed and cleaned up in frontend/components/dashboard/QuizSavedBanner.tsx with appropriate error handling. No inconsistencies detected.

frontend/drizzle/meta/0015_snapshot.json (2)

2218-2271: LGTM - Email verification token table structure is appropriate.

The table design with token as primary key and index on user_id supports efficient token lookup and user-based queries. Note that there's no foreign key to the users table, which means tokens won't be automatically cleaned up if a user is deleted - this may be intentional for audit purposes but consider implementing a cleanup mechanism.


2272-2325: Password reset tokens table mirrors verification tokens structure.

Consistent design with the email verification tokens table. Same consideration applies regarding orphaned tokens if users are deleted.

frontend/lib/email/templates/base-layout.ts (1)

1-103: Well-structured email template with good cross-client compatibility.

The table-based layout is the correct approach for email clients, and the dark mode support via prefers-color-scheme is a nice enhancement. The inline styles ensure consistent rendering across email clients that strip <style> tags.

frontend/app/[locale]/signup/page.tsx (1)

97-147: Form implementation looks good with proper accessibility.

The password visibility toggle includes proper aria-label for screen readers, and the form fields have appropriate validation attributes (required, minLength).

Comment thread frontend/app/[locale]/forgot-password/page.tsx
Comment on lines +56 to +65
<Link
href={
returnTo
? `/login?returnTo=${encodeURIComponent(returnTo)}`
: "/login"
}
className="mt-4 inline-block underline"
>
Back to login
</Link>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, find the login page and related files
find frontend -type f -name "*login*" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Search for returnTo usage across the codebase
rg -n "returnTo" --type ts --type tsx frontend/

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

# Also check if there's any validation or redirect logic in api routes or components
rg -n "redirect\|encodeURI\|pathname" --type ts --type tsx frontend/app/ -A 2 -B 2 | head -100

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

# Search for returnTo without file type restriction
rg -n "returnTo" frontend/

Repository: DevLoversTeam/devlovers.net

Length of output: 1924


🏁 Script executed:

# Find and read the login page
find frontend -type f -name "*login*" -path "*/page.*"

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Search for validation or redirect patterns
rg -n "redirect\|validate\|allowedDomains" frontend/ -A 3 | head -150

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

# Read the login page to see the returnTo handling
cat -n frontend/app/[locale]/login/page.tsx | sed -n '1,110p'

Repository: DevLoversTeam/devlovers.net

Length of output: 4197


🏁 Script executed:

# Check if there's any validation utility or helper
rg -n "validate\|isValidUrl\|isSafeUrl\|allowed" frontend/ -B 2 -A 2 | head -100

Repository: DevLoversTeam/devlovers.net

Length of output: 53


Fix open redirect vulnerability in login redirect logic.

The login page directly redirects to the returnTo parameter without validation at line 104: window.location.href = returnTo || /${locale}/dashboard;. An attacker can craft a link like /login?returnTo=https://evil.com to redirect authenticated users to a malicious site for phishing attacks.

Implement validation to ensure returnTo is a relative path or belongs to an allowed domain before redirecting. Consider using a helper function to validate URLs:

function isSafeRedirectUrl(url: string, locale: string): boolean {
  if (!url) return false;
  // Allow relative paths starting with /
  if (url.startsWith('/')) {
    // Ensure it doesn't contain protocol indicators for external redirects
    return !url.includes('://');
  }
  return false;
}

Then use: window.location.href = isSafeRedirectUrl(returnTo, locale) ? returnTo : /${locale}/dashboard;

Comment on lines +8 to +11
export default function ResetPasswordPage() {
const searchParams = useSearchParams();
const token = searchParams.get("token");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Next.js 15 useSearchParams Suspense boundary requirement client component

💡 Result:

Short answer

  • useSearchParams must be called from a Client Component (add 'use client'). [1]
  • If the route is statically prerendered, any Client Component that calls useSearchParams must be wrapped in a React Suspense boundary (closest Suspense catches it) or the build will bail with "Missing Suspense boundary" / the page will be deopted to CSR. Dev mode may hide this because rendering is on‑demand. [1][2]

Why

  • Next.js needs Suspense so it can render the static shell and let the small dynamic subtree that reads query params hydrate / suspend separately; otherwise the whole page is forced to client render at build time. [1][2]

How to fix

  • Move the useSearchParams code into a child Client Component and wrap that child in in the parent Server Component (page/layout). Or make the route dynamic (opt out of prerendering) or pass searchParams from the Server Component instead. [1][2][9]

References

  1. Next.js docs — useSearchParams behavior and recommendation. [1]
  2. Next.js error docs — "Missing Suspense boundary with useSearchParams" / CSR bailout guidance. [2]
  3. Active GitHub discussion / issue about build errors and Suspense with useSearchParams. [7]

Would you like a minimal code example showing the recommended pattern?


Wrap with Suspense boundary for useSearchParams.

In Next.js 15+, useSearchParams() in a Client Component requires the component to be wrapped in a <Suspense> boundary when the route is statically prerendered. Without it, the build will fail with a "Missing Suspense boundary" error or the entire page will be forced to client-side render. Either wrap this component in <Suspense> at the parent level, make the route dynamic with export const dynamic = 'force-dynamic', or pass searchParams from the parent Server Component instead.

🤖 Prompt for AI Agents
In @frontend/app/[locale]/reset-password/page.tsx around lines 8 - 11, The
ResetPasswordPage component uses useSearchParams() which requires a Suspense
boundary in Next.js 15+ when the route is statically prerendered; to fix, either
wrap the parent/server component that renders ResetPasswordPage in a
React.Suspense boundary, or convert this route to dynamic by adding export const
dynamic = 'force-dynamic' in the module, or stop calling useSearchParams in
ResetPasswordPage and instead accept searchParams/token as a prop passed from
the parent Server Component; update the code accordingly by modifying the parent
or the ResetPasswordPage signature (referencing ResetPasswordPage and
useSearchParams) to implement one of these fixes.

Comment on lines +26 to +42
setLoading(true);

const res = await fetch("/api/auth/password-reset/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
password,
}),
});

setLoading(false);

if (!res.ok) {
setError("Invalid or expired reset link.");
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add error handling for network failures.

The fetch call lacks a try-catch block. If the network request fails (e.g., offline, server unreachable), the promise rejection will be unhandled, setLoading(false) won't execute, and the UI will remain stuck in the loading state.

🛠️ Suggested fix
         setLoading(true);
 
+        try {
         const res = await fetch("/api/auth/password-reset/confirm", {
             method: "POST",
             headers: { "Content-Type": "application/json" },
             body: JSON.stringify({
                 token,
                 password,
             }),
         });
 
-        setLoading(false);
-
         if (!res.ok) {
+            setLoading(false);
             setError("Invalid or expired reset link.");
             return;
         }
 
+        setLoading(false);
         setConfirmed(true);
+        } catch {
+            setLoading(false);
+            setError("Network error. Please try again.");
+        }
     }
📝 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
setLoading(true);
const res = await fetch("/api/auth/password-reset/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
password,
}),
});
setLoading(false);
if (!res.ok) {
setError("Invalid or expired reset link.");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/password-reset/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
password,
}),
});
if (!res.ok) {
setLoading(false);
setError("Invalid or expired reset link.");
return;
}
setLoading(false);
setConfirmed(true);
} catch {
setLoading(false);
setError("Network error. Please try again.");
}
🤖 Prompt for AI Agents
In @frontend/app/[locale]/reset-password/page.tsx around lines 26 - 42, The
fetch to "/api/auth/password-reset/confirm" in the reset handler can throw on
network failures and currently has no try/catch, which can leave loading state
stuck; wrap the fetch and response handling in a try/catch and use finally to
ensure setLoading(false) always runs, set a user-friendly error via setError on
catch (e.g., "Network error, please try again"), and keep the existing res.ok
check to set the "Invalid or expired reset link." message when the request
completes but returns a non-OK response; update the code surrounding the
token/password POST (the fetch call and the calls to setLoading and setError)
accordingly.

Comment on lines 23 to 57
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setLoading(true);
setError(null);

try {
const formData = new FormData(e.currentTarget);

const res = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password"),
}),
});

if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Signup failed");
return;
}

const data = await res.json();
const pendingResult = getPendingQuizResult();

if (pendingResult && data.userId) {
try {
const quizRes = await fetch("/api/quiz/guest-result", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId: data.userId,
quizId: pendingResult.quizId,
answers: pendingResult.answers,
violations: pendingResult.violations,
timeSpentSeconds: pendingResult.timeSpentSeconds,
}),
});
if (!quizRes.ok) {
throw new Error(`Failed to save quiz result: ${quizRes.status}`);
}
const result = await quizRes.json();

if (result.success) {
sessionStorage.setItem('quiz_just_saved', JSON.stringify({
score: result.score,
total: result.totalQuestions,
percentage: result.percentage,
pointsAwarded: result.pointsAwarded,
quizSlug: pendingResult.quizSlug,
}));
}
} catch (err) {
console.error('Failed to save quiz result:', err);
} finally {
clearPendingQuizResult();
}

window.location.href = `/${locale}/dashboard`;
return;
}

window.location.href = returnTo || `/${locale}/dashboard`;
} catch (err) {
console.error('Signup submit error', err);
setError('Signup failed');
} finally {
setLoading(false);
const formData = new FormData(e.currentTarget);
const emailValue = String(formData.get("email") || "");
setEmail(emailValue);

const res = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.get("name"),
email: emailValue,
password: formData.get("password"),
}),
});

const data = await res.json().catch(() => null);
setLoading(false);

if (!res.ok) {
setError(data?.error ?? "Failed to sign up");
return;
}

if (data?.verificationRequired) {
setVerificationRequired(true);
return;
}

window.location.href =
returnTo || `/${locale}/dashboard`;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing error handling for network failures.

If the fetch call throws (network error, timeout), the exception is unhandled and setLoading(false) is never reached, leaving the button in a perpetual loading state.

🔧 Proposed fix to wrap fetch in try/catch
   async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
     e.preventDefault();
     setLoading(true);
     setError(null);

     const formData = new FormData(e.currentTarget);
     const emailValue = String(formData.get("email") || "");
     setEmail(emailValue);

+    try {
       const res = await fetch("/api/auth/signup", {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({
           name: formData.get("name"),
           email: emailValue,
           password: formData.get("password"),
         }),
       });

       const data = await res.json().catch(() => null);
-      setLoading(false);

       if (!res.ok) {
         setError(data?.error ?? "Failed to sign up");
         return;
       }

       if (data?.verificationRequired) {
         setVerificationRequired(true);
         return;
       }

       window.location.href =
         returnTo || `/${locale}/dashboard`;
+    } catch {
+      setError("Network error. Please try again.");
+    } finally {
+      setLoading(false);
+    }
   }
🤖 Prompt for AI Agents
In @frontend/app/[locale]/signup/page.tsx around lines 23 - 57, The onSubmit
handler can throw if fetch fails, leaving loading true and later code
referencing res; wrap the fetch and subsequent res.json() calls in a
try/catch/finally inside onSubmit so network errors are caught, call setError
with a user-friendly message in the catch, and ensure setLoading(false) runs in
finally; also guard the res.ok check to only run if res is defined (or handle
absence by setting an error) and setVerificationRequired when appropriate.

Comment on lines +66 to 71
const origin = (await headers()).get("origin")

await sendVerificationEmail({
to: normalizedEmail,
verifyUrl: `${origin}/api/auth/verify-email?token=${token}`
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Origin header may be null, causing broken verification links.

The origin header is not guaranteed to be present (e.g., for server-to-server requests, curl, or certain browser contexts). If origin is null, the verifyUrl will be malformed (null/api/auth/verify-email?token=...).

🔧 Proposed fix with fallback
-    const origin = (await headers()).get("origin")
+    const headersList = await headers();
+    const origin = headersList.get("origin") 
+      || headersList.get("x-forwarded-host") && `https://${headersList.get("x-forwarded-host")}`
+      || process.env.NEXT_PUBLIC_APP_URL;
+
+    if (!origin) {
+      throw new Error("Could not determine application origin for verification email");
+    }

     await sendVerificationEmail({
       to: normalizedEmail,
       verifyUrl: `${origin}/api/auth/verify-email?token=${token}`
     })

Alternatively, use an environment variable like NEXT_PUBLIC_APP_URL or APP_URL as the canonical base URL.

🤖 Prompt for AI Agents
In @frontend/app/api/auth/signup/route.ts around lines 66 - 71, The origin
header fetched via headers().get("origin") can be null, so update the signup
route to fall back to a canonical app URL when building the verifyUrl: compute a
baseUrl = origin ?? process.env.NEXT_PUBLIC_APP_URL ?? process.env.APP_URL (and
normalize to remove any trailing slash), then call sendVerificationEmail({ to:
normalizedEmail, verifyUrl: `${baseUrl}/api/auth/verify-email?token=${token}` })
so verifyUrl is never built with "null" and always uses a valid absolute URL.

Comment on lines +3 to +17
export const emailVerificationTokens = pgTable(
"email_verification_tokens",
{
token: text("token").primaryKey(),
userId: text("user_id").notNull(),
expiresAt: timestamp("expires_at", { mode: "date" }).notNull(),
createdAt: timestamp("created_at", { mode: "date" })
.notNull()
.defaultNow(),
},
table => ({
userIdIdx: index("email_verification_tokens_user_id_idx").on(
table.userId
),
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for foreign key usage in schema files
rg -n "references|foreignKey" --type ts frontend/db/schema/

Repository: DevLoversTeam/devlovers.net

Length of output: 2919


🏁 Script executed:

#!/bin/bash
# Also check what other tables exist and their patterns
find frontend/db/schema -type f -name "*.ts" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 362


🏁 Script executed:

#!/bin/bash
# Check if users table exists and see its structure
rg -A 10 "pgTable.*users" --type ts frontend/db/schema/

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Check the password reset tokens schema for comparison
cat -n frontend/db/schema/passwordResetTokens.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 751


🏁 Script executed:

#!/bin/bash
# Check the users table schema
cat -n frontend/db/schema/users.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1382


🏁 Script executed:

#!/bin/bash
# Check if there are any comments in emailVerificationTokens about FK or lifecycle
cat -n frontend/db/schema/emailVerificationTokens.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 763


Add foreign key constraint to users table on userId.

Both emailVerificationTokens and passwordResetTokens lack foreign key constraints on userId, despite being referenced columns. The codebase consistently uses .references() with appropriate onDelete rules elsewhere (e.g., points, shop, quiz schemas). Add a constraint with onDelete: 'cascade' to maintain referential integrity, or document that token cleanup is handled at the application level.

🤖 Prompt for AI Agents
In @frontend/db/schema/emailVerificationTokens.ts around lines 3 - 17, Add a
foreign key constraint from emailVerificationTokens.userId to the users table:
update the emailVerificationTokens pgTable definition to call .references(() =>
users.id, { onDelete: "cascade" }) (or equivalent reference to your users
primary key) on the userId column so it enforces referential integrity and
cascades deletes; mirror the pattern used in other schemas (e.g., points, shop,
quiz) and ensure the symbol names match your users table export and primary key
field.

Comment on lines +1 to +16
CREATE TABLE
IF NOT EXISTS email_verification_tokens
(
id uuid PRIMARY KEY DEFAULT gen_random_uuid
(),
email text NOT NULL,
token text NOT NULL,
expires_at timestamp NOT NULL,
created_at timestamp DEFAULT now
() NOT NULL
);

CREATE UNIQUE INDEX
IF NOT EXISTS email_verification_tokens_token_idx
ON email_verification_tokens
(token); No newline at end of file
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Schema mismatch: this migration conflicts with 0013.

This migration has a different schema than 0013_low_roughhouse.sql:

Column 0013 0015
Primary Key token id (uuid)
User reference user_id email
Token column PK separate column

With IF NOT EXISTS, this table won't be created if 0013 already ran, leaving an inconsistent schema. Dependent code (API routes, schema files) may expect one structure but get another.

Please consolidate these migrations into a single consistent schema definition.

🤖 Prompt for AI Agents
In @frontend/drizzle/0015_glamorous_eternity.sql around lines 1 - 16, The
migration email_verification_tokens in 0015_glamorous_eternity.sql conflicts
with 0013_low_roughhouse.sql (0013 uses token as PK and user_id, 0015 uses id
uuid PK and email); consolidate into a single consistent schema for the
email_verification_tokens table by choosing the canonical shape (either keep
token as PRIMARY KEY with user_id or keep id uuid PRIMARY KEY with email),
update the CREATE TABLE statement in the migration to match that chosen schema,
remove or replace the unsafe IF NOT EXISTS behavior (use explicit
create/drop/alter so the schema is deterministic), ensure the index
email_verification_tokens_token_idx matches the token column existence and
uniqueness, and adjust any dependent code/schema files to the chosen column
names (token, id, user_id/email) so all migrations and runtime code agree.

Comment on lines +5 to +9
function addHours(date: Date, hours: number) {
const d = new Date();
d.setHours(d.getHours() + hours);
return d;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Bug: The date parameter is ignored.

The addHours function ignores its date parameter and always creates a new Date() internally. While this doesn't cause issues in the current usage (since new Date() is passed), the function is misleading and will produce incorrect results if reused with a different date.

Proposed fix
 function addHours(date: Date, hours: number) {
-    const d = new Date();
+    const d = new Date(date);
     d.setHours(d.getHours() + hours);
     return d;
 }
📝 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
function addHours(date: Date, hours: number) {
const d = new Date();
d.setHours(d.getHours() + hours);
return d;
}
function addHours(date: Date, hours: number) {
const d = new Date(date);
d.setHours(d.getHours() + hours);
return d;
}
🤖 Prompt for AI Agents
In @frontend/lib/auth/email-verification.ts around lines 5 - 9, The addHours
function currently ignores its date parameter and always uses new Date(); modify
addHours(date: Date, hours: number) to operate on a clone of the passed-in date
(e.g., new Date(date.getTime()) or new Date(date)) so you don't mutate the
original, then add the hours via setHours/getHours and return that cloned date;
keep the function name addHours and ensure it uses the input date rather than
creating a fresh Date().

Comment on lines +5 to +9
function addHours(date: Date, hours: number) {
const d = new Date();
d.setHours(d.getHours() + hours);
return d;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Bug: addHours ignores the date parameter.

The function creates a new Date() on line 6 instead of using the passed date argument. While the current call site (addHours(new Date(), 1)) happens to work, this is a latent bug.

Additionally, frontend/lib/auth/email-verification.ts uses date-fns for the same purpose. Consider using date-fns here for consistency and correctness.

Proposed fix using date-fns (consistent with email-verification.ts)
 import crypto from "crypto";
+import { addHours } from "date-fns";
 import { db } from "@/db";
 import { passwordResetTokens } from "@/db/schema/passwordResetTokens";
 
-function addHours(date: Date, hours: number) {
-    const d = new Date();
-    d.setHours(d.getHours() + hours);
-    return d;
-}
-
 export async function createPasswordResetToken(
     userId: string
 ) {
🤖 Prompt for AI Agents
In @frontend/lib/auth/password-reset.ts around lines 5 - 9, The addHours
function ignores its date parameter by creating a new Date() instead of using
the passed-in date; update addHours (in frontend/lib/auth/password-reset.ts) to
use the provided date argument and return a new Date based on it (or better,
replace the implementation to use date-fns addHours for consistency with
email-verification.ts), ensuring all call sites (e.g., addHours(new Date(), 1))
behave correctly and import addHours from date-fns if you switch to that
approach.

@ViktorSvertoka ViktorSvertoka merged commit af3ce94 into develop Jan 13, 2026
8 checks passed
@ViktorSvertoka ViktorSvertoka deleted the feat/signup-email-verification branch January 13, 2026 22:31
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.

2 participants