Add settings section for public domains (#14543)

As title



https://github.com/user-attachments/assets/1c2adb14-0fc1-4623-88bc-10ff729c72a1
This commit is contained in:
martmull
2025-09-17 11:07:02 +02:00
committed by GitHub
parent 7309717ed7
commit f6530abdf4
26 changed files with 736 additions and 31 deletions
@@ -5,6 +5,7 @@ import { SettingsProtectedRouteWrapper } from '@/settings/components/SettingsPro
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingsPath } from 'twenty-shared/types';
import { PermissionFlagType } from '~/generated/graphql';
import { SettingPublicDomain } from '@/settings/domains/components/SettingPublicDomain';
const SettingsGraphQLPlayground = lazy(() =>
import(
@@ -428,6 +429,10 @@ export const SettingsRoutes = ({
/>
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
<Route path={SettingsPath.Domain} element={<SettingsDomain />} />
<Route
path={SettingsPath.PublicDomain}
element={<SettingPublicDomain />}
/>
</Route>
<Route
element={
@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { useCheckPublicDomainValidRecords } from '@/settings/domains/hooks/useCheckPublicDomainValidRecords';
import { type PublicDomain } from '~/generated/graphql';
export const CheckPublicDomainValidRecordsEffect = ({
publicDomain,
}: {
publicDomain: PublicDomain;
}) => {
const { checkPublicDomainRecords } = useCheckPublicDomainValidRecords();
useEffect(() => {
checkPublicDomainRecords(publicDomain.domain);
// Check public domain only needs to run once at mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <></>;
};
@@ -0,0 +1,203 @@
import { Section } from 'twenty-ui/layout';
import { H2Title, IconReload, IconTrash } from 'twenty-ui/display';
import { Trans, useLingui } from '@lingui/react/macro';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { TextInput } from '@/ui/input/components/TextInput';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { Button, ButtonGroup } from 'twenty-ui/input';
import styled from '@emotion/styled';
import { SettingsDomainRecords } from '@/settings/domains/components/SettingsDomainRecords';
import { useCheckPublicDomainValidRecords } from '@/settings/domains/hooks/useCheckPublicDomainValidRecords';
import {
useCreatePublicDomainMutation,
useDeletePublicDomainMutation,
useFindManyPublicDomainsQuery,
} from '~/generated-metadata/graphql';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CheckPublicDomainValidRecordsEffect } from '@/settings/domains/components/CheckPublicDomainValidRecordsEffect';
import { selectedPublicDomainState } from '@/settings/domains/states/selectedPublicDomainState';
import { useRecoilState } from 'recoil';
import { useState } from 'react';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { getDomainValidationSchema } from '@/settings/domains/utils/get-domain-validation-schema';
const StyledButtonGroup = styled(ButtonGroup)`
& > :not(:first-of-type) > button {
border-left: none;
}
`;
const StyledButton = styled(Button)`
align-self: flex-start;
`;
const StyledDomainFormWrapper = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledRecordsWrapper = styled.div`
margin-top: ${({ theme }) => theme.spacing(2)};
& > :not(:first-of-type) {
margin-top: ${({ theme }) => theme.spacing(4)};
}
`;
export const SettingPublicDomain = () => {
const [selectedPublicDomain, setSelectedPublicDomain] = useRecoilState(
selectedPublicDomainState,
);
const { t } = useLingui();
const navigate = useNavigateSettings();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [createPublicDomain, { loading }] = useCreatePublicDomainMutation();
const [newPublicDomain, setNewPublicDomain] = useState<string | undefined>(
selectedPublicDomain?.domain ?? '',
);
const [newPublicDomainError, setNewPublicDomainError] = useState<
string | undefined
>(undefined);
const { refetch: refetchPublicDomains } = useFindManyPublicDomainsQuery();
const [deletePublicDomain] = useDeletePublicDomainMutation();
const { isLoading, publicDomainRecords, checkPublicDomainRecords } =
useCheckPublicDomainValidRecords();
const onDelete = async () => {
if (!selectedPublicDomain) {
return;
}
await deletePublicDomain({
variables: { domain: selectedPublicDomain.domain },
onCompleted: () => {
enqueueSuccessSnackBar({
message: t`Public domain successfully deleted`,
});
navigate(SettingsPath.Domains);
refetchPublicDomains();
},
onError: (error) =>
enqueueErrorSnackBar({
apolloError: error,
}),
});
};
const validationSchema = getDomainValidationSchema(t);
const onCreate = async () => {
if (!isDefined(newPublicDomain)) {
return;
}
const result = validationSchema.safeParse(newPublicDomain);
if (!result.success) {
setNewPublicDomainError(result.error?.issues[0].message);
return;
}
setNewPublicDomainError(undefined);
await createPublicDomain({
variables: { domain: newPublicDomain },
onCompleted: (data) => {
setSelectedPublicDomain(data.createPublicDomain);
enqueueSuccessSnackBar({
message: t`Public domain created successfully`,
});
},
onError: (error) => {
setNewPublicDomainError(error.message);
enqueueErrorSnackBar({
apolloError: error,
});
},
});
};
return (
<SubMenuTopBarContainer
title={t`Public domain`}
links={[
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Domains</Trans>,
href: getSettingsPath(SettingsPath.Domains),
},
{ children: <Trans>Public Domain</Trans> },
]}
actionButton={
<SaveAndCancelButtons
onCancel={() => navigate(SettingsPath.Domains)}
isSaveDisabled={loading || isDefined(selectedPublicDomain)}
onSave={onCreate}
/>
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Public domain`}
description={t`Set the name of your public domain and configure your DNS records.`}
/>
{isDefined(selectedPublicDomain) && (
<CheckPublicDomainValidRecordsEffect
publicDomain={selectedPublicDomain}
/>
)}
<StyledDomainFormWrapper>
<TextInput
value={newPublicDomain}
onChange={setNewPublicDomain}
error={newPublicDomainError}
type="text"
disabled={isDefined(selectedPublicDomain)}
placeholder="crm.yourPublicDomain.com"
fullWidth
/>
{isDefined(selectedPublicDomain) && (
<StyledButtonGroup>
<StyledButton
isLoading={isLoading}
Icon={IconReload}
title={t`Reload`}
variant="primary"
onClick={() =>
checkPublicDomainRecords(selectedPublicDomain.domain)
}
type="button"
/>
<StyledButton
Icon={IconTrash}
variant="primary"
onClick={onDelete}
/>
</StyledButtonGroup>
)}
</StyledDomainFormWrapper>
{isDefined(selectedPublicDomain) && publicDomainRecords?.domain && (
<StyledRecordsWrapper>
{publicDomainRecords.records && (
<SettingsDomainRecords records={publicDomainRecords.records} />
)}
</StyledRecordsWrapper>
)}
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,70 @@
import { type PublicDomain } from '~/generated/graphql';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import {
useDeletePublicDomainMutation,
useFindManyPublicDomainsQuery,
} from '~/generated-metadata/graphql';
import { useLingui } from '@lingui/react/macro';
import { LightIconButton } from 'twenty-ui/input';
import { IconDotsVertical, IconTrash } from 'twenty-ui/display';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { MenuItem } from 'twenty-ui/navigation';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
export const SettingPublicDomainRowDropdownMenu = ({
publicDomain,
}: {
publicDomain: PublicDomain;
}) => {
const dropdownId = `settings-public-domain-row-${publicDomain.id}`;
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { closeDropdown } = useCloseDropdown();
const { refetch: refetchPublicDomains } = useFindManyPublicDomainsQuery();
const [deletePublicDomain] = useDeletePublicDomainMutation();
const handleDeletePublicDomain = async () => {
await deletePublicDomain({
variables: {
domain: publicDomain.domain,
},
onCompleted: () =>
enqueueSuccessSnackBar({
message: t`Public domain successfully deleted`,
}),
onError: (error) => enqueueErrorSnackBar({ apolloError: error }),
});
};
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="right-start"
clickableComponent={
<LightIconButton Icon={IconDotsVertical} accent="tertiary" />
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItem
accent="danger"
LeftIcon={IconTrash}
text={t`Delete`}
onClick={async () => {
await handleDeletePublicDomain();
closeDropdown(dropdownId);
await refetchPublicDomains();
}}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
@@ -8,8 +8,8 @@ import { useRecoilValue } from 'recoil';
import { H2Title, IconReload, IconTrash } from 'twenty-ui/display';
import { Button, ButtonGroup } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { SettingsCustomDomainRecords } from '@/settings/domains/components/SettingsCustomDomainRecords';
import { CheckCustomDomainValidRecordsEffect } from '@/settings/domains/CheckCustomDomainValidRecordsEffect';
import { SettingsDomainRecords } from '@/settings/domains/components/SettingsDomainRecords';
import { CheckCustomDomainValidRecordsEffect } from '@/settings/domains/components/CheckCustomDomainValidRecordsEffect';
import { useCheckCustomDomainValidRecords } from '@/settings/domains/hooks/useCheckCustomDomainValidRecords';
import { customDomainRecordsState } from '@/settings/domains/states/customDomainRecordsState';
@@ -99,9 +99,7 @@ export const SettingsCustomDomain = () => {
{currentWorkspace?.customDomain && (
<StyledRecordsWrapper>
{customDomainRecords && (
<SettingsCustomDomainRecords
records={customDomainRecords.records}
/>
<SettingsDomainRecords records={customDomainRecords.records} />
)}
</StyledRecordsWrapper>
)}
@@ -46,7 +46,7 @@ const StyledButton = styled(Button)`
width: 100%;
`;
export const SettingsCustomDomainRecords = ({
export const SettingsDomainRecords = ({
records,
}: {
records: DomainValidRecords['records'];
@@ -0,0 +1,70 @@
import { SettingsPath } from 'twenty-shared/types';
import { IconAt, IconMailCog, Status } from 'twenty-ui/display';
import { useFindManyPublicDomainsQuery } from '~/generated-metadata/graphql';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { type PublicDomain } from '~/generated/graphql';
import { SettingPublicDomainRowDropdownMenu } from '@/settings/domains/components/SettingPublicDomainRowDropdownMenu';
import { selectedPublicDomainState } from '@/settings/domains/states/selectedPublicDomainState';
import { useSetRecoilState } from 'recoil';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { useLingui } from '@lingui/react/macro';
import { Link } from 'react-router-dom';
import styled from '@emotion/styled';
const StyledLink = styled(Link)`
text-decoration: none;
`;
export const SettingsPublicDomainsListCard = () => {
const navigate = useNavigateSettings();
const { t } = useLingui();
const setSelectedPublicDomain = useSetRecoilState(selectedPublicDomainState);
const { data, loading } = useFindManyPublicDomainsQuery();
const publicDomains = data?.findManyPublicDomains;
if (loading || !publicDomains) {
return null;
}
if (publicDomains.length === 0) {
setSelectedPublicDomain(undefined);
return (
<StyledLink to={getSettingsPath(SettingsPath.PublicDomain)}>
<SettingsCard title={t`Add Public Domain`} Icon={<IconMailCog />} />
</StyledLink>
);
}
return (
<SettingsListCard
items={publicDomains}
getItemLabel={({ domain }) => domain}
getItemDescription={({ createdAt }) => createdAt}
RowIcon={IconAt}
onRowClick={(publicDomain: PublicDomain) => {
setSelectedPublicDomain(publicDomain);
navigate(SettingsPath.PublicDomain);
}}
RowRightComponent={({ item: publicDomain }) => (
<>
{!publicDomain.isValidated && (
<Status color="orange" text="Pending" />
)}
<SettingPublicDomainRowDropdownMenu publicDomain={publicDomain} />
</>
)}
hasFooter
footerButtonLabel="Add Public Domain"
onFooterButtonClick={() => {
setSelectedPublicDomain(undefined);
navigate(SettingsPath.PublicDomain);
}}
/>
);
};
@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const CHECK_PUBLIC_DOMAIN_VALID_RECORDS = gql`
mutation CheckPublicDomainValidRecords($domain: String!) {
checkPublicDomainValidRecords(domain: $domain) {
id
domain
records {
type
key
value
validationType
status
}
}
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const CREATE_PUBLIC_DOMAIN = gql`
mutation CreatePublicDomain($domain: String!) {
createPublicDomain(domain: $domain) {
id
domain
isValidated
createdAt
}
}
`;
@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_PUBLIC_DOMAIN = gql`
mutation DeletePublicDomain($domain: String!) {
deletePublicDomain(domain: $domain)
}
`;
@@ -0,0 +1,12 @@
import { gql } from '@apollo/client';
export const FIND_MANY_PUBLIC_DOMAINS = gql`
query FindManyPublicDomains {
findManyPublicDomains {
id
domain
isValidated
createdAt
}
}
`;
@@ -0,0 +1,51 @@
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { publicDomainRecordsState } from '@/settings/domains/states/publicDomainRecordsState';
import { useCheckPublicDomainValidRecordsMutation } from '~/generated-metadata/graphql';
export const useCheckPublicDomainValidRecords = () => {
const [checkPublicDomainValidRecords] =
useCheckPublicDomainValidRecordsMutation();
const { enqueueErrorSnackBar } = useSnackBar();
const [{ isLoading, publicDomainRecords }, setPublicDomainRecords] =
useRecoilState(publicDomainRecordsState);
const checkPublicDomainRecords = (domain: string) => {
if (isLoading) {
return;
}
setPublicDomainRecords((currentState) => ({
...currentState,
isLoading: true,
}));
checkPublicDomainValidRecords({
variables: {
domain,
},
onCompleted: (data) => {
setPublicDomainRecords((currentState) => ({
...currentState,
isLoading: false,
...(isDefined(data.checkPublicDomainValidRecords)
? { publicDomainRecords: data.checkPublicDomainValidRecords }
: {}),
}));
},
onError: (error) => {
enqueueErrorSnackBar({ apolloError: error });
setPublicDomainRecords((currentState) => ({
...currentState,
isLoading: false,
}));
},
});
};
return {
isLoading,
publicDomainRecords,
checkPublicDomainRecords,
};
};
@@ -0,0 +1,10 @@
import { type DomainValidRecords } from '~/generated/graphql';
import { createState } from 'twenty-ui/utilities';
export const publicDomainRecordsState = createState<{
publicDomainRecords: DomainValidRecords | null;
isLoading: boolean;
}>({
key: 'publicDomainRecordsState',
defaultValue: { isLoading: false, publicDomainRecords: null },
});
@@ -0,0 +1,7 @@
import { type PublicDomain } from '~/generated/graphql';
import { createState } from 'twenty-ui/utilities';
export const selectedPublicDomainState = createState<PublicDomain | undefined>({
key: 'selectedPublicDomainState',
defaultValue: undefined,
});
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { type useLingui } from '@lingui/react/macro';
export const getDomainValidationSchema = (
t: ReturnType<typeof useLingui>['t'],
) =>
z
.string()
.regex(
/^([a-zA-Z0-9][a-zA-Z0-9-]*\.)+[a-zA-Z0-9][a-zA-Z0-9-]*\.[a-zA-Z]{2,}$/,
{
message: t`Invalid domain. Please include at least one subdomain (e.g., sub.example.com).`,
},
)
.regex(
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/,
{
message: t`Invalid domain. Domains have to be smaller than 256 characters in length, cannot be IP addresses, cannot contain spaces, cannot contain any special characters such as _~\`!@#$%^*()=+{}[]|\\;:'",<>/? and cannot begin or end with a '-' character.`,
},
)
.max(256)
.optional()
.or(z.literal(''));
@@ -0,0 +1,13 @@
import { z } from 'zod';
import { type useLingui } from '@lingui/react/macro';
export const getSubdomainValidationSchema = (
t: ReturnType<typeof useLingui>['t'],
) =>
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]$/, {
message: t`Use letter, number and dash only. Start and finish with a letter or a number`,
});