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:
+81
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user