Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
});
}}
Expand Down Expand Up @@ -135,11 +135,12 @@ export function WorkspaceCreateForm() {
<Input
id="workspaceUrl"
type="text"
value={value.toLocaleLowerCase().trim().replace(/ /g, "-")}
value={normalizeSlug(value)}
onChange={(e) => {
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)}
Expand Down
11 changes: 8 additions & 3 deletions apps/api/plane/license/api/serializers/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

# Third Party Imports
from rest_framework import serializers
from django.utils.text import slugify

# Module imports
from .base import BaseSerializer
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/api/plane/license/api/views/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
15 changes: 15 additions & 0 deletions apps/api/plane/tests/unit/serializers/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"
11 changes: 6 additions & 5 deletions apps/web/core/components/onboarding/create-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});
}}
Expand Down Expand Up @@ -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)}
Expand Down
11 changes: 6 additions & 5 deletions apps/web/core/components/onboarding/steps/workspace/create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});
}}
Expand Down Expand Up @@ -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")}
Expand Down
11 changes: 6 additions & 5 deletions apps/web/core/components/workspace/create-workspace-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});
}}
Expand Down Expand Up @@ -176,12 +176,13 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
<Input
id="workspaceUrl"
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)}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/core/services/workspace.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export class WorkspaceService extends APIService {
}

async workspaceSlugCheck(slug: string): Promise<any> {
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;
Expand Down
19 changes: 19 additions & 0 deletions packages/utils/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve typed hyphen separators in slug inputs

Because the updated workspace URL fields call normalizeSlug on every onChange, this trailing-hyphen strip makes manual slugs with hyphen separators effectively untypeable: after a user types acme-, the stored value immediately becomes acme, so the next character produces acmeteam instead of acme-team. This affects the create-workspace slug inputs that were changed to store normalizeSlug(e.target.value); consider only trimming edge hyphens on submit/blur or using a less destructive live normalizer.

Useful? React with 👍 / 👎.


/**
* @description Validates URL slugs and identifiers
* @param {string} slug - Slug to validate
Expand Down