import { useEffect, Fragment } from "react"; import type { ClassKey } from "keycloakify/login/TemplateProps"; import { clsx } from "keycloakify/tools/clsx"; import { useUserProfileForm, type KcContextLike, type FormAction, type FormFieldError } from "keycloakify/login/lib/useUserProfileForm"; import type { Attribute, LegacyAttribute } from "keycloakify/login/kcContext/KcContext"; import type { I18n } from "../../i18n"; import { assert } from "tsafe/assert"; 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 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 }) => { const formGroupClassName = clsx( getClassName("kcFormGroupClass"), displayableErrors.length !== 0 && getClassName("kcFormGroupErrorClass") ); return ( {BeforeField !== undefined && ( )}
{attribute.required && <>*}
{attribute.annotations.inputHelperTextBefore !== undefined && (
{advancedMsg(attribute.annotations.inputHelperTextBefore)}
)} {attribute.annotations.inputHelperTextAfter !== undefined && (
{advancedMsg(attribute.annotations.inputHelperTextAfter)}
)} {AfterField !== undefined && ( )} {/* TODO: <#list profile.html5DataAnnotations?keys as key> */}
); })} ); } function GroupLabel(props: { attribute: Attribute; getClassName: UserProfileFormFieldsProps["getClassName"]; i18n: I18n; groupNameRef: { current: string; }; formGroupClassName: string; }) { const { attribute, getClassName, i18n, groupNameRef, formGroupClassName } = props; const { advancedMsg } = i18n; keycloak_prior_to_24: { if (attribute.html5DataAnnotations !== undefined) { break keycloak_prior_to_24; } const { group = "", groupDisplayHeader = "", groupDisplayDescription = "" } = attribute as any as LegacyAttribute; return ( <> {group !== groupNameRef.current && (groupNameRef.current = group) !== "" && (
{groupDisplayDescription !== "" && (
)}
)} ); } 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 PropsOfInputFiledByType = { attribute: Attribute; valueOrValues: string | string[]; displayableErrors: FormFieldError[]; formValidationDispatch: React.Dispatch; getClassName: UserProfileFormFieldsProps["getClassName"]; i18n: I18n; }; function InputFiledByType(props: PropsOfInputFiledByType) { const { attribute, valueOrValues } = props; /* <#macro inputFieldByType attribute> <#switch attribute.annotations.inputType!''> <#case 'textarea'> <@textareaTag attribute=attribute/> <#break> <#case 'select'> <#case 'multiselect'> <@selectTag attribute=attribute/> <#break> <#case 'select-radiobuttons'> <#case 'multiselect-checkboxes'> <@inputTagSelects attribute=attribute/> <#break> <#default> <#if attribute.multivalued && attribute.values?has_content> <#list attribute.values as value> <@inputTag attribute=attribute value=value!''/> <#else> <@inputTag attribute=attribute value=attribute.value!''/> */ 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]) => ( ))} ); } return ; } } function InputTag(props: PropsOfInputFiledByType & { fieldIndex: number | undefined }) { /* <#macro inputTag attribute value> disabled <#if attribute.autocomplete??>autocomplete="${attribute.autocomplete}" <#if attribute.annotations.inputTypePlaceholder??>placeholder="${attribute.annotations.inputTypePlaceholder}" <#if attribute.annotations.inputTypePattern??>pattern="${attribute.annotations.inputTypePattern}" <#if attribute.annotations.inputTypeSize??>size="${attribute.annotations.inputTypeSize}" <#if attribute.annotations.inputTypeMaxlength??>maxlength="${attribute.annotations.inputTypeMaxlength}" <#if attribute.annotations.inputTypeMinlength??>minlength="${attribute.annotations.inputTypeMinlength}" <#if attribute.annotations.inputTypeMax??>max="${attribute.annotations.inputTypeMax}" <#if attribute.annotations.inputTypeMin??>min="${attribute.annotations.inputTypeMin}" <#if attribute.annotations.inputTypeStep??>step="${attribute.annotations.inputTypeStep}" <#if attribute.annotations.inputTypeStep??>step="${attribute.annotations.inputTypeStep}" <#list attribute.html5DataAnnotations as key, value> data-${key}="${value}" /> <#macro inputTagType attribute> <#compress> <#if attribute.annotations.inputType??> <#if attribute.annotations.inputType?starts_with("html5-")> ${attribute.annotations.inputType[6..]} <#else> ${attribute.annotations.inputType} <#else> text */ 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(props.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: PropsOfInputFiledByType) { /* <#macro inputTagSelects attribute> <#if attribute.annotations.inputType=='select-radiobuttons'> <#assign inputType='radio'> <#assign classDiv=properties.kcInputClassRadio!> <#assign classInput=properties.kcInputClassRadioInput!> <#assign classLabel=properties.kcInputClassRadioLabel!> <#else> <#assign inputType='checkbox'> <#assign classDiv=properties.kcInputClassCheckbox!> <#assign classInput=properties.kcInputClassCheckboxInput!> <#assign classLabel=properties.kcInputClassCheckboxLabel!> <#if attribute.annotations.inputOptionsFromValidation?? && attribute.validators[attribute.annotations.inputOptionsFromValidation]?? && attribute.validators[attribute.annotations.inputOptionsFromValidation].options??> <#assign options=attribute.validators[attribute.annotations.inputOptionsFromValidation].options> <#elseif attribute.validators.options?? && attribute.validators.options.options??> <#assign options=attribute.validators.options.options> <#else> <#assign options=[]> <#list options as option>
disabled <#if attribute.values?seq_contains(option)>checked />
*/ 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: PropsOfInputFiledByType) { const { attribute, formValidationDispatch, getClassName, displayableErrors, valueOrValues } = props; assert(typeof valueOrValues === "string"); const value = valueOrValues; return (