mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-09 23:55:12 +00:00
Closes #10816 Closes #10759 # Which Problems Are Solved This PR refactors the SAML Post binding flow to address issues with payloads exceeding browser cookie size limits (typically ~4KB). Previously, the SAML form data was serialized into a cookie, and the user was redirected to an intermediate /saml-post page to render the form. This approach failed for large SAML assertions. # How the Problems Are Solved The new implementation bypasses cookies entirely for the payload transfer. Server actions now return the form data directly to the client, which immediately renders and submits the form. - Direct Data Transfer: Updated `startIdentityProviderFlow` to return an object { url, fields } instead of a redirect URL when the next step is formData. - Server Actions: Updated redirectToIdp, sendLoginname, and completeAuthFlow to propagate this samlData object to the client. - Client-Side Auto-Submission: Added a new AutoSubmitForm component that renders a hidden form with the provided fields and automatically submits it to the IdP on mount. --------- Co-authored-by: conblem <[email protected]>
210 lines
6.9 KiB
TypeScript
210 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { registerUser } from "@/lib/server/register";
|
|
import { handleServerActionResponse } from "@/lib/client";
|
|
import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb";
|
|
import { LoginSettings, PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
|
import { useRouter } from "next/navigation";
|
|
import { useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { FieldValues, useForm } from "react-hook-form";
|
|
import { Alert, AlertType } from "./alert";
|
|
import { AuthenticationMethod, AuthenticationMethodRadio, methods } from "./authentication-method-radio";
|
|
import { BackButton } from "./back-button";
|
|
import { Button, ButtonVariants } from "./button";
|
|
import { TextInput } from "./input";
|
|
import { PrivacyPolicyCheckboxes } from "./privacy-policy-checkboxes";
|
|
import { Spinner } from "./spinner";
|
|
import { Translated } from "./translated";
|
|
import { AutoSubmitForm } from "./auto-submit-form";
|
|
|
|
type Inputs =
|
|
| {
|
|
firstname: string;
|
|
lastname: string;
|
|
email: string;
|
|
}
|
|
| FieldValues;
|
|
|
|
type Props = {
|
|
legal: LegalAndSupportSettings;
|
|
firstname?: string;
|
|
lastname?: string;
|
|
email?: string;
|
|
organization: string;
|
|
requestId?: string;
|
|
loginSettings?: LoginSettings;
|
|
idpCount: number;
|
|
};
|
|
|
|
export function RegisterForm({
|
|
legal,
|
|
email,
|
|
firstname,
|
|
lastname,
|
|
organization,
|
|
requestId,
|
|
loginSettings,
|
|
idpCount = 0,
|
|
}: Props) {
|
|
const { register, handleSubmit, formState } = useForm<Inputs>({
|
|
mode: "onChange",
|
|
defaultValues: {
|
|
email: email ?? "",
|
|
firstName: firstname ?? "",
|
|
lastname: lastname ?? "",
|
|
},
|
|
});
|
|
|
|
const t = useTranslations("register");
|
|
|
|
const [loading, setLoading] = useState<boolean>(false);
|
|
const [selected, setSelected] = useState<AuthenticationMethod>(methods[0]);
|
|
const [error, setError] = useState<string>("");
|
|
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
|
|
|
|
const router = useRouter();
|
|
|
|
async function submitAndRegister(values: Inputs) {
|
|
setLoading(true);
|
|
try {
|
|
const response = await registerUser({
|
|
email: values.email,
|
|
firstName: values.firstname,
|
|
lastName: values.lastname,
|
|
organization: organization,
|
|
requestId: requestId,
|
|
});
|
|
|
|
handleServerActionResponse(response, router, setSamlData, setError);
|
|
|
|
return response;
|
|
} catch {
|
|
setError(t("errors.couldNotRegisterUser"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function submitAndContinue(value: Inputs, withPassword: boolean = false) {
|
|
const registerParams: any = value;
|
|
|
|
if (organization) {
|
|
registerParams.organization = organization;
|
|
}
|
|
|
|
if (requestId) {
|
|
registerParams.requestId = requestId;
|
|
}
|
|
|
|
// redirect user to /register/password if password is chosen
|
|
if (withPassword) {
|
|
return router.push(`/register/password?` + new URLSearchParams(registerParams));
|
|
} else {
|
|
return submitAndRegister(value);
|
|
}
|
|
}
|
|
|
|
const { errors } = formState;
|
|
|
|
const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false);
|
|
|
|
// Check if legal acceptance is required
|
|
const isLegalAcceptanceRequired = !!(legal?.tosLink || legal?.privacyPolicyLink);
|
|
const canSubmit = formState.isValid && (!isLegalAcceptanceRequired || tosAndPolicyAccepted);
|
|
|
|
return (
|
|
<>
|
|
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
|
|
<form className="w-full">
|
|
<div className="mb-4 grid grid-cols-2 gap-4">
|
|
<div className="">
|
|
<TextInput
|
|
type="firstname"
|
|
autoComplete="firstname"
|
|
required
|
|
{...register("firstname", { required: t("required.firstname") })}
|
|
label={t("labels.firstname")}
|
|
error={errors.firstname?.message as string}
|
|
data-testid="firstname-text-input"
|
|
/>
|
|
</div>
|
|
<div className="">
|
|
<TextInput
|
|
type="lastname"
|
|
autoComplete="lastname"
|
|
required
|
|
{...register("lastname", { required: t("required.lastname") })}
|
|
label={t("labels.lastname")}
|
|
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: t("required.email") })}
|
|
label={t("labels.email")}
|
|
error={errors.email?.message as string}
|
|
data-testid="email-text-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{(legal?.tosLink || legal?.privacyPolicyLink) && (
|
|
<PrivacyPolicyCheckboxes legal={legal} onChange={setTosAndPolicyAccepted} />
|
|
)}
|
|
{/* show chooser if both methods are allowed */}
|
|
{loginSettings && loginSettings.allowLocalAuthentication && loginSettings.passkeysType == PasskeysType.ALLOWED && (
|
|
<>
|
|
<p className="ztdl-p mb-6 mt-4 block text-left">
|
|
<Translated i18nKey="selectMethod" namespace="register" />
|
|
</p>
|
|
|
|
<div className="pb-4">
|
|
<AuthenticationMethodRadio selected={selected} selectionChanged={setSelected} />
|
|
</div>
|
|
</>
|
|
)}
|
|
{!loginSettings?.allowLocalAuthentication &&
|
|
loginSettings?.passkeysType !== PasskeysType.ALLOWED &&
|
|
(!loginSettings?.allowExternalIdp || !idpCount) && (
|
|
<div className="py-4">
|
|
<Alert type={AlertType.INFO}>
|
|
<Translated i18nKey="noMethodAvailableWarning" namespace="register" />
|
|
</Alert>
|
|
</div>
|
|
)}
|
|
|
|
{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 || !canSubmit}
|
|
onClick={handleSubmit((values) => {
|
|
const usePasswordToContinue: boolean =
|
|
loginSettings?.allowLocalAuthentication && loginSettings?.passkeysType == PasskeysType.ALLOWED
|
|
? !(selected === methods[0]) // choose selection if both available
|
|
: !!loginSettings?.allowLocalAuthentication; // if password is chosen
|
|
// set password as default if only password is allowed
|
|
return submitAndContinue(values, usePasswordToContinue);
|
|
})}
|
|
data-testid="submit-button"
|
|
>
|
|
{loading && <Spinner className="mr-2 h-5 w-5" />}
|
|
<Translated i18nKey="submit" namespace="register" />
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</>
|
|
);
|
|
}
|