diff --git a/src/login/kcContext/KcContext.ts b/src/login/kcContext/KcContext.ts index 6cf37644..22fcd7dc 100644 --- a/src/login/kcContext/KcContext.ts +++ b/src/login/kcContext/KcContext.ts @@ -616,26 +616,28 @@ export type LegacyAttribute = Omit export type Validators = Partial<{ length: Validators.DoIgnoreEmpty & Validators.Range; - double: Validators.DoIgnoreEmpty & Validators.Range; integer: Validators.DoIgnoreEmpty & Validators.Range; email: Validators.DoIgnoreEmpty; + pattern: Validators.DoIgnoreEmpty & Validators.ErrorMessage & { pattern: string }; + options: Validators.Options; + multivalued: Validators.DoIgnoreEmpty & Validators.Range; + // NOTE: Following are the validators for which we don't implement client side validation yet + // or for which the validation can't be performed on the client side. + /* + double: Validators.DoIgnoreEmpty & Validators.Range; "up-immutable-attribute": {}; "up-attribute-required-by-metadata-value": {}; "up-username-has-value": {}; "up-duplicate-username": {}; "up-username-mutation": {}; "up-email-exists-as-username": {}; - "up-blank-attribute-value": Validators.ErrorMessage & { - "fail-on-null": boolean; - }; + "up-blank-attribute-value": Validators.ErrorMessage & { "fail-on-null": boolean; }; "up-duplicate-email": {}; "local-date": Validators.DoIgnoreEmpty; - pattern: Validators.DoIgnoreEmpty & Validators.ErrorMessage & { pattern: string }; "person-name-prohibited-characters": Validators.DoIgnoreEmpty & Validators.ErrorMessage; uri: Validators.DoIgnoreEmpty; "username-prohibited-characters": Validators.DoIgnoreEmpty & Validators.ErrorMessage; - options: Validators.Options; - multivalued: Validators.DoIgnoreEmpty & Validators.Range; + */ }>; export declare namespace Validators { diff --git a/src/login/lib/useUserProfileForm.tsx b/src/login/lib/useUserProfileForm.tsx index 7d9d880f..44d11bbd 100644 --- a/src/login/lib/useUserProfileForm.tsx +++ b/src/login/lib/useUserProfileForm.tsx @@ -12,12 +12,33 @@ import type { I18n } from "../i18n"; export type FormFieldError = { errorMessage: JSX.Element; errorMessageStr: string; - // TODO: This is not enough, we should be able to tell - // if it's a server side error, a validator error or a password policy error. - validatorName: keyof Validators | undefined; + source: FormFieldError.Source; fieldIndex: number | undefined; }; +export namespace FormFieldError { + export type Source = Source.Validator | Source.PasswordPolicy | Source.Server | Source.Other; + + export namespace Source { + export type Validator = { + type: "validator"; + name: keyof Validators; + }; + export type PasswordPolicy = { + type: "passwordPolicy"; + name: keyof PasswordPolicies; + }; + export type Server = { + type: "server"; + }; + + export type Other = { + type: "other"; + rule: "passwordConfirmMatchesPassword" | "requiredField"; + }; + } +} + export type FormFieldState = FormFieldState.Simple | FormFieldState.MultiValued; export namespace FormFieldState { @@ -299,23 +320,67 @@ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTy const formState: FormState = useMemo( () => ({ - "formFieldStates": state.formFieldStates.map(({ errors, hasLostFocusAtLeastOnce, attribute, ...valueOrValuesWrap }) => ({ - "displayableErrors": - typeof hasLostFocusAtLeastOnce === "boolean" - ? hasLostFocusAtLeastOnce - ? errors - : [] - : errors.filter(error => { - //TODO + "formFieldStates": state.formFieldStates.map( + ({ errors, hasLostFocusAtLeastOnce: hasLostFocusAtLeastOnceOrArr, attribute, ...valueOrValuesWrap }) => ({ + "displayableErrors": errors.filter(error => { + const hasLostFocusAtLeastOnce = + typeof hasLostFocusAtLeastOnceOrArr === "boolean" + ? hasLostFocusAtLeastOnceOrArr + : error.fieldIndex !== undefined + ? hasLostFocusAtLeastOnceOrArr[error.fieldIndex] + : hasLostFocusAtLeastOnceOrArr[hasLostFocusAtLeastOnceOrArr.length - 1]; - // TODO: The errors from server should be always displayed. - // even before focus lost. - - return true; - }), - attribute, - ...valueOrValuesWrap - })), + switch (error.source.type) { + case "server": + return true; + case "other": + switch (error.source.rule) { + case "requiredField": + return hasLostFocusAtLeastOnce; + case "passwordConfirmMatchesPassword": + return hasLostFocusAtLeastOnce; + } + assert>(false); + case "passwordPolicy": + switch (error.source.name) { + case "length": + return hasLostFocusAtLeastOnce; + case "digits": + return hasLostFocusAtLeastOnce; + case "lowerCase": + return hasLostFocusAtLeastOnce; + case "upperCase": + return hasLostFocusAtLeastOnce; + case "specialChars": + return hasLostFocusAtLeastOnce; + case "notUsername": + return true; + case "notEmail": + return true; + } + assert>(false); + case "validator": + switch (error.source.name) { + case "length": + return hasLostFocusAtLeastOnce; + case "pattern": + return hasLostFocusAtLeastOnce; + case "email": + return hasLostFocusAtLeastOnce; + case "integer": + return hasLostFocusAtLeastOnce; + case "multivalued": + return hasLostFocusAtLeastOnce; + case "options": + return hasLostFocusAtLeastOnce; + } + assert>(false); + } + }), + attribute, + ...valueOrValuesWrap + }) + ), "isFormSubmittable": state.formFieldStates.every(({ errors }) => errors.length === 0) }), [state] @@ -388,34 +453,36 @@ function useGetErrors(params: { kcContext: Pick{errorMessageStr}, - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "server" + } } ]; } - handle_multi_valued_text_input: { + handle_multi_valued_multi_fields: { if (!attribute.multivalued) { - break handle_multi_valued_text_input; + break handle_multi_valued_multi_fields; } if (attribute.annotations.inputType === "multiselect") { - break handle_multi_valued_text_input; + break handle_multi_valued_multi_fields; } if (attribute.annotations.inputType === "multiselect-checkboxes") { - break handle_multi_valued_text_input; + break handle_multi_valued_multi_fields; } assert("values" in fieldValue); const { values } = fieldValue; - return values + const errors = values .map((value, index) => { - const errors = getErrors({ + const specificValueErrors = getErrors({ attributeName, "fieldValues": fieldValues.map(fieldValue => { if (fieldValue.attribute.name === attributeName) { @@ -435,18 +502,50 @@ function useGetErrors(params: { kcContext: Pick ({ ...error, "fieldIndex": index })); + return specificValueErrors + .filter(error => { + if (error.source.type === "other" && error.source.rule === "requiredField") { + return false; + } + + return true; + }) + .map((error): FormFieldError => ({ ...error, "fieldIndex": index })); }) .reduce((acc, errors) => [...acc, ...errors], []); + + required_field: { + if (!attribute.required) { + break required_field; + } + + if (values.find(value => value !== "") !== undefined) { + break required_field; + } + + const msgArgs = ["error-user-attribute-required"] as const; + + errors.push({ + "errorMessage": {msg(...msgArgs)}, + "errorMessageStr": msgStr(...msgArgs), + "fieldIndex": undefined, + "source": { + "type": "other", + "rule": "requiredField" + } + }); + } + + return errors; } - handle_multi_select_or_checkbox: { + handle_multi_select_single_field: { if (!attribute.multivalued) { - break handle_multi_select_or_checkbox; + break handle_multi_select_single_field; } if (attribute.annotations.inputType !== "multiselect" && attribute.annotations.inputType !== "multiselect-checkboxes") { - break handle_multi_select_or_checkbox; + break handle_multi_select_single_field; } const validatorName = "multivalued"; @@ -483,8 +582,11 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - validatorName, - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "validator", + "name": validatorName + } } ]; } @@ -524,10 +626,13 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "passwordPolicy", + "name": policyName + } }); } @@ -551,10 +656,13 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "passwordPolicy", + "name": policyName + } }); } @@ -580,10 +688,13 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "passwordPolicy", + "name": policyName + } }); } @@ -609,10 +720,13 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "passwordPolicy", + "name": policyName + } }); } @@ -636,10 +750,13 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "passwordPolicy", + "name": policyName + } }); } @@ -667,10 +784,13 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "passwordPolicy", + "name": policyName + } }); } @@ -698,10 +818,13 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "passwordPolicy", + "name": policyName + } }); } } @@ -724,10 +847,13 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "other", + "rule": "passwordConfirmMatchesPassword" + } }); } @@ -745,10 +871,13 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "other", + "rule": "requiredField" + } }); } @@ -767,14 +896,19 @@ function useGetErrors(params: { kcContext: Pick parseInt(max)) { const msgArgs = ["error-invalid-length-too-long", max] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - validatorName, - "fieldIndex": undefined + "fieldIndex": undefined, + source }); } @@ -784,8 +918,8 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - validatorName, - "fieldIndex": undefined + "fieldIndex": undefined, + source }); } } @@ -812,16 +946,22 @@ function useGetErrors(params: { kcContext: Pick("shouldMatchPattern"), pattern] as const; errors.push({ - validatorName, "errorMessage": {advancedMsg(...msgArgs)}, "errorMessageStr": advancedMsgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "validator", + "name": validatorName + } }); } validator_x: { - if ([...errors].reverse()[0]?.validatorName === "pattern") { - break validator_x; + { + const lastError = errors[errors.length - 1]; + if (lastError !== undefined && lastError.source.type === "validator" && lastError.source.name === "pattern") { + break validator_x; + } } const validatorName = "email"; @@ -845,10 +985,13 @@ function useGetErrors(params: { kcContext: Pick("invalidEmailMessage")] as const; errors.push({ - validatorName, "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "validator", + "name": validatorName + } }); } @@ -869,14 +1012,19 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + source }); break validator_x; @@ -886,10 +1034,10 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + source }); break validator_x; @@ -899,10 +1047,10 @@ function useGetErrors(params: { kcContext: Pick{msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + source }); break validator_x; @@ -929,14 +1077,17 @@ function useGetErrors(params: { kcContext: Pick("notAValidOption")] as const; errors.push({ - validatorName, "errorMessage": {advancedMsg(...msgArgs)}, "errorMessageStr": advancedMsgStr(...msgArgs), - "fieldIndex": undefined + "fieldIndex": undefined, + "source": { + "type": "validator", + "name": validatorName + } }); } - //TODO: Implement missing validators. + //TODO: Implement missing validators. See Validators type definition. return errors; }