import { useEffect, useReducer, Fragment } from "react"; import type { ClassKey } from "keycloakify/login/TemplateProps"; import { useUserProfileForm, type KcContextLike, type FormAction, type FormFieldError } from "keycloakify/login/lib/useUserProfileForm"; import type { Attribute } from "keycloakify/login/kcContext/KcContext"; import { assert } from "tsafe/assert"; import type { I18n } from "./i18n"; export type UserProfileFormFieldsProps = { kcContext: KcContextLike; i18n: I18n; getClassName: (classKey: ClassKey) => string; onIsFormSubmittableValueChange: (isFormSubmittable: boolean) => void; BeforeField?: (props: BeforeAfterFieldProps) => JSX.Element | null; AfterField?: (props: BeforeAfterFieldProps) => JSX.Element | null; }; type BeforeAfterFieldProps = { attribute: Attribute; dispatchFormAction: React.Dispatch; displayableErrors: FormFieldError[]; i18n: I18n; valueOrValues: string | string[]; }; // NOTE: Enabled by default but it's a UX best practice to set it to false. const doMakeUserConfirmPassword = true; export default function UserProfileFormFields(props: UserProfileFormFieldsProps) { const { kcContext, onIsFormSubmittableValueChange, i18n, getClassName, BeforeField, AfterField } = props; const { advancedMsg } = i18n; const { formState: { formFieldStates, isFormSubmittable }, dispatchFormAction } = useUserProfileForm({ kcContext, i18n, doMakeUserConfirmPassword }); useEffect(() => { onIsFormSubmittableValueChange(isFormSubmittable); }, [isFormSubmittable]); const groupNameRef = { "current": "" }; return ( <> {formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => { return ( {BeforeField !== undefined && ( )}
{attribute.required && <>*}
{attribute.annotations.inputHelperTextBefore !== undefined && (
{advancedMsg(attribute.annotations.inputHelperTextBefore)}
)} {attribute.annotations.inputHelperTextAfter !== undefined && (
{advancedMsg(attribute.annotations.inputHelperTextAfter)}
)} {AfterField !== undefined && ( )} {/* NOTE: Downloading of html5DataAnnotations scripts is done in the useUserProfileForm hook */}
); })} ); } function GroupLabel(props: { attribute: Attribute; getClassName: UserProfileFormFieldsProps["getClassName"]; i18n: I18n; groupNameRef: { current: string; }; }) { const { attribute, getClassName, i18n, groupNameRef } = props; const { advancedMsg } = i18n; if (attribute.group?.name !== groupNameRef.current) { groupNameRef.current = attribute.group?.name ?? ""; if (groupNameRef.current !== "") { assert(attribute.group !== undefined); return (
[`data-${key}`, value]))} > {(() => { const groupDisplayHeader = attribute.group.displayHeader ?? ""; const groupHeaderText = groupDisplayHeader !== "" ? advancedMsg(groupDisplayHeader) : attribute.group.name; return (
); })()} {(() => { const groupDisplayDescription = attribute.group.displayDescription ?? ""; if (groupDisplayDescription !== "") { const groupDescriptionText = advancedMsg(groupDisplayDescription); return (
); } return null; })()}
); } } return null; } function FieldErrors(props: { attribute: Attribute; getClassName: UserProfileFormFieldsProps["getClassName"]; displayableErrors: FormFieldError[]; fieldIndex: number | undefined; }) { const { attribute, getClassName, fieldIndex } = props; const displayableErrors = props.displayableErrors.filter(error => error.fieldIndex === fieldIndex); if (displayableErrors.length === 0) { return null; } return ( {displayableErrors .filter(error => error.fieldIndex === fieldIndex) .map(({ errorMessage }, i, arr) => ( {errorMessage} {arr.length - 1 !== i &&
}
))}
); } type InputFiledByTypeProps = { attribute: Attribute; valueOrValues: string | string[]; displayableErrors: FormFieldError[]; formValidationDispatch: React.Dispatch; getClassName: UserProfileFormFieldsProps["getClassName"]; i18n: I18n; }; function InputFiledByType(props: InputFiledByTypeProps) { const { attribute, valueOrValues } = props; switch (attribute.annotations.inputType) { case "textarea": return ; case "select": case "multiselect": return ; case "select-radiobuttons": case "multiselect-checkboxes": return ; default: { if (valueOrValues instanceof Array) { return ( <> {valueOrValues.map((...[, i]) => ( ))} ); } const inputNode = ; if (attribute.name === "password" || attribute.name === "password-confirm") { return ( {inputNode} ); } return inputNode; } } } function PasswordWrapper(props: { getClassName: (classKey: ClassKey) => string; i18n: I18n; passwordInputId: string; children: JSX.Element }) { const { getClassName, i18n, passwordInputId, children } = props; const { msgStr } = i18n; const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false); useEffect(() => { const passwordInputElement = document.getElementById(passwordInputId); assert(passwordInputElement instanceof HTMLInputElement); passwordInputElement.type = isPasswordRevealed ? "text" : "password"; }, [isPasswordRevealed]); return (
{children}
); } function InputTag(props: InputFiledByTypeProps & { fieldIndex: number | undefined }) { const { attribute, fieldIndex, getClassName, formValidationDispatch, valueOrValues, i18n, displayableErrors } = props; return ( <> { const { inputType } = attribute.annotations; if (inputType?.startsWith("html5-")) { return inputType.slice(6); } return inputType ?? "text"; })()} id={attribute.name} name={attribute.name} value={(() => { if (fieldIndex !== undefined) { assert(valueOrValues instanceof Array); return valueOrValues[fieldIndex]; } assert(typeof valueOrValues === "string"); return valueOrValues; })()} className={getClassName("kcInputClass")} aria-invalid={displayableErrors.find(error => error.fieldIndex === fieldIndex) !== undefined} disabled={attribute.readOnly} autoComplete={attribute.autocomplete} placeholder={attribute.annotations.inputTypePlaceholder} pattern={attribute.annotations.inputTypePattern} size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)} maxLength={ attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`) } minLength={ attribute.annotations.inputTypeMinlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMinlength}`) } max={attribute.annotations.inputTypeMax} min={attribute.annotations.inputTypeMin} step={attribute.annotations.inputTypeStep} {...Object.fromEntries(Object.entries(attribute.html5DataAnnotations ?? {}).map(([key, value]) => [`data-${key}`, value]))} onChange={event => formValidationDispatch({ "action": "update", "name": attribute.name, "valueOrValues": (() => { if (fieldIndex !== undefined) { assert(valueOrValues instanceof Array); return valueOrValues.map((value, i) => { if (i === fieldIndex) { return event.target.value; } return value; }); } return event.target.value; })() }) } onBlur={() => props.formValidationDispatch({ "action": "focus lost", "name": attribute.name, "fieldIndex": fieldIndex }) } /> {(() => { if (fieldIndex === undefined) { return null; } assert(valueOrValues instanceof Array); const values = valueOrValues; return ( <> ); })()} ); } function AddRemoveButtonsMultiValuedAttribute(props: { attribute: Attribute; values: string[]; fieldIndex: number; dispatchFormAction: React.Dispatch>; i18n: I18n; }) { const { attribute, values, fieldIndex, dispatchFormAction, i18n } = props; const { msg } = i18n; 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 false; } if (values.length === maxCount) { return false; } return true; })(); return ( <> {hasRemove && ( )} {hasAdd && ( )} ); } function InputTagSelects(props: InputFiledByTypeProps) { const { attribute, formValidationDispatch, getClassName, valueOrValues } = props; const { advancedMsg } = props.i18n; const { classDiv, classInput, classLabel, inputType } = (() => { const { inputType } = attribute.annotations; assert(inputType === "select-radiobuttons" || inputType === "multiselect-checkboxes"); switch (inputType) { case "select-radiobuttons": return { "inputType": "radio", "classDiv": getClassName("kcInputClassRadio"), "classInput": getClassName("kcInputClassRadioInput"), "classLabel": getClassName("kcInputClassRadioLabel") }; case "multiselect-checkboxes": return { "inputType": "checkbox", "classDiv": getClassName("kcInputClassCheckbox"), "classInput": getClassName("kcInputClassCheckboxInput"), "classLabel": getClassName("kcInputClassCheckboxLabel") }; } })(); const options = (() => { walk: { const { inputOptionsFromValidation } = attribute.annotations; if (inputOptionsFromValidation === undefined) { break walk; } const validator = (attribute.validators as Record)[inputOptionsFromValidation]; if (validator === undefined) { break walk; } if (validator.options === undefined) { break walk; } return validator.options; } return attribute.validators.options?.options ?? []; })(); return ( <> {options.map(option => (
formValidationDispatch({ "action": "update", "name": attribute.name, "valueOrValues": (() => { const isChecked = event.target.checked; if (valueOrValues instanceof Array) { const newValues = [...valueOrValues]; if (isChecked) { newValues.push(option); } else { newValues.splice(newValues.indexOf(option), 1); } return newValues; } return event.target.checked ? option : ""; })() }) } onBlur={() => formValidationDispatch({ "action": "focus lost", "name": attribute.name, "fieldIndex": undefined }) } />
))} ); } function TextareaTag(props: InputFiledByTypeProps) { const { attribute, formValidationDispatch, getClassName, displayableErrors, valueOrValues } = props; assert(typeof valueOrValues === "string"); const value = valueOrValues; return (