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
@@ -213,6 +213,87 @@ describe('workspace permissions', () => {
);
});
});
it('should return a validation error when subdomain is empty string', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { subdomain: "" }) {
id
subdomain
}
}
`,
};
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.CONFLICT,
);
expect(
response.body.errors[0].extensions.userFriendlyMessage,
).toBe('Invalid subdomain.');
});
it('should return a validation error when subdomain has invalid characters', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { subdomain: "INVALID!" }) {
id
subdomain
}
}
`,
};
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.CONFLICT,
);
expect(
response.body.errors[0].extensions.userFriendlyMessage,
).toBe('Invalid subdomain.');
});
it('should return a validation error when subdomain starts with api-', async () => {
const queryData = {
query: `
mutation updateWorkspace {
updateWorkspace(data: { subdomain: "api-workspace" }) {
id
subdomain
}
}
`,
};
const response = await client
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(queryData);
expect(response.body.data).toBeNull();
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].extensions.code).toBe(
ErrorCode.CONFLICT,
);
expect(
response.body.errors[0].extensions.userFriendlyMessage,
).toBe('Invalid subdomain.');
});
});
describe('custom domain update', () => {