mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-30 19:12:16 +00:00
<!-- 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]>
202 lines
6.1 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|