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
@@ -129,12 +129,20 @@ const SettingsDomains = lazy(() =>
})),
);
const SettingsDomain = lazy(() =>
import('~/pages/settings/domains/SettingsDomain').then((module) => ({
default: module.SettingsDomain,
const SettingsSubdomainPage = lazy(() =>
import('~/pages/settings/domains/SettingsSubdomainPage').then((module) => ({
default: module.SettingsSubdomainPage,
})),
);
const SettingsCustomDomainPage = lazy(() =>
import('~/pages/settings/domains/SettingsCustomDomainPage').then(
(module) => ({
default: module.SettingsCustomDomainPage,
}),
),
);
const SettingsApiWebhooks = lazy(() =>
import('~/pages/settings/workspace/SettingsApiWebhooks').then((module) => ({
default: module.SettingsApiWebhooks,
@@ -497,7 +505,14 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
element={<SettingsLogicFunctionDetail />}
/>
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
<Route path={SettingsPath.Domain} element={<SettingsDomain />} />
<Route
path={SettingsPath.Subdomain}
element={<SettingsSubdomainPage />}
/>
<Route
path={SettingsPath.CustomDomain}
element={<SettingsCustomDomainPage />}
/>
<Route
path={SettingsPath.NewEmailingDomain}
element={<SettingsNewEmailingDomain />}
@@ -99,7 +99,7 @@ export const SettingPublicDomain = () => {
});
};
const validationSchema = getDomainValidationSchema(t);
const validationSchema = getDomainValidationSchema();
const onCreate = async () => {
if (!isDefined(newPublicDomain)) {
@@ -1,18 +1,24 @@
/* @license Enterprise */
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { CheckCustomDomainValidRecordsEffect } from '@/settings/domains/components/CheckCustomDomainValidRecordsEffect';
import { SettingsDomainRecords } from '@/settings/domains/components/SettingsDomainRecords';
import { useSettingsCustomDomain } from '@/settings/domains/hooks/useSettingsCustomDomain';
import { customDomainRecordsState } from '@/settings/domains/states/customDomainRecordsState';
import { TextInput } from '@/ui/input/components/TextInput';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { Controller, useFormContext } from 'react-hook-form';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { Trans, useLingui } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title, IconReload, IconTrash } from 'twenty-ui/display';
import { Button, ButtonGroup } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { SettingsDomainRecords } from '@/settings/domains/components/SettingsDomainRecords';
import { CheckCustomDomainValidRecordsEffect } from '@/settings/domains/components/CheckCustomDomainValidRecordsEffect';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { useCheckCustomDomainValidRecords } from '@/settings/domains/hooks/useCheckCustomDomainValidRecords';
import { customDomainRecordsState } from '@/settings/domains/states/customDomainRecordsState';
const StyledDomainFormWrapper = styled.div`
display: flex;
@@ -38,78 +44,95 @@ const StyledRecordsWrapper = styled.div`
`;
export const SettingsCustomDomain = () => {
const { customDomainRecords, isLoading } = useAtomStateValue(
customDomainRecordsState,
);
const navigate = useNavigateSettings();
const { t } = useLingui();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { customDomainRecords, isLoading: isRecordsLoading } =
useAtomStateValue(customDomainRecordsState);
const { checkCustomDomainRecords } = useCheckCustomDomainValidRecords();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { t } = useLingui();
const { control, setValue, trigger } = useFormContext<{
customDomain: string;
}>();
const deleteCustomDomain = () => {
setValue('customDomain', '');
trigger();
};
const {
customDomain,
error,
isSubmitting,
isSaveDisabled,
handleChange,
handleDelete,
handleSave,
} = useSettingsCustomDomain();
return (
<Section>
<H2Title
title={t`Custom Domain`}
description={t`Set the name of your custom domain and configure your DNS records.`}
/>
<CheckCustomDomainValidRecordsEffect />
<StyledDomainFormWrapper>
<Controller
name="customDomain"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<SubMenuTopBarContainer
title={t`Custom Domain`}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Domains</Trans>,
href: getSettingsPath(SettingsPath.Domains),
},
{ children: <Trans>Custom Domain</Trans> },
]}
actionButton={
<SaveAndCancelButtons
onCancel={() => navigate(SettingsPath.Domains)}
isSaveDisabled={isSaveDisabled}
isLoading={isSubmitting}
onSave={handleSave}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Custom Domain`}
description={t`Set the name of your custom domain and configure your DNS records.`}
/>
<CheckCustomDomainValidRecordsEffect />
<StyledDomainFormWrapper>
<TextInput
value={value}
value={customDomain}
type="text"
onChange={onChange}
onChange={handleChange}
placeholder="crm.yourdomain.com"
error={error?.message}
error={error}
fullWidth
/>
{currentWorkspace?.customDomain && (
<StyledButtonGroupContainer>
<ButtonGroup>
<StyledButtonContainer>
<Button
isLoading={isRecordsLoading}
Icon={IconReload}
title={t`Reload`}
variant="primary"
onClick={checkCustomDomainRecords}
type="button"
/>
</StyledButtonContainer>
<StyledButtonContainer>
<Button
Icon={IconTrash}
variant="primary"
onClick={handleDelete}
/>
</StyledButtonContainer>
</ButtonGroup>
</StyledButtonGroupContainer>
)}
</StyledDomainFormWrapper>
{currentWorkspace?.customDomain && (
<StyledRecordsWrapper>
{customDomainRecords && (
<SettingsDomainRecords records={customDomainRecords.records} />
)}
</StyledRecordsWrapper>
)}
/>
{currentWorkspace?.customDomain && (
<StyledButtonGroupContainer>
<ButtonGroup>
<StyledButtonContainer>
<Button
isLoading={isLoading}
Icon={IconReload}
title={t`Reload`}
variant="primary"
onClick={checkCustomDomainRecords}
type="button"
/>
</StyledButtonContainer>
<StyledButtonContainer>
<Button
Icon={IconTrash}
variant="primary"
onClick={deleteCustomDomain}
/>
</StyledButtonContainer>
</ButtonGroup>
</StyledButtonGroupContainer>
)}
</StyledDomainFormWrapper>
{currentWorkspace?.customDomain && (
<StyledRecordsWrapper>
{customDomainRecords && (
<SettingsDomainRecords records={customDomainRecords.records} />
)}
</StyledRecordsWrapper>
)}
</Section>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -1,14 +1,22 @@
import { TextInput } from '@/ui/input/components/TextInput';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { Controller, useFormContext } from 'react-hook-form';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { isDefined } from 'twenty-shared/utils';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import {
SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID,
useSettingsSubdomain,
} from '@/settings/domains/hooks/useSettingsSubdomain';
import { TextInput } from '@/ui/input/components/TextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { Trans, useLingui } from '@lingui/react/macro';
import { styled } from '@linaria/react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
const StyledDomainFormWrapper = styled.div`
align-items: center;
@@ -16,32 +24,57 @@ const StyledDomainFormWrapper = styled.div`
`;
export const SettingsSubdomain = () => {
const domainConfiguration = useAtomStateValue(domainConfigurationState);
const navigate = useNavigateSettings();
const { t } = useLingui();
const domainConfiguration = useAtomStateValue(domainConfigurationState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const { control } = useFormContext<{
subdomain: string;
}>();
const {
subdomain,
error,
isSubmitting,
isSaveDisabled,
handleChange,
handleSave,
handleConfirm,
} = useSettingsSubdomain();
return (
<Section>
<H2Title
<>
<SubMenuTopBarContainer
title={t`Subdomain`}
description={t`Set the name of your subdomain`}
/>
<StyledDomainFormWrapper>
<Controller
name="subdomain"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<>
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Domains</Trans>,
href: getSettingsPath(SettingsPath.Domains),
},
{ children: <Trans>Subdomain</Trans> },
]}
actionButton={
<SaveAndCancelButtons
onCancel={() => navigate(SettingsPath.Domains)}
isSaveDisabled={isSaveDisabled}
isLoading={isSubmitting}
onSave={handleSave}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Subdomain`}
description={t`Set the name of your subdomain`}
/>
<StyledDomainFormWrapper>
<TextInput
value={value}
value={subdomain}
type="text"
onChange={onChange}
error={error?.message}
onChange={handleChange}
error={error}
disabled={!!currentWorkspace?.customDomain}
rightAdornment={
isDefined(domainConfiguration.frontDomain)
@@ -50,10 +83,16 @@ export const SettingsSubdomain = () => {
}
fullWidth
/>
</>
)}
/>
</StyledDomainFormWrapper>
</Section>
</StyledDomainFormWrapper>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
<ConfirmationModal
modalInstanceId={SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID}
title={t`Change subdomain?`}
subtitle={t`You're about to change your workspace subdomain. This action will log out all users.`}
onConfirmClick={handleConfirm}
/>
</>
);
};
@@ -1,4 +1,5 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { useLingui } from '@lingui/react/macro';
@@ -13,6 +14,9 @@ export const SettingsWorkspaceDomainCard = () => {
const isMultiWorkspaceEnabled = useAtomStateValue(
isMultiWorkspaceEnabledState,
);
const isCloudflareIntegrationEnabled = useAtomStateValue(
isCloudflareIntegrationEnabledState,
);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
if (!isMultiWorkspaceEnabled) {
@@ -20,19 +24,26 @@ export const SettingsWorkspaceDomainCard = () => {
}
return (
<UndecoratedLink to={getSettingsPath(SettingsPath.Domain)}>
<SettingsCard
title={t`Customize Domain`}
Icon={<IconWorld />}
Status={
currentWorkspace?.customDomain &&
currentWorkspace?.isCustomDomainEnabled ? (
<Status text={t`Active`} color="turquoise" />
) : currentWorkspace?.customDomain ? (
<Status text={t`Inactive`} color="orange" />
) : undefined
}
/>
</UndecoratedLink>
<>
<UndecoratedLink to={getSettingsPath(SettingsPath.Subdomain)}>
<SettingsCard title={t`Subdomain`} Icon={<IconWorld />} />
</UndecoratedLink>
{isCloudflareIntegrationEnabled && (
<UndecoratedLink to={getSettingsPath(SettingsPath.CustomDomain)}>
<SettingsCard
title={t`Custom Domain`}
Icon={<IconWorld />}
Status={
currentWorkspace?.customDomain &&
currentWorkspace?.isCustomDomainEnabled ? (
<Status text={t`Active`} color="turquoise" />
) : currentWorkspace?.customDomain ? (
<Status text={t`Inactive`} color="orange" />
) : undefined
}
/>
</UndecoratedLink>
)}
</>
);
};
@@ -0,0 +1,99 @@
/* @license Enterprise */
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useCheckCustomDomainValidRecords } from '@/settings/domains/hooks/useCheckCustomDomainValidRecords';
import { getDomainValidationSchema } from '@/settings/domains/utils/getDomainValidationSchema';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useMutation } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql';
export const useSettingsCustomDomain = () => {
const { t } = useLingui();
const domainSchema = getDomainValidationSchema();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [updateWorkspace] = useMutation(UpdateWorkspaceDocument);
const { checkCustomDomainRecords } = useCheckCustomDomainValidRecords();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const [customDomain, setCustomDomain] = useState(
currentWorkspace?.customDomain ?? '',
);
const [error, setError] = useState<string | undefined>();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (value: string) => {
setCustomDomain(value);
const result = domainSchema.safeParse(value);
setError(result.success ? undefined : result.error.issues[0].message);
};
const handleDelete = () => {
setCustomDomain('');
setError(undefined);
};
const hasChanged = customDomain !== (currentWorkspace?.customDomain ?? '');
const isSaveDisabled = !hasChanged || isDefined(error) || isSubmitting;
const handleSave = () => {
if (!isDefined(currentWorkspace) || isSubmitting) {
return;
}
setIsSubmitting(true);
const domainValue = customDomain.length > 0 ? customDomain : null;
updateWorkspace({
variables: {
input: { customDomain: domainValue },
},
onCompleted: () => {
setCurrentWorkspace({
...currentWorkspace,
customDomain: domainValue,
});
enqueueSuccessSnackBar({ message: t`Custom domain updated` });
setIsSubmitting(false);
checkCustomDomainRecords();
},
onError: (mutationError) => {
if (
CombinedGraphQLErrors.is(mutationError) &&
mutationError.errors[0]?.extensions?.code === 'CONFLICT'
) {
setError(t`Domain already taken`);
setIsSubmitting(false);
return;
}
if (CombinedGraphQLErrors.is(mutationError)) {
enqueueErrorSnackBar({ apolloError: mutationError });
} else {
enqueueErrorSnackBar({});
}
setIsSubmitting(false);
},
});
};
return {
customDomain,
error,
isSubmitting,
isSaveDisabled,
handleChange,
handleDelete,
handleSave,
};
};
@@ -0,0 +1,104 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { getSubdomainValidationSchema } from '@/settings/domains/utils/getSubdomainValidationSchema';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useMutation } from '@apollo/client/react';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql';
export const SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID =
'subdomain-change-confirmation-modal';
export const useSettingsSubdomain = () => {
const { t } = useLingui();
const subdomainSchema = getSubdomainValidationSchema();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [updateWorkspace] = useMutation(UpdateWorkspaceDocument);
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { openModal, closeModal } = useModal();
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
currentWorkspaceState,
);
const [subdomain, setSubdomain] = useState(currentWorkspace?.subdomain ?? '');
const [error, setError] = useState<string | undefined>();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (value: string) => {
setSubdomain(value);
const result = subdomainSchema.safeParse(value);
setError(result.success ? undefined : result.error.issues[0].message);
};
const hasChanged = subdomain !== currentWorkspace?.subdomain;
const isSaveDisabled = !hasChanged || isDefined(error) || isSubmitting;
const handleSave = () => {
if (isDefined(currentWorkspace)) {
openModal(SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID);
}
};
const handleConfirm = () => {
if (!isDefined(currentWorkspace) || isSubmitting) {
return;
}
setIsSubmitting(true);
updateWorkspace({
variables: {
input: { subdomain },
},
onError: (mutationError) => {
if (
CombinedGraphQLErrors.is(mutationError) &&
mutationError.errors[0]?.extensions?.code === 'CONFLICT'
) {
closeModal(SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID);
setError(t`Subdomain already taken`);
setIsSubmitting(false);
return;
}
if (CombinedGraphQLErrors.is(mutationError)) {
enqueueErrorSnackBar({ apolloError: mutationError });
} else {
enqueueErrorSnackBar({});
}
setIsSubmitting(false);
},
onCompleted: async () => {
const currentUrl = new URL(window.location.href);
currentUrl.hostname = new URL(
currentWorkspace.workspaceUrls.subdomainUrl,
).hostname.replace(currentWorkspace.subdomain, subdomain);
setCurrentWorkspace({ ...currentWorkspace, subdomain });
enqueueSuccessSnackBar({ message: t`Subdomain updated` });
setIsSubmitting(false);
await redirectToWorkspaceDomain(currentUrl.toString());
},
});
};
return {
subdomain,
error,
isSubmitting,
isSaveDisabled,
handleChange,
handleSave,
handleConfirm,
};
};
@@ -1,9 +1,7 @@
import { t } from '@lingui/core/macro';
import { z } from 'zod';
import { type useLingui } from '@lingui/react/macro';
export const getDomainValidationSchema = (
t: ReturnType<typeof useLingui>['t'],
) =>
export const getDomainValidationSchema = () =>
z
.string()
.regex(
@@ -1,13 +1,18 @@
import { t } from '@lingui/core/macro';
import {
RESERVED_SUBDOMAINS,
SUBDOMAIN_PATTERN,
} from 'twenty-shared/constants';
import { z } from 'zod';
import { type useLingui } from '@lingui/react/macro';
export const getSubdomainValidationSchema = (
t: ReturnType<typeof useLingui>['t'],
) =>
export const getSubdomainValidationSchema = () =>
z
.string()
.min(3, { message: t`Subdomain can not be shorter than 3 characters` })
.max(30, { message: t`Subdomain can not be longer than 30 characters` })
.regex(/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/, {
.regex(SUBDOMAIN_PATTERN, {
message: t`Use letter, number and dash only. Start and finish with a letter or a number`,
})
.refine((value) => !RESERVED_SUBDOMAINS.includes(value.toLowerCase()), {
message: t`This subdomain is reserved`,
});