diff --git a/src/login/lib/getUserProfileApi.ts b/src/login/lib/getUserProfileApi.ts new file mode 100644 index 00000000..5cc88463 --- /dev/null +++ b/src/login/lib/getUserProfileApi.ts @@ -0,0 +1,133 @@ +import "keycloakify/tools/Array.prototype.every"; +import { assert } from "tsafe/assert"; +import type { + PasswordPolicies, + Attribute, + Validators +} from "keycloakify/login/KcContext"; +import type { KcContext } from "../KcContext"; +import type { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n"; + +export type FormFieldError = { + advancedMsgArgs: [string, ...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 = { + attribute: Attribute; + displayableErrors: FormFieldError[]; + valueOrValues: string | string[]; +}; + +export type FormState = { + isFormSubmittable: boolean; + formFieldStates: FormFieldState[]; +}; + +export type FormAction = + | { + action: "update"; + name: string; + valueOrValues: string | string[]; + /** Default false */ + displayErrorsImmediately?: boolean; + } + | { + action: "focus lost"; + name: string; + fieldIndex: number | undefined; + }; + +export type KcContextLike = KcContextLike_i18n & + KcContextLike_useGetErrors & { + profile: { + attributesByName: Record; + html5DataAnnotations?: Record; + }; + passwordRequired?: boolean; + realm: { registrationEmailAsUsername: boolean }; + url: { + resourcesPath: string; + }; + }; + +type KcContextLike_useGetErrors = KcContextLike_i18n & { + messagesPerField: Pick; + passwordPolicies?: PasswordPolicies; +}; + +assert< + Extract< + Extract, + { pageId: "register.ftl" } + > extends KcContextLike + ? true + : false +>(); + +export type UserProfileApi = { + getFormState: () => FormState; + subscribeToFormState: (callback: () => void) => { unsubscribe: () => void }; + dispatchFormAction: (action: FormAction) => void; +}; + +const cachedUserProfileApiByKcContext = new WeakMap(); + +export type ParamsOfGetUserProfileApi = { + kcContext: KcContextLike; + doMakeUserConfirmPassword: boolean; +}; + +export function getUserProfileApi(params: ParamsOfGetUserProfileApi): UserProfileApi { + const { kcContext } = params; + + use_cache: { + const userProfileApi_cache = cachedUserProfileApiByKcContext.get(kcContext); + + if (userProfileApi_cache === undefined) { + break use_cache; + } + + return userProfileApi_cache; + } + + const userProfileApi = getUserProfileApi_noCache(params); + + cachedUserProfileApiByKcContext.set(kcContext, userProfileApi); + + return userProfileApi; +} + +export function getUserProfileApi_noCache( + params: ParamsOfGetUserProfileApi +): UserProfileApi { + return null as any; +} diff --git a/src/login/lib/useUserProfileForm copy.tsx b/src/login/lib/useUserProfileForm copy.tsx new file mode 100644 index 00000000..31ad939e --- /dev/null +++ b/src/login/lib/useUserProfileForm copy.tsx @@ -0,0 +1,1403 @@ +import "keycloakify/tools/Array.prototype.every"; +import { useMemo, useReducer, useEffect, Fragment, type Dispatch } from "react"; +import { assert, type Equals } from "tsafe/assert"; +import { id } from "tsafe/id"; +import { structuredCloneButFunctions } from "keycloakify/tools/structuredCloneButFunctions"; +import { kcSanitize } from "keycloakify/lib/kcSanitize"; +import { useConstCallback } from "keycloakify/tools/useConstCallback"; +import { emailRegexp } from "keycloakify/tools/emailRegExp"; +import { formatNumber } from "keycloakify/tools/formatNumber"; +import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import type { PasswordPolicies, Attribute, Validators } from "keycloakify/login/KcContext"; +import type { KcContext } from "../KcContext"; +import type { MessageKey_defaultSet } from "keycloakify/login/i18n"; +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 = { + attribute: Attribute; + displayableErrors: FormFieldError[]; + valueOrValues: string | string[]; +}; + +export type FormState = { + isFormSubmittable: boolean; + formFieldStates: FormFieldState[]; +}; + +export type FormAction = + | { + action: "update"; + name: string; + valueOrValues: string | string[]; + /** Default false */ + displayErrorsImmediately?: boolean; + } + | { + action: "focus lost"; + name: string; + fieldIndex: number | undefined; + }; + +export type KcContextLike = KcContextLike_useGetErrors & { + profile: { + attributesByName: Record; + html5DataAnnotations?: Record; + }; + passwordRequired?: boolean; + realm: { registrationEmailAsUsername: boolean }; + url: { + resourcesPath: string; + }; +}; + +assert, { pageId: "register.ftl" }> extends KcContextLike ? true : false>(); + +export type UseUserProfileFormParams = { + kcContext: KcContextLike; + i18n: I18n; + doMakeUserConfirmPassword: boolean; +}; + +export type ReturnTypeOfUseUserProfileForm = { + formState: FormState; + dispatchFormAction: Dispatch; +}; + +namespace internal { + export type FormFieldState = { + attribute: Attribute; + errors: FormFieldError[]; + hasLostFocusAtLeastOnce: boolean | boolean[]; + valueOrValues: string | string[]; + }; + + export type State = { + formFieldStates: FormFieldState[]; + }; +} + +export function useUserProfileForm(params: UseUserProfileFormParams): ReturnTypeOfUseUserProfileForm { + const { kcContext, i18n, doMakeUserConfirmPassword } = params; + + const { insertScriptTags } = useInsertScriptTags({ + componentOrHookName: "useUserProfileForm", + scriptTags: Object.keys(kcContext.profile?.html5DataAnnotations ?? {}) + .filter(key => key !== "kcMultivalued" && key !== "kcNumberFormat") // NOTE: Keycloakify handles it. + .map(key => ({ + type: "module", + src: `${kcContext.url.resourcesPath}/js/${key}.js` + })) + }); + + useEffect(() => { + insertScriptTags(); + }, []); + + const { getErrors } = useGetErrors({ + kcContext, + i18n + }); + + const initialState = useMemo((): internal.State => { + // NOTE: We don't use te kcContext.profile.attributes directly because + // they don't includes the password and password confirm fields and we want to add them. + // We also want to apply some retro-compatibility and consistency patches. + const attributes: Attribute[] = (() => { + mock_user_profile_attributes_for_older_keycloak_versions: { + if ( + "profile" in kcContext && + "attributesByName" in kcContext.profile && + Object.keys(kcContext.profile.attributesByName).length !== 0 + ) { + break mock_user_profile_attributes_for_older_keycloak_versions; + } + + if ("register" in kcContext && kcContext.register instanceof Object && "formData" in kcContext.register) { + //NOTE: Handle legacy register.ftl page + return (["firstName", "lastName", "email", "username"] as const) + .filter(name => (name !== "username" ? true : !kcContext.realm.registrationEmailAsUsername)) + .map(name => + id({ + name: name, + displayName: id<`\${${MessageKey_defaultSet}}`>(`\${${name}}`), + required: true, + value: (kcContext.register as any).formData[name] ?? "", + html5DataAnnotations: {}, + readOnly: false, + validators: {}, + annotations: {}, + autocomplete: (() => { + switch (name) { + case "email": + return "email"; + case "username": + return "username"; + default: + return undefined; + } + })() + }) + ); + } + + if ("user" in kcContext && kcContext.user instanceof Object) { + //NOTE: Handle legacy login-update-profile.ftl + return (["username", "email", "firstName", "lastName"] as const) + .filter(name => (name !== "username" ? true : (kcContext.user as any).editUsernameAllowed)) + .map(name => + id({ + name: name, + displayName: id<`\${${MessageKey_defaultSet}}`>(`\${${name}}`), + required: true, + value: (kcContext as any).user[name] ?? "", + html5DataAnnotations: {}, + readOnly: false, + validators: {}, + annotations: {}, + autocomplete: (() => { + switch (name) { + case "email": + return "email"; + case "username": + return "username"; + default: + return undefined; + } + })() + }) + ); + } + + if ("email" in kcContext && kcContext.email instanceof Object) { + //NOTE: Handle legacy update-email.ftl + return [ + id({ + name: "email", + displayName: id<`\${${MessageKey_defaultSet}}`>(`\${email}`), + required: true, + value: (kcContext.email as any).value ?? "", + html5DataAnnotations: {}, + readOnly: false, + validators: {}, + annotations: {}, + autocomplete: "email" + }) + ]; + } + + assert(false, "Unable to mock user profile from the current kcContext"); + } + + return Object.values(kcContext.profile.attributesByName).map(structuredCloneButFunctions); + })(); + + // Retro-compatibility and consistency patches + attributes.forEach(attribute => { + patch_legacy_group: { + if (typeof attribute.group !== "string") { + break patch_legacy_group; + } + + const { group, groupDisplayHeader, groupDisplayDescription, groupAnnotations } = attribute as Attribute & { + group: string; + groupDisplayHeader?: string; + groupDisplayDescription?: string; + groupAnnotations: Record; + }; + + delete attribute.group; + // @ts-expect-error + delete attribute.groupDisplayHeader; + // @ts-expect-error + delete attribute.groupDisplayDescription; + // @ts-expect-error + delete attribute.groupAnnotations; + + if (group === "") { + break patch_legacy_group; + } + + attribute.group = { + name: group, + displayHeader: groupDisplayHeader, + displayDescription: groupDisplayDescription, + annotations: groupAnnotations, + html5DataAnnotations: {} + }; + } + + // Attributes with options rendered by default as select inputs + if (attribute.validators.options !== undefined && attribute.annotations.inputType === undefined) { + attribute.annotations.inputType = "select"; + } + + // Consistency patch on values/value property + { + if (getIsMultivaluedSingleField({ attribute })) { + attribute.multivalued = true; + } + + if (attribute.multivalued) { + attribute.values ??= attribute.value !== undefined ? [attribute.value] : []; + delete attribute.value; + } else { + attribute.value ??= attribute.values?.[0]; + delete attribute.values; + } + } + }); + + add_password_and_password_confirm: { + if (!kcContext.passwordRequired) { + break add_password_and_password_confirm; + } + + attributes.forEach((attribute, i) => { + 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. + return; + } + + attributes.splice( + i + 1, + 0, + { + name: "password", + displayName: id<`\${${MessageKey_defaultSet}}`>("${password}"), + required: true, + readOnly: false, + validators: {}, + annotations: {}, + autocomplete: "new-password", + html5DataAnnotations: {} + }, + { + name: "password-confirm", + displayName: id<`\${${MessageKey_defaultSet}}`>("${passwordConfirm}"), + required: true, + readOnly: false, + validators: {}, + annotations: {}, + html5DataAnnotations: {}, + autocomplete: "new-password" + } + ); + }); + } + + const initialFormFieldState: { + attribute: Attribute; + valueOrValues: string | string[]; + }[] = []; + + for (const attribute of attributes) { + handle_multi_valued_attribute: { + if (!attribute.multivalued) { + break handle_multi_valued_attribute; + } + + const values = attribute.values?.length ? attribute.values : [""]; + + apply_validator_min_range: { + if (getIsMultivaluedSingleField({ attribute })) { + break apply_validator_min_range; + } + + const validator = attribute.validators.multivalued; + + if (validator === undefined) { + break apply_validator_min_range; + } + + const { min: minStr } = validator; + + if (!minStr) { + break apply_validator_min_range; + } + + const min = parseInt(`${minStr}`); + + for (let index = values.length; index < min; index++) { + values.push(""); + } + } + + initialFormFieldState.push({ + attribute, + valueOrValues: values + }); + + continue; + } + + initialFormFieldState.push({ + attribute, + valueOrValues: attribute.value ?? "" + }); + } + + const initialState: internal.State = { + formFieldStates: initialFormFieldState.map(({ attribute, valueOrValues }) => ({ + attribute, + errors: getErrors({ + attributeName: attribute.name, + formFieldStates: initialFormFieldState + }), + hasLostFocusAtLeastOnce: + valueOrValues instanceof Array && !getIsMultivaluedSingleField({ attribute }) ? valueOrValues.map(() => false) : false, + valueOrValues: valueOrValues + })) + }; + + return initialState; + }, []); + + const [state, dispatchFormAction] = useReducer(function reducer(state: internal.State, formAction: FormAction): internal.State { + const formFieldState = state.formFieldStates.find(({ attribute }) => attribute.name === formAction.name); + + assert(formFieldState !== undefined); + + (() => { + switch (formAction.action) { + case "update": + formFieldState.valueOrValues = formAction.valueOrValues; + + apply_formatters: { + const { attribute } = formFieldState; + + const { kcNumberFormat } = attribute.html5DataAnnotations ?? {}; + + if (!kcNumberFormat) { + break apply_formatters; + } + + if (formFieldState.valueOrValues instanceof Array) { + formFieldState.valueOrValues = formFieldState.valueOrValues.map(value => formatNumber(value, kcNumberFormat)); + } else { + formFieldState.valueOrValues = formatNumber(formFieldState.valueOrValues, kcNumberFormat); + } + } + + formFieldState.errors = getErrors({ + attributeName: formAction.name, + formFieldStates: state.formFieldStates + }); + + simulate_focus_lost: { + const { displayErrorsImmediately = false } = formAction; + + if (!displayErrorsImmediately) { + break simulate_focus_lost; + } + + for (const fieldIndex of formAction.valueOrValues instanceof Array + ? formAction.valueOrValues.map((...[, index]) => index) + : [undefined]) { + state = reducer(state, { + action: "focus lost", + name: formAction.name, + fieldIndex + }); + } + } + + update_password_confirm: { + if (doMakeUserConfirmPassword) { + break update_password_confirm; + } + + if (formAction.name !== "password") { + break update_password_confirm; + } + + state = reducer(state, { + action: "update", + name: "password-confirm", + valueOrValues: formAction.valueOrValues, + displayErrorsImmediately: formAction.displayErrorsImmediately + }); + } + + trigger_password_confirm_validation_on_password_change: { + if (!doMakeUserConfirmPassword) { + break trigger_password_confirm_validation_on_password_change; + } + + if (formAction.name !== "password") { + break trigger_password_confirm_validation_on_password_change; + } + + state = reducer(state, { + action: "update", + name: "password-confirm", + valueOrValues: (() => { + const formFieldState = state.formFieldStates.find(({ attribute }) => attribute.name === "password-confirm"); + + assert(formFieldState !== undefined); + + return formFieldState.valueOrValues; + })(), + displayErrorsImmediately: formAction.displayErrorsImmediately + }); + } + + return; + case "focus lost": + if (formFieldState.hasLostFocusAtLeastOnce instanceof Array) { + const { fieldIndex } = formAction; + assert(fieldIndex !== undefined); + formFieldState.hasLostFocusAtLeastOnce[fieldIndex] = true; + return; + } + + formFieldState.hasLostFocusAtLeastOnce = true; + return; + } + assert>(false); + })(); + + return { ...state }; + }, 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 + }; +} + +type KcContextLike_useGetErrors = { + messagesPerField: Pick; + passwordPolicies?: PasswordPolicies; +}; + +assert(); + +function useGetErrors(params: { kcContext: KcContextLike_useGetErrors; i18n: I18n }) { + const { kcContext, i18n } = params; + + const { messagesPerField, passwordPolicies } = kcContext; + + const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n; + + const getErrors = useConstCallback( + (params: { + attributeName: string; + formFieldStates: { + attribute: Attribute; + valueOrValues: string | string[]; + }[]; + }): FormFieldError[] => { + const { attributeName, formFieldStates } = params; + + const formFieldState = formFieldStates.find(({ attribute }) => attribute.name === attributeName); + + assert(formFieldState !== undefined); + + const { attribute } = formFieldState; + + const valueOrValues = (() => { + let { valueOrValues } = formFieldState; + + unFormat_number: { + const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {}; + + if (!kcNumberUnFormat) { + break unFormat_number; + } + + if (valueOrValues instanceof Array) { + valueOrValues = valueOrValues.map(value => formatNumber(value, kcNumberUnFormat)); + } else { + valueOrValues = formatNumber(valueOrValues, kcNumberUnFormat); + } + } + + return valueOrValues; + })(); + + assert(attribute !== undefined); + + server_side_error: { + if (attribute.multivalued) { + const defaultValues = attribute.values?.length ? attribute.values : [""]; + + assert(valueOrValues instanceof Array); + + const values = valueOrValues; + + if (JSON.stringify(defaultValues) !== JSON.stringify(values.slice(0, defaultValues.length))) { + break server_side_error; + } + } else { + const defaultValue = attribute.value ?? ""; + + assert(typeof valueOrValues === "string"); + + const value = valueOrValues; + + 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: ( + + ), + fieldIndex: undefined, + source: { + type: "server" + } + } + ]; + } + + handle_multi_valued_multi_fields: { + if (!attribute.multivalued) { + break handle_multi_valued_multi_fields; + } + + if (getIsMultivaluedSingleField({ attribute })) { + break handle_multi_valued_multi_fields; + } + + assert(valueOrValues instanceof Array); + + const values = valueOrValues; + + const errors = values + .map((...[, index]) => { + const specificValueErrors = getErrors({ + attributeName, + formFieldStates: formFieldStates.map(formFieldState => { + if (formFieldState.attribute.name === attributeName) { + assert(formFieldState.valueOrValues instanceof Array); + return { + attribute: { + ...attribute, + annotations: { + ...attribute.annotations, + inputType: undefined + }, + multivalued: false + }, + valueOrValues: formFieldState.valueOrValues[index] + }; + } + + return formFieldState; + }) + }); + + 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.every(value => 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" + } + }); + } + + return errors; + } + + handle_multi_valued_single_field: { + if (!attribute.multivalued) { + break handle_multi_valued_single_field; + } + + if (!getIsMultivaluedSingleField({ attribute })) { + break handle_multi_valued_single_field; + } + + const validatorName = "multivalued"; + + const validator = attribute.validators[validatorName]; + + if (validator === undefined) { + return []; + } + + const { min: minStr } = validator; + + const min = minStr ? parseInt(`${minStr}`) : attribute.required ? 1 : 0; + + assert(!isNaN(min)); + + const { max: maxStr } = validator; + + const max = !maxStr ? Infinity : parseInt(`${maxStr}`); + + assert(!isNaN(max)); + + assert(valueOrValues instanceof Array); + + const values = valueOrValues; + + 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(typeof valueOrValues === "string"); + + const value = valueOrValues; + + 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) { + break check_password_policy_x; + } + + const minLength = policy; + + 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) { + break check_password_policy_x; + } + + const minNumberOfDigits = policy; + + 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) { + break check_password_policy_x; + } + + const minNumberOfLowerCaseChar = policy; + + 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) { + break check_password_policy_x; + } + + const minNumberOfUpperCaseChar = policy; + + 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) { + break check_password_policy_x; + } + + const minNumberOfSpecialChar = policy; + + 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 usernameFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "username"); + + if (!usernameFormFieldState) { + break check_password_policy_x; + } + + const usernameValue = (() => { + let { valueOrValues } = usernameFormFieldState; + + assert(typeof valueOrValues === "string"); + + unFormat_number: { + const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {}; + + if (!kcNumberUnFormat) { + break unFormat_number; + } + + valueOrValues = formatNumber(valueOrValues, kcNumberUnFormat); + } + + return valueOrValues; + })(); + + if (usernameValue === "") { + break check_password_policy_x; + } + + if (value !== usernameValue) { + 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 emailFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "email"); + + if (!emailFormFieldState) { + break check_password_policy_x; + } + + assert(typeof emailFormFieldState.valueOrValues === "string"); + + { + const emailValue = emailFormFieldState.valueOrValues; + + if (emailValue === "") { + break check_password_policy_x; + } + + if (value !== emailValue) { + 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 passwordFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "password"); + + assert(passwordFormFieldState !== undefined); + + assert(typeof passwordFormFieldState.valueOrValues === "string"); + + { + const passwordValue = passwordFormFieldState.valueOrValues; + + if (value === passwordValue) { + 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) { + 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 && 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 && 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 && 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 && 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: {msg(...msgArgs)}, + errorMessageStr: msgStr(...msgArgs), + fieldIndex: undefined, + source: { + type: "validator", + name: validatorName + } + }); + } + + //TODO: Implement missing validators. See Validators type definition. + + return errors; + } + ); + + return { getErrors }; +} + +function getIsMultivaluedSingleField(params: { attribute: Attribute }) { + const { attribute } = params; + + return attribute.annotations.inputType?.startsWith("multiselect") ?? false; +} + +export function getButtonToDisplayForMultivaluedAttributeField(params: { attribute: Attribute; values: string[]; fieldIndex: number }) { + const { attribute, values, fieldIndex } = params; + + const hasRemove = (() => { + if (values.length === 1) { + return false; + } + + const minCount = (() => { + const { multivalued } = attribute.validators; + + if (multivalued === undefined) { + return undefined; + } + + const minStr = multivalued.min; + + if (minStr === undefined) { + return undefined; + } + + return parseInt(`${minStr}`); + })(); + + if (minCount === undefined) { + return true; + } + + if (values.length === minCount) { + return false; + } + + return true; + })(); + + const hasAdd = (() => { + if (fieldIndex + 1 !== values.length) { + return false; + } + + const maxCount = (() => { + const { multivalued } = attribute.validators; + + if (multivalued === undefined) { + return undefined; + } + + const maxStr = multivalued.max; + + if (maxStr === undefined) { + return undefined; + } + + return parseInt(`${maxStr}`); + })(); + + if (maxCount === undefined) { + return true; + } + + return values.length !== maxCount; + })(); + + return { hasRemove, hasAdd }; +} diff --git a/src/login/lib/useUserProfileForm.tsx b/src/login/lib/useUserProfileForm.tsx index 3dfce1b9..3d997549 100644 --- a/src/login/lib/useUserProfileForm.tsx +++ b/src/login/lib/useUserProfileForm.tsx @@ -1,17 +1,7 @@ -import "keycloakify/tools/Array.prototype.every"; -import { useMemo, useReducer, useEffect, Fragment, type Dispatch } from "react"; -import { assert, type Equals } from "tsafe/assert"; -import { id } from "tsafe/id"; -import { structuredCloneButFunctions } from "keycloakify/tools/structuredCloneButFunctions"; -import { kcSanitize } from "keycloakify/lib/kcSanitize"; -import { useConstCallback } from "keycloakify/tools/useConstCallback"; -import { emailRegexp } from "keycloakify/tools/emailRegExp"; -import { formatNumber } from "keycloakify/tools/formatNumber"; -import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags"; +import * as reactlessApi from "./getUserProfileApi"; import type { PasswordPolicies, Attribute, Validators } from "keycloakify/login/KcContext"; -import type { KcContext } from "../KcContext"; -import type { MessageKey_defaultSet } from "keycloakify/login/i18n"; -import type { KcContextLike as KcContextLike_i18n } from "keycloakify/login/i18n"; +import { useEffect, useState, useMemo, Fragment } from "react"; +import { assert, type Equals } from "tsafe/assert"; import type { I18n } from "../i18n"; export type FormFieldError = { @@ -21,6 +11,13 @@ export type FormFieldError = { fieldIndex: number | undefined; }; +{ + type A = Omit; + type B = Omit; + + assert>(); +} + export namespace FormFieldError { export type Source = Source.Validator | Source.PasswordPolicy | Source.Server | Source.Other; @@ -44,17 +41,38 @@ export namespace FormFieldError { } } +{ + type A = FormFieldError.Source; + type B = reactlessApi.FormFieldError.Source; + + assert>(); +} + export type FormFieldState = { attribute: Attribute; displayableErrors: FormFieldError[]; valueOrValues: string | string[]; }; +{ + type A = Omit; + type B = Omit; + + assert>(); +} + export type FormState = { isFormSubmittable: boolean; formFieldStates: FormFieldState[]; }; +{ + type A = Omit; + type B = Omit; + + assert>(); +} + export type FormAction = | { action: "update"; @@ -69,1337 +87,78 @@ export type FormAction = fieldIndex: number | undefined; }; -export type KcContextLike = KcContextLike_i18n & - KcContextLike_useGetErrors & { - profile: { - attributesByName: Record; - html5DataAnnotations?: Record; - }; - passwordRequired?: boolean; - realm: { registrationEmailAsUsername: boolean }; - url: { - resourcesPath: string; - }; - }; +{ + type A = FormAction; + type B = reactlessApi.FormAction; -assert, { pageId: "register.ftl" }> extends KcContextLike ? true : false>(); + assert>(); +} -export type UseUserProfileFormParams = { +export type KcContextLike = reactlessApi.KcContextLike; + +export type I18nLike = Pick; + +export type ParamsOfUseUserProfileForm = { kcContext: KcContextLike; - i18n: I18n; doMakeUserConfirmPassword: boolean; + i18n: I18nLike; }; +{ + type A = Omit; + type B = reactlessApi.ParamsOfGetUserProfileApi; + + assert>(); +} + export type ReturnTypeOfUseUserProfileForm = { formState: FormState; - dispatchFormAction: Dispatch; + dispatchFormAction: (action: FormAction) => void; }; -namespace internal { - export type FormFieldState = { - attribute: Attribute; - errors: FormFieldError[]; - hasLostFocusAtLeastOnce: boolean | boolean[]; - valueOrValues: string | string[]; - }; +export function useUserProfileForm(params: ParamsOfUseUserProfileForm): ReturnTypeOfUseUserProfileForm { + const { doMakeUserConfirmPassword, i18n, kcContext } = params; - export type State = { - formFieldStates: FormFieldState[]; - }; -} - -export function useUserProfileForm(params: UseUserProfileFormParams): ReturnTypeOfUseUserProfileForm { - const { kcContext, i18n, doMakeUserConfirmPassword } = params; - - const { insertScriptTags } = useInsertScriptTags({ - componentOrHookName: "useUserProfileForm", - scriptTags: Object.keys(kcContext.profile?.html5DataAnnotations ?? {}) - .filter(key => key !== "kcMultivalued" && key !== "kcNumberFormat") // NOTE: Keycloakify handles it. - .map(key => ({ - type: "module", - src: `${kcContext.url.resourcesPath}/js/${key}.js` - })) + const api = reactlessApi.getUserProfileApi({ + kcContext, + doMakeUserConfirmPassword }); + const [formState_reactless, setFormState_reactless] = useState(() => api.getFormState()); + useEffect(() => { - insertScriptTags(); - }, []); - - const { getErrors } = useGetErrors({ - kcContext, - i18n - }); - - const initialState = useMemo((): internal.State => { - // NOTE: We don't use te kcContext.profile.attributes directly because - // they don't includes the password and password confirm fields and we want to add them. - // We also want to apply some retro-compatibility and consistency patches. - const attributes: Attribute[] = (() => { - mock_user_profile_attributes_for_older_keycloak_versions: { - if ( - "profile" in kcContext && - "attributesByName" in kcContext.profile && - Object.keys(kcContext.profile.attributesByName).length !== 0 - ) { - break mock_user_profile_attributes_for_older_keycloak_versions; - } - - if ("register" in kcContext && kcContext.register instanceof Object && "formData" in kcContext.register) { - //NOTE: Handle legacy register.ftl page - return (["firstName", "lastName", "email", "username"] as const) - .filter(name => (name !== "username" ? true : !kcContext.realm.registrationEmailAsUsername)) - .map(name => - id({ - name: name, - displayName: id<`\${${MessageKey_defaultSet}}`>(`\${${name}}`), - required: true, - value: (kcContext.register as any).formData[name] ?? "", - html5DataAnnotations: {}, - readOnly: false, - validators: {}, - annotations: {}, - autocomplete: (() => { - switch (name) { - case "email": - return "email"; - case "username": - return "username"; - default: - return undefined; - } - })() - }) - ); - } - - if ("user" in kcContext && kcContext.user instanceof Object) { - //NOTE: Handle legacy login-update-profile.ftl - return (["username", "email", "firstName", "lastName"] as const) - .filter(name => (name !== "username" ? true : (kcContext.user as any).editUsernameAllowed)) - .map(name => - id({ - name: name, - displayName: id<`\${${MessageKey_defaultSet}}`>(`\${${name}}`), - required: true, - value: (kcContext as any).user[name] ?? "", - html5DataAnnotations: {}, - readOnly: false, - validators: {}, - annotations: {}, - autocomplete: (() => { - switch (name) { - case "email": - return "email"; - case "username": - return "username"; - default: - return undefined; - } - })() - }) - ); - } - - if ("email" in kcContext && kcContext.email instanceof Object) { - //NOTE: Handle legacy update-email.ftl - return [ - id({ - name: "email", - displayName: id<`\${${MessageKey_defaultSet}}`>(`\${email}`), - required: true, - value: (kcContext.email as any).value ?? "", - html5DataAnnotations: {}, - readOnly: false, - validators: {}, - annotations: {}, - autocomplete: "email" - }) - ]; - } - - assert(false, "Unable to mock user profile from the current kcContext"); - } - - return Object.values(kcContext.profile.attributesByName).map(structuredCloneButFunctions); - })(); - - // Retro-compatibility and consistency patches - attributes.forEach(attribute => { - patch_legacy_group: { - if (typeof attribute.group !== "string") { - break patch_legacy_group; - } - - const { group, groupDisplayHeader, groupDisplayDescription, groupAnnotations } = attribute as Attribute & { - group: string; - groupDisplayHeader?: string; - groupDisplayDescription?: string; - groupAnnotations: Record; - }; - - delete attribute.group; - // @ts-expect-error - delete attribute.groupDisplayHeader; - // @ts-expect-error - delete attribute.groupDisplayDescription; - // @ts-expect-error - delete attribute.groupAnnotations; - - if (group === "") { - break patch_legacy_group; - } - - attribute.group = { - name: group, - displayHeader: groupDisplayHeader, - displayDescription: groupDisplayDescription, - annotations: groupAnnotations, - html5DataAnnotations: {} - }; - } - - // Attributes with options rendered by default as select inputs - if (attribute.validators.options !== undefined && attribute.annotations.inputType === undefined) { - attribute.annotations.inputType = "select"; - } - - // Consistency patch on values/value property - { - if (getIsMultivaluedSingleField({ attribute })) { - attribute.multivalued = true; - } - - if (attribute.multivalued) { - attribute.values ??= attribute.value !== undefined ? [attribute.value] : []; - delete attribute.value; - } else { - attribute.value ??= attribute.values?.[0]; - delete attribute.values; - } - } + const { unsubscribe } = api.subscribeToFormState(() => { + setFormState_reactless(api.getFormState()); }); - add_password_and_password_confirm: { - if (!kcContext.passwordRequired) { - break add_password_and_password_confirm; - } + return () => unsubscribe(); + }, [api]); - attributes.forEach((attribute, i) => { - 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. - return; - } + const { advancedMsg, advancedMsgStr } = i18n; - attributes.splice( - i + 1, - 0, - { - name: "password", - displayName: id<`\${${MessageKey_defaultSet}}`>("${password}"), - required: true, - readOnly: false, - validators: {}, - annotations: {}, - autocomplete: "new-password", - html5DataAnnotations: {} - }, - { - name: "password-confirm", - displayName: id<`\${${MessageKey_defaultSet}}`>("${passwordConfirm}"), - required: true, - readOnly: false, - validators: {}, - annotations: {}, - html5DataAnnotations: {}, - autocomplete: "new-password" - } - ); - }); - } - - const initialFormFieldState: { - attribute: Attribute; - valueOrValues: string | string[]; - }[] = []; - - for (const attribute of attributes) { - handle_multi_valued_attribute: { - if (!attribute.multivalued) { - break handle_multi_valued_attribute; - } - - const values = attribute.values?.length ? attribute.values : [""]; - - apply_validator_min_range: { - if (getIsMultivaluedSingleField({ attribute })) { - break apply_validator_min_range; - } - - const validator = attribute.validators.multivalued; - - if (validator === undefined) { - break apply_validator_min_range; - } - - const { min: minStr } = validator; - - if (!minStr) { - break apply_validator_min_range; - } - - const min = parseInt(`${minStr}`); - - for (let index = values.length; index < min; index++) { - values.push(""); - } - } - - initialFormFieldState.push({ - attribute, - valueOrValues: values - }); - - continue; - } - - initialFormFieldState.push({ - attribute, - valueOrValues: attribute.value ?? "" - }); - } - - const initialState: internal.State = { - formFieldStates: initialFormFieldState.map(({ attribute, valueOrValues }) => ({ - attribute, - errors: getErrors({ - attributeName: attribute.name, - formFieldStates: initialFormFieldState - }), - hasLostFocusAtLeastOnce: - valueOrValues instanceof Array && !getIsMultivaluedSingleField({ attribute }) ? valueOrValues.map(() => false) : false, - valueOrValues: valueOrValues + const formState = useMemo( + (): FormState => ({ + isFormSubmittable: formState_reactless.isFormSubmittable, + formFieldStates: formState_reactless.formFieldStates.map(formFieldState_reactless => ({ + attribute: formFieldState_reactless.attribute, + valueOrValues: formFieldState_reactless.valueOrValues, + displayableErrors: formFieldState_reactless.displayableErrors.map((formFieldError_reactless, i) => ({ + errorMessage: ( + + {advancedMsg(...formFieldError_reactless.advancedMsgArgs)} + + ), + errorMessageStr: advancedMsgStr(...formFieldError_reactless.advancedMsgArgs), + source: formFieldError_reactless.source, + fieldIndex: formFieldError_reactless.fieldIndex + })) })) - }; - - return initialState; - }, []); - - const [state, dispatchFormAction] = useReducer(function reducer(state: internal.State, formAction: FormAction): internal.State { - const formFieldState = state.formFieldStates.find(({ attribute }) => attribute.name === formAction.name); - - assert(formFieldState !== undefined); - - (() => { - switch (formAction.action) { - case "update": - formFieldState.valueOrValues = formAction.valueOrValues; - - apply_formatters: { - const { attribute } = formFieldState; - - const { kcNumberFormat } = attribute.html5DataAnnotations ?? {}; - - if (!kcNumberFormat) { - break apply_formatters; - } - - if (formFieldState.valueOrValues instanceof Array) { - formFieldState.valueOrValues = formFieldState.valueOrValues.map(value => formatNumber(value, kcNumberFormat)); - } else { - formFieldState.valueOrValues = formatNumber(formFieldState.valueOrValues, kcNumberFormat); - } - } - - formFieldState.errors = getErrors({ - attributeName: formAction.name, - formFieldStates: state.formFieldStates - }); - - simulate_focus_lost: { - const { displayErrorsImmediately = false } = formAction; - - if (!displayErrorsImmediately) { - break simulate_focus_lost; - } - - for (const fieldIndex of formAction.valueOrValues instanceof Array - ? formAction.valueOrValues.map((...[, index]) => index) - : [undefined]) { - state = reducer(state, { - action: "focus lost", - name: formAction.name, - fieldIndex - }); - } - } - - update_password_confirm: { - if (doMakeUserConfirmPassword) { - break update_password_confirm; - } - - if (formAction.name !== "password") { - break update_password_confirm; - } - - state = reducer(state, { - action: "update", - name: "password-confirm", - valueOrValues: formAction.valueOrValues, - displayErrorsImmediately: formAction.displayErrorsImmediately - }); - } - - trigger_password_confirm_validation_on_password_change: { - if (!doMakeUserConfirmPassword) { - break trigger_password_confirm_validation_on_password_change; - } - - if (formAction.name !== "password") { - break trigger_password_confirm_validation_on_password_change; - } - - state = reducer(state, { - action: "update", - name: "password-confirm", - valueOrValues: (() => { - const formFieldState = state.formFieldStates.find(({ attribute }) => attribute.name === "password-confirm"); - - assert(formFieldState !== undefined); - - return formFieldState.valueOrValues; - })(), - displayErrorsImmediately: formAction.displayErrorsImmediately - }); - } - - return; - case "focus lost": - if (formFieldState.hasLostFocusAtLeastOnce instanceof Array) { - const { fieldIndex } = formAction; - assert(fieldIndex !== undefined); - formFieldState.hasLostFocusAtLeastOnce[fieldIndex] = true; - return; - } - - formFieldState.hasLostFocusAtLeastOnce = true; - return; - } - assert>(false); - })(); - - return { ...state }; - }, 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] + [formState_reactless] ); return { formState, - dispatchFormAction + dispatchFormAction: api.dispatchFormAction }; } - -type KcContextLike_useGetErrors = KcContextLike_i18n & { - messagesPerField: Pick; - passwordPolicies?: PasswordPolicies; -}; - -assert(); - -function useGetErrors(params: { kcContext: KcContextLike_useGetErrors; i18n: I18n }) { - const { kcContext, i18n } = params; - - const { messagesPerField, passwordPolicies } = kcContext; - - const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n; - - const getErrors = useConstCallback( - (params: { - attributeName: string; - formFieldStates: { - attribute: Attribute; - valueOrValues: string | string[]; - }[]; - }): FormFieldError[] => { - const { attributeName, formFieldStates } = params; - - const formFieldState = formFieldStates.find(({ attribute }) => attribute.name === attributeName); - - assert(formFieldState !== undefined); - - const { attribute } = formFieldState; - - const valueOrValues = (() => { - let { valueOrValues } = formFieldState; - - unFormat_number: { - const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {}; - - if (!kcNumberUnFormat) { - break unFormat_number; - } - - if (valueOrValues instanceof Array) { - valueOrValues = valueOrValues.map(value => formatNumber(value, kcNumberUnFormat)); - } else { - valueOrValues = formatNumber(valueOrValues, kcNumberUnFormat); - } - } - - return valueOrValues; - })(); - - assert(attribute !== undefined); - - server_side_error: { - if (attribute.multivalued) { - const defaultValues = attribute.values?.length ? attribute.values : [""]; - - assert(valueOrValues instanceof Array); - - const values = valueOrValues; - - if (JSON.stringify(defaultValues) !== JSON.stringify(values.slice(0, defaultValues.length))) { - break server_side_error; - } - } else { - const defaultValue = attribute.value ?? ""; - - assert(typeof valueOrValues === "string"); - - const value = valueOrValues; - - 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: ( - - ), - fieldIndex: undefined, - source: { - type: "server" - } - } - ]; - } - - handle_multi_valued_multi_fields: { - if (!attribute.multivalued) { - break handle_multi_valued_multi_fields; - } - - if (getIsMultivaluedSingleField({ attribute })) { - break handle_multi_valued_multi_fields; - } - - assert(valueOrValues instanceof Array); - - const values = valueOrValues; - - const errors = values - .map((...[, index]) => { - const specificValueErrors = getErrors({ - attributeName, - formFieldStates: formFieldStates.map(formFieldState => { - if (formFieldState.attribute.name === attributeName) { - assert(formFieldState.valueOrValues instanceof Array); - return { - attribute: { - ...attribute, - annotations: { - ...attribute.annotations, - inputType: undefined - }, - multivalued: false - }, - valueOrValues: formFieldState.valueOrValues[index] - }; - } - - return formFieldState; - }) - }); - - 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.every(value => 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" - } - }); - } - - return errors; - } - - handle_multi_valued_single_field: { - if (!attribute.multivalued) { - break handle_multi_valued_single_field; - } - - if (!getIsMultivaluedSingleField({ attribute })) { - break handle_multi_valued_single_field; - } - - const validatorName = "multivalued"; - - const validator = attribute.validators[validatorName]; - - if (validator === undefined) { - return []; - } - - const { min: minStr } = validator; - - const min = minStr ? parseInt(`${minStr}`) : attribute.required ? 1 : 0; - - assert(!isNaN(min)); - - const { max: maxStr } = validator; - - const max = !maxStr ? Infinity : parseInt(`${maxStr}`); - - assert(!isNaN(max)); - - assert(valueOrValues instanceof Array); - - const values = valueOrValues; - - 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(typeof valueOrValues === "string"); - - const value = valueOrValues; - - 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) { - break check_password_policy_x; - } - - const minLength = policy; - - 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) { - break check_password_policy_x; - } - - const minNumberOfDigits = policy; - - 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) { - break check_password_policy_x; - } - - const minNumberOfLowerCaseChar = policy; - - 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) { - break check_password_policy_x; - } - - const minNumberOfUpperCaseChar = policy; - - 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) { - break check_password_policy_x; - } - - const minNumberOfSpecialChar = policy; - - 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 usernameFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "username"); - - if (!usernameFormFieldState) { - break check_password_policy_x; - } - - const usernameValue = (() => { - let { valueOrValues } = usernameFormFieldState; - - assert(typeof valueOrValues === "string"); - - unFormat_number: { - const { kcNumberUnFormat } = attribute.html5DataAnnotations ?? {}; - - if (!kcNumberUnFormat) { - break unFormat_number; - } - - valueOrValues = formatNumber(valueOrValues, kcNumberUnFormat); - } - - return valueOrValues; - })(); - - if (usernameValue === "") { - break check_password_policy_x; - } - - if (value !== usernameValue) { - 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 emailFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "email"); - - if (!emailFormFieldState) { - break check_password_policy_x; - } - - assert(typeof emailFormFieldState.valueOrValues === "string"); - - { - const emailValue = emailFormFieldState.valueOrValues; - - if (emailValue === "") { - break check_password_policy_x; - } - - if (value !== emailValue) { - 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 passwordFormFieldState = formFieldStates.find(formFieldState => formFieldState.attribute.name === "password"); - - assert(passwordFormFieldState !== undefined); - - assert(typeof passwordFormFieldState.valueOrValues === "string"); - - { - const passwordValue = passwordFormFieldState.valueOrValues; - - if (value === passwordValue) { - 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) { - 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 && 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 && 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 && 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 && 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: {msg(...msgArgs)}, - errorMessageStr: msgStr(...msgArgs), - fieldIndex: undefined, - source: { - type: "validator", - name: validatorName - } - }); - } - - //TODO: Implement missing validators. See Validators type definition. - - return errors; - } - ); - - return { getErrors }; -} - -function getIsMultivaluedSingleField(params: { attribute: Attribute }) { - const { attribute } = params; - - return attribute.annotations.inputType?.startsWith("multiselect") ?? false; -} - -export function getButtonToDisplayForMultivaluedAttributeField(params: { attribute: Attribute; values: string[]; fieldIndex: number }) { - const { attribute, values, fieldIndex } = params; - - const hasRemove = (() => { - if (values.length === 1) { - return false; - } - - const minCount = (() => { - const { multivalued } = attribute.validators; - - if (multivalued === undefined) { - return undefined; - } - - const minStr = multivalued.min; - - if (minStr === undefined) { - return undefined; - } - - return parseInt(`${minStr}`); - })(); - - if (minCount === undefined) { - return true; - } - - if (values.length === minCount) { - return false; - } - - return true; - })(); - - const hasAdd = (() => { - if (fieldIndex + 1 !== values.length) { - return false; - } - - const maxCount = (() => { - const { multivalued } = attribute.validators; - - if (multivalued === undefined) { - return undefined; - } - - const maxStr = multivalued.max; - - if (maxStr === undefined) { - return undefined; - } - - return parseInt(`${maxStr}`); - })(); - - if (maxCount === undefined) { - return true; - } - - return values.length !== maxCount; - })(); - - return { hasRemove, hasAdd }; -}