diff --git a/package-lock.json b/package-lock.json index d08166676d..5d31e65334 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "shesha-antd-x", + "name": "shesha-framework", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/shesha-functional-tests/backend/src/Boxfusion.SheshaFunctionalTests.Web.Host/appsettings.json b/shesha-functional-tests/backend/src/Boxfusion.SheshaFunctionalTests.Web.Host/appsettings.json index 449d7008db..1925a5460c 100644 --- a/shesha-functional-tests/backend/src/Boxfusion.SheshaFunctionalTests.Web.Host/appsettings.json +++ b/shesha-functional-tests/backend/src/Boxfusion.SheshaFunctionalTests.Web.Host/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "Default": "Data Source=.; Initial Catalog=functional-shesha-test;Integrated Security=SSPI;TrustServerCertificate=True;" + "Default": "Data Source=.; Initial Catalog=main20jan;Integrated Security=SSPI;TrustServerCertificate=True;" }, "App": { "ServerRootAddress": "http://localhost:21021", diff --git a/shesha-reactjs/eslint.config.mjs b/shesha-reactjs/eslint.config.mjs index c4eb154db2..233fc588d8 100644 --- a/shesha-reactjs/eslint.config.mjs +++ b/shesha-reactjs/eslint.config.mjs @@ -418,6 +418,9 @@ const makeStrictConfig = (path) => { } export default [ + { + ignores: [".next/**", "dist/**"], + }, { ...baseTsConfig, files: [ diff --git a/shesha-reactjs/package-lock.json b/shesha-reactjs/package-lock.json index 711a0a95ef..9bfc0ab3f1 100644 --- a/shesha-reactjs/package-lock.json +++ b/shesha-reactjs/package-lock.json @@ -23740,6 +23740,21 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.32", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.32.tgz", + "integrity": "sha512-jHUeDPVHrgFltqoAqDB6g6OStNnFxnc7Aks3p0KE0FbwAvRg6qWKYF5mSTdCTxA3axoSAUwxYdILzXJfUwlHhA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/shesha-reactjs/src/components/componentErrors/component-docs.json b/shesha-reactjs/src/components/componentErrors/component-docs.json index 882218a66d..dccd8c3f9e 100644 --- a/shesha-reactjs/src/components/componentErrors/component-docs.json +++ b/shesha-reactjs/src/components/componentErrors/component-docs.json @@ -51,7 +51,6 @@ "numberField": "https://docs.shesha.io/docs/front-end-basics/form-components/data-entry/numberfield", "panel": "https://docs.shesha.io/docs/front-end-basics/form-components/Layouts/panel", - "passwordCombo": "https://docs.shesha.io/docs/front-end-basics/form-components/Advanced/password-combo", "progress": "https://docs.shesha.io/docs/front-end-basics/form-components/Advanced/progress", "queryBuilder": "https://docs.shesha.io/docs/category/form-components", diff --git a/shesha-reactjs/src/designer-components/passwordCombo/index.tsx b/shesha-reactjs/src/designer-components/passwordCombo/index.tsx index 48bc774652..75c97d3ed7 100644 --- a/shesha-reactjs/src/designer-components/passwordCombo/index.tsx +++ b/shesha-reactjs/src/designer-components/passwordCombo/index.tsx @@ -1,64 +1,124 @@ import React from 'react'; import { - confirmModel, defaultStyles, - getDefaultModel, - getFormItemProps, - getInputProps, IPasswordComponentProps, } from './utils'; import { DataTypes, StringFormats } from '@/interfaces/dataTypes'; import { IToolboxComponent } from '@/interfaces'; import { LockOutlined } from '@ant-design/icons'; import { migrateCustomFunctions, migratePropertyName, migrateReadOnly } from '@/designer-components/_common-migrations/migrateSettings'; -import { PasswordCombo } from './passwordCombo'; -import { IInputStyles, useForm } from '@/providers'; +import { IInputStyles } from '@/providers'; import { validateConfigurableComponentSettings } from '@/providers/form/utils'; import { migrateFormApi } from '../_common-migrations/migrateFormApi1'; -import { ValidationErrors } from '@/components/validationErrors'; -import { isValidGuid } from '@/components/formDesigner/components/utils'; import { migratePrevStyles } from '../_common-migrations/migrateStyles'; import { getSettings } from './settingsForm'; -import { useStyles } from './styles'; +import { ITextFieldComponentProps } from '../textField/interfaces'; +import { defaultStyles as textFieldDefaultStyles } from '../textField/utils'; +import { SettingsMigrationContext } from '@/interfaces/formDesigner'; +import { nanoid } from '@/utils/uuid'; -const PasswordComboComponent: IToolboxComponent = { - type: 'passwordCombo', - isInput: true, - name: 'Password combo', - preserveDimensionsInDesigner: true, - icon: , - dataTypeSupported: ({ dataType, dataFormat }) => - dataType === DataTypes.string && dataFormat === StringFormats.password, - Factory: ({ model }) => { - const defaultModel = getDefaultModel(model); - const { placeholder, confirmPlaceholder, message, minLength, repeatPropertyName } = defaultModel || {}; - const { formData } = useForm(); +const CONFIRM_MATCH_VALIDATOR = ` + try { + const formValues = form?.getFieldsValue() ?? {}; + const confirmValue = formValues['__PROPERTY_NAME__Confirm'] ?? formValues['__PROPERTY_NAME__confirm']; + if (value && confirmValue && value !== confirmValue) { + return Promise.reject('Passwords do not match'); + } else { + return Promise.resolve(); + } + } catch (e) { + return Promise.resolve(); + } +`; - const options = { hidden: model.hidden, formData }; +export const migratePasswordComboToTextField = ( + prev: IPasswordComponentProps, + context: SettingsMigrationContext, +): ITextFieldComponentProps => { + const { flatStructure } = context; - const { styles } = useStyles({ fontFamily: model.font?.type, fontWeight: model.font?.weight, textAlign: model.font?.align }); + const confirmId = nanoid(); + const confirmComponent: ITextFieldComponentProps = { + id: confirmId, + type: 'textField', + propertyName: prev.repeatPropertyName || `${prev.propertyName}Confirm`, + label: prev.confirmLabel || 'Confirm Password', + placeholder: prev.confirmPlaceholder || 'Confirm password', + description: prev.confirmDescription, + textType: 'password', + hidden: prev.hidden, + hideLabel: true, + editMode: prev.editMode, + readOnly: prev.readOnly, + parentId: prev.parentId, + validate: { + required: prev.validate?.required, + validator: CONFIRM_MATCH_VALIDATOR.replace('__PROPERTY_NAME__', prev.propertyName), + message: prev.message || 'Passwords do not match', + }, + version: 6, + ...textFieldDefaultStyles(), + }; + flatStructure.allComponents[confirmId] = confirmComponent; - if (model?.background?.type === 'storedFile' && model.background.storedFile?.id && !isValidGuid(model?.background.storedFile.id)) { - return ; + const parentId = prev.parentId || 'root'; + const siblings = flatStructure.componentRelations[parentId]; + if (siblings) { + const originalIndex = siblings.indexOf(prev.id); + if (originalIndex !== -1) { + siblings.splice(originalIndex + 1, 0, confirmId); + } else { + siblings.push(confirmId); } + } - return ( + const converted: ITextFieldComponentProps = { + id: prev.id, + type: 'textField', + propertyName: prev.propertyName, + label: prev.label, + placeholder: prev.placeholder, + description: prev.description, + textType: 'password', + hidden: prev.hidden, + editMode: prev.editMode, + readOnly: prev.readOnly, + parentId: prev.parentId, + validate: { + required: prev.validate?.required, + }, + version: 6, + ...textFieldDefaultStyles(), + desktop: prev.desktop, + tablet: prev.tablet, + mobile: prev.mobile, + font: prev.font, + background: prev.background, + border: prev.border, + shadow: prev.shadow, + dimensions: prev.dimensions, + stylingBox: prev.stylingBox, + }; - - ); - }, + return converted; +}; + +/** + * @deprecated Use the textField component with textType: 'password' instead. + * This component is kept only for migration of existing forms. + * All instances are automatically migrated to two separate textField components (password + confirm) via the migrator. + */ +const PasswordComboComponent: IToolboxComponent = { + type: 'passwordCombo', + isInput: true, + isHidden: true, + name: 'Password combo (Legacy)', + preserveDimensionsInDesigner: true, + icon: , + dataTypeSupported: ({ dataType, dataFormat }) => + dataType === DataTypes.string && dataFormat === StringFormats.password, + Factory: () => null, settingsFormMarkup: getSettings, validateSettings: (model) => validateConfigurableComponentSettings(getSettings, model), migrator: (m) => m @@ -74,7 +134,8 @@ const PasswordComboComponent: IToolboxComponent = { return { ...prev, desktop: { ...styles }, tablet: { ...styles }, mobile: { ...styles } }; }) - .add(7, (prev) => ({ ...migratePrevStyles(prev, defaultStyles()), editMode: 'inherited' })), + .add(7, (prev) => ({ ...migratePrevStyles(prev, defaultStyles()), editMode: 'inherited' })) + .add(8, (prev: IPasswordComponentProps, context) => migratePasswordComboToTextField(prev, context)), }; export default PasswordComboComponent; diff --git a/shesha-reactjs/src/designer-components/textField/settingsForm.ts b/shesha-reactjs/src/designer-components/textField/settingsForm.ts index ed3cf6689c..c2967b50e2 100644 --- a/shesha-reactjs/src/designer-components/textField/settingsForm.ts +++ b/shesha-reactjs/src/designer-components/textField/settingsForm.ts @@ -145,6 +145,11 @@ export const getSettings: SettingsFormMarkupFactory = ({ fbf }) => { .addSettingsInputRow({ id: nanoid(), parentId: validationTabId, + hidden: { + _code: 'return getSettingValue(data?.textType) === "password";', + _mode: 'code', + _value: false, + } as any, inputs: [ { type: 'numberField', diff --git a/shesha-reactjs/src/designer-components/textField/styles.ts b/shesha-reactjs/src/designer-components/textField/styles.ts index f8f04efdf2..71dc287f84 100644 --- a/shesha-reactjs/src/designer-components/textField/styles.ts +++ b/shesha-reactjs/src/designer-components/textField/styles.ts @@ -13,9 +13,19 @@ export const useStyles = createStyles(({ css, cx, token }, { fontWeight, fontFam :hover { border-color: ${token.colorPrimary} !important; } + `); + const passwordFieldWrapper = cx("sha-password-field-wrapper", css` + .ant-form-item-explain-error { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: default; + } `); + return { textField, + passwordFieldWrapper, }; }); diff --git a/shesha-reactjs/src/designer-components/textField/textField.tsx b/shesha-reactjs/src/designer-components/textField/textField.tsx index 650f4819a2..90c8d6af93 100644 --- a/shesha-reactjs/src/designer-components/textField/textField.tsx +++ b/shesha-reactjs/src/designer-components/textField/textField.tsx @@ -1,7 +1,7 @@ import { CodeOutlined } from '@ant-design/icons'; -import { Input } from 'antd'; +import { Input, Tooltip } from 'antd'; import { InputProps } from 'antd/lib/input'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { ConfigurableFormItem } from '@/components/formDesigner/components/formItem'; import { getAllEventHandlers } from '@/components/formDesigner/components/utils'; import { DataTypes, StringFormats } from '@/interfaces/dataTypes'; @@ -16,7 +16,7 @@ import { IconType, ShaIcon } from '@/components/shaIcon'; import { useStyles } from './styles'; import { migratePrevStyles } from '../_common-migrations/migrateStyles'; import { getSettings } from './settingsForm'; -import { defaultStyles } from './utils'; +import { defaultStyles, buildPasswordValidatorString, usePasswordComplexitySettings, validatePasswordValue } from './utils'; const TextFieldComponent: TextFieldComponentDefinition = { type: 'textField', @@ -51,7 +51,29 @@ const TextFieldComponent: TextFieldComponentDefinition = { console.warn(`Invalid regExp pattern for '${model.propertyName}':`, model, error); return null; } - }, [model.regExp]); + }, [model]); + + const isPassword = model.textType === 'password'; + const passwordComplexity = usePasswordComplexitySettings(); + const [passwordError, setPasswordError] = useState(null); + + const passwordValidator = useMemo(() => + isPassword ? buildPasswordValidatorString(passwordComplexity) : null, + [isPassword, passwordComplexity], + ); + + const modelWithValidation = useMemo(() => { + if (!isPassword || !passwordValidator || model.validate?.validator) return model; + return { + ...model, + validate: { + ...(model.validate || {}), + minLength: undefined, + maxLength: undefined, + validator: passwordValidator, + }, + }; + }, [model, isPassword, passwordValidator]); if (model.hidden) return null; @@ -66,18 +88,28 @@ const TextFieldComponent: TextFieldComponentDefinition = { readOnly: model.readOnly, spellCheck: model.spellCheck, style: model.allStyles.fullStyle, - maxLength: model.validate?.maxLength, - max: model.validate?.maxLength, - minLength: model.validate?.minLength, }; - return ( - + const fieldContent = ( + {(value, onChange) => { const customEvents = calculatedModel.eventHandlers; - const onChangeInternal = (...args: any[]): void => { - const inputValue: string | undefined = args[0]?.currentTarget?.value?.toString(); + const onChangeInternal = (...args: unknown[]): void => { + const arg = args[0]; + const inputValue: string | undefined = + arg !== null && typeof arg === 'object' && 'currentTarget' in arg && + arg.currentTarget !== null && typeof arg.currentTarget === 'object' && 'value' in arg.currentTarget + ? arg.currentTarget.value?.toString() + : undefined; const isEmpty = inputValue === undefined || inputValue === null || inputValue === ''; + + if (isPassword && inputValue) { + const errors = validatePasswordValue(inputValue, passwordComplexity); + setPasswordError(errors.length > 0 ? `Password must contain ${errors.join(', ')}` : null); + } else if (isPassword) { + setPasswordError(null); + } + const isRegExpMatch = regExpObj && Boolean(inputValue?.match(regExpObj)); if ((!isEmpty && isRegExpMatch) || !regExpObj || isEmpty) { const changedValue = customEvents.onChange({ value: inputValue }, args[0]); @@ -98,6 +130,16 @@ const TextFieldComponent: TextFieldComponentDefinition = { }} ); + + if (isPassword) { + return ( + +
{fieldContent}
+
+ ); + } + + return fieldContent; }, settingsFormMarkup: getSettings, validateSettings: (model) => validateConfigurableComponentSettings(getSettings, model), diff --git a/shesha-reactjs/src/designer-components/textField/utils.ts b/shesha-reactjs/src/designer-components/textField/utils.ts index dde5d7b520..75749e43a4 100644 --- a/shesha-reactjs/src/designer-components/textField/utils.ts +++ b/shesha-reactjs/src/designer-components/textField/utils.ts @@ -1,4 +1,7 @@ import { IStyleType } from "@/providers/form/models"; +import { useSettingValue } from '@/providers/settings'; +import { ISettingIdentifier } from '@/providers/settings/models'; +import { useMemo } from 'react'; export const defaultStyles = (): IStyleType => { return { @@ -19,3 +22,88 @@ export const defaultStyles = (): IStyleType => { dimensions: { width: '100%', height: '32px', minHeight: '0px', maxHeight: 'auto', minWidth: '0px', maxWidth: 'auto' }, }; }; + +export interface IPasswordComplexitySettings { + requireDigit: boolean; + requireLowercase: boolean; + requireUppercase: boolean; + requireNonAlphanumeric: boolean; + requiredLength: number; +} + +const requireDigitSetting: ISettingIdentifier = { module: 'Shesha', name: 'Abp.Zero.UserManagement.PasswordComplexity.RequireDigit' }; +const requireLowercaseSetting: ISettingIdentifier = { module: 'Shesha', name: 'Abp.Zero.UserManagement.PasswordComplexity.RequireLowercase' }; +const requireUppercaseSetting: ISettingIdentifier = { module: 'Shesha', name: 'Abp.Zero.UserManagement.PasswordComplexity.RequireUppercase' }; +const requireNonAlphanumericSetting: ISettingIdentifier = { module: 'Shesha', name: 'Abp.Zero.UserManagement.PasswordComplexity.RequireNonAlphanumeric' }; +const requiredLengthSetting: ISettingIdentifier = { module: 'Shesha', name: 'Abp.Zero.UserManagement.PasswordComplexity.RequiredLength' }; + +export const usePasswordComplexitySettings = (): IPasswordComplexitySettings => { + const { value: requireDigit } = useSettingValue(requireDigitSetting); + const { value: requireLowercase } = useSettingValue(requireLowercaseSetting); + const { value: requireUppercase } = useSettingValue(requireUppercaseSetting); + const { value: requireNonAlphanumeric } = useSettingValue(requireNonAlphanumericSetting); + const { value: requiredLength } = useSettingValue(requiredLengthSetting); + + return useMemo(() => ({ + requireDigit: requireDigit ?? false, + requireLowercase: requireLowercase ?? false, + requireUppercase: requireUppercase ?? false, + requireNonAlphanumeric: requireNonAlphanumeric ?? false, + requiredLength: requiredLength ?? 8, + }), [requireDigit, requireLowercase, requireUppercase, requireNonAlphanumeric, requiredLength]); +}; + +export const validatePasswordValue = (value: string, settings: IPasswordComplexitySettings): string[] => { + const errors: string[] = []; + + if (settings.requiredLength > 0 && value.length < settings.requiredLength) { + errors.push(`at least ${settings.requiredLength} characters`); + } + if (settings.requireDigit && !/[0-9]/.test(value)) { + errors.push('at least one digit'); + } + if (settings.requireLowercase && !/[a-z]/.test(value)) { + errors.push('at least one lowercase letter'); + } + if (settings.requireUppercase && !/[A-Z]/.test(value)) { + errors.push('at least one uppercase letter'); + } + if (settings.requireNonAlphanumeric && !/[^a-zA-Z0-9]/.test(value)) { + errors.push('at least one non-alphanumeric character'); + } + + return errors; +}; + +export const buildPasswordValidatorString = (settings: IPasswordComplexitySettings): string => { + const checks: string[] = []; + if (settings.requiredLength > 0) { + checks.push(`if (pwd.length < ${settings.requiredLength}) errors.push('at least ${settings.requiredLength} characters');`); + } + if (settings.requireDigit) { + checks.push(`if (!/[0-9]/.test(pwd)) errors.push('at least one digit');`); + } + if (settings.requireLowercase) { + checks.push(`if (!/[a-z]/.test(pwd)) errors.push('at least one lowercase letter');`); + } + if (settings.requireUppercase) { + checks.push(`if (!/[A-Z]/.test(pwd)) errors.push('at least one uppercase letter');`); + } + if (settings.requireNonAlphanumeric) { + checks.push(`if (!/[^a-zA-Z0-9]/.test(pwd)) errors.push('at least one non-alphanumeric character');`); + } + + return ` + try { + const pwd = typeof value === 'string' ? value : ''; + const errors = []; + ${checks.join('\n ')} + if (errors.length > 0) return Promise.reject('Password must contain ' + errors.join(', ')); + return Promise.resolve(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error('[TextField] Password validator error:', msg); + return Promise.reject('Password validation failed: ' + msg); + } + `; +}; diff --git a/shesha-reactjs/src/providers/form/defaults/toolboxComponents.ts b/shesha-reactjs/src/providers/form/defaults/toolboxComponents.ts index 1c342eb2be..9eb5d8dbc8 100644 --- a/shesha-reactjs/src/providers/form/defaults/toolboxComponents.ts +++ b/shesha-reactjs/src/providers/form/defaults/toolboxComponents.ts @@ -161,7 +161,6 @@ export const getToolboxComponents = ( Image, RichTextEditor, Markdown, - PasswordCombo, Progress, RefListStatusComponent, StatusTag, @@ -230,6 +229,7 @@ export const getToolboxComponents = ( Toolbar, List, EditableTagGroup, + PasswordCombo, FormAutocompleteComponent, ReferenceListAutocompleteComponent, NotificationAutocompleteComponent,