fix: Registration Form Legal Checkbox Logic (#10597)

Closes #10498

The registration form's legal checkboxes had incorrect validation logic
that prevented users from completing registration when only one legal
document (ToS or Privacy Policy) was configured, or when no legal
documents were required.

additionally removes a duplicate description for "or use Identity
Provider"

# Which Problems Are Solved

Having only partial legal documents was blocking users to register. The
logic now conditionally renders checkboxes and checks if all provided
documents are accepted.

# How the Problems Are Solved

- Fixed checkbox validation: Now properly validates based on which legal
documents are actually available
- acceptance logic: Only requires acceptance of checkboxes that are
shown
- No legal docs support: Users can proceed when no legal documents are
configured
- Proper state management: Fixed checkbox state tracking and mixed-up
test IDs

---------

Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
This commit is contained in:
Max Peintner
2025-09-09 09:37:32 +02:00
committed by GitHub
parent a0c3ccecf7
commit b9b9baf67f
11 changed files with 2597 additions and 2631 deletions

View File

@@ -222,7 +222,6 @@
"termsOfService": "Nutzungsbedingungen",
"privacyPolicy": "Datenschutzrichtlinie",
"submit": "Weiter",
"orUseIDP": "oder verwenden Sie einen Identitätsanbieter",
"password": {
"title": "Passwort festlegen",
"description": "Legen Sie das Passwort für Ihr Konto fest",

View File

@@ -222,7 +222,6 @@
"termsOfService": "Terms of Service",
"privacyPolicy": "Privacy Policy",
"submit": "Continue",
"orUseIDP": "or use an Identity Provider",
"password": {
"title": "Set Password",
"description": "Set the password for your account",

View File

@@ -222,7 +222,6 @@
"termsOfService": "Términos de Servicio",
"privacyPolicy": "Política de Privacidad",
"submit": "Continuar",
"orUseIDP": "o usa un Proveedor de Identidad",
"password": {
"title": "Establecer Contraseña",
"description": "Establece la contraseña para tu cuenta",

View File

@@ -222,7 +222,6 @@
"termsOfService": "Termini di Servizio",
"privacyPolicy": "Informativa sulla Privacy",
"submit": "Continua",
"orUseIDP": "o usa un Identity Provider",
"password": {
"title": "Imposta Password",
"description": "Imposta la password per il tuo account",

View File

@@ -222,7 +222,6 @@
"termsOfService": "Regulamin",
"privacyPolicy": "Polityka prywatności",
"submit": "Kontynuuj",
"orUseIDP": "lub użyj dostawcy tożsamości",
"password": {
"title": "Ustaw hasło",
"description": "Ustaw hasło dla swojego konta",

View File

@@ -222,7 +222,6 @@
"termsOfService": "Условия использования",
"privacyPolicy": "Политика конфиденциальности",
"submit": "Продолжить",
"orUseIDP": "или используйте Identity Provider",
"password": {
"title": "Установить пароль",
"description": "Установите пароль для вашего аккаунта",

View File

@@ -222,7 +222,6 @@
"termsOfService": "服务条款",
"privacyPolicy": "隐私政策",
"submit": "继续",
"orUseIDP": "或使用身份提供者",
"password": {
"title": "设置密码",
"description": "为您的账户设置密码",

View File

@@ -20,12 +20,10 @@ import { headers } from "next/headers";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("register");
return { title: t('title')};
return { title: t("title") };
}
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}) {
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
const searchParams = await props.searchParams;
let { firstname, lastname, email, organization, requestId } = searchParams;
@@ -104,12 +102,9 @@ export default async function Page(props: {
{legal &&
passwordComplexitySettings &&
organization &&
(loginSettings.allowUsernamePassword ||
loginSettings.passkeysType == PasskeysType.ALLOWED) && (
(loginSettings.allowUsernamePassword || loginSettings.passkeysType == PasskeysType.ALLOWED) && (
<RegisterForm
idpCount={
!loginSettings?.allowExternalIdp ? 0 : identityProviders.length
}
idpCount={!loginSettings?.allowExternalIdp ? 0 : identityProviders.length}
legal={legal}
organization={organization}
firstname={firstname}
@@ -122,12 +117,6 @@ export default async function Page(props: {
{loginSettings?.allowExternalIdp && !!identityProviders.length && (
<>
<div className="flex flex-col items-center py-3">
<p className="ztdl-p text-center">
<Translated i18nKey="orUseIDP" namespace="register" />
</p>
</div>
<SignInWithIdp
identityProviders={identityProviders}
requestId={requestId}

View File

@@ -21,6 +21,18 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
privacyPolicyAccepted: false,
});
// Helper function to check if all required checkboxes are accepted
const checkAllAccepted = (newState: AcceptanceState) => {
const hasTosLink = !!legal?.tosLink;
const hasPrivacyLink = !!legal?.privacyPolicyLink;
// Check that all required checkboxes are accepted
return (
(!hasTosLink || newState.tosAccepted) &&
(!hasPrivacyLink || newState.privacyPolicyAccepted)
);
};
return (
<>
<p className="mt-4 flex flex-row items-center text-sm text-text-light-secondary-500 dark:text-text-dark-secondary-500">
@@ -50,16 +62,17 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
<div className="mt-4 flex items-center">
<Checkbox
className="mr-4"
checked={false}
value={"privacypolicy"}
checked={acceptanceState.tosAccepted}
value={"tos"}
onChangeVal={(checked: boolean) => {
setAcceptanceState({
const newState = {
...acceptanceState,
tosAccepted: checked,
});
onChange(checked && acceptanceState.privacyPolicyAccepted);
};
setAcceptanceState(newState);
onChange(checkAllAccepted(newState));
}}
data-testid="privacy-policy-checkbox"
data-testid="tos-checkbox"
/>
<div className="mr-4 w-[28rem]">
@@ -75,25 +88,22 @@ export function PrivacyPolicyCheckboxes({ legal, onChange }: Props) {
<div className="mt-4 flex items-center">
<Checkbox
className="mr-4"
checked={false}
value={"tos"}
checked={acceptanceState.privacyPolicyAccepted}
value={"privacypolicy"}
onChangeVal={(checked: boolean) => {
setAcceptanceState({
const newState = {
...acceptanceState,
privacyPolicyAccepted: checked,
});
onChange(checked && acceptanceState.tosAccepted);
};
setAcceptanceState(newState);
onChange(checkAllAccepted(newState));
}}
data-testid="tos-checkbox"
data-testid="privacy-policy-checkbox"
/>
<div className="mr-4 w-[28rem]">
<p className="text-sm text-text-light-500 dark:text-text-dark-500">
<Link
href={legal.privacyPolicyLink}
className="underline"
target="_blank"
>
<Link href={legal.privacyPolicyLink} className="underline" target="_blank">
<Translated i18nKey="privacyPolicy" namespace="register" />
</Link>
</p>

View File

@@ -2,20 +2,13 @@
import { registerUser } from "@/lib/server/register";
import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb";
import {
LoginSettings,
PasskeysType,
} from "@zitadel/proto/zitadel/settings/v2/login_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 { AuthenticationMethod, AuthenticationMethodRadio, methods } from "./authentication-method-radio";
import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
@@ -98,10 +91,7 @@ export function RegisterForm({
return response;
}
async function submitAndContinue(
value: Inputs,
withPassword: boolean = false,
) {
async function submitAndContinue(value: Inputs, withPassword: boolean = false) {
const registerParams: any = value;
if (organization) {
@@ -114,9 +104,7 @@ export function RegisterForm({
// redirect user to /register/password if password is chosen
if (withPassword) {
return router.push(
`/register/password?` + new URLSearchParams(registerParams),
);
return router.push(`/register/password?` + new URLSearchParams(registerParams));
} else {
return submitAndRegister(value);
}
@@ -125,6 +113,11 @@ export function RegisterForm({
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 (
<form className="w-full">
<div className="mb-4 grid grid-cols-2 gap-4">
@@ -162,38 +155,27 @@ export function RegisterForm({
/>
</div>
</div>
{legal && (
<PrivacyPolicyCheckboxes
legal={legal}
onChange={setTosAndPolicyAccepted}
/>
{(legal?.tosLink || legal?.privacyPolicyLink) && (
<PrivacyPolicyCheckboxes legal={legal} onChange={setTosAndPolicyAccepted} />
)}
{/* show chooser if both methods are allowed */}
{loginSettings &&
loginSettings.allowUsernamePassword &&
loginSettings.passkeysType == PasskeysType.ALLOWED && (
<>
<p className="ztdl-p mb-6 mt-4 block text-left">
<Translated i18nKey="selectMethod" namespace="register" />
</p>
{loginSettings && loginSettings.allowUsernamePassword && 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>
</>
)}
<div className="pb-4">
<AuthenticationMethodRadio selected={selected} selectionChanged={setSelected} />
</div>
</>
)}
{!loginSettings?.allowUsernamePassword &&
loginSettings?.passkeysType !== PasskeysType.ALLOWED &&
(!loginSettings?.allowExternalIdp || !idpCount) && (
<div className="py-4">
<Alert type={AlertType.INFO}>
<Translated
i18nKey="noMethodAvailableWarning"
namespace="register"
/>
<Translated i18nKey="noMethodAvailableWarning" namespace="register" />
</Alert>
</div>
)}
@@ -209,11 +191,10 @@ export function RegisterForm({
<Button
type="submit"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid || !tosAndPolicyAccepted}
disabled={loading || !canSubmit}
onClick={handleSubmit((values) => {
const usePasswordToContinue: boolean =
loginSettings?.allowUsernamePassword &&
loginSettings?.passkeysType == PasskeysType.ALLOWED
loginSettings?.allowUsernamePassword && loginSettings?.passkeysType == PasskeysType.ALLOWED
? !(selected === methods[0]) // choose selection if both available
: !!loginSettings?.allowUsernamePassword; // if password is chosen
// set password as default if only password is allowed

File diff suppressed because it is too large Load Diff