diff --git a/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx b/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx
index d250b763059..7e0e7012bb5 100644
--- a/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx
+++ b/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx
@@ -14,7 +14,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";
+import { normalizeSlug, validateSlug, validateWorkspaceName } from "@plane/utils";
// components
import { CustomSelect, Input } from "@plane/ui";
// hooks
@@ -107,7 +107,7 @@ export function WorkspaceCreateForm() {
onChange={(e) => {
onChange(e.target.value);
setValue("name", e.target.value);
- setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), {
+ setValue("slug", normalizeSlug(e.target.value), {
shouldValidate: true,
});
}}
@@ -135,11 +135,12 @@ export function WorkspaceCreateForm() {
{
- if (/^[a-zA-Z0-9_-]+$/.test(e.target.value)) setInvalidSlug(false);
+ const normalizedSlug = normalizeSlug(e.target.value);
+ if (validateSlug(normalizedSlug) === true) setInvalidSlug(false);
else setInvalidSlug(true);
- onChange(e.target.value.toLowerCase());
+ onChange(normalizedSlug);
}}
ref={ref}
hasError={Boolean(errors.slug)}
diff --git a/apps/api/plane/license/api/serializers/workspace.py b/apps/api/plane/license/api/serializers/workspace.py
index d12473e2047..749e8e2ff4f 100644
--- a/apps/api/plane/license/api/serializers/workspace.py
+++ b/apps/api/plane/license/api/serializers/workspace.py
@@ -4,6 +4,7 @@
# Third Party Imports
from rest_framework import serializers
+from django.utils.text import slugify
# Module imports
from .base import BaseSerializer
@@ -17,15 +18,19 @@ class WorkspaceSerializer(BaseSerializer):
logo_url = serializers.CharField(read_only=True)
total_projects = serializers.IntegerField(read_only=True)
total_members = serializers.IntegerField(read_only=True)
+ slug = serializers.CharField(max_length=48)
def validate_slug(self, value):
+ normalized_slug = slugify(value)
+ if not normalized_slug:
+ raise serializers.ValidationError("Slug is not valid")
# Check if the slug is restricted
- if value in RESTRICTED_WORKSPACE_SLUGS:
+ if normalized_slug in RESTRICTED_WORKSPACE_SLUGS:
raise serializers.ValidationError("Slug is not valid")
# Check uniqueness case-insensitively
- if Workspace.objects.filter(slug__iexact=value).exists():
+ if Workspace.objects.filter(slug__iexact=normalized_slug).exists():
raise serializers.ValidationError("Slug is already in use")
- return value
+ return normalized_slug
class Meta:
model = Workspace
diff --git a/apps/api/plane/license/api/views/workspace.py b/apps/api/plane/license/api/views/workspace.py
index 966b3b3e8f9..2c0d04b1054 100644
--- a/apps/api/plane/license/api/views/workspace.py
+++ b/apps/api/plane/license/api/views/workspace.py
@@ -7,6 +7,7 @@
from rest_framework import status
from django.db import IntegrityError
from django.db.models import OuterRef, Func, F
+from django.utils.text import slugify
# Module imports
from plane.app.views.base import BaseAPIView
@@ -28,6 +29,10 @@ def get(self, request):
status=status.HTTP_400_BAD_REQUEST,
)
+ slug = slugify(slug)
+ if not slug:
+ return Response({"status": False}, status=status.HTTP_200_OK)
+
workspace = Workspace.objects.filter(slug__iexact=slug).exists() or slug in RESTRICTED_WORKSPACE_SLUGS
return Response({"status": not workspace}, status=status.HTTP_200_OK)
diff --git a/apps/api/plane/tests/unit/serializers/test_workspace.py b/apps/api/plane/tests/unit/serializers/test_workspace.py
index f59667f701b..b2e6894aaf6 100644
--- a/apps/api/plane/tests/unit/serializers/test_workspace.py
+++ b/apps/api/plane/tests/unit/serializers/test_workspace.py
@@ -6,6 +6,7 @@
from uuid import uuid4
from plane.api.serializers import WorkspaceLiteSerializer
+from plane.license.api.serializers import WorkspaceSerializer
from plane.db.models import Workspace, User
@@ -52,3 +53,17 @@ def test_workspace_lite_serializer_read_only(self, db):
updated_workspace = serializer.save()
assert updated_workspace.name == "Test Workspace"
assert updated_workspace.slug == "test-workspace"
+
+@pytest.mark.unit
+class TestWorkspaceSerializer:
+ """Test the WorkspaceSerializer"""
+
+ def test_workspace_serializer_normalizes_unicode_slug(self, db):
+ """Test that unicode slugs are normalized to ASCII-safe values"""
+ owner = User.objects.create(email="test3@example.com", first_name="Test", last_name="User")
+ Workspace.objects.create(name="Existing Workspace", slug="existing-workspace", id=uuid4(), owner=owner)
+
+ serializer = WorkspaceSerializer(data={"name": "Novo Workspace", "slug": "çã", "organization_size": "Just myself"})
+
+ assert serializer.is_valid(), serializer.errors
+ assert serializer.validated_data["slug"] == "ca"
diff --git a/apps/web/core/components/onboarding/create-workspace.tsx b/apps/web/core/components/onboarding/create-workspace.tsx
index 34f7a937ced..75e1cb263fb 100644
--- a/apps/web/core/components/onboarding/create-workspace.tsx
+++ b/apps/web/core/components/onboarding/create-workspace.tsx
@@ -16,7 +16,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";
+import { normalizeSlug, validateWorkspaceName, validateSlug } from "@plane/utils";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserProfile, useUserSettings } from "@/hooks/store/user";
@@ -155,7 +155,7 @@ export const CreateWorkspace = observer(function CreateWorkspace(props: Props) {
onChange={(event) => {
onChange(event.target.value);
setValue("name", event.target.value);
- setValue("slug", event.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), {
+ setValue("slug", normalizeSlug(event.target.value), {
shouldValidate: true,
});
}}
@@ -198,12 +198,13 @@ export const CreateWorkspace = observer(function CreateWorkspace(props: Props) {
id="slug"
name="slug"
type="text"
- value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
+ value={normalizeSlug(value)}
onChange={(e) => {
- const validation = validateSlug(e.target.value);
+ const normalizedSlug = normalizeSlug(e.target.value);
+ const validation = validateSlug(normalizedSlug);
if (validation === true) setInvalidSlug(false);
else setInvalidSlug(true);
- onChange(e.target.value.toLowerCase());
+ onChange(normalizedSlug);
}}
ref={ref}
hasError={Boolean(errors.slug)}
diff --git a/apps/web/core/components/onboarding/steps/workspace/create.tsx b/apps/web/core/components/onboarding/steps/workspace/create.tsx
index f9c32a91f26..1803b7722dc 100644
--- a/apps/web/core/components/onboarding/steps/workspace/create.tsx
+++ b/apps/web/core/components/onboarding/steps/workspace/create.tsx
@@ -15,7 +15,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, validateWorkspaceName, validateSlug } from "@plane/utils";
+import { cn, normalizeSlug, validateWorkspaceName, validateSlug } from "@plane/utils";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useWorkspace } from "@/hooks/store/use-workspace";
@@ -162,7 +162,7 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({
onChange={(event) => {
onChange(event.target.value);
setValue("name", event.target.value);
- setValue("slug", event.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), {
+ setValue("slug", normalizeSlug(event.target.value), {
shouldValidate: true,
});
}}
@@ -217,12 +217,13 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({
id="slug"
name="slug"
type="text"
- value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
+ value={normalizeSlug(value)}
onChange={(e) => {
- const validation = validateSlug(e.target.value);
+ const normalizedSlug = normalizeSlug(e.target.value);
+ const validation = validateSlug(normalizedSlug);
if (validation === true) setInvalidSlug(false);
else setInvalidSlug(true);
- onChange(e.target.value.toLowerCase());
+ onChange(normalizedSlug);
}}
ref={ref}
placeholder={t("workspace_creation.form.url.placeholder")}
diff --git a/apps/web/core/components/workspace/create-workspace-form.tsx b/apps/web/core/components/workspace/create-workspace-form.tsx
index 2274f1832bb..a8a779f3a37 100644
--- a/apps/web/core/components/workspace/create-workspace-form.tsx
+++ b/apps/web/core/components/workspace/create-workspace-form.tsx
@@ -15,7 +15,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";
+import { normalizeSlug, validateWorkspaceName, validateSlug } from "@plane/utils";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useAppRouter } from "@/hooks/use-app-router";
@@ -141,7 +141,7 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
onChange={(e) => {
onChange(e.target.value);
setValue("name", e.target.value);
- setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-"), {
+ setValue("slug", normalizeSlug(e.target.value), {
shouldValidate: true,
});
}}
@@ -176,12 +176,13 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
{
- const validation = validateSlug(e.target.value);
+ const normalizedSlug = normalizeSlug(e.target.value);
+ const validation = validateSlug(normalizedSlug);
if (validation === true) setInvalidSlug(false);
else setInvalidSlug(true);
- onChange(e.target.value.toLowerCase());
+ onChange(normalizedSlug);
}}
ref={ref}
hasError={Boolean(errors.slug)}
diff --git a/apps/web/core/services/workspace.service.ts b/apps/web/core/services/workspace.service.ts
index 3c2565322a0..10893c6bfd8 100644
--- a/apps/web/core/services/workspace.service.ts
+++ b/apps/web/core/services/workspace.service.ts
@@ -198,7 +198,7 @@ export class WorkspaceService extends APIService {
}
async workspaceSlugCheck(slug: string): Promise {
- return this.get(`/api/workspace-slug-check/?slug=${slug}`)
+ return this.get(`/api/workspace-slug-check/?slug=${encodeURIComponent(slug)}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
diff --git a/packages/utils/src/validation.ts b/packages/utils/src/validation.ts
index 41c52aaa133..d7cbfcad6a1 100644
--- a/packages/utils/src/validation.ts
+++ b/packages/utils/src/validation.ts
@@ -173,6 +173,25 @@ export const validateWorkspaceName = (workspaceName: string, required: boolean =
return true;
};
+/**
+ * @description Normalizes slug input for URL-safe workspace identifiers
+ * @param {string} slug - Slug-like value to normalize
+ * @returns {string} normalized slug
+ * @example
+ * normalizeSlug("çã") // returns "ca"
+ * normalizeSlug("My Workspace") // returns "my-workspace"
+ */
+export const normalizeSlug = (slug: string): string =>
+ slug
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .trim()
+ .replace(/\s+/g, "-")
+ .replace(/[^a-z0-9_-]/g, "")
+ .replace(/-+/g, "-")
+ .replace(/^-+|-+$/g, "");
+
/**
* @description Validates URL slugs and identifiers
* @param {string} slug - Slug to validate