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:
@@ -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 />}
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ export const SettingPublicDomain = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const validationSchema = getDomainValidationSchema(t);
|
||||
const validationSchema = getDomainValidationSchema();
|
||||
|
||||
const onCreate = async () => {
|
||||
if (!isDefined(newPublicDomain)) {
|
||||
|
||||
+92
-69
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+69
-30
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+25
-14
@@ -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,
|
||||
};
|
||||
};
|
||||
+2
-4
@@ -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(
|
||||
|
||||
+10
-5
@@ -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`,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/* @license Enterprise */
|
||||
import { SettingsCustomDomain } from '@/settings/domains/components/SettingsCustomDomain';
|
||||
|
||||
export const SettingsCustomDomainPage = () => <SettingsCustomDomain />;
|
||||
@@ -1,260 +0,0 @@
|
||||
import {
|
||||
type CurrentWorkspace,
|
||||
currentWorkspaceState,
|
||||
} from '@/auth/states/currentWorkspaceState';
|
||||
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
||||
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||
import { CombinedGraphQLErrors } from '@apollo/client/errors';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { SettingsCustomDomain } from '@/settings/domains/components/SettingsCustomDomain';
|
||||
import { SettingsSubdomain } from '@/settings/domains/components/SettingsSubdomain';
|
||||
import { useState } from 'react';
|
||||
import { getSubdomainValidationSchema } from '@/settings/domains/utils/getSubdomainValidationSchema';
|
||||
import { getDomainValidationSchema } from '@/settings/domains/utils/getDomainValidationSchema';
|
||||
import { useCheckCustomDomainValidRecords } from '@/settings/domains/hooks/useCheckCustomDomainValidRecords';
|
||||
import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState';
|
||||
|
||||
export const SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID =
|
||||
'subdomain-change-confirmation-modal';
|
||||
|
||||
export const SettingsDomain = () => {
|
||||
const navigate = useNavigateSettings();
|
||||
const { checkCustomDomainRecords } = useCheckCustomDomainValidRecords();
|
||||
const { t } = useLingui();
|
||||
const isCloudflareIntegrationEnabled = useAtomStateValue(
|
||||
isCloudflareIntegrationEnabledState,
|
||||
);
|
||||
|
||||
const validationSchema = z
|
||||
.object({
|
||||
subdomain: getSubdomainValidationSchema(t),
|
||||
customDomain: getDomainValidationSchema(t),
|
||||
})
|
||||
.required();
|
||||
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
const [updateWorkspace] = useMutation(UpdateWorkspaceDocument);
|
||||
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [currentWorkspace, setCurrentWorkspace] = useAtomState(
|
||||
currentWorkspaceState,
|
||||
);
|
||||
|
||||
const { openModal, closeModal } = useModal();
|
||||
|
||||
const form = useForm<{
|
||||
subdomain: string;
|
||||
customDomain: string | null;
|
||||
}>({
|
||||
mode: 'onSubmit',
|
||||
delayError: 500,
|
||||
defaultValues: {
|
||||
subdomain: currentWorkspace?.subdomain ?? '',
|
||||
customDomain: currentWorkspace?.customDomain ?? '',
|
||||
},
|
||||
resolver: zodResolver(validationSchema),
|
||||
});
|
||||
|
||||
const subdomainValue = form.watch('subdomain');
|
||||
const customDomainValue = form.watch('customDomain');
|
||||
|
||||
const updateCustomDomain = (
|
||||
customDomain: string | null,
|
||||
currentWorkspace: CurrentWorkspace,
|
||||
) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
updateWorkspace({
|
||||
variables: {
|
||||
input: {
|
||||
customDomain:
|
||||
isDefined(customDomain) && customDomain.length > 0
|
||||
? customDomain
|
||||
: null,
|
||||
},
|
||||
},
|
||||
onCompleted: () => {
|
||||
setCurrentWorkspace({
|
||||
...currentWorkspace,
|
||||
customDomain:
|
||||
customDomain && customDomain.length > 0 ? customDomain : null,
|
||||
});
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Custom domain updated`,
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
checkCustomDomainRecords();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (
|
||||
CombinedGraphQLErrors.is(error) &&
|
||||
error.errors[0]?.extensions?.code === 'CONFLICT'
|
||||
) {
|
||||
return form.control.setError('subdomain', {
|
||||
type: 'manual',
|
||||
message: t`Subdomain already taken`,
|
||||
});
|
||||
}
|
||||
if (CombinedGraphQLErrors.is(error)) {
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: error,
|
||||
});
|
||||
} else {
|
||||
enqueueErrorSnackBar({});
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateSubdomain = (
|
||||
subdomain: string,
|
||||
currentWorkspace: CurrentWorkspace,
|
||||
) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
updateWorkspace({
|
||||
variables: {
|
||||
input: {
|
||||
subdomain,
|
||||
},
|
||||
},
|
||||
onError: (error) => {
|
||||
if (
|
||||
CombinedGraphQLErrors.is(error) &&
|
||||
error.errors[0]?.extensions?.code === 'CONFLICT'
|
||||
) {
|
||||
closeModal(SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID);
|
||||
return form.control.setError('subdomain', {
|
||||
type: 'manual',
|
||||
message: t`Subdomain already taken`,
|
||||
});
|
||||
}
|
||||
if (CombinedGraphQLErrors.is(error)) {
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: error,
|
||||
});
|
||||
} 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());
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = form.getValues();
|
||||
|
||||
if (
|
||||
subdomainValue === currentWorkspace?.subdomain &&
|
||||
customDomainValue === currentWorkspace?.customDomain
|
||||
) {
|
||||
return enqueueErrorSnackBar({
|
||||
message: t`No change detected`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isDefined(values) || !isDefined(currentWorkspace)) {
|
||||
return enqueueErrorSnackBar({
|
||||
message: t`Invalid form values`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(values.subdomain) &&
|
||||
values.subdomain !== currentWorkspace.subdomain
|
||||
) {
|
||||
openModal(SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.customDomain !== currentWorkspace.customDomain) {
|
||||
return updateCustomDomain(values.customDomain, currentWorkspace);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={form.handleSubmit(handleSave)}>
|
||||
{/* oxlint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<FormProvider {...form}>
|
||||
<SubMenuTopBarContainer
|
||||
title={t`Domain`}
|
||||
links={[
|
||||
{
|
||||
children: <Trans>Workspace</Trans>,
|
||||
href: getSettingsPath(SettingsPath.Workspace),
|
||||
},
|
||||
{
|
||||
children: <Trans>Domains</Trans>,
|
||||
href: getSettingsPath(SettingsPath.Domains),
|
||||
},
|
||||
{ children: <Trans>Domain</Trans> },
|
||||
]}
|
||||
actionButton={
|
||||
<SaveAndCancelButtons
|
||||
onCancel={() => navigate(SettingsPath.Domains)}
|
||||
isSaveDisabled={isSubmitting}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
<SettingsSubdomain />
|
||||
{isCloudflareIntegrationEnabled && <SettingsCustomDomain />}
|
||||
</SettingsPageContainer>
|
||||
</SubMenuTopBarContainer>
|
||||
</FormProvider>
|
||||
</form>
|
||||
<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={() => {
|
||||
const values = form.getValues();
|
||||
currentWorkspace &&
|
||||
updateSubdomain(values.subdomain, currentWorkspace);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { SettingsSubdomain } from '@/settings/domains/components/SettingsSubdomain';
|
||||
|
||||
export const SettingsSubdomainPage = () => <SettingsSubdomain />;
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
PageDecorator,
|
||||
type PageDecoratorArgs,
|
||||
} from '~/testing/decorators/PageDecorator';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
|
||||
import { SettingsCustomDomainPage } from '~/pages/settings/domains/SettingsCustomDomainPage';
|
||||
|
||||
const meta: Meta<PageDecoratorArgs> = {
|
||||
title: 'Pages/Settings/Domains/SettingsCustomDomain',
|
||||
component: SettingsCustomDomainPage,
|
||||
decorators: [PageDecorator],
|
||||
args: { routePath: '/settings/domains/custom-domain' },
|
||||
parameters: {
|
||||
msw: graphqlMocks,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export type Story = StoryObj<typeof SettingsCustomDomainPage>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const InvalidDomain: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = await canvas.findByRole('textbox', {}, { timeout: 5000 });
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'not-a-domain');
|
||||
|
||||
const errorMessage = await canvas.findByText(/Invalid domain/);
|
||||
|
||||
await expect(errorMessage).toBeVisible();
|
||||
|
||||
const saveButton = canvas.getByText('Save');
|
||||
|
||||
await expect(saveButton.closest('button')).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const ValidDomain: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = await canvas.findByRole('textbox', {}, { timeout: 5000 });
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'crm.example.com');
|
||||
|
||||
const saveButton = canvas.getByText('Save');
|
||||
|
||||
await expect(saveButton.closest('button')).toBeEnabled();
|
||||
},
|
||||
};
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
|
||||
import {
|
||||
PageDecorator,
|
||||
type PageDecoratorArgs,
|
||||
} from '~/testing/decorators/PageDecorator';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
|
||||
import { SettingsSubdomainPage } from '~/pages/settings/domains/SettingsSubdomainPage';
|
||||
|
||||
const meta: Meta<PageDecoratorArgs> = {
|
||||
title: 'Pages/Settings/Domains/SettingsSubdomain',
|
||||
component: SettingsSubdomainPage,
|
||||
decorators: [PageDecorator],
|
||||
args: { routePath: '/settings/domains/subdomain' },
|
||||
parameters: {
|
||||
msw: graphqlMocks,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export type Story = StoryObj<typeof SettingsSubdomainPage>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const TooShortSubdomain: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = await canvas.findByRole('textbox', {}, { timeout: 5000 });
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'ab');
|
||||
|
||||
const errorMessage = await canvas.findByText(
|
||||
'Subdomain can not be shorter than 3 characters',
|
||||
);
|
||||
|
||||
await expect(errorMessage).toBeVisible();
|
||||
|
||||
const saveButton = canvas.getByText('Save');
|
||||
|
||||
await expect(saveButton.closest('button')).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const InvalidCharacters: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = await canvas.findByRole('textbox', {}, { timeout: 5000 });
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'api-test');
|
||||
|
||||
const errorMessage = await canvas.findByText(
|
||||
'Use letter, number and dash only. Start and finish with a letter or a number',
|
||||
);
|
||||
|
||||
await expect(errorMessage).toBeVisible();
|
||||
|
||||
const saveButton = canvas.getByText('Save');
|
||||
|
||||
await expect(saveButton.closest('button')).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const ReservedSubdomain: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = await canvas.findByRole('textbox', {}, { timeout: 5000 });
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'api');
|
||||
|
||||
const errorMessage = await canvas.findByText('This subdomain is reserved');
|
||||
|
||||
await expect(errorMessage).toBeVisible();
|
||||
|
||||
const saveButton = canvas.getByText('Save');
|
||||
|
||||
await expect(saveButton.closest('button')).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const ValidSubdomain: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const input = await canvas.findByRole('textbox', {}, { timeout: 5000 });
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'my-workspace');
|
||||
|
||||
const saveButton = canvas.getByText('Save');
|
||||
|
||||
await expect(saveButton.closest('button')).toBeEnabled();
|
||||
},
|
||||
};
|
||||
+3
-3
@@ -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())
|
||||
);
|
||||
};
|
||||
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
export const VALID_SUBDOMAIN_PATTERN =
|
||||
/^(?!api-).*^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/;
|
||||
+4
-1
@@ -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,
|
||||
);
|
||||
|
||||
+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', () => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// 3–30 chars: lowercase alphanumeric and hyphens, must start and end
|
||||
// with a letter or number, must not start with "api-"
|
||||
export const SUBDOMAIN_PATTERN = /^(?!api-)[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/;
|
||||
@@ -40,7 +40,9 @@ export { QUERY_MAX_RECORDS_FROM_RELATION } from './QueryMaxRecordsFromRelation';
|
||||
export { QUOTED_STRING_REGEX } from './QuotedStringRegex';
|
||||
export { RATING_VALUES } from './RatingValues';
|
||||
export { RELATION_NESTED_QUERY_KEYWORDS } from './RelationNestedQueriesKeyword';
|
||||
export { RESERVED_SUBDOMAINS } from './ReservedSubdomains';
|
||||
export { STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS } from './StandardObjectRecordsUnderObjectRecordsPermissions';
|
||||
export { SUBDOMAIN_PATTERN } from './SubdomainPattern';
|
||||
export { TWENTY_COMPANIES_BASE_URL } from './TwentyCompaniesBaseUrl';
|
||||
export { TWENTY_ICONS_BASE_URL } from './TwentyIconsBaseUrl';
|
||||
export { VIEW_GROUP_VISIBLE_OPTIONS_MAX } from './ViewGroupVisibleOptionsMax';
|
||||
|
||||
@@ -22,7 +22,8 @@ export enum SettingsPath {
|
||||
WorkspaceMemberPage = 'members/:workspaceMemberId',
|
||||
Workspace = 'general',
|
||||
Domains = 'domains',
|
||||
Domain = 'domains/domain',
|
||||
Subdomain = 'domains/subdomain',
|
||||
CustomDomain = 'domains/custom-domain',
|
||||
PublicDomain = 'domains/public-domain',
|
||||
NewApprovedAccessDomain = 'domains/approved-access-domain/new',
|
||||
NewEmailingDomain = 'domains/emailing-domain/new',
|
||||
|
||||
@@ -194,6 +194,7 @@ export { isDefined } from './validation/isDefined';
|
||||
export { isEmptyObject } from './validation/isEmptyObject';
|
||||
export { isLabelIdentifierFieldMetadataTypes } from './validation/isLabelIdentifierFieldMetadataTypes';
|
||||
export { isValidLocale } from './validation/isValidLocale';
|
||||
export { isValidTwentySubdomain } from './validation/isValidTwentySubdomain';
|
||||
export { isValidUuid } from './validation/isValidUuid';
|
||||
export { isValidVariable } from './validation/isValidVariable';
|
||||
export { normalizeLocale } from './validation/normalizeLocale';
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { isValidTwentySubdomain } from '@/utils/validation/isValidTwentySubdomain';
|
||||
|
||||
describe('isValidTwentySubdomain', () => {
|
||||
describe('valid subdomains', () => {
|
||||
it('should accept standard alphanumeric subdomains', () => {
|
||||
expect(isValidTwentySubdomain('abc')).toBe(true);
|
||||
expect(isValidTwentySubdomain('test123')).toBe(true);
|
||||
expect(isValidTwentySubdomain('company1')).toBe(true);
|
||||
expect(isValidTwentySubdomain('workspace2024')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept subdomains with hyphens in the middle', () => {
|
||||
expect(isValidTwentySubdomain('my-company')).toBe(true);
|
||||
expect(isValidTwentySubdomain('test-workspace')).toBe(true);
|
||||
expect(isValidTwentySubdomain('multi-word-subdomain')).toBe(true);
|
||||
expect(isValidTwentySubdomain('a-b-c-d-e')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept minimum length subdomains (3 characters)', () => {
|
||||
expect(isValidTwentySubdomain('abc')).toBe(true);
|
||||
expect(isValidTwentySubdomain('a1b')).toBe(true);
|
||||
expect(isValidTwentySubdomain('a-b')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept maximum length subdomains (30 characters)', () => {
|
||||
const exactly30 = 'a' + 'b'.repeat(28) + 'c';
|
||||
|
||||
expect(exactly30.length).toBe(30);
|
||||
expect(isValidTwentySubdomain(exactly30)).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept numeric-only subdomains', () => {
|
||||
expect(isValidTwentySubdomain('123')).toBe(true);
|
||||
expect(isValidTwentySubdomain('456789')).toBe(true);
|
||||
expect(isValidTwentySubdomain('1-2-3')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid subdomains', () => {
|
||||
it('should reject empty strings', () => {
|
||||
expect(isValidTwentySubdomain('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject subdomains shorter than 3 characters', () => {
|
||||
expect(isValidTwentySubdomain('a')).toBe(false);
|
||||
expect(isValidTwentySubdomain('ab')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject subdomains longer than 30 characters', () => {
|
||||
const tooLong = 'a'.repeat(31);
|
||||
|
||||
expect(isValidTwentySubdomain(tooLong)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject subdomains starting with a hyphen', () => {
|
||||
expect(isValidTwentySubdomain('-test')).toBe(false);
|
||||
expect(isValidTwentySubdomain('-abc')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject subdomains ending with a hyphen', () => {
|
||||
expect(isValidTwentySubdomain('test-')).toBe(false);
|
||||
expect(isValidTwentySubdomain('abc-')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject subdomains with uppercase letters', () => {
|
||||
expect(isValidTwentySubdomain('Test')).toBe(false);
|
||||
expect(isValidTwentySubdomain('MyCompany')).toBe(false);
|
||||
expect(isValidTwentySubdomain('WORKSPACE')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject subdomains with special characters', () => {
|
||||
expect(isValidTwentySubdomain('test@company')).toBe(false);
|
||||
expect(isValidTwentySubdomain('my_workspace')).toBe(false);
|
||||
expect(isValidTwentySubdomain('test.company')).toBe(false);
|
||||
expect(isValidTwentySubdomain('workspace#1')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject subdomains with spaces', () => {
|
||||
expect(isValidTwentySubdomain('test company')).toBe(false);
|
||||
expect(isValidTwentySubdomain(' test')).toBe(false);
|
||||
expect(isValidTwentySubdomain('test ')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject subdomains starting with "api-"', () => {
|
||||
expect(isValidTwentySubdomain('api-test')).toBe(false);
|
||||
expect(isValidTwentySubdomain('api-company')).toBe(false);
|
||||
expect(isValidTwentySubdomain('api-123')).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept subdomains containing "api" not as prefix', () => {
|
||||
expect(isValidTwentySubdomain('myapi')).toBe(true);
|
||||
expect(isValidTwentySubdomain('rapid')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject subdomains with only hyphens', () => {
|
||||
expect(isValidTwentySubdomain('---')).toBe(false);
|
||||
expect(isValidTwentySubdomain('----')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject whitespace-only strings', () => {
|
||||
expect(isValidTwentySubdomain(' ')).toBe(false);
|
||||
expect(isValidTwentySubdomain('\t')).toBe(false);
|
||||
expect(isValidTwentySubdomain('\n')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject unicode characters', () => {
|
||||
expect(isValidTwentySubdomain('café')).toBe(false);
|
||||
expect(isValidTwentySubdomain('tëst')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './isDefined';
|
||||
export * from './assertIsDefinedOrThrow';
|
||||
export * from './isValidLocale';
|
||||
export * from './isValidTwentySubdomain';
|
||||
export * from './isValidUuid';
|
||||
export * from './normalizeLocale';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SUBDOMAIN_PATTERN } from '@/constants/SubdomainPattern';
|
||||
|
||||
export const isValidTwentySubdomain = (subdomain: string): boolean => {
|
||||
return SUBDOMAIN_PATTERN.test(subdomain);
|
||||
};
|
||||
Reference in New Issue
Block a user