diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index 8ec02de4d3..39c36e81b7 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -1,11 +1,9 @@ -import { Alert, AlertType } from "@/components/alert"; +import { Alert } from "@/components/alert"; import { DynamicTheme } from "@/components/dynamic-theme"; import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { VerifyForm } from "@/components/verify-form"; import { UNKNOWN_USER_ID } from "@/lib/constants"; -import { getPublicHostWithProtocol } from "@/lib/server/host"; -import { sendEmailCode, sendInviteEmailCode } from "@/lib/server/verify"; import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getLoginSettings, getUserByID, searchUsers } from "@/lib/zitadel"; @@ -36,39 +34,10 @@ export default async function Page(props: { searchParams: Promise }) { let id: string | undefined; let loginSettings: LoginSettings | undefined; - let error: string | undefined; - const doSend = send === "true"; - const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; const autoSubmitCode = process.env.NEXT_PUBLIC_AUTO_SUBMIT_CODE === "true"; - async function sendEmail(userId: string) { - const hostWithProtocol = await getPublicHostWithProtocol(_headers); - - if (invite === "true") { - await sendInviteEmailCode({ - userId, - urlTemplate: - `${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + - (requestId ? `&requestId=${requestId}` : ""), - }).catch((apiError) => { - console.error("Could not send invitation email", apiError); - error = "inviteSendFailed"; - }); - } else { - await sendEmailCode({ - userId, - urlTemplate: - `${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + - (requestId ? `&requestId=${requestId}` : ""), - }).catch((apiError) => { - console.error("Could not send verification email", apiError); - error = "emailSendFailed"; - }); - } - } - if ("loginName" in searchParams) { sessionFactors = await loadMostRecentSession({ serviceConfig, @@ -84,15 +53,7 @@ export default async function Page(props: { searchParams: Promise }) { // ignore error, as we might not have a session yet return undefined; }); - - if (doSend && sessionFactors?.factors?.user?.id) { - await sendEmail(sessionFactors.factors.user.id); - } } else if ("userId" in searchParams && userId) { - if (doSend) { - await sendEmail(userId); - } - const userResponse = await getUserByID({ serviceConfig, userId }); if (userResponse) { user = userResponse.user; @@ -128,11 +89,6 @@ export default async function Page(props: { searchParams: Promise }) { if (user.type.case === "human") { human = user.type.value as HumanUser; } - - // If we found the user and need to send email, do it now - if (doSend && id) { - await sendEmail(id); - } } else if (loginSettings?.ignoreUnknownUsernames) { // Prevent enumeration by pretending we found a user id = UNKNOWN_USER_ID; @@ -189,14 +145,6 @@ export default async function Page(props: { searchParams: Promise }) {
- {error && ( -
- - - -
- )} - {!id && (
@@ -205,14 +153,6 @@ export default async function Page(props: { searchParams: Promise }) {
)} - {id && send && ( -
- - - -
- )} - {id && ( }) { isInvite={invite === "true"} requestId={requestId} submit={autoSubmitCode} + doSend={doSend} /> )}
diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index ebbc20deb9..3a94d85dea 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -3,7 +3,7 @@ import { Alert, AlertType } from "@/components/alert"; import { handleServerActionResponse } from "@/lib/client-utils"; import { UNKNOWN_USER_ID } from "@/lib/constants"; -import { resendVerification, sendVerification } from "@/lib/server/verify"; +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"; @@ -27,9 +27,10 @@ type Props = { isInvite: boolean; requestId?: string; submit: boolean; + doSend?: boolean; }; -export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite, submit }: Props) { +export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite, submit, doSend }: Props) { const router = useRouter(); const { register, handleSubmit, formState } = useForm({ @@ -46,8 +47,27 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i const [loading, setLoading] = useState(false); + const initialSendDone = useRef(false); + const [initialSendError, setInitialSendError] = useState(""); + 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 @@ -83,6 +103,7 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i const fcn = useCallback( async function submitCodeAndContinue(value: Inputs): Promise { setError(""); + setInitialSendError(""); setLoading(true); try { @@ -115,6 +136,13 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i return ( <> {samlData && } + {codeSent && !initialSendError && ( +
+ + + +
+ )}
@@ -146,9 +174,9 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i />
- {error && ( + {(error || initialSendError) && (
- {error} + {error || initialSendError}
)} diff --git a/apps/login/src/lib/server/verify.test.ts b/apps/login/src/lib/server/verify.test.ts index 34bd65baa3..1dde16d081 100644 --- a/apps/login/src/lib/server/verify.test.ts +++ b/apps/login/src/lib/server/verify.test.ts @@ -1,7 +1,14 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; -import { sendVerification } from "./verify"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { initialSendVerification, sendVerification } from "./verify"; -import { getSession, getUserByID, listAuthenticationMethodTypes, verifyEmail } from "@/lib/zitadel"; +import { + createInviteCode, + getSession, + getUserByID, + listAuthenticationMethodTypes, + verifyEmail, + sendEmailCode as zitadelSendEmailCode, +} from "@/lib/zitadel"; import { cookies } from "next/headers"; import { getSessionCookieByLoginName } from "../cookies"; import { createSessionAndUpdateCookie } from "./cookie"; @@ -14,6 +21,8 @@ vi.mock("@/lib/zitadel", () => ({ getSession: vi.fn(), listAuthenticationMethodTypes: vi.fn(), getLoginSettings: vi.fn(), + sendEmailCode: vi.fn(), + createInviteCode: vi.fn(), })); vi.mock("next/headers", () => ({ @@ -37,6 +46,10 @@ vi.mock("../fingerprint", () => ({ getOrSetFingerprintId: vi.fn(), })); +vi.mock("./host", () => ({ + getPublicHostWithProtocol: vi.fn(() => "https://example.com"), +})); + vi.mock("next-intl/server", () => ({ getTranslations: vi.fn(() => (key: string) => key), })); @@ -111,3 +124,102 @@ describe("sendVerification", () => { expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled(); }); }); + +describe("initialSendVerification", () => { + let mockSendEmailCode: any; + let mockCreateInviteCode: any; + let originalBasePath: string | undefined; + + beforeEach(() => { + originalBasePath = process.env.NEXT_PUBLIC_BASE_PATH; + process.env.NEXT_PUBLIC_BASE_PATH = "/ui/v2/login"; + + vi.clearAllMocks(); + mockSendEmailCode = zitadelSendEmailCode; + mockCreateInviteCode = createInviteCode; + mockSendEmailCode.mockResolvedValue({}); + mockCreateInviteCode.mockResolvedValue({}); + }); + + afterEach(() => { + if (originalBasePath === undefined) { + delete process.env.NEXT_PUBLIC_BASE_PATH; + } else { + process.env.NEXT_PUBLIC_BASE_PATH = originalBasePath; + } + }); + + test("should call sendEmailCode with correct URL template for non-invite", async () => { + await initialSendVerification({ + userId: "user-1", + isInvite: false, + }); + + expect(mockSendEmailCode).toHaveBeenCalledWith({ + serviceConfig: {}, + userId: "user-1", + urlTemplate: "https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}", + }); + expect(mockCreateInviteCode).not.toHaveBeenCalled(); + }); + + test("should call createInviteCode with correct URL template for invite", async () => { + await initialSendVerification({ + userId: "user-1", + isInvite: true, + }); + + expect(mockCreateInviteCode).toHaveBeenCalledWith({ + serviceConfig: {}, + userId: "user-1", + urlTemplate: + "https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true", + }); + expect(mockSendEmailCode).not.toHaveBeenCalled(); + }); + + test("should include URL-encoded requestId in URL template", async () => { + await initialSendVerification({ + userId: "user-1", + isInvite: false, + requestId: "req-123", + }); + + expect(mockSendEmailCode).toHaveBeenCalledWith({ + serviceConfig: {}, + userId: "user-1", + urlTemplate: + "https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&requestId=req-123", + }); + }); + + test("should URL-encode special characters in requestId", async () => { + await initialSendVerification({ + userId: "user-1", + isInvite: false, + requestId: "req&id=injected", + }); + + expect(mockSendEmailCode).toHaveBeenCalledWith({ + serviceConfig: {}, + userId: "user-1", + urlTemplate: + "https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&requestId=req%26id%3Dinjected", + }); + }); + + test("should include invite=true and requestId for invite with requestId", async () => { + await initialSendVerification({ + userId: "user-1", + isInvite: true, + requestId: "req-456", + }); + + expect(mockCreateInviteCode).toHaveBeenCalledWith({ + serviceConfig: {}, + userId: "user-1", + urlTemplate: + "https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true&requestId=req-456", + }); + }); +}); diff --git a/apps/login/src/lib/server/verify.ts b/apps/login/src/lib/server/verify.ts index 95d329fbc4..387c233242 100644 --- a/apps/login/src/lib/server/verify.ts +++ b/apps/login/src/lib/server/verify.ts @@ -236,6 +236,25 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { ); } +function buildVerificationUrlTemplate( + hostWithProtocol: string, + basePath: string, + isInvite: boolean, + requestId?: string, +): string { + let urlTemplate = `${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}`; + + if (isInvite) { + urlTemplate += "&invite=true"; + } + + if (requestId) { + urlTemplate += `&requestId=${encodeURIComponent(requestId)}`; + } + + return urlTemplate; +} + type resendVerifyEmailCommand = { userId: string; isInvite: boolean; @@ -249,14 +268,13 @@ export async function resendVerification(command: resendVerifyEmailCommand) { const hostWithProtocol = await getPublicHostWithProtocol(_headers); const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + const urlTemplate = buildVerificationUrlTemplate(hostWithProtocol, basePath, command.isInvite, command.requestId); return command.isInvite ? createInviteCode({ serviceConfig, userId: command.userId, - urlTemplate: - `${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + - (command.requestId ? `&requestId=${command.requestId}` : ""), + urlTemplate, }).catch((error) => { if (error.code === 9) { return { error: t("errors.userAlreadyVerified") }; @@ -266,9 +284,7 @@ export async function resendVerification(command: resendVerifyEmailCommand) { : zitadelSendEmailCode({ serviceConfig, userId: command.userId, - urlTemplate: - `${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + - (command.requestId ? `&requestId=${command.requestId}` : ""), + urlTemplate, }); } @@ -290,3 +306,32 @@ export async function sendInviteEmailCode(command: SendEmailCommand) { return createInviteCode({ serviceConfig, userId: command.userId, urlTemplate: command.urlTemplate }); } + +type InitialSendVerificationCommand = { + userId: string; + isInvite: boolean; + requestId?: string; +}; + +export async function initialSendVerification(command: InitialSendVerificationCommand) { + const _headers = await headers(); + const { serviceConfig } = getServiceConfig(_headers); + const hostWithProtocol = await getPublicHostWithProtocol(_headers); + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + const urlTemplate = buildVerificationUrlTemplate(hostWithProtocol, basePath, command.isInvite, command.requestId); + + if (command.isInvite) { + return createInviteCode({ + serviceConfig, + userId: command.userId, + urlTemplate, + }); + } else { + return zitadelSendEmailCode({ + serviceConfig, + userId: command.userId, + urlTemplate, + }); + } +}