user missing data form for IDPs

This commit is contained in:
Max Peintner
2025-06-06 14:21:11 +02:00
parent 738f1f0448
commit 830e495cd0
7 changed files with 341 additions and 77 deletions

View File

@@ -1,5 +1,6 @@
import { DynamicTheme } from "@/components/dynamic-theme";
import { IdpSignin } from "@/components/idp-signin";
import { completeIDP } from "@/components/idps/pages/complete-idp";
import { linkingFailed } from "@/components/idps/pages/linking-failed";
import { linkingSuccess } from "@/components/idps/pages/linking-success";
import { loginFailed } from "@/components/idps/pages/login-failed";
@@ -24,7 +25,6 @@ import {
} from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
const ORG_SUFFIX_REGEX = /(?<=@)(.+)/;
@@ -177,9 +177,10 @@ export default async function Page(props: {
}
}
if (options?.isAutoCreation) {
let orgToRegisterOn: string | undefined = organization;
let newUser;
// automatic creation of a user is allowed and data is complete
if (options?.isAutoCreation && addHumanUser) {
let orgToRegisterOn: string | undefined = organization;
if (
!orgToRegisterOn &&
@@ -206,8 +207,6 @@ export default async function Page(props: {
}
}
// if addHumanUser is provided in the intent, expect that it can be created otherwise show an error
if (addHumanUser) {
let addHumanUserWithOrganization: AddHumanUserRequest;
if (orgToRegisterOn) {
const organizationSchema = create(OrganizationSchema, {
@@ -243,16 +242,17 @@ export default async function Page(props: {
: "Could not create user",
);
}
} else {
// if no user was found, we will create a new user manually / redirect to the registration page
if (options.isCreationAllowed) {
const registerParams = new URLSearchParams({
idpIntentId: id,
idpIntentToken: token,
organization: organization ?? "",
});
return redirect(`/register?${registerParams})}`);
}
// if no user was found, we will create a new user manually / redirect to the registration page
if (options?.isCreationAllowed) {
return completeIDP({
branding,
idpInformation,
organization,
requestId,
userId,
});
}
if (newUser) {
@@ -270,7 +270,6 @@ export default async function Page(props: {
</DynamicTheme>
);
}
}
// return login failed if no linking or creation is allowed and no user was found
return loginFailed(branding, "No user found");

View File

@@ -0,0 +1,38 @@
import { RegisterFormIDPIncomplete } from "@/components/register-form-idp-incomplete";
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
import { IDPInformation } from "@zitadel/proto/zitadel/user/v2/idp_pb";
import { getLocale, getTranslations } from "next-intl/server";
import { DynamicTheme } from "../../dynamic-theme";
export async function completeIDP({
userId,
idpInformation,
requestId,
organization,
branding,
}: {
userId: string;
idpInformation: IDPInformation;
requestId?: string;
organization?: string;
branding?: BrandingSettings;
}) {
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "idp" });
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("loginSuccess.title")}</h1>
<p className="ztdl-p">{t("loginSuccess.description")}</p>
<RegisterFormIDPIncomplete
userId={userId}
idpInformation={idpInformation}
requestId={requestId}
organization={organization}
/>
</div>
</DynamicTheme>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { IDPInformation } from "@zitadel/proto/zitadel/user/v2/idp_pb";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { FieldValues, useForm } from "react-hook-form";
import { Alert } from "./alert";
import { AuthenticationMethod, methods } from "./authentication-method-radio";
import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
type Inputs =
| {
firstname: string;
lastname: string;
email: string;
}
| FieldValues;
type Props = {
organization?: string;
requestId?: string;
idpInformation?: IDPInformation;
};
export function RegisterFormIDPIncomplete({
organization,
requestId,
idpInformation,
}: Props) {
const t = useTranslations("register");
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
defaultValues: {
email: idpInformation?.rawInformation?.email ?? "",
firstName: idpInformation?.rawInformation?.firstname ?? "",
lastname: idpInformation?.rawInformation?.lastname ?? "",
},
});
const [loading, setLoading] = useState<boolean>(false);
const [selected, setSelected] = useState<AuthenticationMethod>(methods[0]);
const [error, setError] = useState<string>("");
const router = useRouter();
async function submitAndRegister(values: Inputs) {
setLoading(true);
const response = await registerUserAndLinkToIDP({
email: values.email,
firstName: values.firstname,
lastName: values.lastname,
organization: organization,
requestId: requestId,
})
.catch(() => {
setError("Could not register user");
return;
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
if (response && "redirect" in response && response.redirect) {
return router.push(response.redirect);
}
return response;
}
const { errors } = formState;
return (
<form className="w-full">
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="">
<TextInput
type="firstname"
autoComplete="firstname"
required
{...register("firstname", { required: "This field is required" })}
label="First name"
error={errors.firstname?.message as string}
data-testid="firstname-text-input"
/>
</div>
<div className="">
<TextInput
type="lastname"
autoComplete="lastname"
required
{...register("lastname", { required: "This field is required" })}
label="Last name"
error={errors.lastname?.message as string}
data-testid="lastname-text-input"
/>
</div>
<div className="col-span-2">
<TextInput
type="email"
autoComplete="email"
required
{...register("email", { required: "This field is required" })}
label="E-mail"
error={errors.email?.message as string}
data-testid="email-text-input"
/>
</div>
</div>
<p className="mt-4 ztdl-p mb-6 block text-left">{t("completeData")}</p>
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center justify-between">
<BackButton data-testid="back-button" />
<Button
type="submit"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid || !tosAndPolicyAccepted}
onClick={handleSubmit(submitAndRegister)}
data-testid="submit-button"
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
{t("submit")}
</Button>
</div>
</form>
);
}

View File

@@ -109,15 +109,20 @@ export async function createSessionAndUpdateCookie(command: {
}
}
export async function createSessionForIdpAndUpdateCookie(
userId: string,
export async function createSessionForIdpAndUpdateCookie({
userId,
idpIntent,
requestId,
lifetime,
}: {
userId: string;
idpIntent: {
idpIntentId?: string | undefined;
idpIntentToken?: string | undefined;
},
requestId: string | undefined,
lifetime?: Duration,
): Promise<Session> {
};
requestId: string | undefined;
lifetime?: Duration;
}): Promise<Session> {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);

View File

@@ -122,12 +122,12 @@ export async function createNewSessionFromIdpIntent(
organization: userResponse.user.details?.resourceOwner,
});
const session = await createSessionForIdpAndUpdateCookie(
command.userId,
command.idpIntent,
command.requestId,
loginSettings?.externalLoginCheckLifetime,
);
const session = await createSessionForIdpAndUpdateCookie({
userId: command.userId,
idpIntent: command.idpIntent,
requestId: command.requestId,
lifetime: loginSettings?.externalLoginCheckLifetime,
});
if (!session || !session.factors?.user) {
return { error: "Could not create session" };

View File

@@ -1,6 +1,9 @@
"use server";
import { createSessionAndUpdateCookie } from "@/lib/server/cookie";
import {
createSessionAndUpdateCookie,
createSessionForIdpAndUpdateCookie,
} from "@/lib/server/cookie";
import { addHumanUser, getLoginSettings, getUserByID } from "@/lib/zitadel";
import { create } from "@zitadel/client";
import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb";
@@ -133,3 +136,79 @@ export async function registerUser(command: RegisterUserCommand) {
return { redirect: url };
}
}
type RegisterUserAndLinkToIDPommand = {
email: string;
firstName: string;
lastName: string;
organization?: string;
requestId?: string;
idpIntent: {
idpIntentId: string;
idpIntentToken: string;
};
userId: string;
};
export type registerUserAndLinkToIDPResponse = {
userId: string;
sessionId: string;
factors: Factors | undefined;
};
export async function registerUserAndLinkToIDP(
command: RegisterUserAndLinkToIDPommand,
) {
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 addResponse = await addHumanUser({
serviceUrl,
email: command.email,
firstName: command.firstName,
lastName: command.lastName,
organization: command.organization,
});
if (!addResponse) {
return { error: "Could not create user" };
}
const loginSettings = await getLoginSettings({
serviceUrl,
organization: command.organization,
});
// TODO: addIDPLink to addResponse
const session = await createSessionForIdpAndUpdateCookie({
requestId: command.requestId,
userId: command.userId,
idpIntent: command.idpIntent,
lifetime: loginSettings?.externalLoginCheckLifetime,
});
if (!session || !session.factors?.user) {
return { error: "Could not create session" };
}
const url = await getNextUrl(
command.requestId && session.id
? {
sessionId: session.id,
requestId: command.requestId,
organization: session.factors.user.organizationId,
}
: {
loginName: session.factors.user.loginName,
organization: session.factors.user.organizationId,
},
loginSettings?.defaultRedirectUri,
);
return { redirect: url };
}

View File

@@ -387,7 +387,7 @@ export type AddHumanUserData = {
firstName: string;
lastName: string;
email: string;
password: string | undefined;
password?: string;
organization: string | undefined;
};