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
@@ -1047,6 +1047,7 @@ export enum FeatureFlagKey {
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
@@ -2678,6 +2679,7 @@ export type Query = {
findAgentHandoffs: Array<AgentHandoffDto>;
findDistantTablesWithStatus: Array<RemoteTable>;
findManyAgents: Array<Agent>;
findManyPublicDomains: Array<PublicDomain>;
findManyRemoteServersByType: Array<RemoteServer>;
findManyServerlessFunctions: Array<ServerlessFunction>;
findOneAgent: Agent;
@@ -4805,6 +4807,32 @@ export type GetWebhooksQueryVariables = Exact<{ [key: string]: never; }>;
export type GetWebhooksQuery = { __typename?: 'Query', webhooks: Array<{ __typename?: 'Webhook', id: string, targetUrl: string, operations: Array<string>, description?: string | null, secret: string }> };
export type CheckPublicDomainValidRecordsMutationVariables = Exact<{
domain: Scalars['String'];
}>;
export type CheckPublicDomainValidRecordsMutation = { __typename?: 'Mutation', checkPublicDomainValidRecords?: { __typename?: 'DomainValidRecords', id: string, domain: string, records: Array<{ __typename?: 'DomainRecord', type: string, key: string, value: string, validationType: string, status: string }> } | null };
export type CreatePublicDomainMutationVariables = Exact<{
domain: Scalars['String'];
}>;
export type CreatePublicDomainMutation = { __typename?: 'Mutation', createPublicDomain: { __typename?: 'PublicDomain', id: string, domain: string, isValidated: boolean, createdAt: string } };
export type DeletePublicDomainMutationVariables = Exact<{
domain: Scalars['String'];
}>;
export type DeletePublicDomainMutation = { __typename?: 'Mutation', deletePublicDomain: boolean };
export type FindManyPublicDomainsQueryVariables = Exact<{ [key: string]: never; }>;
export type FindManyPublicDomainsQuery = { __typename?: 'Query', findManyPublicDomains: Array<{ __typename?: 'PublicDomain', id: string, domain: string, isValidated: boolean, createdAt: string }> };
export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
input: UpdateLabPublicFeatureFlagInput;
}>;
@@ -9576,6 +9604,151 @@ export function useGetWebhooksLazyQuery(baseOptions?: Apollo.LazyQueryHookOption
export type GetWebhooksQueryHookResult = ReturnType<typeof useGetWebhooksQuery>;
export type GetWebhooksLazyQueryHookResult = ReturnType<typeof useGetWebhooksLazyQuery>;
export type GetWebhooksQueryResult = Apollo.QueryResult<GetWebhooksQuery, GetWebhooksQueryVariables>;
export const CheckPublicDomainValidRecordsDocument = gql`
mutation CheckPublicDomainValidRecords($domain: String!) {
checkPublicDomainValidRecords(domain: $domain) {
id
domain
records {
type
key
value
validationType
status
}
}
}
`;
export type CheckPublicDomainValidRecordsMutationFn = Apollo.MutationFunction<CheckPublicDomainValidRecordsMutation, CheckPublicDomainValidRecordsMutationVariables>;
/**
* __useCheckPublicDomainValidRecordsMutation__
*
* To run a mutation, you first call `useCheckPublicDomainValidRecordsMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCheckPublicDomainValidRecordsMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [checkPublicDomainValidRecordsMutation, { data, loading, error }] = useCheckPublicDomainValidRecordsMutation({
* variables: {
* domain: // value for 'domain'
* },
* });
*/
export function useCheckPublicDomainValidRecordsMutation(baseOptions?: Apollo.MutationHookOptions<CheckPublicDomainValidRecordsMutation, CheckPublicDomainValidRecordsMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CheckPublicDomainValidRecordsMutation, CheckPublicDomainValidRecordsMutationVariables>(CheckPublicDomainValidRecordsDocument, options);
}
export type CheckPublicDomainValidRecordsMutationHookResult = ReturnType<typeof useCheckPublicDomainValidRecordsMutation>;
export type CheckPublicDomainValidRecordsMutationResult = Apollo.MutationResult<CheckPublicDomainValidRecordsMutation>;
export type CheckPublicDomainValidRecordsMutationOptions = Apollo.BaseMutationOptions<CheckPublicDomainValidRecordsMutation, CheckPublicDomainValidRecordsMutationVariables>;
export const CreatePublicDomainDocument = gql`
mutation CreatePublicDomain($domain: String!) {
createPublicDomain(domain: $domain) {
id
domain
isValidated
createdAt
}
}
`;
export type CreatePublicDomainMutationFn = Apollo.MutationFunction<CreatePublicDomainMutation, CreatePublicDomainMutationVariables>;
/**
* __useCreatePublicDomainMutation__
*
* To run a mutation, you first call `useCreatePublicDomainMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreatePublicDomainMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [createPublicDomainMutation, { data, loading, error }] = useCreatePublicDomainMutation({
* variables: {
* domain: // value for 'domain'
* },
* });
*/
export function useCreatePublicDomainMutation(baseOptions?: Apollo.MutationHookOptions<CreatePublicDomainMutation, CreatePublicDomainMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreatePublicDomainMutation, CreatePublicDomainMutationVariables>(CreatePublicDomainDocument, options);
}
export type CreatePublicDomainMutationHookResult = ReturnType<typeof useCreatePublicDomainMutation>;
export type CreatePublicDomainMutationResult = Apollo.MutationResult<CreatePublicDomainMutation>;
export type CreatePublicDomainMutationOptions = Apollo.BaseMutationOptions<CreatePublicDomainMutation, CreatePublicDomainMutationVariables>;
export const DeletePublicDomainDocument = gql`
mutation DeletePublicDomain($domain: String!) {
deletePublicDomain(domain: $domain)
}
`;
export type DeletePublicDomainMutationFn = Apollo.MutationFunction<DeletePublicDomainMutation, DeletePublicDomainMutationVariables>;
/**
* __useDeletePublicDomainMutation__
*
* To run a mutation, you first call `useDeletePublicDomainMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeletePublicDomainMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deletePublicDomainMutation, { data, loading, error }] = useDeletePublicDomainMutation({
* variables: {
* domain: // value for 'domain'
* },
* });
*/
export function useDeletePublicDomainMutation(baseOptions?: Apollo.MutationHookOptions<DeletePublicDomainMutation, DeletePublicDomainMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeletePublicDomainMutation, DeletePublicDomainMutationVariables>(DeletePublicDomainDocument, options);
}
export type DeletePublicDomainMutationHookResult = ReturnType<typeof useDeletePublicDomainMutation>;
export type DeletePublicDomainMutationResult = Apollo.MutationResult<DeletePublicDomainMutation>;
export type DeletePublicDomainMutationOptions = Apollo.BaseMutationOptions<DeletePublicDomainMutation, DeletePublicDomainMutationVariables>;
export const FindManyPublicDomainsDocument = gql`
query FindManyPublicDomains {
findManyPublicDomains {
id
domain
isValidated
createdAt
}
}
`;
/**
* __useFindManyPublicDomainsQuery__
*
* To run a query within a React component, call `useFindManyPublicDomainsQuery` and pass it any options that fit your needs.
* When your component renders, `useFindManyPublicDomainsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useFindManyPublicDomainsQuery({
* variables: {
* },
* });
*/
export function useFindManyPublicDomainsQuery(baseOptions?: Apollo.QueryHookOptions<FindManyPublicDomainsQuery, FindManyPublicDomainsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<FindManyPublicDomainsQuery, FindManyPublicDomainsQueryVariables>(FindManyPublicDomainsDocument, options);
}
export function useFindManyPublicDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FindManyPublicDomainsQuery, FindManyPublicDomainsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<FindManyPublicDomainsQuery, FindManyPublicDomainsQueryVariables>(FindManyPublicDomainsDocument, options);
}
export type FindManyPublicDomainsQueryHookResult = ReturnType<typeof useFindManyPublicDomainsQuery>;
export type FindManyPublicDomainsLazyQueryHookResult = ReturnType<typeof useFindManyPublicDomainsLazyQuery>;
export type FindManyPublicDomainsQueryResult = Apollo.QueryResult<FindManyPublicDomainsQuery, FindManyPublicDomainsQueryVariables>;
export const UpdateLabPublicFeatureFlagDocument = gql`
mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) {
updateLabPublicFeatureFlag(input: $input) {
@@ -1011,6 +1011,7 @@ export enum FeatureFlagKey {
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED',
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
@@ -2588,6 +2589,7 @@ export type Query = {
findAgentHandoffTargets: Array<Agent>;
findAgentHandoffs: Array<AgentHandoffDto>;
findManyAgents: Array<Agent>;
findManyPublicDomains: Array<PublicDomain>;
findManyServerlessFunctions: Array<ServerlessFunction>;
findOneAgent: Agent;
findOneServerlessFunction: ServerlessFunction;
@@ -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`,
});
@@ -22,6 +22,8 @@ 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/get-subdomain-validation-schema';
import { getDomainValidationSchema } from '@/settings/domains/utils/get-domain-validation-schema';
export const SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID =
'subdomain-change-confirmation-modal';
@@ -32,30 +34,8 @@ export const SettingsDomain = () => {
const validationSchema = z
.object({
subdomain: 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`,
}),
customDomain: 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 custom 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('')),
subdomain: getSubdomainValidationSchema(t),
customDomain: getDomainValidationSchema(t),
})
.required();
@@ -9,6 +9,9 @@ import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { SettingsPublicDomainsListCard } from '@/settings/domains/components/SettingsPublicDomainsListCard';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { FeatureFlagKey } from '~/generated/graphql';
const StyledMainContent = styled.div`
display: flex;
@@ -23,6 +26,10 @@ const StyledSection = styled(Section)`
export const SettingsDomains = () => {
const { t } = useLingui();
const featureFlags = useFeatureFlagsMap();
const isPublicDomainEnabled =
featureFlags[FeatureFlagKey.IS_PUBLIC_DOMAIN_ENABLED];
return (
<SubMenuTopBarContainer
@@ -51,6 +58,15 @@ export const SettingsDomains = () => {
/>
<SettingsApprovedAccessDomainsListCard />
</StyledSection>
{isPublicDomainEnabled && (
<StyledSection>
<H2Title
title={t`Public Domains`}
description={t`Provision a complete and secure hosting environment on these domains.`}
/>
<SettingsPublicDomainsListCard />
</StyledSection>
)}
</StyledMainContent>
</SettingsPageContainer>
</SubMenuTopBarContainer>
@@ -17,4 +17,5 @@ export enum FeatureFlagKey {
IS_DATABASE_EVENT_TRIGGER_ENABLED = 'IS_DATABASE_EVENT_TRIGGER_ENABLED',
IS_CALENDAR_VIEW_ENABLED = 'IS_CALENDAR_VIEW_ENABLED',
IS_GROUP_BY_ENABLED = 'IS_GROUP_BY_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
}
@@ -1,4 +1,4 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { Query, Args, Mutation, Resolver } from '@nestjs/graphql';
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -30,6 +30,15 @@ export class PublicDomainResolver {
private readonly publicDomainService: PublicDomainService,
) {}
@Query(() => [PublicDomainDTO])
async findManyPublicDomains(
@AuthWorkspace() currentWorkspace: Workspace,
): Promise<PublicDomainDTO[]> {
return await this.publicDomainRepository.find({
where: { workspaceId: currentWorkspace.id },
});
}
@Mutation(() => PublicDomainDTO)
async createPublicDomain(
@Args() { domain }: PublicDomainInput,
@@ -140,6 +140,7 @@ describe('WorkspaceEntityManager', () => {
IS_DATABASE_EVENT_TRIGGER_ENABLED: false,
IS_CALENDAR_VIEW_ENABLED: false,
IS_GROUP_BY_ENABLED: false,
IS_PUBLIC_DOMAIN_ENABLED: false,
},
eventEmitterService: {
emitMutationEvent: jest.fn(),
@@ -80,6 +80,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IS_PUBLIC_DOMAIN_ENABLED,
workspaceId: workspaceId,
value: false,
},
])
.execute();
};
@@ -23,6 +23,7 @@ export enum SettingsPath {
Workspace = 'general',
Domains = 'domains',
Domain = 'domains/domain',
PublicDomain = 'domains/public-domain',
NewApprovedAccessDomain = 'domains/approved-access-domain/new',
Releases = 'releases',
AI = 'ai',