Merge pull request #467 from zitadel/invite-expired-resend

fix: invite flow when expired
This commit is contained in:
Max Peintner
2025-05-23 13:20:12 +02:00
committed by GitHub
4 changed files with 64 additions and 55 deletions

View File

@@ -7,6 +7,7 @@ import { UserAvatar } from "@/components/user-avatar";
import { getSessionCookieById } from "@/lib/cookies"; import { getSessionCookieById } from "@/lib/cookies";
import { getServiceUrlFromHeaders } from "@/lib/service-url"; import { getServiceUrlFromHeaders } from "@/lib/service-url";
import { loadMostRecentSession } from "@/lib/session"; import { loadMostRecentSession } from "@/lib/session";
import { checkUserVerification } from "@/lib/verify-helper";
import { import {
getActiveIdentityProviders, getActiveIdentityProviders,
getBrandingSettings, getBrandingSettings,
@@ -18,6 +19,7 @@ import {
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function Page(props: { export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>; searchParams: Promise<Record<string | number | symbol, string | undefined>>;
@@ -92,20 +94,49 @@ export default async function Page(props: {
}); });
} }
if (!sessionWithData) { if (
!sessionWithData ||
!sessionWithData.factors ||
!sessionWithData.factors.user
) {
return <Alert>{tError("unknownContext")}</Alert>; return <Alert>{tError("unknownContext")}</Alert>;
} }
const branding = await getBrandingSettings({ const branding = await getBrandingSettings({
serviceUrl, serviceUrl,
organization: sessionWithData.factors?.user?.organizationId, organization: sessionWithData.factors.user?.organizationId,
}); });
const loginSettings = await getLoginSettings({ const loginSettings = await getLoginSettings({
serviceUrl, serviceUrl,
organization: sessionWithData.factors?.user?.organizationId, organization: sessionWithData.factors.user?.organizationId,
}); });
// check if user was verified recently
const isUserVerified = await checkUserVerification(
sessionWithData.factors.user?.id,
);
if (!isUserVerified) {
const params = new URLSearchParams({
loginName: sessionWithData.factors.user.loginName as string,
send: "true", // set this to true to request a new code immediately
});
if (requestId) {
params.append("requestId", requestId);
}
if (organization || sessionWithData.factors.user.organizationId) {
params.append(
"organization",
organization ?? (sessionWithData.factors.user.organizationId as string),
);
}
redirect(`/verify?` + params);
}
const identityProviders = await getActiveIdentityProviders({ const identityProviders = await getActiveIdentityProviders({
serviceUrl, serviceUrl,
orgId: sessionWithData.factors?.user?.organizationId, orgId: sessionWithData.factors?.user?.organizationId,

View File

@@ -2,18 +2,11 @@ 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 { VerifyRedirectButton } from "@/components/verify-redirect-button";
import { sendEmailCode } from "@/lib/server/verify"; import { sendEmailCode } 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 { checkUserVerification } from "@/lib/verify-helper"; import { getBrandingSettings, getUserByID } from "@/lib/zitadel";
import {
getBrandingSettings,
getUserByID,
listAuthenticationMethodTypes,
} from "@/lib/zitadel";
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb"; import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers"; import { headers } from "next/headers";
@@ -101,19 +94,6 @@ export default async function Page(props: { searchParams: Promise<any> }) {
throw Error("Failed to get user id"); throw Error("Failed to get user id");
} }
let authMethods: AuthenticationMethodType[] | null = null;
if (human?.email?.isVerified) {
const authMethodsResponse = await listAuthenticationMethodTypes({
serviceUrl,
userId,
});
if (authMethodsResponse.authMethodTypes) {
authMethods = authMethodsResponse.authMethodTypes;
}
}
const hasValidUserVerificationCheck = await checkUserVerification(id);
const params = new URLSearchParams({ const params = new URLSearchParams({
userId: userId, userId: userId,
initial: "true", // defines that a code is not required and is therefore not shown in the UI initial: "true", // defines that a code is not required and is therefore not shown in the UI
@@ -171,27 +151,15 @@ export default async function Page(props: { searchParams: Promise<any> }) {
) )
)} )}
{/* show a button to setup auth method for the user otherwise show the UI for reverifying */} {/* 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) */}
{human?.email?.isVerified && hasValidUserVerificationCheck ? ( <VerifyForm
// show page for already verified users loginName={loginName}
<VerifyRedirectButton organization={organization}
userId={id} userId={id}
loginName={loginName} code={code}
organization={organization} isInvite={invite === "true"}
requestId={requestId} requestId={requestId}
authMethods={authMethods} />
/>
) : (
// check if auth methods are set
<VerifyForm
loginName={loginName}
organization={organization}
userId={id}
code={code}
isInvite={invite === "true"}
requestId={requestId}
/>
)}
</div> </div>
</DynamicTheme> </DynamicTheme>
); );

View File

@@ -6,6 +6,7 @@ import {
} from "@/lib/server/verify"; } from "@/lib/server/verify";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { Alert, AlertType } from "./alert"; import { Alert, AlertType } from "./alert";
import { BackButton } from "./back-button"; import { BackButton } from "./back-button";
@@ -29,6 +30,7 @@ export function VerifyRedirectButton({
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
async function submitAndContinue(): Promise<boolean | void> { async function submitAndContinue(): Promise<boolean | void> {
setLoading(true); setLoading(true);
@@ -50,7 +52,7 @@ export function VerifyRedirectButton({
} as SendVerificationRedirectWithoutCheckCommand; } as SendVerificationRedirectWithoutCheckCommand;
} }
await sendVerificationRedirectWithoutCheck(command) const response = await sendVerificationRedirectWithoutCheck(command)
.catch(() => { .catch(() => {
setError("Could not verify"); setError("Could not verify");
return; return;
@@ -58,6 +60,16 @@ export function VerifyRedirectButton({
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
}); });
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
if (response && "redirect" in response && response.redirect) {
router.push(response.redirect);
return true;
}
} }
return ( return (

View File

@@ -1,11 +1,11 @@
"use server"; "use server";
import { import {
createInviteCode,
getLoginSettings, getLoginSettings,
getSession, getSession,
getUserByID, getUserByID,
listAuthenticationMethodTypes, listAuthenticationMethodTypes,
resendInviteCode,
verifyEmail, verifyEmail,
verifyInviteCode, verifyInviteCode,
verifyTOTPRegistration, verifyTOTPRegistration,
@@ -71,14 +71,16 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
serviceUrl, serviceUrl,
userId: command.userId, userId: command.userId,
verificationCode: command.code, verificationCode: command.code,
}).catch(() => { }).catch((error) => {
console.warn(error);
return { error: "Could not verify invite" }; return { error: "Could not verify invite" };
}) })
: await verifyEmail({ : await verifyEmail({
serviceUrl, serviceUrl,
userId: command.userId, userId: command.userId,
verificationCode: command.code, verificationCode: command.code,
}).catch(() => { }).catch((error) => {
console.warn(error);
return { error: "Could not verify email" }; return { error: "Could not verify email" };
}); });
@@ -273,15 +275,11 @@ export async function resendVerification(command: resendVerifyEmailCommand) {
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
// create a new invite whenever the resend is called
return command.isInvite return command.isInvite
? createInviteCode({ ? resendInviteCode({
serviceUrl, serviceUrl,
userId: command.userId, userId: command.userId,
urlTemplate: })
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` +
(command.requestId ? `&requestId=${command.requestId}` : ""),
}) //resendInviteCode({ serviceUrl, userId: command.userId })
: sendEmailCode({ : sendEmailCode({
userId: command.userId, userId: command.userId,
serviceUrl, serviceUrl,