import "keycloakify/tools/Array.prototype.every"; import { useMemo, useReducer, Fragment, type Dispatch } from "react"; import { id } from "tsafe/id"; import type { MessageKey } from "keycloakify/login/i18n/i18n"; import type { Attribute, Validators } from "keycloakify/login/kcContext/KcContext"; import { useConstCallback } from "keycloakify/tools/useConstCallback"; import { emailRegexp } from "keycloakify/tools/emailRegExp"; import type { KcContext, PasswordPolicies } from "keycloakify/login/kcContext/KcContext"; import { assert, type Equals } from "tsafe/assert"; import type { I18n } from "../i18n"; export type FormFieldError = { errorMessage: JSX.Element; errorMessageStr: string; 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 { export type Common = { attribute: Attribute; displayableErrors: FormFieldError[]; }; export type Simple = Common & { value: string; }; export type MultiValued = Common & { values: string[]; }; } export type FormState = { isFormSubmittable: boolean; formFieldStates: FormFieldState[]; }; export type FormAction = | { action: "update"; name: string; value: string; } | { action: "update multi-valued"; name: string; values: string[]; } | { action: "focus lost"; name: string; } | { action: "multi-valued text input focus lost"; name: string; fieldIndex: number; }; export type KcContextLike = { messagesPerField: Pick; profile: { attributes: Attribute[]; }; passwordRequired?: boolean; realm: { registrationEmailAsUsername: boolean }; passwordPolicies?: PasswordPolicies; }; export type ParamsOfUseUserProfileForm = { kcContext: KcContextLike; i18n: I18n; doMakeUserConfirmPassword: boolean; }; export type ReturnTypeOfUseUserProfileForm = { formState: FormState; dispatchFormAction: Dispatch; }; namespace internal { export type FormFieldState = FormFieldState.Simple | FormFieldState.MultiValued; export namespace FormFieldState { export type Common = { attribute: Attribute; errors: FormFieldError[]; hasLostFocusAtLeastOnce: boolean | boolean[]; }; export type Simple = Common & { value: string; }; export type MultiValued = Common & { values: string[]; }; } export type State = { formFieldStates: FormFieldState[]; }; } /** * NOTE: The attributesWithPassword returned is actually augmented with * artificial password related attributes only if kcContext.passwordRequired === true */ export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTypeOfUseUserProfileForm { const { kcContext, i18n, doMakeUserConfirmPassword } = params; const attributesWithPassword = useMemo(() => { const attributesWithPassword: Attribute[] = []; for (const attribute of kcContext.profile.attributes) { attributesWithPassword.push(attribute); add_password_and_password_confirm: { if (attribute.name !== (kcContext.realm.registrationEmailAsUsername ? "email" : "username")) { // NOTE: We want to add password and password-confirm after the field that identifies the user. // It's either email or username. break add_password_and_password_confirm; } attributesWithPassword.push( { "name": "password", "displayName": id<`\${${MessageKey}}`>("${password}"), "required": true, "readOnly": false, "validators": {}, "annotations": {}, "autocomplete": "new-password", "html5DataAnnotations": {}, // NOTE: Compat with Keycloak version prior to 24 ...({ "groupAnnotations": {} } as {}) }, { "name": "password-confirm", "displayName": id<`\${${MessageKey}}`>("${passwordConfirm}"), "required": true, "readOnly": false, "validators": {}, "annotations": {}, "html5DataAnnotations": {}, "autocomplete": "new-password", // NOTE: Compat with Keycloak version prior to 24 ...({ "groupAnnotations": {} } as {}) } ); } } return attributesWithPassword; }, []); const { getErrors } = useGetErrors({ kcContext, i18n }); const [state, dispatchFormAction] = useReducer( function reducer(state: internal.State, params: FormAction): internal.State { if (params.action === "add value to multi-valued attribute") { const formFieldStates = state.formFieldStates.filter(({ name }) => name === params.name); state.formFieldStates.splice(state.formFieldStates.indexOf(formFieldStates[formFieldStates.length - 1]) + 1, 0, { "index": formFieldStates.length, "name": params.name, "value": "", "errors": getErrors({ "name": params.name, "index": formFieldStates.length, "fieldValues": state.formFieldStates }), "hasLostFocusAtLeastOnce": false, "attribute": formFieldStates[0].attribute }); return state; } const formFieldState = state.formFieldStates.find(({ name, index }) => name === params.name && index === params.index); assert(formFieldState !== undefined); switch (params.action) { case "focus lost": formFieldState.hasLostFocusAtLeastOnce = true; return state; case "update value": update_password_confirm: { if (params.name !== "password") { break update_password_confirm; } if (doMakeUserConfirmPassword) { break update_password_confirm; } state = reducer(state, { "action": "update value", "name": "password-confirm", "index": 0, "newValue": params.newValue }); } formFieldState.value = params.newValue; formFieldState.errors = getErrors({ "name": params.name, "index": params.index, "fieldValues": state.formFieldStates }); return state; case "remove value from multi-valued attribute": state.formFieldStates.splice(state.formFieldStates.indexOf(formFieldState), 1); return state; } assert>(false); }, useMemo(function getInitialState(): internal.State { const initialFormFieldValues = (() => { const initialFormFieldValues: ({ attribute: Attribute } & ({ value: string } | { values: string[] }))[] = []; for (const attribute of attributesWithPassword) { handle_multi_valued_attribute: { if (!attribute.multivalued) { break handle_multi_valued_attribute; } const values = attribute.values ?? [""]; apply_validator_min_range: { if (attribute.annotations.inputType === "multiselect") { break apply_validator_min_range; } if (attribute.annotations.inputType === "multiselect-checkboxes") { break apply_validator_min_range; } const validator = attribute.validators.multivalued; if (validator === undefined) { break apply_validator_min_range; } const { min: minStr } = validator; if (minStr === undefined) { break apply_validator_min_range; } const min = parseInt(minStr); for (let index = values.length; index < min; index++) { values.push(""); } } initialFormFieldValues.push({ attribute, values }); continue; } initialFormFieldValues.push({ "value": attribute.value ?? "", attribute }); } return initialFormFieldValues; })(); const initialState: internal.State = { "formFieldStates": initialFormFieldValues.map(({ attribute, ...valueOrValuesWrap }) => ({ attribute, "errors": getErrors({ "attributeName": attribute.name, "fieldValues": initialFormFieldValues }), "hasLostFocusAtLeastOnce": "values" in valueOrValuesWrap ? valueOrValuesWrap.values.map(() => false) : false, ...valueOrValuesWrap })) }; return initialState; }, []) ); const formState: FormState = useMemo( () => ({ "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]; 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] ); return { formState, dispatchFormAction }; } function useGetErrors(params: { kcContext: Pick; i18n: I18n }) { const { kcContext, i18n } = params; const { messagesPerField, passwordPolicies } = kcContext; const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n; const getErrors = useConstCallback( (params: { attributeName: string; fieldValues: ({ attribute: Attribute } & ({ value: string } | { values: string[] }))[]; }): FormFieldError[] => { const { attributeName, fieldValues } = params; const fieldValue = fieldValues.find(({ attribute }) => attribute.name === attributeName); assert(fieldValue !== undefined); const { attribute } = fieldValue; assert(attribute !== undefined); server_side_error: { if (attribute.multivalued) { const defaultValues = attribute.values ?? [""]; assert("values" in fieldValue); const { values } = fieldValue; if (JSON.stringify(defaultValues) !== JSON.stringify(values.slice(0, defaultValues.length))) { break server_side_error; } } else { const defaultValue = attribute.value ?? ""; assert("value" in fieldValue); const { value } = fieldValue; if (defaultValue !== value) { break server_side_error; } } let doesErrorExist: boolean; try { doesErrorExist = messagesPerField.existsError(attributeName); } catch { break server_side_error; } if (!doesErrorExist) { break server_side_error; } const errorMessageStr = messagesPerField.get(attributeName); return [ { errorMessageStr, "errorMessage": {errorMessageStr}, "fieldIndex": undefined, "source": { "type": "server" } } ]; } handle_multi_valued_multi_fields: { if (!attribute.multivalued) { break handle_multi_valued_multi_fields; } if (attribute.annotations.inputType === "multiselect") { break handle_multi_valued_multi_fields; } if (attribute.annotations.inputType === "multiselect-checkboxes") { break handle_multi_valued_multi_fields; } assert("values" in fieldValue); const { values } = fieldValue; const errors = values .map((value, index) => { const specificValueErrors = getErrors({ attributeName, "fieldValues": fieldValues.map(fieldValue => { if (fieldValue.attribute.name === attributeName) { return { "attribute": { ...attribute, "annotations": { ...attribute.annotations, "inputType": undefined } }, value }; } return fieldValue; }) }); 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_single_field: { if (!attribute.multivalued) { break handle_multi_select_single_field; } if (attribute.annotations.inputType !== "multiselect" && attribute.annotations.inputType !== "multiselect-checkboxes") { break handle_multi_select_single_field; } const validatorName = "multivalued"; const validator = attribute.validators[validatorName]; if (validator === undefined) { return []; } const { min: minStr } = validator; const min = minStr === undefined ? 0 : parseInt(minStr); assert(!isNaN(min)); const { max: maxStr } = validator; const max = maxStr === undefined ? Infinity : parseInt(maxStr); assert(!isNaN(max)); assert("values" in fieldValue); const { values } = fieldValue; if (min <= values.length && values.length <= max) { return []; } const msgArgs = ["error-invalid-multivalued-size", `${min}`, `${max}`] as const; return [ { "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, "source": { "type": "validator", "name": validatorName } } ]; } assert("value" in fieldValue); const { value } = fieldValue; const errors: FormFieldError[] = []; check_password_policies: { if (attributeName !== "password") { break check_password_policies; } if (passwordPolicies === undefined) { break check_password_policies; } check_password_policy_x: { const policyName = "length"; const policy = passwordPolicies[policyName]; if (policy === undefined) { break check_password_policy_x; } const minLength = parseInt(policy); assert(!isNaN(minLength)); if (value.length >= minLength) { break check_password_policy_x; } const msgArgs = ["invalidPasswordMinLengthMessage", `${minLength}`] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, "source": { "type": "passwordPolicy", "name": policyName } }); } check_password_policy_x: { const policyName = "digits"; const policy = passwordPolicies[policyName]; if (policy === undefined) { break check_password_policy_x; } const minNumberOfDigits = parseInt(policy); assert(!isNaN(minNumberOfDigits)); if (value.split("").filter(char => !isNaN(parseInt(char))).length >= minNumberOfDigits) { break check_password_policy_x; } const msgArgs = ["invalidPasswordMinDigitsMessage", `${minNumberOfDigits}`] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, "source": { "type": "passwordPolicy", "name": policyName } }); } check_password_policy_x: { const policyName = "lowerCase"; const policy = passwordPolicies[policyName]; if (policy === undefined) { break check_password_policy_x; } const minNumberOfLowerCaseChar = parseInt(policy); assert(!isNaN(minNumberOfLowerCaseChar)); if ( value.split("").filter(char => char === char.toLowerCase() && char !== char.toUpperCase()).length >= minNumberOfLowerCaseChar ) { break check_password_policy_x; } const msgArgs = ["invalidPasswordMinLowerCaseCharsMessage", `${minNumberOfLowerCaseChar}`] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, "source": { "type": "passwordPolicy", "name": policyName } }); } check_password_policy_x: { const policyName = "upperCase"; const policy = passwordPolicies[policyName]; if (policy === undefined) { break check_password_policy_x; } const minNumberOfUpperCaseChar = parseInt(policy); assert(!isNaN(minNumberOfUpperCaseChar)); if ( value.split("").filter(char => char === char.toUpperCase() && char !== char.toLowerCase()).length >= minNumberOfUpperCaseChar ) { break check_password_policy_x; } const msgArgs = ["invalidPasswordMinUpperCaseCharsMessage", `${minNumberOfUpperCaseChar}`] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, "source": { "type": "passwordPolicy", "name": policyName } }); } check_password_policy_x: { const policyName = "specialChars"; const policy = passwordPolicies[policyName]; if (policy === undefined) { break check_password_policy_x; } const minNumberOfSpecialChar = parseInt(policy); assert(!isNaN(minNumberOfSpecialChar)); if (value.split("").filter(char => !char.match(/[a-zA-Z0-9]/)).length >= minNumberOfSpecialChar) { break check_password_policy_x; } const msgArgs = ["invalidPasswordMinSpecialCharsMessage", `${minNumberOfSpecialChar}`] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, "source": { "type": "passwordPolicy", "name": policyName } }); } check_password_policy_x: { const policyName = "notUsername"; const notUsername = passwordPolicies[policyName]; if (!notUsername) { break check_password_policy_x; } const usernameFieldValue = fieldValues.find(fieldValue => fieldValue.attribute.name === "username"); if (usernameFieldValue === undefined) { break check_password_policy_x; } assert("value" in usernameFieldValue); if (value !== usernameFieldValue.value) { break check_password_policy_x; } const msgArgs = ["invalidPasswordNotUsernameMessage"] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, "source": { "type": "passwordPolicy", "name": policyName } }); } check_password_policy_x: { const policyName = "notEmail"; const notEmail = passwordPolicies[policyName]; if (!notEmail) { break check_password_policy_x; } const emailFieldValue = fieldValues.find(fieldValue => fieldValue.attribute.name === "email"); if (emailFieldValue === undefined) { break check_password_policy_x; } assert("value" in emailFieldValue); if (value !== emailFieldValue.value) { break check_password_policy_x; } const msgArgs = ["invalidPasswordNotEmailMessage"] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, "source": { "type": "passwordPolicy", "name": policyName } }); } } password_confirm_matches_password: { if (attributeName !== "password-confirm") { break password_confirm_matches_password; } const passwordFieldValue = fieldValues.find(fieldValue => fieldValue.attribute.name === "password"); assert(passwordFieldValue !== undefined); assert("value" in passwordFieldValue); if (passwordFieldValue.value === value) { break password_confirm_matches_password; } const msgArgs = ["invalidPasswordConfirmMessage"] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, "source": { "type": "other", "rule": "passwordConfirmMatchesPassword" } }); } const { validators } = attribute; required_field: { if (!attribute.required) { break required_field; } if (value !== "") { 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" } }); } validator_x: { const validatorName = "length"; const validator = validators[validatorName]; if (validator === undefined) { break validator_x; } const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; if (ignoreEmptyValue && value === "") { break validator_x; } const source: FormFieldError.Source = { "type": "validator", "name": validatorName }; if (max !== undefined && value.length > parseInt(max)) { const msgArgs = ["error-invalid-length-too-long", max] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, source }); } if (min !== undefined && value.length < parseInt(min)) { const msgArgs = ["error-invalid-length-too-short", min] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, source }); } } validator_x: { const validatorName = "pattern"; const validator = validators[validatorName]; if (validator === undefined) { break validator_x; } const { "ignore.empty.value": ignoreEmptyValue = false, pattern, "error-message": errorMessageKey } = validator; if (ignoreEmptyValue && value === "") { break validator_x; } if (new RegExp(pattern).test(value)) { break validator_x; } const msgArgs = [errorMessageKey ?? id("shouldMatchPattern"), pattern] as const; errors.push({ "errorMessage": {advancedMsg(...msgArgs)}, "errorMessageStr": advancedMsgStr(...msgArgs), "fieldIndex": undefined, "source": { "type": "validator", "name": validatorName } }); } 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"; const validator = validators[validatorName]; if (validator === undefined) { break validator_x; } const { "ignore.empty.value": ignoreEmptyValue = false } = validator; if (ignoreEmptyValue && value === "") { break validator_x; } if (emailRegexp.test(value)) { break validator_x; } const msgArgs = [id("invalidEmailMessage")] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, "source": { "type": "validator", "name": validatorName } }); } validator_x: { const validatorName = "integer"; const validator = validators[validatorName]; if (validator === undefined) { break validator_x; } const { "ignore.empty.value": ignoreEmptyValue = false, max, min } = validator; if (ignoreEmptyValue && value === "") { break validator_x; } const intValue = parseInt(value); const source: FormFieldError.Source = { "type": "validator", "name": validatorName }; if (isNaN(intValue)) { const msgArgs = ["mustBeAnInteger"] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, source }); break validator_x; } if (max !== undefined && intValue > parseInt(max)) { const msgArgs = ["error-number-out-of-range-too-big", max] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, source }); break validator_x; } if (min !== undefined && intValue < parseInt(min)) { const msgArgs = ["error-number-out-of-range-too-small", min] as const; errors.push({ "errorMessage": {msg(...msgArgs)}, "errorMessageStr": msgStr(...msgArgs), "fieldIndex": undefined, source }); break validator_x; } } validator_x: { const validatorName = "options"; const validator = validators[validatorName]; if (validator === undefined) { break validator_x; } if (value === "") { break validator_x; } if (validator.options.indexOf(value) >= 0) { break validator_x; } const msgArgs = [id("notAValidOption")] as const; errors.push({ "errorMessage": {advancedMsg(...msgArgs)}, "errorMessageStr": advancedMsgStr(...msgArgs), "fieldIndex": undefined, "source": { "type": "validator", "name": validatorName } }); } //TODO: Implement missing validators. See Validators type definition. return errors; } ); return { getErrors }; }