diff --git a/apps/login/src/app/(login)/authenticator/set/page.tsx b/apps/login/src/app/(login)/authenticator/set/page.tsx index 63704b87eb..5a8dfe810d 100644 --- a/apps/login/src/app/(login)/authenticator/set/page.tsx +++ b/apps/login/src/app/(login)/authenticator/set/page.tsx @@ -120,6 +120,7 @@ export default async function Page(props: { if (!isUserVerified) { const params = new URLSearchParams({ loginName: sessionWithData.factors.user.loginName as string, + invite: "true", send: "true", // set this to true to request a new code immediately }); diff --git a/apps/login/src/app/(login)/mfa/set/page.tsx b/apps/login/src/app/(login)/mfa/set/page.tsx index c7f2fa6599..11c44a22fa 100644 --- a/apps/login/src/app/(login)/mfa/set/page.tsx +++ b/apps/login/src/app/(login)/mfa/set/page.tsx @@ -134,6 +134,7 @@ export default async function Page(props: { {!(loginName || sessionId) && {tError("unknownContext")}} + {/* this happens if you register a user and open up the email verification link on a different device than the device where the registration was made. */} {!valid && {tError("sessionExpired")}} {isSessionValid(sessionWithData).valid && diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index 121e250dc3..cecaa5fcf7 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -2,7 +2,7 @@ import { Alert, AlertType } from "@/components/alert"; import { DynamicTheme } from "@/components/dynamic-theme"; import { UserAvatar } from "@/components/user-avatar"; import { VerifyForm } from "@/components/verify-form"; -import { sendEmailCode } from "@/lib/server/verify"; +import { sendEmailCode, sendInviteEmailCode } from "@/lib/server/verify"; import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; import { getBrandingSettings, getUserByID } from "@/lib/zitadel"; @@ -21,11 +21,6 @@ export default async function Page(props: { searchParams: Promise }) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const host = _headers.get("host"); - - if (!host || typeof host !== "string") { - throw new Error("No host found"); - } const branding = await getBrandingSettings({ serviceUrl, @@ -41,29 +36,25 @@ export default async function Page(props: { searchParams: Promise }) { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; - if ("loginName" in searchParams) { - sessionFactors = await loadMostRecentSession({ - serviceUrl, - sessionParams: { - loginName, - organization, - }, - }); + async function sendEmail() { + const host = _headers.get("host"); - if (doSend && sessionFactors?.factors?.user?.id) { - await sendEmailCode({ + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + if (invite === "true") { + await sendInviteEmailCode({ serviceUrl, - userId: sessionFactors?.factors?.user?.id, + userId, urlTemplate: - `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + (requestId ? `&requestId=${requestId}` : ""), }).catch((error) => { console.error("Could not resend verification email", error); throw Error("Failed to send verification email"); }); - } - } else if ("userId" in searchParams && userId) { - if (doSend) { + } else { await sendEmailCode({ serviceUrl, userId, @@ -75,6 +66,24 @@ export default async function Page(props: { searchParams: Promise }) { throw Error("Failed to send verification email"); }); } + } + + if ("loginName" in searchParams) { + sessionFactors = await loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }); + + if (doSend && sessionFactors?.factors?.user?.id) { + await sendEmail(); + } + } else if ("userId" in searchParams && userId) { + if (doSend) { + await sendEmail(); + } const userResponse = await getUserByID({ serviceUrl, @@ -151,15 +160,19 @@ export default async function Page(props: { searchParams: Promise }) { ) )} - {/* always show the code form / TODO improve UI for email links which were already used (currently we get an error code 3 due being reused) */} - + {/* always show the code form, except code is an invite code and the email is verified */} + {invite === "true" && human?.email?.isVerified ? ( + {t("success")} + ) : ( + + )} ); diff --git a/apps/login/src/app/(login)/verify/success/page.tsx b/apps/login/src/app/(login)/verify/success/page.tsx new file mode 100644 index 0000000000..aed9f79854 --- /dev/null +++ b/apps/login/src/app/(login)/verify/success/page.tsx @@ -0,0 +1,111 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { UserAvatar } from "@/components/user-avatar"; +import { getSessionCookieById } from "@/lib/cookies"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getSession, + getUserByID, +} from "@/lib/zitadel"; +import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +async function loadSessionById( + serviceUrl: string, + sessionId: string, + organization?: string, +) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); +} + +export default async function Page(props: { searchParams: Promise }) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "signedin" }); + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { loginName, requestId, organization, userId } = searchParams; + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + const sessionFactors = await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }).catch((error) => { + console.warn("Error loading session:", error); + }); + + let loginSettings; + if (!requestId) { + loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + } + + const id = userId ?? sessionFactors?.factors?.user?.id; + + if (!id) { + throw Error("Failed to get user id"); + } + + const userResponse = await getUserByID({ + serviceUrl, + userId: id, + }); + + let user: User | undefined; + let human: HumanUser | undefined; + + if (userResponse) { + user = userResponse.user; + if (user?.type.case === "human") { + human = user.type.value as HumanUser; + } + } + + return ( + +
+

+ {t("title", { user: sessionFactors?.factors?.user?.displayName })} +

+

{t("description")}

+ + {sessionFactors ? ( + + ) : ( + user && ( + + ) + )} +
+
+ ); +} diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index e09642eecf..0933f598dd 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -63,6 +63,11 @@ export function VerifyForm({ setLoading(false); }); + if (response && "error" in response && response?.error) { + setError(response.error); + return; + } + return response; } diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index 83a6f90abb..1e7a1fe3de 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -9,7 +9,6 @@ import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp"; import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { getServiceUrlFromHeaders } from "../service-url"; -import { checkEmailVerified, checkUserVerification } from "../verify-helper"; import { getActiveIdentityProviders, getIDPByID, @@ -254,62 +253,63 @@ export async function sendLoginname(command: SendLoginnameCommand) { userId: session.factors?.user?.id, }); - // this can be expected to be an invite as users created in console have a password set. + // always resend invite if user has no auth method set if (!methods.authMethodTypes || !methods.authMethodTypes.length) { // redirect to /verify invite if no auth method is set and email is not verified - const inviteCheck = checkEmailVerified( - session, - humanUser, - session.factors.user.organizationId, - command.requestId, - ); + // const inviteCheck = checkEmailVerified( + // session, + // humanUser, + // session.factors.user.organizationId, + // command.requestId, + // ); - if (inviteCheck?.redirect) { - return inviteCheck; - } + // if (inviteCheck?.redirect) { + // return inviteCheck; + // } - // check if user was verified recently - const isUserVerified = await checkUserVerification( - session.factors.user.id, - ); - if (!isUserVerified) { - const params = new URLSearchParams({ - loginName: session.factors?.user?.loginName as string, - send: "true", // set this to true to request a new code immediately - }); - - if (command.requestId) { - params.append("requestId", command.requestId); - } - - if (command.organization || session.factors?.user?.organizationId) { - params.append( - "organization", - command.organization ?? - (session.factors?.user?.organizationId as string), - ); - } - - return { redirect: `/verify?` + params }; - } - - const paramsAuthenticatorSetup = new URLSearchParams({ - loginName: session.factors?.user?.loginName, - userId: session.factors?.user?.id, // verify needs user id + // // check if user was verified recently + // const isUserVerified = await checkUserVerification( + // session.factors.user.id, + // ); + // if (!isUserVerified) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + send: "true", // set this to true to request a new code immediately + invite: "true", }); + if (command.requestId) { + params.append("requestId", command.requestId); + } + if (command.organization || session.factors?.user?.organizationId) { - paramsAuthenticatorSetup.append( + params.append( "organization", - command.organization ?? session.factors?.user?.organizationId, + command.organization ?? + (session.factors?.user?.organizationId as string), ); } - if (command.requestId) { - paramsAuthenticatorSetup.append("requestId", command.requestId); - } + return { redirect: `/verify?` + params }; + // } - return { redirect: "/authenticator/set?" + paramsAuthenticatorSetup }; + // const paramsAuthenticatorSetup = new URLSearchParams({ + // loginName: session.factors?.user?.loginName, + // userId: session.factors?.user?.id, // verify needs user id + // }); + + // if (command.organization || session.factors?.user?.organizationId) { + // paramsAuthenticatorSetup.append( + // "organization", + // command.organization ?? session.factors?.user?.organizationId, + // ); + // } + + // if (command.requestId) { + // paramsAuthenticatorSetup.append("requestId", command.requestId); + // } + + // return { redirect: "/authenticator/set?" + paramsAuthenticatorSetup }; } if (methods.authMethodTypes.length == 1) { diff --git a/apps/login/src/lib/server/verify.ts b/apps/login/src/lib/server/verify.ts index 88bb0139b3..dd93cc0935 100644 --- a/apps/login/src/lib/server/verify.ts +++ b/apps/login/src/lib/server/verify.ts @@ -1,6 +1,7 @@ "use server"; import { + createInviteCode, getLoginSettings, getSession, getUserByID, @@ -93,88 +94,26 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { } let session: Session | undefined; - let user: User | undefined; + const userResponse = await getUserByID({ + serviceUrl, + userId: command.userId, + }); - if ("loginName" in command) { - const sessionCookie = await getSessionCookieByLoginName({ - loginName: command.loginName, - organization: command.organization, - }).catch((error) => { - console.warn("Ignored error:", error); - }); - - if (!sessionCookie) { - return { error: "Could not load session cookie" }; - } - - session = await getSession({ - serviceUrl, - sessionId: sessionCookie.id, - sessionToken: sessionCookie.token, - }).then((response) => { - if (response?.session) { - return response.session; - } - }); - - if (!session?.factors?.user?.id) { - return { error: "Could not create session for user" }; - } - - const userResponse = await getUserByID({ - serviceUrl, - userId: session?.factors?.user?.id, - }); - - if (!userResponse?.user) { - return { error: "Could not load user" }; - } - - user = userResponse.user; - } else { - const userResponse = await getUserByID({ - serviceUrl, - userId: command.userId, - }); - - if (!userResponse || !userResponse.user) { - return { error: "Could not load user" }; - } - - user = userResponse.user; - - const checks = create(ChecksSchema, { - user: { - search: { - case: "loginName", - value: userResponse.user.preferredLoginName, - }, - }, - }); - - session = await createSessionAndUpdateCookie({ - checks, - requestId: command.requestId, - }); - } - - if (!session?.factors?.user?.id) { - return { error: "Could not create session for user" }; - } - - if (!session?.factors?.user?.id) { - return { error: "Could not create session for user" }; - } - - if (!user) { + if (!userResponse || !userResponse.user) { return { error: "Could not load user" }; } - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: user.details?.resourceOwner, + const user = userResponse.user; + + const sessionCookie = await getSessionCookieByLoginName({ + loginName: + "loginName" in command ? command.loginName : user.preferredLoginName, + organization: command.organization, + }).catch((error) => { + console.warn("Ignored error:", error); // checked later }); + // load auth methods for user const authMethodResponse = await listAuthenticationMethodTypes({ serviceUrl, userId: user.userId, @@ -190,6 +129,36 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { authMethodResponse.authMethodTypes && authMethodResponse.authMethodTypes.length == 0 ) { + if (!sessionCookie) { + const checks = create(ChecksSchema, { + user: { + search: { + case: "loginName", + value: userResponse.user.preferredLoginName, + }, + }, + }); + + session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + }); + } else { + session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); + } + + if (!session) { + return { error: "Could not create session" }; + } + const params = new URLSearchParams({ sessionId: session.id, }); @@ -218,44 +187,80 @@ export async function sendVerification(command: VerifyUserByEmailCommand) { return { redirect: `/authenticator/set?${params}` }; } - // redirect to mfa factor if user has one, or redirect to set one up - const mfaFactorCheck = await checkMFAFactors( - serviceUrl, - session, - loginSettings, - authMethodResponse.authMethodTypes, - command.organization, - command.requestId, - ); + // if no session found and user is not invited, only show success page, + // if user is invited, recreate invite flow to not depend on session - if (mfaFactorCheck?.redirect) { - return mfaFactorCheck; - } + if (!sessionCookie || !session?.factors?.user?.id) { + const verifySuccessParams = new URLSearchParams({}); - // login user if no additional steps are required - if (command.requestId && session.id) { - const nextUrl = await getNextUrl( + if (command.userId) { + verifySuccessParams.set("userId", command.userId); + } + + if ( + ("loginName" in command && command.loginName) || + user.preferredLoginName + ) { + verifySuccessParams.set( + "loginName", + "loginName" in command && command.loginName + ? command.loginName + : user.preferredLoginName, + ); + } + if (command.requestId) { + verifySuccessParams.set("requestId", command.requestId); + } + if (command.organization) { + verifySuccessParams.set("organization", command.organization); + } + + return { redirect: `/verify/success?${verifySuccessParams}` }; + } else { + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: user.details?.resourceOwner, + }); + + // redirect to mfa factor if user has one, or redirect to set one up + const mfaFactorCheck = await checkMFAFactors( + serviceUrl, + session, + loginSettings, + authMethodResponse.authMethodTypes, + command.organization, + command.requestId, + ); + + if (mfaFactorCheck?.redirect) { + return mfaFactorCheck; + } + + // login user if no additional steps are required + if (command.requestId && session.id) { + const nextUrl = await getNextUrl( + { + sessionId: session.id, + requestId: command.requestId, + organization: + command.organization ?? session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: nextUrl }; + } + + const url = await getNextUrl( { - sessionId: session.id, - requestId: command.requestId, - organization: - command.organization ?? session.factors?.user?.organizationId, + loginName: session.factors.user.loginName, + organization: session.factors?.user?.organizationId, }, loginSettings?.defaultRedirectUri, ); - return { redirect: nextUrl }; + return { redirect: url }; } - - const url = await getNextUrl( - { - loginName: session.factors.user.loginName, - organization: session.factors?.user?.organizationId, - }, - loginSettings?.defaultRedirectUri, - ); - - return { redirect: url }; } type resendVerifyEmailCommand = { @@ -279,6 +284,11 @@ export async function resendVerification(command: resendVerifyEmailCommand) { ? resendInviteCode({ serviceUrl, userId: command.userId, + }).catch((error) => { + if (error.code === 9) { + return { error: "User is already verified!" }; + } + return { error: "Could not resend invite" }; }) : sendEmailCode({ userId: command.userId, @@ -303,6 +313,15 @@ export async function sendEmailCode(command: sendEmailCommand) { }); } +export async function sendInviteEmailCode(command: sendEmailCommand) { + // TODO: change this to sendInvite + return createInviteCode({ + serviceUrl: command.serviceUrl, + userId: command.userId, + urlTemplate: command.urlTemplate, + }); +} + export type SendVerificationRedirectWithoutCheckCommand = { organization?: string; requestId?: string;