From 04f078628cbdd83dc893501aed4b5bea1849e786 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Wed, 28 Feb 2024 11:00:57 +0100 Subject: [PATCH 1/6] feat(ui-utils): add `validateString` utility --- webapp/src/utils/validationUtils.ts | 80 +++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 webapp/src/utils/validationUtils.ts diff --git a/webapp/src/utils/validationUtils.ts b/webapp/src/utils/validationUtils.ts new file mode 100644 index 0000000000..438236f617 --- /dev/null +++ b/webapp/src/utils/validationUtils.ts @@ -0,0 +1,80 @@ +import { t } from "i18next"; + +interface ValidationOptions { + pattern?: RegExp; + existingEntries?: string[]; + excludedEntries?: string[]; + isCaseSensitive?: boolean; + min?: number; + max?: number; +} + +/** + * Validates a single string value against specified options. + * + * @param {string} value - The string to validate, leading/trailing spaces will be trimmed. + * @param {ValidationOptions} [options] - Customizable options for validation. + * - `pattern`: RegExp for matching the string. Default to alphanumeric, spaces and "&()_-" pattern. + * - `existingEntries`: Array of strings to check for duplicates. Optional, case-insensitive by default. + * - `excludedEntries`: Array of strings that are explicitly not allowed. + * - `isCaseSensitive`: Default to case-insensitive comparison with `existingEntries`. e.g: "A" and "a" are considered the same. + * - `min`: Minimum length required. Defaults to 0. + * - `max`: Maximum allowed length. Defaults to 50. + * @returns {string | true} - True if validation is successful, or a localized error message if it fails. + */ +export const validateString = ( + value: string, + options?: ValidationOptions, +): string | true => { + const { + pattern, + existingEntries = [], + excludedEntries = [], + isCaseSensitive, + min, + max, + } = { + pattern: /^[a-zA-Z0-9_\-() &]+$/, + isCaseSensitive: false, + min: 0, + max: 50, + ...options, + }; + + const trimmedValue = value.trim(); + + if (!trimmedValue) { + return t("form.field.required"); + } + + if (!pattern.test(trimmedValue)) { + return t("form.field.specialChars", { 0: "&()_-" }); + } + + if (trimmedValue.length < min) { + return t("form.field.minValue", { 0: min }); + } + + if (trimmedValue.length > max) { + return t("form.field.maxValue", { 0: max }); + } + + const normalize = (entry: string) => + isCaseSensitive ? entry.trim() : entry.toLowerCase().trim(); + + const comparisonArray = existingEntries.map(normalize); + + const comparisonValue = normalize(trimmedValue); + + if (comparisonArray.includes(comparisonValue)) { + return t("form.field.duplicate", { 0: value }); + } + + const normalizedExcludedValues = excludedEntries.map(normalize); + + if (normalizedExcludedValues.includes(comparisonValue)) { + return t("form.field.notAllowedValue", { 0: value }); + } + + return true; +}; From 7740146564983a00a9e247d410612158390dcb08 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Wed, 28 Feb 2024 09:55:38 +0100 Subject: [PATCH 2/6] feat(ui-map): add areas validation on `CreateAreaDialog` --- .../explore/Modelization/Map/CreateAreaDialog.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx index cd11395262..50354330b6 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx @@ -3,16 +3,23 @@ import AddCircleIcon from "@mui/icons-material/AddCircle"; import FormDialog from "../../../../../common/dialogs/FormDialog"; import StringFE from "../../../../../common/fieldEditors/StringFE"; import { SubmitHandlerPlus } from "../../../../../common/Form/types"; +import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; +import { getAreas } from "../../../../../../redux/selectors"; +import { validateString } from "../../../../../../utils/validationUtils"; interface Props { + studyId: string; open: boolean; onClose: () => void; createArea: (name: string) => void; } function CreateAreaDialog(props: Props) { - const { open, onClose, createArea } = props; + const { studyId, open, onClose, createArea } = props; const [t] = useTranslation(); + const existingAreas = useAppSelector((state) => + getAreas(state, studyId).map((area) => area.name), + ); const defaultValues = { name: "", @@ -48,8 +55,8 @@ function CreateAreaDialog(props: Props) { control={control} fullWidth rules={{ - required: true, - validate: (val) => val.trim().length > 0, + validate: (v) => + validateString(v, { existingEntries: existingAreas }), }} /> )} From b7e51caf6ac5580d688f5cb01f036f809eebbaf5 Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Wed, 28 Feb 2024 09:57:03 +0100 Subject: [PATCH 3/6] fix(ui-map): add missing api error return --- .../explore/Modelization/Map/CreateAreaDialog.tsx | 2 +- .../App/Singlestudy/explore/Modelization/Map/index.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx index 50354330b6..a05e2b9262 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/CreateAreaDialog.tsx @@ -30,7 +30,7 @@ function CreateAreaDialog(props: Props) { //////////////////////////////////////////////////////////////// const handleSubmit = (data: SubmitHandlerPlus) => { - createArea(data.values.name); + return createArea(data.values.name.trim()); }; //////////////////////////////////////////////////////////////// diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/index.tsx index 4ca4ccdf1c..db03961ef4 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/index.tsx @@ -116,10 +116,13 @@ function Map() { //////////////////////////////////////////////////////////////// const handleCreateArea = async (name: string) => { - setOpenDialog(false); try { if (study) { - dispatch(createStudyMapNode({ studyId: study.id, name })); + return dispatch(createStudyMapNode({ studyId: study.id, name })) + .unwrap() + .then(() => { + setOpenDialog(false); + }); } } catch (e) { enqueueErrorSnackbar(t("study.error.createArea"), e as AxiosError); @@ -206,6 +209,7 @@ function Map() { /> {openDialog && ( Date: Wed, 6 Mar 2024 13:33:36 +0100 Subject: [PATCH 4/6] refactor(ui): replace existing validation logic with `validateString` utility --- webapp/public/locales/en/main.json | 11 +- webapp/public/locales/fr/main.json | 11 +- .../dialog/GroupFormDialog/GroupForm.tsx | 31 ++- .../Users/dialog/UserFormDialog/UserForm.tsx | 47 +++-- .../InformationView/CreateVariantDialog.tsx | 12 +- .../App/Singlestudy/PropertiesDialog.tsx | 3 +- .../BindingConstraints/AddDialog.tsx | 11 +- .../Modelization/Map/CreateAreaDialog.tsx | 2 +- .../Districts/CreateDistrictDialog.tsx | 17 +- .../MapConfig/Layers/CreateLayerDialog.tsx | 14 +- .../MapConfig/Layers/UpdateLayerDialog.tsx | 31 +-- .../dialogs/TableTemplateFormDialog.tsx | 26 ++- .../Candidates/CreateCandidateDialog.tsx | 20 +- .../explore/Xpansion/Candidates/index.tsx | 1 + .../common/GroupedDataTable/CreateDialog.tsx | 16 +- .../GroupedDataTable/DuplicateDialog.tsx | 16 +- webapp/src/utils/validationUtils.ts | 188 ++++++++++++++---- 17 files changed, 300 insertions(+), 157 deletions(-) diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index ad3911dcc8..26965fd8b3 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -27,6 +27,7 @@ "global.group": "Group", "global.variants": "Variants management", "global.password": "Password", + "global.confirmPassword": "Confirm password", "global.create": "Create", "global.open": "Open", "global.name": "Name", @@ -117,7 +118,14 @@ "form.field.minValue": "The minimum value is {{0}}", "form.field.maxValue": "The maximum value is {{0}}", "form.field.notAllowedValue": "Not allowed value", - "form.field.specialChars": "Special characters allowed: {{0}}", + "form.field.allowedChars": "Special characters allowed: {{0}}", + "form.field.specialCharsNotAllowed": "Special characters are not allowed", + "form.field.spacesNotAllowed": "Spaces are not allowed", + "form.field.requireLowercase": "Password must contain at least one lowercase letter.", + "form.field.requireUppercase": "Password must contain at least one uppercase letter.", + "form.field.requireDigit": "Password must contain at least one digit.", + "form.field.requireSpecialChars": "Password must contain at least one special character.", + "form.field.requireMinimumLength": "Password must be at least 8 characters long.", "matrix.graphSelector": "Columns", "matrix.message.importHint": "Click or drag and drop a matrix here", "matrix.importNewMatrix": "Import a new matrix", @@ -186,6 +194,7 @@ "settings.error.groupRolesSave": "Role(s) for group '{{0}}' not saved", "settings.error.tokenSave": "'{{0}}' token not saved", "settings.error.updateMaintenance": "Maintenance mode not updated", + "settings.error.passwordMismatch": "Passwords do not match", "launcher.additionalModes": "Additional modes", "launcher.autoUnzip": "Automatically unzip", "launcher.xpress": "Xpress (>= 8.3)", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 4429c67f41..85edfce9bb 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -27,6 +27,7 @@ "global.group": "Groupe", "global.variants": "Gestion des variantes", "global.password": "Mot de passe", + "global.confirmPassword": "Confirmer le mot de passe", "global.create": "Créer", "global.open": "Ouvrir", "global.name": "Nom", @@ -117,7 +118,14 @@ "form.field.minValue": "La valeur minimum est {{0}}", "form.field.maxValue": "La valeur maximum est {{0}}", "form.field.notAllowedValue": "Valeur non autorisée", - "form.field.specialChars": "Caractères spéciaux autorisés: {{0}}", + "form.field.allowedChars": "Caractères spéciaux autorisés: {{0}}", + "form.field.specialCharsNotAllowed": "Les caractères spéciaux ne sont pas autorisés", + "form.field.spacesNotAllowed": "Les espaces ne sont pas autorisés", + "form.field.requireLowercase": "Le mot de passe doit contenir au moins une lettre minuscule.", + "form.field.requireUppercase": "Le mot de passe doit contenir au moins une lettre majuscule.", + "form.field.requireDigit": "Le mot de passe doit contenir au moins un chiffre.", + "form.field.requireSpecialChars": "Le mot de passe doit contenir au moins un caractère spécial.", + "form.field.requireMinimumLength": "Le mot de passe doit comporter au moins 8 caractères.", "matrix.graphSelector": "Colonnes", "matrix.message.importHint": "Cliquer ou glisser une matrice ici", "matrix.importNewMatrix": "Import d'une nouvelle matrice", @@ -186,6 +194,7 @@ "settings.error.groupRolesSave": "Role(s) pour le groupe '{{0}}' non sauvegardé", "settings.error.tokenSave": "Token '{{0}}' non sauvegardé", "settings.error.updateMaintenance": "Erreur lors du changement du status de maintenance", + "settings.error.passwordMismatch": "Les mots de passe ne correspondent pas", "launcher.additionalModes": "Mode additionnels", "launcher.autoUnzip": "Dézippage automatique", "launcher.xpress": "Xpress (>= 8.3)", diff --git a/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx b/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx index 6a360e2d5e..362866eca5 100644 --- a/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx +++ b/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx @@ -31,10 +31,11 @@ import { import { RoleType, UserDTO } from "../../../../../../common/types"; import { roleToString, sortByName } from "../../../../../../services/utils"; import usePromise from "../../../../../../hooks/usePromise"; -import { getUsers } from "../../../../../../services/api/user"; +import { getGroups, getUsers } from "../../../../../../services/api/user"; import { getAuthUser } from "../../../../../../redux/selectors"; import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import { UseFormReturnPlus } from "../../../../../common/Form/types"; +import { validateString } from "../../../../../../utils/validationUtils"; function GroupForm(props: UseFormReturnPlus) { const { @@ -44,15 +45,23 @@ function GroupForm(props: UseFormReturnPlus) { formState: { errors, defaultValues }, } = props; + const { t } = useTranslation(); + const authUser = useAppSelector(getAuthUser); const userLabelId = useRef(uuidv4()).current; + const [selectedUser, setSelectedUser] = useState(); + const { data: users, isLoading: isUsersLoading } = usePromise(getUsers); + const { data: groups } = usePromise(getGroups); + + const existingGroups = useMemo( + () => groups?.map((group) => group.name), + [groups], + ); + const { fields, append, remove } = useFieldArray({ control, name: "permissions", }); - const [selectedUser, setSelectedUser] = useState(); - const { data: users, isLoading: isUsersLoading } = usePromise(getUsers); - const { t } = useTranslation(); - const authUser = useAppSelector(getAuthUser); + const allowToAddPermission = selectedUser && !getValues("permissions").some( @@ -63,6 +72,7 @@ function GroupForm(props: UseFormReturnPlus) { if (!users) { return []; } + return sortByName( users.filter( (user) => @@ -101,12 +111,11 @@ function GroupForm(props: UseFormReturnPlus) { } fullWidth {...register("name", { - required: t("form.field.required") as string, - validate: (value) => { - if (RESERVED_GROUP_NAMES.includes(value)) { - return t("form.field.notAllowedValue") as string; - } - }, + validate: (v) => + validateString(v, { + existingValues: existingGroups, + excludedValues: RESERVED_GROUP_NAMES, + }) || undefined, })} /> {/* Permissions */} diff --git a/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx b/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx index 43d7f915ef..884b7a118b 100644 --- a/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx +++ b/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx @@ -31,16 +31,18 @@ import { import { GroupDTO, RoleType } from "../../../../../../common/types"; import { roleToString, sortByName } from "../../../../../../services/utils"; import usePromise from "../../../../../../hooks/usePromise"; -import { getGroups } from "../../../../../../services/api/user"; +import { getGroups, getUsers } from "../../../../../../services/api/user"; import { UserFormDialogProps } from "."; import { UseFormReturnPlus } from "../../../../../common/Form/types"; +import { + validatePassword, + validateString, +} from "../../../../../../utils/validationUtils"; interface Props extends UseFormReturnPlus { onlyPermissions?: UserFormDialogProps["onlyPermissions"]; } -const PASSWORD_MIN_LENGTH = 8; - function UserForm(props: Props) { const { control, @@ -50,14 +52,19 @@ function UserForm(props: Props) { onlyPermissions, } = props; + const { t } = useTranslation(); const groupLabelId = useRef(uuidv4()).current; + const [selectedGroup, setSelectedGroup] = useState(); + const { data: groups, isLoading: isGroupsLoading } = usePromise(getGroups); + const { data: users } = usePromise(getUsers); + + const existingUsers = useMemo(() => users?.map(({ name }) => name), [users]); + const { fields, append, remove } = useFieldArray({ control, name: "permissions", }); - const [selectedGroup, setSelectedGroup] = useState(); - const { data: groups, isLoading: isGroupsLoading } = usePromise(getGroups); - const { t } = useTranslation(); + const commonTextFieldProps = { required: true, sx: { mx: 0 }, @@ -104,12 +111,11 @@ function UserForm(props: Props) { helperText={errors.username?.message?.toString()} {...commonTextFieldProps} {...register("username", { - required: t("form.field.required") as string, - validate: (value) => { - if (RESERVED_USER_NAMES.includes(value)) { - return t("form.field.notAllowedValue") as string; - } - }, + validate: (v) => + validateString(v, { + existingValues: existingUsers, + excludedValues: RESERVED_USER_NAMES, + }) || undefined, })} /> validatePassword(v) || undefined, + })} + /> + validatePassword(v, getValues("password")), })} /> diff --git a/webapp/src/components/App/Singlestudy/HomeView/InformationView/CreateVariantDialog.tsx b/webapp/src/components/App/Singlestudy/HomeView/InformationView/CreateVariantDialog.tsx index 68d570a302..5e7c1213f7 100644 --- a/webapp/src/components/App/Singlestudy/HomeView/InformationView/CreateVariantDialog.tsx +++ b/webapp/src/components/App/Singlestudy/HomeView/InformationView/CreateVariantDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import AddCircleIcon from "@mui/icons-material/AddCircle"; @@ -10,6 +10,7 @@ import StringFE from "../../../../common/fieldEditors/StringFE"; import Fieldset from "../../../../common/Fieldset"; import SelectFE from "../../../../common/fieldEditors/SelectFE"; import { SubmitHandlerPlus } from "../../../../common/Form/types"; +import { validateString } from "../../../../../utils/validationUtils"; interface Props { parentId: string; @@ -25,6 +26,11 @@ function CreateVariantDialog(props: Props) { const [sourceList, setSourceList] = useState([]); const defaultValues = { name: "", sourceId: parentId }; + const existingVariants = useMemo( + () => sourceList.map((variant) => variant.name), + [sourceList], + ); + useEffect(() => { setSourceList(createListFromTree(tree)); }, [tree]); @@ -67,8 +73,8 @@ function CreateVariantDialog(props: Props) { name="name" control={control} rules={{ - required: true, - validate: (val) => val.trim().length > 0, + validate: (v) => + validateString(v, { existingValues: existingVariants }), }} /> val.trim().length > 0 }} + rules={{ validate: (v) => validateString(v) }} sx={{ mx: 0 }} fullWidth /> diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/AddDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/AddDialog.tsx index 06056b3b4e..21e174ff96 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/AddDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/AddDialog.tsx @@ -14,6 +14,7 @@ import SelectFE from "../../../../../common/fieldEditors/SelectFE"; import StringFE from "../../../../../common/fieldEditors/StringFE"; import SwitchFE from "../../../../../common/fieldEditors/SwitchFE"; import { StudyMetadata } from "../../../../../../common/types"; +import { validateString } from "../../../../../../utils/validationUtils"; interface Props { studyId: StudyMetadata["id"]; @@ -102,14 +103,8 @@ function AddDialog({ studyId, existingConstraints, open, onClose }: Props) { label={t("global.name")} control={control} rules={{ - validate: (v) => { - if (v.trim().length <= 0) { - return t("form.field.required"); - } - if (existingConstraints.includes(v.trim().toLowerCase())) { - return t("form.field.duplicate", { 0: v }); - } - }, + validate: (v) => + validateString(v, { existingValues: existingConstraints }), }} /> - validateString(v, { existingEntries: existingAreas }), + validateString(v, { existingValues: existingAreas }), }} /> )} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Districts/CreateDistrictDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Districts/CreateDistrictDialog.tsx index e6ddb89687..f8b4191ac2 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Districts/CreateDistrictDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Districts/CreateDistrictDialog.tsx @@ -12,6 +12,7 @@ import useAppDispatch from "../../../../../../../../redux/hooks/useAppDispatch"; import { createStudyMapDistrict } from "../../../../../../../../redux/ducks/studyMaps"; import useAppSelector from "../../../../../../../../redux/hooks/useAppSelector"; import { getStudyMapDistrictsById } from "../../../../../../../../redux/selectors"; +import { validateString } from "../../../../../../../../utils/validationUtils"; interface Props { open: boolean; @@ -32,10 +33,7 @@ function CreateDistrictDialog(props: Props) { const districtsById = useAppSelector(getStudyMapDistrictsById); const existingDistricts = useMemo( - () => - Object.values(districtsById).map((district) => - district.name.toLowerCase(), - ), + () => Object.values(districtsById).map(({ name }) => name), [districtsById], ); @@ -81,15 +79,8 @@ function CreateDistrictDialog(props: Props) { control={control} fullWidth rules={{ - required: { value: true, message: t("form.field.required") }, - validate: (v) => { - if (v.trim().length <= 0) { - return false; - } - if (existingDistricts.includes(v.toLowerCase())) { - return `The District "${v}" already exists`; - } - }, + validate: (v) => + validateString(v, { existingValues: existingDistricts }), }} sx={{ m: 0 }} /> diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/CreateLayerDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/CreateLayerDialog.tsx index 48071a7b57..46c01c2b45 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/CreateLayerDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/CreateLayerDialog.tsx @@ -12,6 +12,7 @@ import useAppDispatch from "../../../../../../../../redux/hooks/useAppDispatch"; import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; import useAppSelector from "../../../../../../../../redux/hooks/useAppSelector"; import { getStudyMapLayersById } from "../../../../../../../../redux/selectors"; +import { validateString } from "../../../../../../../../utils/validationUtils"; interface Props { open: boolean; @@ -31,7 +32,7 @@ function CreateLayerDialog(props: Props) { const layersById = useAppSelector(getStudyMapLayersById); const existingLayers = useMemo( - () => Object.values(layersById).map((layer) => layer.name.toLowerCase()), + () => Object.values(layersById).map(({ name }) => name), [layersById], ); @@ -73,15 +74,8 @@ function CreateLayerDialog(props: Props) { control={control} fullWidth rules={{ - required: { value: true, message: t("form.field.required") }, - validate: (v) => { - if (v.trim().length <= 0) { - return false; - } - if (existingLayers.includes(v.toLowerCase())) { - return `The layer "${v}" already exists`; - } - }, + validate: (v) => + validateString(v, { existingValues: existingLayers }), }} /> )} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/UpdateLayerDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/UpdateLayerDialog.tsx index 1160b309bc..aa1c97b4eb 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/UpdateLayerDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/MapConfig/Layers/UpdateLayerDialog.tsx @@ -17,6 +17,7 @@ import { updateStudyMapLayer, } from "../../../../../../../../redux/ducks/studyMaps"; import useAppDispatch from "../../../../../../../../redux/hooks/useAppDispatch"; +import { validateString } from "../../../../../../../../utils/validationUtils"; interface Props { open: boolean; @@ -44,7 +45,7 @@ function UpdateLayerDialog(props: Props) { })); const existingLayers = useMemo( - () => Object.values(layersById).map((layer) => layer.name.toLowerCase()), + () => Object.values(layersById).map(({ name }) => name), [layersById], ); @@ -56,6 +57,7 @@ function UpdateLayerDialog(props: Props) { data: SubmitHandlerPlus, ) => { const { layerId, name } = data.values; + if (layerId && name) { return dispatch(updateStudyMapLayer({ studyId: study.id, layerId, name })) .unwrap() @@ -67,6 +69,7 @@ function UpdateLayerDialog(props: Props) { if (layerId) { dispatch(deleteStudyMapLayer({ studyId: study.id, layerId })); } + setOpenConfirmationModal(false); onClose(); }; @@ -86,7 +89,7 @@ function UpdateLayerDialog(props: Props) { defaultValues, }} > - {({ control, setValue, getValues }) => ( + {({ control, getValues, reset }) => (
- setValue("name", layersById[String(e.target.value)].name) - } + onChange={({ target: { value } }) => { + reset({ + layerId: value as string, + name: layersById[value as string].name, + }); + }} /> { - if (v.trim().length <= 0) { - return false; - } - if (existingLayers.includes(v.toLowerCase())) { - return `The Layer "${v}" already exists`; - } - }, + validate: (v) => + validateString(v, { + existingValues: existingLayers, + // Excludes the current layer's original name to allow edits without false duplicates. + editedValue: layersById[getValues("layerId")].name, + }), }} disabled={getValues("layerId") === ""} sx={{ mx: 0 }} diff --git a/webapp/src/components/App/Singlestudy/explore/TableModeList/dialogs/TableTemplateFormDialog.tsx b/webapp/src/components/App/Singlestudy/explore/TableModeList/dialogs/TableTemplateFormDialog.tsx index e5cbbd9a92..8517268e45 100644 --- a/webapp/src/components/App/Singlestudy/explore/TableModeList/dialogs/TableTemplateFormDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/TableModeList/dialogs/TableTemplateFormDialog.tsx @@ -9,6 +9,8 @@ import SelectFE from "../../../../../common/fieldEditors/SelectFE"; import StringFE from "../../../../../common/fieldEditors/StringFE"; import { getTableColumnsForType, type TableTemplate } from "../utils"; import { TABLE_MODE_TYPES } from "../../../../../../services/api/studies/tableMode/constants"; +import { validateString } from "../../../../../../utils/validationUtils"; +import { useMemo } from "react"; export interface TableTemplateFormDialogProps extends Pick< @@ -23,6 +25,15 @@ function TableTemplateFormDialog(props: TableTemplateFormDialogProps) { props; const { t } = useTranslation(); + const existingTables = useMemo( + () => templates.map(({ name }) => name), + [templates], + ); + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + return ( { - const id = getValues("id"); - const hasDuplicate = templates.find( - (tp) => tp.id !== id && tp.name.trim() === value.trim(), - ); - if (hasDuplicate) { - return t("form.field.notAllowedValue") as string; - } - }, - required: true, + validate: (v) => + validateString(v, { + existingValues: existingTables, + editedValue: config?.defaultValues?.name, + }), }} /> void; onSave: (candidate: XpansionCandidate) => void; + candidates: XpansionCandidate[]; } function CreateCandidateDialog(props: PropType) { - const { open, links, onClose, onSave } = props; + const { open, links, onClose, onSave, candidates } = props; const [t] = useTranslation(); const [isToggled, setIsToggled] = useState(true); + const existingCandidates = useMemo( + () => candidates.map(({ name }) => name), + [candidates], + ); + //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// @@ -70,7 +77,14 @@ function CreateCandidateDialog(props: PropType) { label={t("global.name")} name="name" control={control} - rules={{ required: t("form.field.required") }} + rules={{ + validate: (v) => + validateString(v, { + existingValues: existingCandidates, + allowSpaces: false, + allowedChars: "&_*", + }), + }} sx={{ mx: 0 }} /> )} {!!capacityViewDialog && ( diff --git a/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx b/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx index d85cd669fd..5c8313a352 100644 --- a/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx +++ b/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx @@ -7,6 +7,7 @@ import { SubmitHandlerPlus } from "../Form/types"; import SelectFE from "../fieldEditors/SelectFE"; import { nameToId } from "../../../services/utils"; import { TRow } from "./utils"; +import { validateString } from "../../../utils/validationUtils"; interface Props { open: boolean; @@ -65,19 +66,8 @@ function CreateDialog({ control={control} fullWidth rules={{ - required: { value: true, message: t("form.field.required") }, - validate: (v) => { - const regex = /^[a-zA-Z0-9_\-() &]+$/; - if (!regex.test(v.trim())) { - return t("form.field.specialChars", { 0: "&()_-" }); - } - if (v.trim().length <= 0) { - return t("form.field.required"); - } - if (existingNames.includes(v.trim().toLowerCase())) { - return t("form.field.duplicate", { 0: v }); - } - }, + validate: (v) => + validateString(v, { existingValues: existingNames }), }} sx={{ m: 0 }} /> diff --git a/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx b/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx index 93daa1a3bc..34664b8f3a 100644 --- a/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx +++ b/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx @@ -4,6 +4,7 @@ import Fieldset from "../Fieldset"; import FormDialog from "../dialogs/FormDialog"; import { SubmitHandlerPlus } from "../Form/types"; import StringFE from "../fieldEditors/StringFE"; +import { validateString } from "../../../utils/validationUtils"; interface Props { open: boolean; @@ -51,19 +52,8 @@ function DuplicateDialog(props: Props) { control={control} fullWidth rules={{ - required: { value: true, message: t("form.field.required") }, - validate: (v) => { - const regex = /^[a-zA-Z0-9_\-() &]+$/; - if (!regex.test(v.trim())) { - return t("form.field.specialChars", { 0: "&()_-" }); - } - if (v.trim().length <= 0) { - return t("form.field.required"); - } - if (existingNames.includes(v.trim().toLowerCase())) { - return t("form.field.duplicate", { 0: v }); - } - }, + validate: (v) => + validateString(v, { existingValues: existingNames }), }} sx={{ m: 0 }} /> diff --git a/webapp/src/utils/validationUtils.ts b/webapp/src/utils/validationUtils.ts index 438236f617..656d8e17f7 100644 --- a/webapp/src/utils/validationUtils.ts +++ b/webapp/src/utils/validationUtils.ts @@ -1,45 +1,59 @@ import { t } from "i18next"; +//////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////// + interface ValidationOptions { - pattern?: RegExp; - existingEntries?: string[]; - excludedEntries?: string[]; + existingValues?: string[]; + excludedValues?: string[]; isCaseSensitive?: boolean; + allowSpecialChars?: boolean; + allowedChars?: string; + allowSpaces?: boolean; + editedValue?: string; min?: number; max?: number; } +//////////////////////////////////////////////////////////////// +// Validators +//////////////////////////////////////////////////////////////// + /** - * Validates a single string value against specified options. + * Validates a single string value against specified criteria. + * + * Validates the input string against a variety of checks including length restrictions, + * character validations, and uniqueness against provided arrays of existing and excluded values. * - * @param {string} value - The string to validate, leading/trailing spaces will be trimmed. - * @param {ValidationOptions} [options] - Customizable options for validation. - * - `pattern`: RegExp for matching the string. Default to alphanumeric, spaces and "&()_-" pattern. - * - `existingEntries`: Array of strings to check for duplicates. Optional, case-insensitive by default. - * - `excludedEntries`: Array of strings that are explicitly not allowed. - * - `isCaseSensitive`: Default to case-insensitive comparison with `existingEntries`. e.g: "A" and "a" are considered the same. - * - `min`: Minimum length required. Defaults to 0. - * - `max`: Maximum allowed length. Defaults to 50. - * @returns {string | true} - True if validation is successful, or a localized error message if it fails. + * @param value - The string to validate. Leading and trailing spaces will be trimmed. + * @param options - Configuration options for validation. + * @param options.existingValues - An array of strings to check against for duplicates. Comparison is case-insensitive by default. + * @param options.excludedValues - An array of strings that the value should not match. + * @param options.isCaseSensitive - Whether the comparison with `existingValues` and `excludedValues` is case-sensitive. Defaults to false. + * @param options.allowSpecialChars - Flags if special characters are permitted in the value. + * @param options.allowedChars - A string representing additional allowed characters outside the typical alphanumeric scope. + * @param options.allowSpaces - Flags if spaces are allowed in the value. + * @param options.editedValue - The current value being edited, to exclude it from duplicate checks. + * @param options.min - Minimum length required for the string. Defaults to 0. + * @param options.max - Maximum allowed length for the string. Defaults to 255. + * @returns True if validation is successful, or a localized error message if it fails. */ -export const validateString = ( +export function validateString( value: string, options?: ValidationOptions, -): string | true => { +): string | true { const { - pattern, - existingEntries = [], - excludedEntries = [], - isCaseSensitive, - min, - max, - } = { - pattern: /^[a-zA-Z0-9_\-() &]+$/, - isCaseSensitive: false, - min: 0, - max: 50, - ...options, - }; + existingValues = [], + excludedValues = [], + isCaseSensitive = false, + allowSpecialChars = true, + allowSpaces = true, + allowedChars = "&()_-", + editedValue = "", + min = 0, + max = 255, + } = options || {}; const trimmedValue = value.trim(); @@ -47,8 +61,8 @@ export const validateString = ( return t("form.field.required"); } - if (!pattern.test(trimmedValue)) { - return t("form.field.specialChars", { 0: "&()_-" }); + if (!allowSpaces && trimmedValue.includes(" ")) { + return t("form.field.spacesNotAllowed"); } if (trimmedValue.length < min) { @@ -59,22 +73,120 @@ export const validateString = ( return t("form.field.maxValue", { 0: max }); } - const normalize = (entry: string) => - isCaseSensitive ? entry.trim() : entry.toLowerCase().trim(); + // Compiles a regex pattern based on allowed characters and flags. + const allowedCharsPattern = new RegExp( + generatePattern(allowSpaces, allowSpecialChars, allowedChars), + ); + + // Validates the string against the allowed characters regex. + if (!allowedCharsPattern.test(trimmedValue)) { + return allowSpecialChars + ? t("form.field.allowedChars", { 0: allowedChars }) + : t("form.field.specialCharsNotAllowed"); + } - const comparisonArray = existingEntries.map(normalize); + // Normalize the value for comparison, based on case sensitivity option. + const normalize = (v: string) => + isCaseSensitive ? v.trim() : v.toLowerCase().trim(); + // Prepare the value for duplicate and exclusion checks. const comparisonValue = normalize(trimmedValue); - if (comparisonArray.includes(comparisonValue)) { - return t("form.field.duplicate", { 0: value }); + // Some forms requires to keep the original value while updating other fields. + if (normalize(editedValue) === comparisonValue) { + return true; } - const normalizedExcludedValues = excludedEntries.map(normalize); + // Check for duplication against existing values. + if (existingValues.map(normalize).includes(comparisonValue)) { + return t("form.field.duplicate", { 0: value }); + } - if (normalizedExcludedValues.includes(comparisonValue)) { + // Check for inclusion in the list of excluded values. + if (excludedValues.map(normalize).includes(comparisonValue)) { return t("form.field.notAllowedValue", { 0: value }); } return true; -}; +} + +/** + * Validates a password string for strong security criteria. + * + * @param password - The password to validate. + * @param confirmPassword - An optional second password to compare against the first for matching. + * @returns True if validation is successful, or a localized error message if it fails. + */ +export function validatePassword( + password: string, + confirmPassword?: string, +): string | true { + const trimmedPassword = password.trim(); + + if (!trimmedPassword) { + return t("form.field.required"); + } + + if (!/(?=.*[a-z])/.test(trimmedPassword)) { + return t("form.field.requireLowercase"); + } + + if (!/(?=.*[A-Z])/.test(trimmedPassword)) { + return t("form.field.requireUppercase"); + } + + if (!/(?=.*\d)/.test(trimmedPassword)) { + return t("form.field.requireDigit"); + } + + if (!/(?=.*[^\w\s])/.test(trimmedPassword)) { + return t("form.field.requireSpecialChars"); + } + + if (trimmedPassword.length < 8) { + return t("form.field.minValue", { 0: 8 }); + } + + if (trimmedPassword.length > 30) { + return t("form.field.maxValue", { 0: 30 }); + } + + if ( + confirmPassword !== undefined && + trimmedPassword !== confirmPassword.trim() + ) { + return t("settings.error.passwordMismatch"); + } + + return true; +} + +//////////////////////////////////////////////////////////////// +// Utils +//////////////////////////////////////////////////////////////// + +// Function to escape special characters in allowedChars +const escapeSpecialChars = (chars: string) => + chars.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"); + +/** + * Generates a regular expression pattern for string validation based on specified criteria. + * This pattern includes considerations for allowing spaces, special characters, and any additional + * characters specified in `allowedChars`. + * + * @param allowSpaces - Indicates if spaces are permitted in the string. + * @param allowSpecialChars - Indicates if special characters are permitted. + * @param allowedChars - Specifies additional characters to allow in the string. + * @returns The regular expression pattern as a string. + */ +function generatePattern( + allowSpaces: boolean, + allowSpecialChars: boolean, + allowedChars: string, +): string { + const basePattern = "^[a-zA-Z0-9"; + const spacePattern = allowSpaces ? " " : ""; + const specialCharsPattern = + allowSpecialChars && allowedChars ? escapeSpecialChars(allowedChars) : ""; + return basePattern + spacePattern + specialCharsPattern + "]*$"; +} From d380bf7a269dda837253984cfe46071f3941a91e Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Tue, 12 Mar 2024 07:35:02 +0100 Subject: [PATCH 5/6] fix(ui-utils): optimize `validatePassword` regex to mitigate potential backtracking issues --- webapp/src/utils/validationUtils.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/webapp/src/utils/validationUtils.ts b/webapp/src/utils/validationUtils.ts index 656d8e17f7..3d771356e6 100644 --- a/webapp/src/utils/validationUtils.ts +++ b/webapp/src/utils/validationUtils.ts @@ -127,28 +127,28 @@ export function validatePassword( return t("form.field.required"); } - if (!/(?=.*[a-z])/.test(trimmedPassword)) { - return t("form.field.requireLowercase"); + if (trimmedPassword.length < 8) { + return t("form.field.minValue", { 0: 8 }); } - if (!/(?=.*[A-Z])/.test(trimmedPassword)) { - return t("form.field.requireUppercase"); + if (trimmedPassword.length > 50) { + return t("form.field.maxValue", { 0: 50 }); } - if (!/(?=.*\d)/.test(trimmedPassword)) { - return t("form.field.requireDigit"); + if (!/[a-z]/.test(trimmedPassword)) { + return t("form.field.requireLowercase"); } - if (!/(?=.*[^\w\s])/.test(trimmedPassword)) { - return t("form.field.requireSpecialChars"); + if (!/[A-Z]/.test(trimmedPassword)) { + return t("form.field.requireUppercase"); } - if (trimmedPassword.length < 8) { - return t("form.field.minValue", { 0: 8 }); + if (!/\d/.test(trimmedPassword)) { + return t("form.field.requireDigit"); } - if (trimmedPassword.length > 30) { - return t("form.field.maxValue", { 0: 30 }); + if (!/[^\w\s]/.test(trimmedPassword)) { + return t("form.field.requireSpecialChars"); } if ( From c59d8104e1254b6b4dd7215bcb94efb2d5684fed Mon Sep 17 00:00:00 2001 From: hatim dinia Date: Wed, 13 Mar 2024 14:33:03 +0100 Subject: [PATCH 6/6] refactor(ui): polish `validatePassword` and add minor optimizations --- webapp/public/locales/en/main.json | 15 ++--- webapp/public/locales/fr/main.json | 15 ++--- .../dialog/GroupFormDialog/GroupForm.tsx | 2 +- .../Users/dialog/UserFormDialog/UserForm.tsx | 10 +-- .../Candidates/CreateCandidateDialog.tsx | 2 +- webapp/src/utils/validationUtils.ts | 64 ++++++++----------- 6 files changed, 49 insertions(+), 59 deletions(-) diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 26965fd8b3..4c540dfb4b 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -27,7 +27,6 @@ "global.group": "Group", "global.variants": "Variants management", "global.password": "Password", - "global.confirmPassword": "Confirm password", "global.create": "Create", "global.open": "Open", "global.name": "Name", @@ -118,14 +117,13 @@ "form.field.minValue": "The minimum value is {{0}}", "form.field.maxValue": "The maximum value is {{0}}", "form.field.notAllowedValue": "Not allowed value", - "form.field.allowedChars": "Special characters allowed: {{0}}", + "form.field.specialChars": "Special characters allowed: {{0}}", "form.field.specialCharsNotAllowed": "Special characters are not allowed", "form.field.spacesNotAllowed": "Spaces are not allowed", - "form.field.requireLowercase": "Password must contain at least one lowercase letter.", - "form.field.requireUppercase": "Password must contain at least one uppercase letter.", - "form.field.requireDigit": "Password must contain at least one digit.", - "form.field.requireSpecialChars": "Password must contain at least one special character.", - "form.field.requireMinimumLength": "Password must be at least 8 characters long.", + "form.field.requireLowercase": "Must contain at least one lowercase letter.", + "form.field.requireUppercase": "Must contain at least one uppercase letter.", + "form.field.requireDigit": "Must contain at least one digit.", + "form.field.requireSpecialChars": "Must contain at least one special character.", "matrix.graphSelector": "Columns", "matrix.message.importHint": "Click or drag and drop a matrix here", "matrix.importNewMatrix": "Import a new matrix", @@ -194,7 +192,8 @@ "settings.error.groupRolesSave": "Role(s) for group '{{0}}' not saved", "settings.error.tokenSave": "'{{0}}' token not saved", "settings.error.updateMaintenance": "Maintenance mode not updated", - "settings.error.passwordMismatch": "Passwords do not match", + "settings.user.form.confirmPassword":"Confirm password", + "settings.user.form.error.passwordMismatch": "Passwords do not match", "launcher.additionalModes": "Additional modes", "launcher.autoUnzip": "Automatically unzip", "launcher.xpress": "Xpress (>= 8.3)", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 85edfce9bb..2e79cffd7e 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -27,7 +27,6 @@ "global.group": "Groupe", "global.variants": "Gestion des variantes", "global.password": "Mot de passe", - "global.confirmPassword": "Confirmer le mot de passe", "global.create": "Créer", "global.open": "Ouvrir", "global.name": "Nom", @@ -118,14 +117,13 @@ "form.field.minValue": "La valeur minimum est {{0}}", "form.field.maxValue": "La valeur maximum est {{0}}", "form.field.notAllowedValue": "Valeur non autorisée", - "form.field.allowedChars": "Caractères spéciaux autorisés: {{0}}", + "form.field.specialChars": "Caractères spéciaux autorisés: {{0}}", "form.field.specialCharsNotAllowed": "Les caractères spéciaux ne sont pas autorisés", "form.field.spacesNotAllowed": "Les espaces ne sont pas autorisés", - "form.field.requireLowercase": "Le mot de passe doit contenir au moins une lettre minuscule.", - "form.field.requireUppercase": "Le mot de passe doit contenir au moins une lettre majuscule.", - "form.field.requireDigit": "Le mot de passe doit contenir au moins un chiffre.", - "form.field.requireSpecialChars": "Le mot de passe doit contenir au moins un caractère spécial.", - "form.field.requireMinimumLength": "Le mot de passe doit comporter au moins 8 caractères.", + "form.field.requireLowercase": "Doit contenir au moins une lettre minuscule.", + "form.field.requireUppercase": "Doit contenir au moins une lettre majuscule.", + "form.field.requireDigit": "Doit contenir au moins un chiffre.", + "form.field.requireSpecialChars": "Doit contenir au moins un caractère spécial.", "matrix.graphSelector": "Colonnes", "matrix.message.importHint": "Cliquer ou glisser une matrice ici", "matrix.importNewMatrix": "Import d'une nouvelle matrice", @@ -194,7 +192,8 @@ "settings.error.groupRolesSave": "Role(s) pour le groupe '{{0}}' non sauvegardé", "settings.error.tokenSave": "Token '{{0}}' non sauvegardé", "settings.error.updateMaintenance": "Erreur lors du changement du status de maintenance", - "settings.error.passwordMismatch": "Les mots de passe ne correspondent pas", + "settings.user.form.confirmPassword": "Confirmer le mot de passe", + "settings.user.form.error.passwordMismatch": "Les mots de passe ne correspondent pas", "launcher.additionalModes": "Mode additionnels", "launcher.autoUnzip": "Dézippage automatique", "launcher.xpress": "Xpress (>= 8.3)", diff --git a/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx b/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx index 362866eca5..e17dee147b 100644 --- a/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx +++ b/webapp/src/components/App/Settings/Groups/dialog/GroupFormDialog/GroupForm.tsx @@ -115,7 +115,7 @@ function GroupForm(props: UseFormReturnPlus) { validateString(v, { existingValues: existingGroups, excludedValues: RESERVED_GROUP_NAMES, - }) || undefined, + }), })} /> {/* Permissions */} diff --git a/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx b/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx index 884b7a118b..59acfc6dc5 100644 --- a/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx +++ b/webapp/src/components/App/Settings/Users/dialog/UserFormDialog/UserForm.tsx @@ -115,7 +115,7 @@ function UserForm(props: Props) { validateString(v, { existingValues: existingUsers, excludedValues: RESERVED_USER_NAMES, - }) || undefined, + }), })} /> validatePassword(v) || undefined, + validate: (v) => validatePassword(v), })} /> validatePassword(v, getValues("password")), + validate: (v) => + v === getValues("password") || + t("settings.user.form.error.passwordMismatch"), })} /> diff --git a/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/CreateCandidateDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/CreateCandidateDialog.tsx index 58f7451a20..eca88218e4 100644 --- a/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/CreateCandidateDialog.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/CreateCandidateDialog.tsx @@ -82,7 +82,7 @@ function CreateCandidateDialog(props: PropType) { validateString(v, { existingValues: existingCandidates, allowSpaces: false, - allowedChars: "&_*", + specialChars: "&_*", }), }} sx={{ mx: 0 }} diff --git a/webapp/src/utils/validationUtils.ts b/webapp/src/utils/validationUtils.ts index 3d771356e6..94f1f95c30 100644 --- a/webapp/src/utils/validationUtils.ts +++ b/webapp/src/utils/validationUtils.ts @@ -9,7 +9,7 @@ interface ValidationOptions { excludedValues?: string[]; isCaseSensitive?: boolean; allowSpecialChars?: boolean; - allowedChars?: string; + specialChars?: string; allowSpaces?: boolean; editedValue?: string; min?: number; @@ -27,16 +27,16 @@ interface ValidationOptions { * character validations, and uniqueness against provided arrays of existing and excluded values. * * @param value - The string to validate. Leading and trailing spaces will be trimmed. - * @param options - Configuration options for validation. - * @param options.existingValues - An array of strings to check against for duplicates. Comparison is case-insensitive by default. - * @param options.excludedValues - An array of strings that the value should not match. - * @param options.isCaseSensitive - Whether the comparison with `existingValues` and `excludedValues` is case-sensitive. Defaults to false. - * @param options.allowSpecialChars - Flags if special characters are permitted in the value. - * @param options.allowedChars - A string representing additional allowed characters outside the typical alphanumeric scope. - * @param options.allowSpaces - Flags if spaces are allowed in the value. - * @param options.editedValue - The current value being edited, to exclude it from duplicate checks. - * @param options.min - Minimum length required for the string. Defaults to 0. - * @param options.max - Maximum allowed length for the string. Defaults to 255. + * @param options - Configuration options for validation. (Optional) + * @param [options.existingValues=[]] - An array of strings to check against for duplicates. Comparison is case-insensitive by default. + * @param [options.excludedValues=[]] - An array of strings that the value should not match. + * @param [options.isCaseSensitive=false] - Whether the comparison with `existingValues` and `excludedValues` is case-sensitive. Defaults to false. + * @param [options.allowSpecialChars=true] - Flags if special characters are permitted in the value. + * @param [options.specialChars="&()_-"] - A string representing additional allowed characters outside the typical alphanumeric scope. + * @param [options.allowSpaces=true] - Flags if spaces are allowed in the value. + * @param [options.editedValue=""] - The current value being edited, to exclude it from duplicate checks. + * @param [options.min=0] - Minimum length required for the string. Defaults to 0. + * @param [options.max=255] - Maximum allowed length for the string. Defaults to 255. * @returns True if validation is successful, or a localized error message if it fails. */ export function validateString( @@ -49,7 +49,7 @@ export function validateString( isCaseSensitive = false, allowSpecialChars = true, allowSpaces = true, - allowedChars = "&()_-", + specialChars = "&()_-", editedValue = "", min = 0, max = 255, @@ -74,15 +74,15 @@ export function validateString( } // Compiles a regex pattern based on allowed characters and flags. - const allowedCharsPattern = new RegExp( - generatePattern(allowSpaces, allowSpecialChars, allowedChars), + const specialCharsPattern = new RegExp( + generatePattern(allowSpaces, allowSpecialChars, specialChars), ); // Validates the string against the allowed characters regex. - if (!allowedCharsPattern.test(trimmedValue)) { - return allowSpecialChars - ? t("form.field.allowedChars", { 0: allowedChars }) - : t("form.field.specialCharsNotAllowed"); + if (!specialCharsPattern.test(trimmedValue)) { + return specialChars === "" || !allowSpecialChars + ? t("form.field.specialCharsNotAllowed") + : t("form.field.specialChars", { 0: specialChars }); } // Normalize the value for comparison, based on case sensitivity option. @@ -114,13 +114,9 @@ export function validateString( * Validates a password string for strong security criteria. * * @param password - The password to validate. - * @param confirmPassword - An optional second password to compare against the first for matching. * @returns True if validation is successful, or a localized error message if it fails. */ -export function validatePassword( - password: string, - confirmPassword?: string, -): string | true { +export function validatePassword(password: string): string | true { const trimmedPassword = password.trim(); if (!trimmedPassword) { @@ -151,13 +147,6 @@ export function validatePassword( return t("form.field.requireSpecialChars"); } - if ( - confirmPassword !== undefined && - trimmedPassword !== confirmPassword.trim() - ) { - return t("settings.error.passwordMismatch"); - } - return true; } @@ -165,28 +154,29 @@ export function validatePassword( // Utils //////////////////////////////////////////////////////////////// -// Function to escape special characters in allowedChars -const escapeSpecialChars = (chars: string) => - chars.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"); +// Escape special characters in specialChars +function escapeSpecialChars(chars: string) { + return chars.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"); +} /** * Generates a regular expression pattern for string validation based on specified criteria. * This pattern includes considerations for allowing spaces, special characters, and any additional - * characters specified in `allowedChars`. + * characters specified in `specialChars`. * * @param allowSpaces - Indicates if spaces are permitted in the string. * @param allowSpecialChars - Indicates if special characters are permitted. - * @param allowedChars - Specifies additional characters to allow in the string. + * @param specialChars - Specifies additional characters to allow in the string. * @returns The regular expression pattern as a string. */ function generatePattern( allowSpaces: boolean, allowSpecialChars: boolean, - allowedChars: string, + specialChars: string, ): string { const basePattern = "^[a-zA-Z0-9"; const spacePattern = allowSpaces ? " " : ""; const specialCharsPattern = - allowSpecialChars && allowedChars ? escapeSpecialChars(allowedChars) : ""; + allowSpecialChars && specialChars ? escapeSpecialChars(specialChars) : ""; return basePattern + spacePattern + specialCharsPattern + "]*$"; }