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) {
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
});

View File

@@ -134,6 +134,7 @@ export default async function Page(props: {
{!(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>}
{isSessionValid(sessionWithData).valid &&

View File

@@ -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<any> }) {
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<any> }) {
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<any> }) {
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<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) */}
<VerifyForm
loginName={loginName}
organization={organization}
userId={id}
code={code}
isInvite={invite === "true"}
requestId={requestId}
/>
{/* 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
loginName={loginName}
organization={organization}
userId={id}
code={code}
isInvite={invite === "true"}
requestId={requestId}
/>
)}
</div>
</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);
});
if (response && "error" in response && response?.error) {
setError(response.error);
return;
}
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 { 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) {

View File

@@ -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;