fix: use invite code whenever authmethods are zero, otherwise use email code

This commit is contained in:
Max Peintner
2025-05-23 18:18:26 +02:00
parent e304760240
commit 0af7185a90
7 changed files with 331 additions and 181 deletions

View File

@@ -120,6 +120,7 @@ export default async function Page(props: {
if (!isUserVerified) { if (!isUserVerified) {
const params = new URLSearchParams({ const params = new URLSearchParams({
loginName: sessionWithData.factors.user.loginName as string, loginName: sessionWithData.factors.user.loginName as string,
invite: "true",
send: "true", // set this to true to request a new code immediately send: "true", // set this to true to request a new code immediately
}); });

View File

@@ -134,6 +134,7 @@ export default async function Page(props: {
{!(loginName || sessionId) && <Alert>{tError("unknownContext")}</Alert>} {!(loginName || sessionId) && <Alert>{tError("unknownContext")}</Alert>}
{/* 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 && <Alert>{tError("sessionExpired")}</Alert>} {!valid && <Alert>{tError("sessionExpired")}</Alert>}
{isSessionValid(sessionWithData).valid && {isSessionValid(sessionWithData).valid &&

View File

@@ -2,7 +2,7 @@ import { Alert, AlertType } from "@/components/alert";
import { DynamicTheme } from "@/components/dynamic-theme"; import { DynamicTheme } from "@/components/dynamic-theme";
import { UserAvatar } from "@/components/user-avatar"; import { UserAvatar } from "@/components/user-avatar";
import { VerifyForm } from "@/components/verify-form"; 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 { getServiceUrlFromHeaders } from "@/lib/service-url";
import { loadMostRecentSession } from "@/lib/session"; import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings, getUserByID } from "@/lib/zitadel"; import { getBrandingSettings, getUserByID } from "@/lib/zitadel";
@@ -21,11 +21,6 @@ export default async function Page(props: { searchParams: Promise<any> }) {
const _headers = await headers(); const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_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({ const branding = await getBrandingSettings({
serviceUrl, serviceUrl,
@@ -41,29 +36,25 @@ export default async function Page(props: { searchParams: Promise<any> }) {
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
if ("loginName" in searchParams) { async function sendEmail() {
sessionFactors = await loadMostRecentSession({ const host = _headers.get("host");
serviceUrl,
sessionParams: {
loginName,
organization,
},
});
if (doSend && sessionFactors?.factors?.user?.id) { if (!host || typeof host !== "string") {
await sendEmailCode({ throw new Error("No host found");
}
if (invite === "true") {
await sendInviteEmailCode({
serviceUrl, serviceUrl,
userId: sessionFactors?.factors?.user?.id, userId,
urlTemplate: 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}` : ""), (requestId ? `&requestId=${requestId}` : ""),
}).catch((error) => { }).catch((error) => {
console.error("Could not resend verification email", error); console.error("Could not resend verification email", error);
throw Error("Failed to send verification email"); throw Error("Failed to send verification email");
}); });
} } else {
} else if ("userId" in searchParams && userId) {
if (doSend) {
await sendEmailCode({ await sendEmailCode({
serviceUrl, serviceUrl,
userId, userId,
@@ -75,6 +66,24 @@ export default async function Page(props: { searchParams: Promise<any> }) {
throw Error("Failed to send verification email"); 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({ const userResponse = await getUserByID({
serviceUrl, serviceUrl,
@@ -151,7 +160,10 @@ export default async function Page(props: { searchParams: Promise<any> }) {
) )
)} )}
{/* 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 ? (
<Alert type={AlertType.INFO}>{t("success")}</Alert>
) : (
<VerifyForm <VerifyForm
loginName={loginName} loginName={loginName}
organization={organization} organization={organization}
@@ -160,6 +172,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
isInvite={invite === "true"} isInvite={invite === "true"}
requestId={requestId} requestId={requestId}
/> />
)}
</div> </div>
</DynamicTheme> </DynamicTheme>
); );

View File

@@ -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<any> }) {
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 (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>
{t("title", { user: sessionFactors?.factors?.user?.displayName })}
</h1>
<p className="ztdl-p mb-6 block">{t("description")}</p>
{sessionFactors ? (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
) : (
user && (
<UserAvatar
loginName={user.preferredLoginName}
displayName={human?.profile?.displayName}
showDropdown={false}
/>
)
)}
</div>
</DynamicTheme>
);
}

View File

@@ -63,6 +63,11 @@ export function VerifyForm({
setLoading(false); setLoading(false);
}); });
if (response && "error" in response && response?.error) {
setError(response.error);
return;
}
return response; return response;
} }

View File

@@ -9,7 +9,6 @@ import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp";
import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { getServiceUrlFromHeaders } from "../service-url"; import { getServiceUrlFromHeaders } from "../service-url";
import { checkEmailVerified, checkUserVerification } from "../verify-helper";
import { import {
getActiveIdentityProviders, getActiveIdentityProviders,
getIDPByID, getIDPByID,
@@ -254,28 +253,29 @@ export async function sendLoginname(command: SendLoginnameCommand) {
userId: session.factors?.user?.id, 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) { if (!methods.authMethodTypes || !methods.authMethodTypes.length) {
// redirect to /verify invite if no auth method is set and email is not verified // redirect to /verify invite if no auth method is set and email is not verified
const inviteCheck = checkEmailVerified( // const inviteCheck = checkEmailVerified(
session, // session,
humanUser, // humanUser,
session.factors.user.organizationId, // session.factors.user.organizationId,
command.requestId, // command.requestId,
); // );
if (inviteCheck?.redirect) { // if (inviteCheck?.redirect) {
return inviteCheck; // return inviteCheck;
} // }
// check if user was verified recently // // check if user was verified recently
const isUserVerified = await checkUserVerification( // const isUserVerified = await checkUserVerification(
session.factors.user.id, // session.factors.user.id,
); // );
if (!isUserVerified) { // if (!isUserVerified) {
const params = new URLSearchParams({ const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string, loginName: session.factors?.user?.loginName as string,
send: "true", // set this to true to request a new code immediately send: "true", // set this to true to request a new code immediately
invite: "true",
}); });
if (command.requestId) { if (command.requestId) {
@@ -291,25 +291,25 @@ export async function sendLoginname(command: SendLoginnameCommand) {
} }
return { redirect: `/verify?` + params }; return { redirect: `/verify?` + params };
} // }
const paramsAuthenticatorSetup = new URLSearchParams({ // const paramsAuthenticatorSetup = new URLSearchParams({
loginName: session.factors?.user?.loginName, // loginName: session.factors?.user?.loginName,
userId: session.factors?.user?.id, // verify needs user id // userId: session.factors?.user?.id, // verify needs user id
}); // });
if (command.organization || session.factors?.user?.organizationId) { // if (command.organization || session.factors?.user?.organizationId) {
paramsAuthenticatorSetup.append( // paramsAuthenticatorSetup.append(
"organization", // "organization",
command.organization ?? session.factors?.user?.organizationId, // command.organization ?? session.factors?.user?.organizationId,
); // );
} // }
if (command.requestId) { // if (command.requestId) {
paramsAuthenticatorSetup.append("requestId", command.requestId); // paramsAuthenticatorSetup.append("requestId", command.requestId);
} // }
return { redirect: "/authenticator/set?" + paramsAuthenticatorSetup }; // return { redirect: "/authenticator/set?" + paramsAuthenticatorSetup };
} }
if (methods.authMethodTypes.length == 1) { if (methods.authMethodTypes.length == 1) {

View File

@@ -1,6 +1,7 @@
"use server"; "use server";
import { import {
createInviteCode,
getLoginSettings, getLoginSettings,
getSession, getSession,
getUserByID, getUserByID,
@@ -93,45 +94,6 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
} }
let session: Session | undefined; let session: Session | undefined;
let user: User | undefined;
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({ const userResponse = await getUserByID({
serviceUrl, serviceUrl,
userId: command.userId, userId: command.userId,
@@ -141,40 +103,17 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
return { error: "Could not load user" }; return { error: "Could not load user" };
} }
user = userResponse.user; const user = userResponse.user;
const checks = create(ChecksSchema, { const sessionCookie = await getSessionCookieByLoginName({
user: { loginName:
search: { "loginName" in command ? command.loginName : user.preferredLoginName,
case: "loginName", organization: command.organization,
value: userResponse.user.preferredLoginName, }).catch((error) => {
}, console.warn("Ignored error:", error); // checked later
},
});
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) {
return { error: "Could not load user" };
}
const loginSettings = await getLoginSettings({
serviceUrl,
organization: user.details?.resourceOwner,
}); });
// load auth methods for user
const authMethodResponse = await listAuthenticationMethodTypes({ const authMethodResponse = await listAuthenticationMethodTypes({
serviceUrl, serviceUrl,
userId: user.userId, userId: user.userId,
@@ -190,6 +129,36 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
authMethodResponse.authMethodTypes && authMethodResponse.authMethodTypes &&
authMethodResponse.authMethodTypes.length == 0 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({ const params = new URLSearchParams({
sessionId: session.id, sessionId: session.id,
}); });
@@ -218,6 +187,41 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
return { redirect: `/authenticator/set?${params}` }; return { redirect: `/authenticator/set?${params}` };
} }
// 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 (!sessionCookie || !session?.factors?.user?.id) {
const verifySuccessParams = new URLSearchParams({});
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 // redirect to mfa factor if user has one, or redirect to set one up
const mfaFactorCheck = await checkMFAFactors( const mfaFactorCheck = await checkMFAFactors(
serviceUrl, serviceUrl,
@@ -256,6 +260,7 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
); );
return { redirect: url }; return { redirect: url };
}
} }
type resendVerifyEmailCommand = { type resendVerifyEmailCommand = {
@@ -279,6 +284,11 @@ export async function resendVerification(command: resendVerifyEmailCommand) {
? resendInviteCode({ ? resendInviteCode({
serviceUrl, serviceUrl,
userId: command.userId, userId: command.userId,
}).catch((error) => {
if (error.code === 9) {
return { error: "User is already verified!" };
}
return { error: "Could not resend invite" };
}) })
: sendEmailCode({ : sendEmailCode({
userId: command.userId, 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 = { export type SendVerificationRedirectWithoutCheckCommand = {
organization?: string; organization?: string;
requestId?: string; requestId?: string;