fix(login): redirect to email verification when forced MFA has no visible factors (#12060)

# Which Problems Are Solved

When MFA is enforced with only Email OTP as a second factor, new users
hit a dead end on the MFA setup screen. The `ChooseSecondFactorToSetup`
component hides Email OTP when `emailVerified` is `false`, and if email
verification was skipped earlier in the flow (e.g. `EMAIL_VERIFICATION`
env not set to `"true"`), no MFA options are visible and the user cannot
proceed.

# How the Problems Are Solved

In the MFA setup server component (`mfa/set/page.tsx`), before
rendering, detect when forced MFA has no visible second factors because
all configured factors require verification the user hasn't completed.
When Email OTP is configured but the user's email is unverified,
redirect to `/verify` with `send=true` so a verification code is sent
immediately.

After the user verifies their email, the existing flow in `verify.ts`
calls `checkMFAFactors` which redirects back to `/mfa/set` — where Email
OTP is now visible since `emailVerified` is `true`.

The redirect pattern follows the existing precedent in
`authenticator/set/page.tsx`.

# Additional Changes

None

# Additional Context

- Closes #11536

---------

Co-authored-by: Max Peintner <[email protected]>
This commit is contained in:
Liam Neville
2026-05-22 11:40:40 +00:00
committed by GitHub
co-authored by Max Peintner
parent b4f2da2171
commit 404c23834a
@@ -16,9 +16,11 @@ import {
} from "@/lib/zitadel";
import { Timestamp, timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { SecondFactorType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("mfa");
@@ -111,6 +113,41 @@ export default async function Page(props: { searchParams: Promise<Record<string
const { valid } = sessionWithData ? isSessionValid(sessionWithData) : { valid: false };
if (force === "true" && valid && sessionWithData?.factors?.user?.loginName && loginSettings) {
const emailVerified = sessionWithData.emailVerified ?? false;
const phoneVerified = sessionWithData.phoneVerified ?? false;
const hasVisibleFactor = loginSettings.secondFactors.some((f) => {
switch (f) {
case SecondFactorType.OTP:
case SecondFactorType.U2F:
return true;
case SecondFactorType.OTP_EMAIL:
return emailVerified;
case SecondFactorType.OTP_SMS:
return phoneVerified;
default:
return false;
}
});
if (!hasVisibleFactor && !emailVerified && loginSettings.secondFactors.includes(SecondFactorType.OTP_EMAIL)) {
const verifyParams = new URLSearchParams({
loginName: sessionWithData.factors.user.loginName,
send: "true",
});
const org = organization ?? sessionWithData.factors.user.organizationId;
if (requestId) {
verifyParams.set("requestId", requestId);
}
if (org) {
verifyParams.set("organization", org as string);
}
redirect(`/verify?${verifyParams}`);
}
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">