Skip to content

feat: add email code#2007

Merged
mfts merged 6 commits intomainfrom
feat/email-code
Jan 23, 2026
Merged

feat: add email code#2007
mfts merged 6 commits intomainfrom
feat/email-code

Conversation

@mfts
Copy link
Owner

@mfts mfts commented Jan 22, 2026

Summary by CodeRabbit

  • New Features

    • Email verification now uses 10-character, time-limited codes (15 min) with a dedicated verification page and client UI: prefilled/lockable email, code input, expired-state flow, and testimonial panel.
    • Terms & Privacy links included; friendly loading states.
  • Bug Fixes

    • Added rate-limiting and clearer error messages for verification attempts.
    • Sign-in flow now stores the email and navigates users to the verification page after requesting a code.

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

@vercel
Copy link

vercel bot commented Jan 22, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
papermark Ready Ready Preview, Comment Jan 23, 2026 2:55am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 22, 2026

Walkthrough

Replaces the magic-link flow with a 10-character verification-code flow: adds client and page routes for code entry, a POST verification API with per-email and per-IP rate limiting, updates email template and send logic to issue codes, adjusts login/verify redirects, and excludes /auth/email from middleware routing.

Changes

Cohort / File(s) Summary
Email Verification UI
app/(auth)/auth/email/[[...params]]/page-client.tsx, app/(auth)/auth/email/[[...params]]/page.tsx
Adds EmailVerificationClient (client-side form reads pendingVerificationEmail from sessionStorage, accepts email + 10-char code, focuses inputs, shows expired state, posts to /api/auth/verify-code, handles errors/loading) and a page export with metadata that renders it.
Login / Verify Pages
app/(auth)/login/page-client.tsx, app/(auth)/verify/page.tsx
Login now stores the entered email in sessionStorage and navigates to /auth/email; legacy /auth/verify page simplified to an unconditional redirect to /auth/email.
Verification API
app/api/auth/verify-code/route.ts, app/api/verify/login-link/route.ts
Adds POST /api/auth/verify-code with input normalization, client IP extraction, per-email and per-IP rate limiting, atomic fetch-and-delete of login code, and structured errors; legacy verify/login-link now redirects to /auth/email.
Email Template & Sending
components/emails/verification-link.tsx, lib/emails/send-verification-request.ts
Replaces magic-link email with VerificationCodeEmail showing a 10-char code; switches Redis keys to login_code:email:<email>:<CODE>, TTL to 15 minutes, renames data model to LoginCodeData, and updates send flow to emit codes.
Middleware
middleware.ts
Adds /auth/email to middleware exclusion list so requests to that path bypass AppMiddleware.
Manifest / Package
manifest_file, package.json
Manifest and package metadata updated (lines changed reported).

Possibly related PRs

  • feat: add gtm #1789: Modifies app/(auth)/login/page-client.tsx and sign-in response flow—overlaps with this PR’s change to store email in sessionStorage and navigation to /auth/email.
  • feat: update email sending and newsletter subscription #1970: Edits components/emails/verification-link.tsx—overlaps with this PR’s renaming and conversion to a code-based VerificationCodeEmail.
  • feat: add ratelimiting #1874: Implements or changes rate-limiting and IP-extraction utilities used by the new /api/auth/verify-code per-email and per-IP limits.
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% 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 'feat: add email code' is concise and directly related to the main change, which implements an email-based verification code login flow.

✏️ 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
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: 2

🤖 Fix all issues with AI agents
In `@app/`(auth)/auth/email/[[...params]]/page-client.tsx:
- Around line 76-84: The success path currently only calls
router.push(data.callbackUrl) and leaves the component loading if callbackUrl is
missing; update the try block after receiving data so that if data.callbackUrl
exists you call router.push(data.callbackUrl), otherwise call
setIsLoading(false) and setError with a user-visible message (e.g., "Unable to
complete sign-in: missing callback URL") or perform a safe fallback redirect;
modify the logic around router.push, setIsLoading, and setError in the same
function where data is handled to ensure loading always stops and the user sees
feedback when callbackUrl is absent.

In `@app/api/auth/verify-code/route.ts`:
- Around line 65-83: The current flow calls getLoginCodeData(...) then
deleteLoginCodeData(...), which creates a TOCTOU race allowing the same code to
be used twice; change this to a single atomic fetch-and-delete operation (use
Redis GETDEL if available or a small Lua EVAL script) and call that from the
route instead of separate calls. Concretely: implement a new helper (e.g.,
fetchAndDeleteLoginCodeData or update getLoginCodeData to perform GETDEL/EVAL)
that returns the stored data and removes the key in one atomic step, then
replace the pair of calls in route.ts with a single call to that helper, handle
the null/undefined result the same way as before, and remove the separate
deleteLoginCodeData invocation. Ensure the returned shape still includes
callbackUrl so existing logic (const { callbackUrl } = ...) continues to work.
🧹 Nitpick comments (3)
components/emails/verification-link.tsx (1)

16-22: Avoid optional code to prevent accidental placeholder emails.
If code isn’t passed, the default could ship to users, leading to invalid logins. Consider requiring code and only using a preview fallback for dev/testing.

♻️ Suggested change
-const VerificationCodeEmail = ({
-  email = "user@example.com",
-  code = "45PFSNUDYW",
-}: {
-  email?: string;
-  code?: string;
-}) => {
+const VerificationCodeEmail = ({
+  email = "user@example.com",
+  code,
+}: {
+  email?: string;
+  code: string;
+}) => {

Also applies to: 42-47

app/(auth)/auth/email/[[...params]]/page-client.tsx (1)

218-233: Add rel="noopener noreferrer" to external links.

External links with target="_blank" should include rel="noopener noreferrer" to prevent the new page from accessing window.opener and for performance optimization.

Proposed fix
             <a
               href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms`}
               target="_blank"
+              rel="noopener noreferrer"
               className="underline"
             >
               Terms of Service
             </a>{" "}
             and{" "}
             <a
               href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}
               target="_blank"
+              rel="noopener noreferrer"
               className="underline"
             >
               Privacy Policy
             </a>
lib/emails/send-verification-request.ts (1)

15-17: Remove unused constant LOGIN_CODE_PREFIX.

LOGIN_CODE_PREFIX is defined but never used in this file. Only LOGIN_CODE_EMAIL_PREFIX is used for Redis key construction.

Proposed fix
-// Redis key prefixes for login codes
-const LOGIN_CODE_PREFIX = "login_code:";
-const LOGIN_CODE_EMAIL_PREFIX = "login_code:email:";
+// Redis key prefix for login codes
+const LOGIN_CODE_EMAIL_PREFIX = "login_code:email:";

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

🤖 Fix all issues with AI agents
In `@app/`(auth)/auth/email/[[...params]]/page-client.tsx:
- Around line 62-66: The expired-state branch is unreachable because the verify
API returns 401 for expired/missing codes; update the client-side check in the
error handling around response.ok (the block using response.status === 410 and
setIsExpired/setError) to also treat response.status === 401 or
response.data.error/messages that indicate "invalid" as expired, or
alternatively adjust the API to return 410 for expired codes; specifically
modify the conditional that references response.status === 410 and
data.error?.includes("expired") to include response.status === 401 (or the API's
exact error string) so setIsExpired(true) and setError("This code has expired or
is invalid.") are reached when the verify API returns 401.
- Around line 222-233: Update the external anchor elements that open in a new
tab (the <a> tags using `${process.env.NEXT_PUBLIC_MARKETING_URL}/terms` and
`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy` in page-client.tsx) to
include rel="noopener noreferrer" alongside target="_blank" to prevent
reverse-tabnabbing; ensure both the "Terms of Service" and "privacy" links have
this attribute added.

In `@app/api/auth/verify-code/route.ts`:
- Around line 19-31: The request currently calls .trim() and .toUpperCase()
without ensuring email/code are strings and performs presence checks before
trimming; change the validation order in route handler: after parsing body from
request.json(), first check typeof email === 'string' and typeof code ===
'string', then assign trimmed values to normalizedEmail =
email.trim().toLowerCase() and normalizedCode = code.trim().toUpperCase(), then
validate that normalizedEmail is non-empty and normalizedCode has exactly 10
characters (return NextResponse.json with a 400 for invalid input). Ensure these
string/type/length checks happen before any rate-limiting or Redis access so you
never call .trim() on non-strings or process short/whitespace-only codes.

In `@lib/emails/send-verification-request.ts`:
- Around line 81-96: The catch block in fetchAndDeleteLoginCodeData (in
send-verification-request.ts) masks Redis failures by returning null, causing
callers to treat errors as "invalid code"; change the catch to rethrow the
caught error (or throw a typed Redis/StorageError) after optional logging so
callers can return a 5xx; keep returning null only for the "not found" branch
(if (!data) return null) and do not swallow exceptions in the try/catch around
redis.getdel.
🧹 Nitpick comments (2)
lib/emails/send-verification-request.ts (1)

34-50: Consider invalidating older codes per email.
Each request stores a new email+code key, so multiple codes remain valid until TTL. If the intent is “latest code only,” delete existing keys for the email or store a single per‑email record that is overwritten.

app/api/auth/verify-code/route.ts (1)

10-13: Prefer a trusted IP source before falling back to "unknown".
Next.js 14's NextRequest exposes an ip property (platform-provided by Vercel; other hosts must supply via X-Forwarded-For or equivalent). Using request.ip before the "unknown" fallback reduces false throttling when proxy headers are absent and improves security by relying on platform-verified sources.

💡 Recommended adjustment
 function getClientIp(request: NextRequest): string {
   const forwarded = request.headers.get("x-forwarded-for");
   const realIp = request.headers.get("x-real-ip");
-  return forwarded?.split(",")[0]?.trim() || realIp || "unknown";
+  return (
+    forwarded?.split(",")[0]?.trim() ||
+    realIp ||
+    request.ip ||
+    "unknown"
+  );
 }

Comment on lines +222 to +233
<a
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms`}
target="_blank"
className="underline"
>
Terms of Service
</a>{" "}
and{" "}
<a
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}
target="_blank"
className="underline"
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

Add rel="noopener noreferrer" to external links opened in a new tab.
Without rel, the new tab can access window.opener (reverse‑tabnabbing).

🔒 Safe link attributes
             <a
               href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms`}
               target="_blank"
+              rel="noopener noreferrer"
               className="underline"
             >
               Terms of Service
             </a>{" "}
@@
             <a
               href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}
               target="_blank"
+              rel="noopener noreferrer"
               className="underline"
             >
               Privacy Policy
             </a>
📝 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
<a
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms`}
target="_blank"
className="underline"
>
Terms of Service
</a>{" "}
and{" "}
<a
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}
target="_blank"
className="underline"
<a
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Terms of Service
</a>{" "}
and{" "}
<a
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Privacy Policy
</a>
🤖 Prompt for AI Agents
In `@app/`(auth)/auth/email/[[...params]]/page-client.tsx around lines 222 - 233,
Update the external anchor elements that open in a new tab (the <a> tags using
`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms` and
`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy` in page-client.tsx) to
include rel="noopener noreferrer" alongside target="_blank" to prevent
reverse-tabnabbing; ensure both the "Terms of Service" and "privacy" links have
this attribute added.

Comment on lines 81 to 96
try {
const data = await redis.get(`${MAGIC_LINK_PREFIX}${token}`);
const key = `${LOGIN_CODE_EMAIL_PREFIX}${email.toLowerCase()}:${code.toUpperCase()}`;

// Use GETDEL for atomic get-and-delete operation
// This prevents race conditions where two requests could use the same code
const data = await redis.getdel(key);
if (!data) return null;

// Handle both string and already-parsed object (Redis client behavior)
if (typeof data === "string") {
return JSON.parse(data) as MagicLinkData;
return JSON.parse(data) as LoginCodeData;
}
return data as MagicLinkData;
return data as LoginCodeData;
} catch (error) {
console.error("Error fetching magic link data:", error);
console.error("Error fetching and deleting login code data:", error);
return null;
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

Don’t mask Redis failures as “invalid code.”
fetchAndDeleteLoginCodeData returns null on Redis errors, which causes the API to respond 401 instead of 500—hiding outages and confusing users. Prefer rethrowing (or returning a typed error) so callers can emit a 5xx.

🛠️ Let the caller handle failures
   } catch (error) {
     console.error("Error fetching and deleting login code data:", error);
-    return null;
+    throw error;
   }
📝 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
try {
const data = await redis.get(`${MAGIC_LINK_PREFIX}${token}`);
const key = `${LOGIN_CODE_EMAIL_PREFIX}${email.toLowerCase()}:${code.toUpperCase()}`;
// Use GETDEL for atomic get-and-delete operation
// This prevents race conditions where two requests could use the same code
const data = await redis.getdel(key);
if (!data) return null;
// Handle both string and already-parsed object (Redis client behavior)
if (typeof data === "string") {
return JSON.parse(data) as MagicLinkData;
return JSON.parse(data) as LoginCodeData;
}
return data as MagicLinkData;
return data as LoginCodeData;
} catch (error) {
console.error("Error fetching magic link data:", error);
console.error("Error fetching and deleting login code data:", error);
return null;
try {
const key = `${LOGIN_CODE_EMAIL_PREFIX}${email.toLowerCase()}:${code.toUpperCase()}`;
// Use GETDEL for atomic get-and-delete operation
// This prevents race conditions where two requests could use the same code
const data = await redis.getdel(key);
if (!data) return null;
// Handle both string and already-parsed object (Redis client behavior)
if (typeof data === "string") {
return JSON.parse(data) as LoginCodeData;
}
return data as LoginCodeData;
} catch (error) {
console.error("Error fetching and deleting login code data:", error);
throw error;
}
🤖 Prompt for AI Agents
In `@lib/emails/send-verification-request.ts` around lines 81 - 96, The catch
block in fetchAndDeleteLoginCodeData (in send-verification-request.ts) masks
Redis failures by returning null, causing callers to treat errors as "invalid
code"; change the catch to rethrow the caught error (or throw a typed
Redis/StorageError) after optional logging so callers can return a 5xx; keep
returning null only for the "not found" branch (if (!data) return null) and do
not swallow exceptions in the try/catch around redis.getdel.

@mfts mfts merged commit 9989f38 into main Jan 23, 2026
9 checks passed
@github-actions github-actions bot locked and limited conversation to collaborators Jan 23, 2026
@mfts mfts deleted the feat/email-code branch February 12, 2026 06:14
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant