diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 1467f5b0b1..bd2b95b082 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -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; findDistantTablesWithStatus: Array; findManyAgents: Array; + findManyPublicDomains: Array; findManyRemoteServersByType: Array; findManyServerlessFunctions: Array; 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, 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; export type GetWebhooksLazyQueryHookResult = ReturnType; export type GetWebhooksQueryResult = Apollo.QueryResult; +export const CheckPublicDomainValidRecordsDocument = gql` + mutation CheckPublicDomainValidRecords($domain: String!) { + checkPublicDomainValidRecords(domain: $domain) { + id + domain + records { + type + key + value + validationType + status + } + } +} + `; +export type CheckPublicDomainValidRecordsMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CheckPublicDomainValidRecordsDocument, options); + } +export type CheckPublicDomainValidRecordsMutationHookResult = ReturnType; +export type CheckPublicDomainValidRecordsMutationResult = Apollo.MutationResult; +export type CheckPublicDomainValidRecordsMutationOptions = Apollo.BaseMutationOptions; +export const CreatePublicDomainDocument = gql` + mutation CreatePublicDomain($domain: String!) { + createPublicDomain(domain: $domain) { + id + domain + isValidated + createdAt + } +} + `; +export type CreatePublicDomainMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreatePublicDomainDocument, options); + } +export type CreatePublicDomainMutationHookResult = ReturnType; +export type CreatePublicDomainMutationResult = Apollo.MutationResult; +export type CreatePublicDomainMutationOptions = Apollo.BaseMutationOptions; +export const DeletePublicDomainDocument = gql` + mutation DeletePublicDomain($domain: String!) { + deletePublicDomain(domain: $domain) +} + `; +export type DeletePublicDomainMutationFn = Apollo.MutationFunction; + +/** + * __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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeletePublicDomainDocument, options); + } +export type DeletePublicDomainMutationHookResult = ReturnType; +export type DeletePublicDomainMutationResult = Apollo.MutationResult; +export type DeletePublicDomainMutationOptions = Apollo.BaseMutationOptions; +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) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(FindManyPublicDomainsDocument, options); + } +export function useFindManyPublicDomainsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(FindManyPublicDomainsDocument, options); + } +export type FindManyPublicDomainsQueryHookResult = ReturnType; +export type FindManyPublicDomainsLazyQueryHookResult = ReturnType; +export type FindManyPublicDomainsQueryResult = Apollo.QueryResult; export const UpdateLabPublicFeatureFlagDocument = gql` mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) { updateLabPublicFeatureFlag(input: $input) { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 9ac1f9ddc7..0c00d3e50d 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -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; findAgentHandoffs: Array; findManyAgents: Array; + findManyPublicDomains: Array; findManyServerlessFunctions: Array; findOneAgent: Agent; findOneServerlessFunction: ServerlessFunction; diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 649d5df1bb..059d623167 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -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 = ({ /> } /> } /> + } + /> { + 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 <>; +}; diff --git a/packages/twenty-front/src/modules/settings/domains/components/SettingPublicDomain.tsx b/packages/twenty-front/src/modules/settings/domains/components/SettingPublicDomain.tsx new file mode 100644 index 0000000000..66b5d3a33b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/components/SettingPublicDomain.tsx @@ -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( + 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 ( + Workspace, + href: getSettingsPath(SettingsPath.Workspace), + }, + { + children: Domains, + href: getSettingsPath(SettingsPath.Domains), + }, + { children: Public Domain }, + ]} + actionButton={ + navigate(SettingsPath.Domains)} + isSaveDisabled={loading || isDefined(selectedPublicDomain)} + onSave={onCreate} + /> + } + > + +
+ + {isDefined(selectedPublicDomain) && ( + + )} + + + {isDefined(selectedPublicDomain) && ( + + + checkPublicDomainRecords(selectedPublicDomain.domain) + } + type="button" + /> + + + )} + + {isDefined(selectedPublicDomain) && publicDomainRecords?.domain && ( + + {publicDomainRecords.records && ( + + )} + + )} +
+
+
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/domains/components/SettingPublicDomainRowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/domains/components/SettingPublicDomainRowDropdownMenu.tsx new file mode 100644 index 0000000000..c17298a5c2 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/components/SettingPublicDomainRowDropdownMenu.tsx @@ -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 ( + + } + dropdownComponents={ + + + { + await handleDeletePublicDomain(); + closeDropdown(dropdownId); + await refetchPublicDomains(); + }} + /> + + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomain.tsx b/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomain.tsx index 80c37e24ab..099fed003c 100644 --- a/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomain.tsx +++ b/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomain.tsx @@ -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 && ( {customDomainRecords && ( - + )} )} diff --git a/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomainRecords.tsx b/packages/twenty-front/src/modules/settings/domains/components/SettingsDomainRecords.tsx similarity index 98% rename from packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomainRecords.tsx rename to packages/twenty-front/src/modules/settings/domains/components/SettingsDomainRecords.tsx index e133c444b1..4d884e4ea7 100644 --- a/packages/twenty-front/src/modules/settings/domains/components/SettingsCustomDomainRecords.tsx +++ b/packages/twenty-front/src/modules/settings/domains/components/SettingsDomainRecords.tsx @@ -46,7 +46,7 @@ const StyledButton = styled(Button)` width: 100%; `; -export const SettingsCustomDomainRecords = ({ +export const SettingsDomainRecords = ({ records, }: { records: DomainValidRecords['records']; diff --git a/packages/twenty-front/src/modules/settings/domains/components/SettingsPublicDomainsListCard.tsx b/packages/twenty-front/src/modules/settings/domains/components/SettingsPublicDomainsListCard.tsx new file mode 100644 index 0000000000..8c46a7321e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/components/SettingsPublicDomainsListCard.tsx @@ -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 ( + + } /> + + ); + } + + return ( + domain} + getItemDescription={({ createdAt }) => createdAt} + RowIcon={IconAt} + onRowClick={(publicDomain: PublicDomain) => { + setSelectedPublicDomain(publicDomain); + navigate(SettingsPath.PublicDomain); + }} + RowRightComponent={({ item: publicDomain }) => ( + <> + {!publicDomain.isValidated && ( + + )} + + + )} + hasFooter + footerButtonLabel="Add Public Domain" + onFooterButtonClick={() => { + setSelectedPublicDomain(undefined); + navigate(SettingsPath.PublicDomain); + }} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/domains/graphql/mutations/checkPublicDomainValidRecords.ts b/packages/twenty-front/src/modules/settings/domains/graphql/mutations/checkPublicDomainValidRecords.ts new file mode 100644 index 0000000000..66a1d77280 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/graphql/mutations/checkPublicDomainValidRecords.ts @@ -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 + } + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/domains/graphql/mutations/createPublicDomain.ts b/packages/twenty-front/src/modules/settings/domains/graphql/mutations/createPublicDomain.ts new file mode 100644 index 0000000000..39783c900d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/graphql/mutations/createPublicDomain.ts @@ -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 + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/domains/graphql/mutations/deletePublicDomain.ts b/packages/twenty-front/src/modules/settings/domains/graphql/mutations/deletePublicDomain.ts new file mode 100644 index 0000000000..3fcd616420 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/graphql/mutations/deletePublicDomain.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const DELETE_PUBLIC_DOMAIN = gql` + mutation DeletePublicDomain($domain: String!) { + deletePublicDomain(domain: $domain) + } +`; diff --git a/packages/twenty-front/src/modules/settings/domains/graphql/queries/findManyPublicDomains.ts b/packages/twenty-front/src/modules/settings/domains/graphql/queries/findManyPublicDomains.ts new file mode 100644 index 0000000000..3ffda31462 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/graphql/queries/findManyPublicDomains.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +export const FIND_MANY_PUBLIC_DOMAINS = gql` + query FindManyPublicDomains { + findManyPublicDomains { + id + domain + isValidated + createdAt + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/domains/hooks/useCheckPublicDomainValidRecords.ts b/packages/twenty-front/src/modules/settings/domains/hooks/useCheckPublicDomainValidRecords.ts new file mode 100644 index 0000000000..1a9d0fbaa6 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/hooks/useCheckPublicDomainValidRecords.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/domains/states/publicDomainRecordsState.ts b/packages/twenty-front/src/modules/settings/domains/states/publicDomainRecordsState.ts new file mode 100644 index 0000000000..d8be1a34aa --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/states/publicDomainRecordsState.ts @@ -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 }, +}); diff --git a/packages/twenty-front/src/modules/settings/domains/states/selectedPublicDomainState.ts b/packages/twenty-front/src/modules/settings/domains/states/selectedPublicDomainState.ts new file mode 100644 index 0000000000..b33e2d33a6 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/states/selectedPublicDomainState.ts @@ -0,0 +1,7 @@ +import { type PublicDomain } from '~/generated/graphql'; +import { createState } from 'twenty-ui/utilities'; + +export const selectedPublicDomainState = createState({ + key: 'selectedPublicDomainState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/settings/domains/utils/get-domain-validation-schema.ts b/packages/twenty-front/src/modules/settings/domains/utils/get-domain-validation-schema.ts new file mode 100644 index 0000000000..a19000ea45 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/utils/get-domain-validation-schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import { type useLingui } from '@lingui/react/macro'; + +export const getDomainValidationSchema = ( + t: ReturnType['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('')); diff --git a/packages/twenty-front/src/modules/settings/domains/utils/get-subdomain-validation-schema.ts b/packages/twenty-front/src/modules/settings/domains/utils/get-subdomain-validation-schema.ts new file mode 100644 index 0000000000..ba8cab8ff4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/domains/utils/get-subdomain-validation-schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; +import { type useLingui } from '@lingui/react/macro'; + +export const getSubdomainValidationSchema = ( + t: ReturnType['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`, + }); diff --git a/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx index 2e88454f34..99f6af6304 100644 --- a/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/domains/SettingsDomain.tsx @@ -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(); diff --git a/packages/twenty-front/src/pages/settings/domains/SettingsDomains.tsx b/packages/twenty-front/src/pages/settings/domains/SettingsDomains.tsx index cdba2fea0a..fbbba80847 100644 --- a/packages/twenty-front/src/pages/settings/domains/SettingsDomains.tsx +++ b/packages/twenty-front/src/pages/settings/domains/SettingsDomains.tsx @@ -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 ( { /> + {isPublicDomainEnabled && ( + + + + + )} diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index d0ea0dccd6..adeb6dd3d0 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -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', } diff --git a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts index 48405ebbf8..4a13ab061d 100644 --- a/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/public-domain/public-domain.resolver.ts @@ -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 { + return await this.publicDomainRepository.find({ + where: { workspaceId: currentWorkspace.id }, + }); + } + @Mutation(() => PublicDomainDTO) async createPublicDomain( @Args() { domain }: PublicDomainInput, diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index e507d7d76d..1b470f49c2 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -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(), diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index 5197596f72..40732b7a7c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -80,6 +80,11 @@ export const seedFeatureFlags = async ( workspaceId: workspaceId, value: false, }, + { + key: FeatureFlagKey.IS_PUBLIC_DOMAIN_ENABLED, + workspaceId: workspaceId, + value: false, + }, ]) .execute(); }; diff --git a/packages/twenty-shared/src/types/SettingsPath.ts b/packages/twenty-shared/src/types/SettingsPath.ts index e54833e843..36774efa5e 100644 --- a/packages/twenty-shared/src/types/SettingsPath.ts +++ b/packages/twenty-shared/src/types/SettingsPath.ts @@ -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',