diff --git a/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx b/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx index 270a81fee5b..b375be76ca9 100644 --- a/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx @@ -8,6 +8,7 @@ import { Button, getButtonStyling } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { InstanceWorkspaceService } from "@plane/services"; import type { IWorkspace } from "@plane/types"; +import { validateSlug, validateWorkspaceName } from "@plane/utils"; // components import { CustomSelect, Input } from "@plane/ui"; // hooks @@ -90,14 +91,7 @@ export function WorkspaceCreateForm() { control={control} name="name" rules={{ - required: "This is a required field.", - validate: (value) => - /^[\w\s-]*$/.test(value) || - `Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`, - maxLength: { - value: 80, - message: "Limit your name to 80 characters.", - }, + validate: (value) => validateWorkspaceName(value, true), }} render={({ field: { value, ref, onChange } }) => ( validateSlug(value), }} render={({ field: { onChange, value, ref } }) => ( handleFormChange("first_name", e.target.value)} - autoComplete="on" + onChange={(e) => { + const validation = validatePersonName(e.target.value); + if (validation === true || e.target.value === "") { + handleFormChange("first_name", e.target.value); + } + }} + autoComplete="off" autoFocus + maxLength={50} />
@@ -184,8 +190,14 @@ export function InstanceSetupForm() { inputSize="md" placeholder="Wright" value={formData.last_name} - onChange={(e) => handleFormChange("last_name", e.target.value)} - autoComplete="on" + onChange={(e) => { + const validation = validatePersonName(e.target.value); + if (validation === true || e.target.value === "") { + handleFormChange("last_name", e.target.value); + } + }} + autoComplete="off" + maxLength={50} />
@@ -223,7 +235,13 @@ export function InstanceSetupForm() { inputSize="md" placeholder="Company name" value={formData.company_name} - onChange={(e) => handleFormChange("company_name", e.target.value)} + onChange={(e) => { + const validation = validateCompanyName(e.target.value, false); + if (validation === true || e.target.value === "") { + handleFormChange("company_name", e.target.value); + } + }} + maxLength={80} /> diff --git a/apps/web/core/components/onboarding/create-workspace.tsx b/apps/web/core/components/onboarding/create-workspace.tsx index 4f145e1befc..9fd2df5147e 100644 --- a/apps/web/core/components/onboarding/create-workspace.tsx +++ b/apps/web/core/components/onboarding/create-workspace.tsx @@ -10,6 +10,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IUser, IWorkspace, TOnboardingSteps } from "@plane/types"; // ui import { CustomSelect, Input, Spinner } from "@plane/ui"; +import { validateWorkspaceName, validateSlug } from "@plane/utils"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUserProfile, useUserSettings } from "@/hooks/store/user"; @@ -132,8 +133,7 @@ export const CreateWorkspace = observer(function CreateWorkspace(props: Props) { name="name" rules={{ required: t("common.errors.required"), - validate: (value) => - /^[\w\s-]*$/.test(value) || t("workspace_creation.errors.validation.name_alphanumeric"), + validate: (value) => validateWorkspaceName(value, true), maxLength: { value: 80, message: t("workspace_creation.errors.validation.name_length"), @@ -194,7 +194,8 @@ export const CreateWorkspace = observer(function CreateWorkspace(props: Props) { type="text" value={value.toLocaleLowerCase().trim().replace(/ /g, "-")} onChange={(e) => { - if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false); + const validation = validateSlug(e.target.value); + if (validation === true) setInvalidSlug(false); else setInvalidSlug(true); onChange(e.target.value.toLowerCase()); }} diff --git a/apps/web/core/components/onboarding/profile-setup.tsx b/apps/web/core/components/onboarding/profile-setup.tsx index 0374a8ee05d..a8989e24dc1 100644 --- a/apps/web/core/components/onboarding/profile-setup.tsx +++ b/apps/web/core/components/onboarding/profile-setup.tsx @@ -11,7 +11,7 @@ import type { IUser, TUserProfile, TOnboardingSteps } from "@plane/types"; // ui import { Input, PasswordStrengthIndicator, Spinner } from "@plane/ui"; // components -import { cn, getFileURL, getPasswordStrength } from "@plane/utils"; +import { cn, getFileURL, getPasswordStrength, validatePersonName } from "@plane/utils"; import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal"; // hooks import { useUser, useUserProfile } from "@/hooks/store/user"; @@ -297,9 +297,10 @@ export const ProfileSetup = observer(function ProfileSetup(props: Props) { name="first_name" rules={{ required: "First name is required", + validate: validatePersonName, maxLength: { - value: 24, - message: "First name must be within 24 characters.", + value: 50, + message: "First name must be within 50 characters.", }, }} render={({ field: { value, onChange, ref } }) => ( @@ -334,9 +335,10 @@ export const ProfileSetup = observer(function ProfileSetup(props: Props) { name="last_name" rules={{ required: "Last name is required", + validate: validatePersonName, maxLength: { - value: 24, - message: "Last name must be within 24 characters.", + value: 50, + message: "Last name must be within 50 characters.", }, }} render={({ field: { value, onChange, ref } }) => ( diff --git a/apps/web/core/components/onboarding/steps/profile/root.tsx b/apps/web/core/components/onboarding/steps/profile/root.tsx index 06a10624909..633f49a7f21 100644 --- a/apps/web/core/components/onboarding/steps/profile/root.tsx +++ b/apps/web/core/components/onboarding/steps/profile/root.tsx @@ -8,7 +8,7 @@ import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IUser } from "@plane/types"; import { EOnboardingSteps } from "@plane/types"; -import { cn, getFileURL, getPasswordStrength } from "@plane/utils"; +import { cn, getFileURL, getPasswordStrength, validatePersonName } from "@plane/utils"; // components import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal"; // hooks @@ -202,9 +202,10 @@ export const ProfileSetupStep = observer(function ProfileSetupStep({ handleStepC name="first_name" rules={{ required: "Name is required", + validate: validatePersonName, maxLength: { - value: 24, - message: "Name must be within 24 characters.", + value: 50, + message: "Name must be within 50 characters.", }, }} render={({ field: { value, onChange, ref } }) => ( diff --git a/apps/web/core/components/onboarding/steps/workspace/create.tsx b/apps/web/core/components/onboarding/steps/workspace/create.tsx index 6100f75c1a8..f1cc09236fc 100644 --- a/apps/web/core/components/onboarding/steps/workspace/create.tsx +++ b/apps/web/core/components/onboarding/steps/workspace/create.tsx @@ -9,7 +9,7 @@ import { Button } from "@plane/propel/button"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IUser, IWorkspace } from "@plane/types"; import { Spinner } from "@plane/ui"; -import { cn } from "@plane/utils"; +import { cn, validateWorkspaceName, validateSlug } from "@plane/utils"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; import { useUserProfile, useUserSettings } from "@/hooks/store/user"; @@ -139,8 +139,7 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({ name="name" rules={{ required: t("common.errors.required"), - validate: (value) => - /^[\w\s-]*$/.test(value) || t("workspace_creation.errors.validation.name_alphanumeric"), + validate: (value) => validateWorkspaceName(value, true), maxLength: { value: 80, message: t("workspace_creation.errors.validation.name_length"), @@ -213,7 +212,8 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({ type="text" value={value.toLocaleLowerCase().trim().replace(/ /g, "-")} onChange={(e) => { - if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false); + const validation = validateSlug(e.target.value); + if (validation === true) setInvalidSlug(false); else setInvalidSlug(true); onChange(e.target.value.toLowerCase()); }} diff --git a/apps/web/core/components/settings/profile/content/pages/general/form.tsx b/apps/web/core/components/settings/profile/content/pages/general/form.tsx index 8d47033da3c..80b58a6f842 100644 --- a/apps/web/core/components/settings/profile/content/pages/general/form.tsx +++ b/apps/web/core/components/settings/profile/content/pages/general/form.tsx @@ -22,6 +22,8 @@ import { handleCoverImageChange } from "@/helpers/cover-image.helper"; // hooks import { useInstance } from "@/hooks/store/use-instance"; import { useUser, useUserProfile } from "@/hooks/store/user"; +// utils +import { validatePersonName, validateDisplayName } from "@plane/utils"; type TUserProfileForm = { avatar_url: string; @@ -254,6 +256,7 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin name="first_name" rules={{ required: "Please enter first name", + validate: validatePersonName, }} render={({ field: { value, onChange, ref } }) => ( )} @@ -278,6 +281,9 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin ( )} /> + {errors.last_name && {errors.last_name.message}}

@@ -305,14 +312,7 @@ export const GeneralProfileSettingsForm = observer(function GeneralProfileSettin name="display_name" rules={{ required: "Display name is required.", - validate: (value) => { - if (value.trim().length < 1) return "Display name can't be empty."; - if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces."; - if (value.replace(/\s/g, "").length < 1) return "Display name must be at least 1 character long."; - if (value.replace(/\s/g, "").length > 20) - return "Display name must be less than 20 characters long."; - return true; - }, + validate: validateDisplayName, }} render={({ field: { value, onChange, ref } }) => ( )} /> diff --git a/apps/web/core/components/workspace/create-workspace-form.tsx b/apps/web/core/components/workspace/create-workspace-form.tsx index 2af67c09e97..ff06adc773b 100644 --- a/apps/web/core/components/workspace/create-workspace-form.tsx +++ b/apps/web/core/components/workspace/create-workspace-form.tsx @@ -9,6 +9,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IWorkspace } from "@plane/types"; // ui import { CustomSelect, Input } from "@plane/ui"; +import { validateWorkspaceName, validateSlug } from "@plane/utils"; // hooks import { useWorkspace } from "@/hooks/store/use-workspace"; import { useAppRouter } from "@/hooks/use-app-router"; @@ -120,8 +121,7 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props: name="name" rules={{ required: t("common.errors.required"), - validate: (value) => - /^[\w\s-]*$/.test(value) || t("workspace_creation.errors.validation.name_alphanumeric"), + validate: (value) => validateWorkspaceName(value, true), maxLength: { value: 80, message: t("workspace_creation.errors.validation.name_length"), @@ -172,7 +172,8 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props: type="text" value={value.toLocaleLowerCase().trim().replace(/ /g, "-")} onChange={(e) => { - if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false); + const validation = validateSlug(e.target.value); + if (validation === true) setInvalidSlug(false); else setInvalidSlug(true); onChange(e.target.value.toLowerCase()); }} diff --git a/apps/web/core/components/workspace/settings/workspace-details.tsx b/apps/web/core/components/workspace/settings/workspace-details.tsx index 66cb59c7202..039365c96d3 100644 --- a/apps/web/core/components/workspace/settings/workspace-details.tsx +++ b/apps/web/core/components/workspace/settings/workspace-details.tsx @@ -9,7 +9,7 @@ import { EditIcon } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IWorkspace } from "@plane/types"; import { CustomSelect, Input } from "@plane/ui"; -import { cn, copyUrlToClipboard, getFileURL } from "@plane/utils"; +import { copyUrlToClipboard, getFileURL, validateWorkspaceName, cn } from "@plane/utils"; // components import { WorkspaceImageUploadModal } from "@/components/core/modals/workspace-image-upload-modal"; import { TimezoneSelect } from "@/components/global/timezone-select"; @@ -189,11 +189,7 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() { control={control} name="name" rules={{ - required: t("workspace_settings.settings.general.errors.name.required"), - maxLength: { - value: 80, - message: t("workspace_settings.settings.general.errors.name.max_length"), - }, + validate: (value) => validateWorkspaceName(value, true), }} render={({ field: { value, onChange, ref } }) => ( )} /> + {errors.name &&

{errors.name.message}

}

diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 4b72ed3c941..fee819cdf63 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -30,6 +30,7 @@ export * from "./tab-indices"; export * from "./theme"; export { resolveGeneralTheme } from "./theme-legacy"; export * from "./url"; +export * from "./validation"; export * from "./work-item-filters"; export * from "./work-item"; export * from "./workspace"; diff --git a/packages/utils/src/validation.ts b/packages/utils/src/validation.ts new file mode 100644 index 00000000000..4a96f47f665 --- /dev/null +++ b/packages/utils/src/validation.ts @@ -0,0 +1,210 @@ +/** + * Input Validation Utilities + * Following OWASP Input Validation best practices using allowlist approach + * + * Security: Blocks injection-risk characters: < > ' " % # { } [ ] * ^ ! + * These patterns are designed to prevent XSS, SQL injection, template injection, + * and other security vulnerabilities while maintaining good UX + */ + +// ============================================================================= +// VALIDATION REGEX PATTERNS +// ============================================================================= + +/** + * Person Name Pattern (for first_name, last_name) + * Allows: Unicode letters (\p{L}), spaces, hyphens, apostrophes + * Use case: Accommodates international names like "José", "李明", "محمد", "Müller" + * Blocks: Injection-risk characters and special symbols + */ +export const PERSON_NAME_REGEX = /^[\p{L}\s'-]+$/u; + +/** + * Display Name Pattern (for display_name, usernames) + * Allows: Unicode letters (\p{L}), numbers (\p{N}), underscore, period, hyphen + * Use case: International usernames like "josé_123", "李明.dev", "müller-2024" + * Blocks: Spaces and injection-risk characters + */ +export const DISPLAY_NAME_REGEX = /^[\p{L}\p{N}_.-]+$/u; + +/** + * Company/Organization Name Pattern (for company_name, workspace names) + * Allows: Unicode letters (\p{L}), numbers (\p{N}), spaces, underscores, hyphens + * Use case: International business names like "Société Générale", "株式会社", "Müller GmbH" + * Blocks: Special punctuation and injection-risk chars + */ +export const COMPANY_NAME_REGEX = /^[\p{L}\p{N}\s_-]+$/u; + +/** + * URL Slug Pattern (for workspace slugs, URL-safe identifiers) + * Allows: Unicode letters (\p{L}), numbers (\p{N}), underscores, hyphens + * Use case: International URL-safe identifiers like "josé-workspace", "李明-project" + * Blocks: Spaces and special characters (URL encoding will handle Unicode in actual URLs) + */ +export const SLUG_REGEX = /^[\p{L}\p{N}_-]+$/u; + +// ============================================================================= +// VALIDATION FUNCTIONS +// ============================================================================= + +/** + * @description Validates person names (first name, last name) + * @param {string} name - Name to validate + * @returns {boolean | string} true if valid, error message if invalid + * @example + * validatePersonName("John") // returns true + * validatePersonName("O'Brien") // returns true + * validatePersonName("Jean-Paul") // returns true + * validatePersonName("John