Files
zitadel/apps/login/src/components/verify-form.tsx
T
604d5cb0a9 fix(login): prevent duplicate email-code verification issue (#11893)
<!--
Please inform yourself about the contribution guidelines on submitting a
PR here:
https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr.
Take note of how PR/commit titles should be written and replace the
template texts in the sections below. Don't remove any of the sections.
It is important that the commit history clearly shows what is changed
and why.
Important: By submitting a contribution you agree to the terms from our
Licensing Policy as described here:
https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions.
-->

# Which Problems Are Solved

- With Login V2 and EMAIL_VERIFICATION=true, opening the verify page
with send=true can trigger email code generation twice.
- The second code invalidates the first one, so users may receive two
emails where the first code is always invalid.
- This causes failed verification attempts and blocks onboarding for
affected users.

# How the Problems Are Solved

- Removed side-effectful email-code sending from direct server component
execution path.
- Ensured code sending is executed only once in a safe server-side flow,
so React Server Component re-fetches do not generate additional codes.
- Kept verify flow behavior intact while preventing duplicate code
invalidation.

# Additional Changes

- Added/updated logic around the verify flow to make repeated
render/re-fetch paths idempotent for email-code sending.
- Improved reliability of the Login V2 email verification step under App
Router navigation behavior.

# Additional Context

Fixes #11857
- Tested manually with Login V2 and EMAIL_VERIFICATION=true by
completing a new user registration flow to /verify?send=true.
- Verified that only one verification email is sent and the first
received code is valid (no duplicate invalidating code generated).
Related bug report with reproduction details:
[https://github.com/zitadel/zitadel/issues/11857](vscode-file://vscode-app/Applications/Visual%20Studio%20Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html)

---------

Co-authored-by: Max Peintner <[email protected]>
2026-03-31 12:04:28 +00:00

202 lines
6.1 KiB
TypeScript

"use client";
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 { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { AutoSubmitForm } from "./auto-submit-form";
import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
type Inputs = {
code: string;
};
type Props = {
userId: string;
loginName?: string;
organization?: string;
code?: string;
isInvite: boolean;
requestId?: string;
submit: boolean;
doSend?: boolean;
};
export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite, submit, doSend }: Props) {
const router = useRouter();
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onChange",
defaultValues: {
code: code ?? "",
},
});
const t = useTranslations("verify");
const [error, setError] = useState<string>("");
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
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
if (userId === UNKNOWN_USER_ID) {
await new Promise((resolve) => setTimeout(resolve, 1000));
setLoading(false);
return;
}
const response = await resendVerification({
userId,
isInvite: isInvite,
requestId: requestId,
})
.catch(() => {
setError(t("errors.couldNotResendEmail"));
return;
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response && response?.error) {
setError(response.error);
return;
}
return response;
}
const processedCode = useRef<string | undefined>(undefined);
const fcn = useCallback(
async function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
setError("");
setInitialSendError("");
setLoading(true);
try {
const response = await sendVerification({
code: value.code,
userId,
isInvite: isInvite,
loginName: loginName,
organization: organization,
requestId: requestId,
});
handleServerActionResponse(response, router, setSamlData, setError);
} catch {
setError(t("errors.couldNotVerifyUser"));
} finally {
setLoading(false);
}
},
[isInvite, userId, loginName, organization, requestId, router, t],
);
useEffect(() => {
if (submit && code && code !== processedCode.current) {
processedCode.current = code;
fcn({ code });
}
}, [submit, code, fcn]);
return (
<>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
{codeSent && !initialSendError && (
<div className="w-full py-4">
<Alert type={AlertType.INFO}>
<Translated i18nKey="verify.codeSent" namespace="verify" />
</Alert>
</div>
)}
<form className="w-full">
<Alert type={AlertType.INFO}>
<div className="flex flex-row">
<span className="mr-auto flex-1 text-left">
<Translated i18nKey="verify.noCodeReceived" namespace="verify" />
</span>
<button
aria-label="Resend Code"
disabled={loading}
type="button"
className="ml-4 cursor-pointer text-primary-light-500 hover:text-primary-light-400 disabled:cursor-default disabled:text-gray-400 dark:text-primary-dark-500 hover:dark:text-primary-dark-400 dark:disabled:text-gray-700"
onClick={() => {
resendCode();
}}
data-testid="resend-button"
>
<Translated i18nKey="verify.resendCode" namespace="verify" />
</button>
</div>
</Alert>
<div className="mt-4">
<TextInput
type="text"
autoComplete="one-time-code"
autoFocus
{...register("code", { required: t("verify.required.code") })}
label={t("verify.labels.code")}
data-testid="code-text-input"
/>
</div>
{(error || initialSendError) && (
<div className="py-4" data-testid="error">
<Alert>{error || initialSendError}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
<BackButton />
<span className="flex-grow"></span>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit(fcn)}
data-testid="submit-button"
>
{loading && <Spinner className="mr-2 h-5 w-5" />}
<Translated i18nKey="verify.submit" namespace="verify" />
</Button>
</div>
</form>
</>
);
}