From 82712457c012d84eec75b8fa38763c439fa4c6bb Mon Sep 17 00:00:00 2001 From: Nader Elkhouri Date: Thu, 28 May 2026 15:55:29 -0300 Subject: [PATCH] fix: normalize workspace slugs from names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize workspace slug input before validation and submission so accented names like çã are converted to ASCII-safe slugs (e.g. ca). Also normalize slug availability checks and add serializer coverage for the normalization path. --- .../(dashboard)/workspace/create/form.tsx | 11 ++++++----- .../license/api/serializers/workspace.py | 11 ++++++++--- apps/api/plane/license/api/views/workspace.py | 5 +++++ .../tests/unit/serializers/test_workspace.py | 15 +++++++++++++++ .../onboarding/create-workspace.tsx | 11 ++++++----- .../onboarding/steps/workspace/create.tsx | 11 ++++++----- .../workspace/create-workspace-form.tsx | 11 ++++++----- apps/web/core/services/workspace.service.ts | 2 +- packages/utils/src/validation.ts | 19 +++++++++++++++++++ 9 files changed, 72 insertions(+), 24 deletions(-) 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