fix: prevent double triggering of verification emails (#11995)

# Which Problems Are Solved

This fixes a critical bug where initial verification emails (or invite
codes) were occasionally sent twice, invalidating the first code and
confusing users.

Previously, the initial verification email was triggered via a
`send=true` URL parameter executing inside a frontend useEffect exactly
when the `/verify` page mounted. This was fragile and prone to race
conditions caused by component remounts or partial hydration.

# How the Problems Are Solved

- Removed `send=true` from URL state and ripped out the doSend effect in
`VerifyForm.tsx`.
- Shifted execution strictly to the Next.js server. The email is now
automatically dispatched via await `initialSendVerification(...)` during
the POST requests (acting over `sendLoginname`, `register`, `password`,
`passkeys`, and `idp`).
- The login flow is now idempotent and robust against unintended
frontend re-renders.
- Refactored `checkEmailVerification()` to be async and updated the
associated unit-test coverage (all tests passing).
This commit is contained in:
Max Peintner
2026-06-29 11:42:45 +03:00
committed by GitHub
parent 14874d6546
commit 3a18cef281
14 changed files with 359 additions and 305 deletions
@@ -3,7 +3,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { VerifyForm } from "./verify-form";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/verify",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("next-intl", () => ({
+14 -27
View File
@@ -3,9 +3,9 @@
import { Alert, AlertType } from "@/components/alert";
import { handleServerActionResponse } from "@/lib/client-utils";
import { UNKNOWN_USER_ID } from "@/lib/constants";
import { initialSendVerification, resendVerification, sendVerification } from "@/lib/server/verify";
import { resendVerification, sendVerification } from "@/lib/server/verify";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { AutoSubmitForm } from "./auto-submit-form";
@@ -27,11 +27,13 @@ type Props = {
isInvite: boolean;
requestId?: string;
submit: boolean;
doSend?: boolean;
};
export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite, submit, doSend }: Props) {
export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite, submit }: Props) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const codeSent = searchParams.get("codeSent") === "true";
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onChange",
@@ -47,27 +49,8 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
const [loading, setLoading] = useState<boolean>(false);
const initialSendDone = useRef(false);
const [initialSendError, setInitialSendError] = useState<string>("");
const [codeSent, setCodeSent] = useState(false);
useEffect(() => {
if (doSend && userId && userId !== UNKNOWN_USER_ID && !initialSendDone.current) {
initialSendDone.current = true;
setError("");
initialSendVerification({ userId, isInvite, requestId })
.then(() => {
setCodeSent(true);
})
.catch(() => {
setInitialSendError(isInvite ? t("errors.couldNotResendInvite") : t("errors.couldNotResendEmail"));
});
}
}, [doSend, userId, isInvite, requestId, t]);
async function resendCode() {
setError("");
setInitialSendError("");
setLoading(true);
// do not send code for dummy userid that is set to prevent user enumeration
@@ -95,6 +78,11 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
return;
}
// Signal success via URL search param so the "code sent" alert is shown
const params = new URLSearchParams(searchParams.toString());
params.set("codeSent", "true");
router.replace(`${pathname}?${params.toString()}`);
return response;
}
@@ -103,7 +91,6 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
const fcn = useCallback(
async function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
setError("");
setInitialSendError("");
setLoading(true);
try {
@@ -136,7 +123,7 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
return (
<>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
{codeSent && !initialSendError && (
{codeSent && (
<div className="w-full py-4">
<Alert type={AlertType.INFO}>
<Translated i18nKey="verify.codeSent" namespace="verify" />
@@ -174,9 +161,9 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
/>
</div>
{(error || initialSendError) && (
{error && (
<div className="py-4" data-testid="error">
<Alert>{error || initialSendError}</Alert>
<Alert>{error}</Alert>
</div>
)}