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