fix: prevent blank subdomain from being saved (#18812)

## Summary

Fixes #17941 — Saving a blank subdomain causes a redirect to
`.website.com`, effectively breaking the workspace.

**Root cause:** Three layers all fail to reject an empty string `""`:

1. **Frontend (`SettingsDomain.tsx`):** `SaveButton` has both
`onClick={onSave}` and `type="submit"`. The `onClick` fires first,
calling `handleSave()` directly without running Zod validation. So
`isDefined("")` returns `true`, the confirmation modal opens, and the
blank subdomain is submitted.

2. **Backend DTO (`update-workspace-input.ts`):** The `subdomain` field
has `@IsString()` + `@IsOptional()` but no pattern validation, so an
empty string passes the DTO layer.

3. **Backend service (`workspace.service.ts:152`):** `if
(payload.subdomain && ...)` — empty string is falsy in JS, so it skips
`validateSubdomainOrThrow()` entirely and writes `subdomain: ""` to the
database.

**The crash:** After save, the redirect logic does
`"myworkspace.website.com".replace("myworkspace", "")` →
`".website.com"`, sending the user to an invalid URL.

## Fix

- **Frontend:** Call `form.trigger()` at the start of `handleSave` to
run Zod validation regardless of whether the function was invoked via
`onClick` or `form.handleSubmit`. Returns early with validation error if
invalid.
- **Backend DTO:** Add `@Matches(/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/)`
to reject invalid subdomains at the request validation layer
(defense-in-depth).
- **Backend service:** Change `if (payload.subdomain && ...)` to `if
(isDefined(payload.subdomain) && ...)` so empty strings route through
`validateSubdomainOrThrow()` instead of being silently skipped.

## Test plan

- [x] Existing `is-subdomain-valid.util.spec.ts` tests pass (36/36)
- [x] TypeScript type checks pass for both `twenty-server` and
`twenty-front`
- [x] oxlint passes on all changed files
- [x] Prettier passes on all changed files
- [ ] Manual: Navigate to Settings > Domains, clear the subdomain field,
click Save — should show validation error, not redirect

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
oniani1
2026-03-21 18:49:21 +04:00
committed by GitHub
parent fc9723949b
commit cd651f57cb
26 changed files with 803 additions and 394 deletions
@@ -1,9 +1,9 @@
import { RESERVED_SUBDOMAINS } from 'src/engine/core-modules/workspace/constants/reserved-subdomains.constant';
import { VALID_SUBDOMAIN_PATTERN } from 'src/engine/core-modules/workspace/constants/valid-subdomain-pattern.constant';
import { RESERVED_SUBDOMAINS } from 'twenty-shared/constants';
import { isValidTwentySubdomain } from 'twenty-shared/utils';
export const isSubdomainValid = (subdomain: string) => {
return (
VALID_SUBDOMAIN_PATTERN.test(subdomain) &&
isValidTwentySubdomain(subdomain) &&
!RESERVED_SUBDOMAINS.includes(subdomain.toLowerCase())
);
};
@@ -1,123 +0,0 @@
export const RESERVED_SUBDOMAINS = [
'about',
'admin',
'africa',
'al',
'america',
'api',
'app',
'asia',
'assets',
'au',
'audio',
'auth',
'az',
'ba',
'be',
'bg',
'billing',
'blog',
'ca',
'careers',
'cdn',
'ch',
'chat',
'community',
'companies',
'config',
'contact',
'cy',
'cz',
'dash',
'dashboard',
'db',
'de',
'demo',
'dev',
'developer',
'dk',
'docs',
'ee',
'es',
'eu',
'europe',
'events',
'favicon',
'feedback',
'fi',
'files',
'forum',
'fr',
'gr',
'help',
'hr',
'hu',
'image',
'images',
'is',
'it',
'jobs',
'legal',
'login',
'logs',
'london',
'lt',
'lv',
'mail',
'main',
'me',
'media',
'merch',
'metrics',
'mk',
'new-york',
'news',
'next',
'nl',
'no',
'north-africa',
'north-america',
'nz',
'oceania',
'otel-collector',
'paris',
'partners',
'partnership',
'partnerships',
'payment',
'pl',
'privacy',
'production',
'pt',
'register',
'ro',
'rs',
'ru',
'san-francisco',
'se',
'settings',
'shop',
'si',
'signin',
'signup',
'sk',
'south-africa',
'south-america',
'staging',
'storage',
'store',
'support',
't',
'telemetry',
'terms',
'test',
'testing',
'tr',
'trust',
'ua',
'uk',
'us',
'video',
'www',
'za',
];
@@ -1,2 +0,0 @@
export const VALID_SUBDOMAIN_PATTERN =
/^(?!api-).*^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/;
@@ -149,7 +149,10 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
workspaceActivationStatus: workspace.activationStatus,
});
if (payload.subdomain && workspace.subdomain !== payload.subdomain) {
if (
isDefined(payload.subdomain) &&
workspace.subdomain !== payload.subdomain
) {
await this.subdomainManagerService.validateSubdomainOrThrow(
payload.subdomain,
);