Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions apps/web/app/(org)/verify-otp/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ export function VerifyOTPForm({
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const router = useRouter();

let verifyEmail = email;
if (!email && typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
const urlEmail = urlParams.get('email');
if (urlEmail) {
verifyEmail = urlEmail;
} else {
router.push('/login');
}
}
Comment on lines +30 to +39
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 | 🔴 Critical

Critical: SSR hydration mismatch and imperative redirect in render.

This code has several critical issues:

  1. Hydration mismatch: Accessing window.location.search during render in a client component causes server/client HTML mismatch since server-rendered output won't have the URL param logic.
  2. Race condition: router.push('/login') is async but execution continues, potentially using an undefined verifyEmail.
  3. Render-phase side effect: Router navigation should occur in useEffect, not during render.
  4. Redundant guard: The parent page component already redirects when email is missing, making this fallback unnecessary.

Recommended approach: Remove this client-side fallback entirely since page.tsx line 14-16 already guards the email requirement. The email prop will always be present when this component renders.

If fallback is needed for some edge case, use this pattern:

-	let verifyEmail = email;
-	if (!email && typeof window !== 'undefined') {
-		const urlParams = new URLSearchParams(window.location.search);
-		const urlEmail = urlParams.get('email');
-		if (urlEmail) {
-			verifyEmail = urlEmail;
-		} else {
-			router.push('/login');
-		}
-	}
+	const searchParams = useSearchParams();
+	const verifyEmail = email || searchParams.get('email') || '';
+
+	useEffect(() => {
+		if (!verifyEmail) {
+			router.push('/login');
+		}
+	}, [verifyEmail, router]);

However, given the page-level guard, this entire block should be removed and email used directly.

🤖 Prompt for AI Agents
In apps/web/app/(org)/verify-otp/form.tsx around lines 30 to 39, remove the
client-side fallback that reads window.location.search and calls router.push
during render; instead use the incoming email prop directly (the page guard
ensures it is always present). Do not access window or call router.push in
render; if you absolutely need a client-side fallback for an edge case,
implement it with useEffect and local state (read URLSearchParams and then set
state or navigate), but prefer simply deleting this block so no SSR hydration
mismatch, race condition, or render-phase side effects remain.


useEffect(() => {
inputRefs.current[0]?.focus();
}, []);
Expand Down Expand Up @@ -75,9 +86,8 @@ export function VerifyOTPForm({
const otpCode = code.join("");
if (otpCode.length !== 6) throw "Please enter a complete 6-digit code";

// shoutout https://github.com/buoyad/Tally/pull/14
const res = await fetch(
`/api/auth/callback/email?email=${encodeURIComponent(email)}&token=${encodeURIComponent(otpCode)}&callbackUrl=${encodeURIComponent("/login-success")}`,
`/api/auth/callback/email?email=${encodeURIComponent(verifyEmail)}&token=${encodeURIComponent(otpCode)}&callbackUrl=${encodeURIComponent("/login-success")}`,
);

if (!res.url.includes("/login-success")) {
Expand All @@ -101,10 +111,9 @@ export function VerifyOTPForm({

const handleResend = useMutation({
mutationFn: async () => {
// Check client-side rate limiting
if (lastResendTime) {
const timeSinceLastRequest = Date.now() - lastResendTime;
const waitTime = 30000; // 30 seconds
const waitTime = 30000;
if (timeSinceLastRequest < waitTime) {
const remainingSeconds = Math.ceil(
(waitTime - timeSinceLastRequest) / 1000,
Expand All @@ -115,12 +124,11 @@ export function VerifyOTPForm({
}

const result = await signIn("email", {
email,
email: verifyEmail,
redirect: false,
});

if (result?.error) {
// NextAuth returns generic "EmailSignin" error for all email errors
throw "Please wait 30 seconds before requesting a new code";
}
},
Expand Down Expand Up @@ -164,7 +172,7 @@ export function VerifyOTPForm({
Enter verification code
</h1>
<p className="text-sm text-gray-10">
We sent a 6-digit code to {email}
We sent a 6-digit code to {verifyEmail}
</p>
</div>

Expand Down
8 changes: 1 addition & 7 deletions apps/web/app/(org)/verify-otp/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { getSession } from "@cap/database/auth/session";
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { VerifyOTPForm } from "./form";
Expand All @@ -11,13 +10,8 @@ export default async function VerifyOTPPage(props: {
searchParams: Promise<{ email?: string; next?: string; lastSent?: string }>;
}) {
const searchParams = await props.searchParams;
const session = await getSession();

if (session?.user) {
redirect(searchParams.next || "/dashboard");
}

if (!searchParams.email) {
if (!searchParams?.email) {
redirect("/login");
}

Expand Down
Loading