feat(custom-domains): allow to register a custom domain (without UI) (#9879)

# In this PR
- Allow to register a custom domain
- Refacto subdomain generation

# In other PRs
- Add UI to deal with a custom domain
- Add logic to work with custom domain
This commit is contained in:
Antoine Moreaux
2025-01-30 13:51:16 +01:00
committed by GitHub
parent ae4bf8d929
commit e895aa27e6
33 changed files with 1049 additions and 240 deletions

View File

@ -109,6 +109,7 @@ export type AuthorizeApp = {
export type AvailableWorkspaceOutput = {
__typename?: 'AvailableWorkspaceOutput';
displayName?: Maybe<Scalars['String']['output']>;
hostname?: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output'];
logo?: Maybe<Scalars['String']['output']>;
sso: Array<SsoConnection>;
@ -375,6 +376,28 @@ export type CursorPaging = {
last?: InputMaybe<Scalars['Int']['input']>;
};
export type CustomHostnameDetails = {
__typename?: 'CustomHostnameDetails';
hostname: Scalars['String']['output'];
id: Scalars['String']['output'];
ownershipVerifications: Array<OwnershipVerification>;
status?: Maybe<Scalars['String']['output']>;
};
export type CustomHostnameOwnershipVerificationHttp = {
__typename?: 'CustomHostnameOwnershipVerificationHttp';
body: Scalars['String']['output'];
type: Scalars['String']['output'];
url: Scalars['String']['output'];
};
export type CustomHostnameOwnershipVerificationTxt = {
__typename?: 'CustomHostnameOwnershipVerificationTxt';
name: Scalars['String']['output'];
type: Scalars['String']['output'];
value: Scalars['String']['output'];
};
export type DeleteOneFieldInput = {
/** The id of the field to delete. */
id: Scalars['UUID']['input'];
@ -1209,6 +1232,8 @@ export type OnboardingStepSuccess = {
success: Scalars['Boolean']['output'];
};
export type OwnershipVerification = CustomHostnameOwnershipVerificationHttp | CustomHostnameOwnershipVerificationTxt;
export type PageInfo = {
__typename?: 'PageInfo';
/** The cursor of the last returned record. */
@ -1246,6 +1271,7 @@ export type PublicWorkspaceDataOutput = {
__typename?: 'PublicWorkspaceDataOutput';
authProviders: AuthProviders;
displayName?: Maybe<Scalars['String']['output']>;
hostname?: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output'];
logo?: Maybe<Scalars['String']['output']>;
subdomain: Scalars['String']['output'];
@ -1275,6 +1301,7 @@ export type Query = {
findWorkspaceFromInviteHash: Workspace;
findWorkspaceInvitations: Array<WorkspaceInvitation>;
getAvailablePackages: Scalars['JSON']['output'];
getHostnameDetails?: Maybe<CustomHostnameDetails>;
getPostgresCredentials?: Maybe<PostgresCredentials>;
getProductPrices: BillingProductPricesOutput;
getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput;
@ -1850,7 +1877,7 @@ export type UpdateWorkflowVersionStepInput = {
export type UpdateWorkspaceInput = {
allowImpersonation?: InputMaybe<Scalars['Boolean']['input']>;
displayName?: InputMaybe<Scalars['String']['input']>;
domainName?: InputMaybe<Scalars['String']['input']>;
hostname?: InputMaybe<Scalars['String']['input']>;
inviteHash?: InputMaybe<Scalars['String']['input']>;
isGoogleAuthEnabled?: InputMaybe<Scalars['Boolean']['input']>;
isMicrosoftAuthEnabled?: InputMaybe<Scalars['Boolean']['input']>;

View File

@ -102,6 +102,7 @@ export type AuthorizeApp = {
export type AvailableWorkspaceOutput = {
__typename?: 'AvailableWorkspaceOutput';
displayName?: Maybe<Scalars['String']>;
hostname?: Maybe<Scalars['String']>;
id: Scalars['String'];
logo?: Maybe<Scalars['String']>;
sso: Array<SsoConnection>;
@ -312,6 +313,28 @@ export type CursorPaging = {
last?: InputMaybe<Scalars['Int']>;
};
export type CustomHostnameDetails = {
__typename?: 'CustomHostnameDetails';
hostname: Scalars['String'];
id: Scalars['String'];
ownershipVerifications: Array<OwnershipVerification>;
status?: Maybe<Scalars['String']>;
};
export type CustomHostnameOwnershipVerificationHttp = {
__typename?: 'CustomHostnameOwnershipVerificationHttp';
body: Scalars['String'];
type: Scalars['String'];
url: Scalars['String'];
};
export type CustomHostnameOwnershipVerificationTxt = {
__typename?: 'CustomHostnameOwnershipVerificationTxt';
name: Scalars['String'];
type: Scalars['String'];
value: Scalars['String'];
};
export type DeleteOneFieldInput = {
/** The id of the field to delete. */
id: Scalars['UUID'];
@ -1076,6 +1099,8 @@ export type OnboardingStepSuccess = {
success: Scalars['Boolean'];
};
export type OwnershipVerification = CustomHostnameOwnershipVerificationHttp | CustomHostnameOwnershipVerificationTxt;
export type PageInfo = {
__typename?: 'PageInfo';
/** The cursor of the last returned record. */
@ -1113,6 +1138,7 @@ export type PublicWorkspaceDataOutput = {
__typename?: 'PublicWorkspaceDataOutput';
authProviders: AuthProviders;
displayName?: Maybe<Scalars['String']>;
hostname?: Maybe<Scalars['String']>;
id: Scalars['String'];
logo?: Maybe<Scalars['String']>;
subdomain: Scalars['String'];
@ -1139,6 +1165,7 @@ export type Query = {
findWorkspaceFromInviteHash: Workspace;
findWorkspaceInvitations: Array<WorkspaceInvitation>;
getAvailablePackages: Scalars['JSON'];
getHostnameDetails?: Maybe<CustomHostnameDetails>;
getPostgresCredentials?: Maybe<PostgresCredentials>;
getProductPrices: BillingProductPricesOutput;
getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput;
@ -1638,7 +1665,7 @@ export type UpdateWorkflowVersionStepInput = {
export type UpdateWorkspaceInput = {
allowImpersonation?: InputMaybe<Scalars['Boolean']>;
displayName?: InputMaybe<Scalars['String']>;
domainName?: InputMaybe<Scalars['String']>;
hostname?: InputMaybe<Scalars['String']>;
inviteHash?: InputMaybe<Scalars['String']>;
isGoogleAuthEnabled?: InputMaybe<Scalars['Boolean']>;
isMicrosoftAuthEnabled?: InputMaybe<Scalars['Boolean']>;
@ -2041,12 +2068,12 @@ export type CheckUserExistsQueryVariables = Exact<{
}>;
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, isEmailVerified: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, logo?: string | null, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } };
export type CheckUserExistsQuery = { __typename?: 'Query', checkUserExists: { __typename: 'UserExists', exists: boolean, isEmailVerified: boolean, availableWorkspaces: Array<{ __typename?: 'AvailableWorkspaceOutput', id: string, displayName?: string | null, subdomain: string, hostname?: string | null, logo?: string | null, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } | { __typename: 'UserNotExists', exists: boolean } };
export type GetPublicWorkspaceDataBySubdomainQueryVariables = Exact<{ [key: string]: never; }>;
export type GetPublicWorkspaceDataBySubdomainQuery = { __typename?: 'Query', getPublicWorkspaceDataBySubdomain: { __typename?: 'PublicWorkspaceDataOutput', id: string, logo?: string | null, displayName?: string | null, subdomain: string, authProviders: { __typename?: 'AuthProviders', google: boolean, magicLink: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> } } };
export type GetPublicWorkspaceDataBySubdomainQuery = { __typename?: 'Query', getPublicWorkspaceDataBySubdomain: { __typename?: 'PublicWorkspaceDataOutput', id: string, logo?: string | null, displayName?: string | null, subdomain: string, hostname?: string | null, authProviders: { __typename?: 'AuthProviders', google: boolean, magicLink: boolean, password: boolean, microsoft: boolean, sso: Array<{ __typename?: 'SSOIdentityProvider', id: string, name: string, type: IdentityProviderType, status: SsoIdentityProviderStatus, issuer: string }> } } };
export type ValidatePasswordResetTokenQueryVariables = Exact<{
token: Scalars['String'];
@ -2150,7 +2177,7 @@ export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key:
export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string } | null }> };
export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, hostname?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string } | null }> };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@ -2167,7 +2194,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string } | null }> } };
export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentWorkspace?: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, hostname?: string | null, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: FeatureFlagKey, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus }> } | null, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, subdomain: string } | null }> } };
export type ActivateWorkflowVersionMutationVariables = Exact<{
workflowVersionId: Scalars['String'];
@ -2270,7 +2297,7 @@ export type UpdateWorkspaceMutationVariables = Exact<{
}>;
export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: any, subdomain: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean } };
export type UpdateWorkspaceMutation = { __typename?: 'Mutation', updateWorkspace: { __typename?: 'Workspace', id: any, hostname?: string | null, subdomain: string, displayName?: string | null, logo?: string | null, allowImpersonation: boolean, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean } };
export type UploadWorkspaceLogoMutationVariables = Exact<{
file: Scalars['Upload'];
@ -2279,6 +2306,11 @@ export type UploadWorkspaceLogoMutationVariables = Exact<{
export type UploadWorkspaceLogoMutation = { __typename?: 'Mutation', uploadWorkspaceLogo: string };
export type GetHostnameDetailsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetHostnameDetailsQuery = { __typename?: 'Query', getHostnameDetails?: { __typename?: 'CustomHostnameDetails', hostname: string, status?: string | null, ownershipVerifications: Array<{ __typename?: 'CustomHostnameOwnershipVerificationHttp', type: string, body: string, url: string } | { __typename?: 'CustomHostnameOwnershipVerificationTxt', type: string, name: string, value: string }> } | null };
export type GetWorkspaceFromInviteHashQueryVariables = Exact<{
inviteHash: Scalars['String'];
}>;
@ -2436,6 +2468,7 @@ export const UserQueryFragmentFragmentDoc = gql`
isPasswordAuthEnabled
subdomain
hasValidEnterpriseKey
hostname
featureFlags {
id
key
@ -3223,6 +3256,7 @@ export const CheckUserExistsDocument = gql`
id
displayName
subdomain
hostname
logo
sso {
type
@ -3276,6 +3310,7 @@ export const GetPublicWorkspaceDataBySubdomainDocument = gql`
logo
displayName
subdomain
hostname
authProviders {
sso {
id
@ -4512,6 +4547,7 @@ export const UpdateWorkspaceDocument = gql`
mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
updateWorkspace(data: $input) {
id
hostname
subdomain
displayName
logo
@ -4580,6 +4616,53 @@ export function useUploadWorkspaceLogoMutation(baseOptions?: Apollo.MutationHook
export type UploadWorkspaceLogoMutationHookResult = ReturnType<typeof useUploadWorkspaceLogoMutation>;
export type UploadWorkspaceLogoMutationResult = Apollo.MutationResult<UploadWorkspaceLogoMutation>;
export type UploadWorkspaceLogoMutationOptions = Apollo.BaseMutationOptions<UploadWorkspaceLogoMutation, UploadWorkspaceLogoMutationVariables>;
export const GetHostnameDetailsDocument = gql`
query GetHostnameDetails {
getHostnameDetails {
hostname
ownershipVerifications {
... on CustomHostnameOwnershipVerificationTxt {
type
name
value
}
... on CustomHostnameOwnershipVerificationHttp {
type
body
url
}
}
status
}
}
`;
/**
* __useGetHostnameDetailsQuery__
*
* To run a query within a React component, call `useGetHostnameDetailsQuery` and pass it any options that fit your needs.
* When your component renders, `useGetHostnameDetailsQuery` 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 } = useGetHostnameDetailsQuery({
* variables: {
* },
* });
*/
export function useGetHostnameDetailsQuery(baseOptions?: Apollo.QueryHookOptions<GetHostnameDetailsQuery, GetHostnameDetailsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetHostnameDetailsQuery, GetHostnameDetailsQueryVariables>(GetHostnameDetailsDocument, options);
}
export function useGetHostnameDetailsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetHostnameDetailsQuery, GetHostnameDetailsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetHostnameDetailsQuery, GetHostnameDetailsQueryVariables>(GetHostnameDetailsDocument, options);
}
export type GetHostnameDetailsQueryHookResult = ReturnType<typeof useGetHostnameDetailsQuery>;
export type GetHostnameDetailsLazyQueryHookResult = ReturnType<typeof useGetHostnameDetailsLazyQuery>;
export type GetHostnameDetailsQueryResult = Apollo.QueryResult<GetHostnameDetailsQuery, GetHostnameDetailsQueryVariables>;
export const GetWorkspaceFromInviteHashDocument = gql`
query GetWorkspaceFromInviteHash($inviteHash: String!) {
findWorkspaceFromInviteHash(inviteHash: $inviteHash) {

View File

@ -10,6 +10,7 @@ export const CHECK_USER_EXISTS = gql`
id
displayName
subdomain
hostname
logo
sso {
type

View File

@ -7,6 +7,7 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql`
logo
displayName
subdomain
hostname
authProviders {
sso {
id

View File

@ -20,6 +20,7 @@ export type CurrentWorkspace = Pick<
| 'isPasswordAuthEnabled'
| 'hasValidEnterpriseKey'
| 'subdomain'
| 'hostname'
| 'metadataVersion'
>;

View File

@ -158,6 +158,7 @@ export const queries = {
isPasswordAuthEnabled
subdomain
hasValidEnterpriseKey
hostname
featureFlags {
id
key
@ -307,6 +308,7 @@ export const responseData = {
isMicrosoftAuthEnabled: false,
isPasswordAuthEnabled: true,
subdomain: 'test',
hostname: null,
featureFlags: [],
metadataVersion: 1,
currentBillingSubscription: null,

View File

@ -37,6 +37,7 @@ export const USER_QUERY_FRAGMENT = gql`
isPasswordAuthEnabled
subdomain
hasValidEnterpriseKey
hostname
featureFlags {
id
key

View File

@ -4,6 +4,7 @@ export const UPDATE_WORKSPACE = gql`
mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
updateWorkspace(data: $input) {
id
hostname
subdomain
displayName
logo

View File

@ -0,0 +1,22 @@
import { gql } from '@apollo/client';
export const GET_HOSTNAME_DETAILS = gql`
query GetHostnameDetails {
getHostnameDetails {
hostname
ownershipVerifications {
... on CustomHostnameOwnershipVerificationTxt {
type
name
value
}
... on CustomHostnameOwnershipVerificationHttp {
type
body
url
}
}
status
}
}
`;

View File

@ -1,42 +1,27 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { useRecoilState } from 'recoil';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsPath } from '@/types/SettingsPath';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { ApolloError } from '@apollo/client';
import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Controller, useForm } from 'react-hook-form';
import { useRecoilState, useRecoilValue } from 'recoil';
import { H2Title, Section } from 'twenty-ui';
import { z } from 'zod';
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
import {
FeatureFlagKey,
useUpdateWorkspaceMutation,
} from '~/generated/graphql';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { SettingsHostname } from '~/pages/settings/workspace/SettingsHostname';
import { SettingsSubdomain } from '~/pages/settings/workspace/SettingsSubdomain';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { isDefined } from '~/utils/isDefined';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
type Form = {
subdomain: string;
};
const StyledDomainFromWrapper = styled.div`
align-items: center;
display: flex;
`;
const StyledDomain = styled.h2`
align-self: flex-start;
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin: ${({ theme }) => theme.spacing(2)};
`;
import { ApolloError } from '@apollo/client';
import { Trans, useLingui } from '@lingui/react/macro';
import { z } from 'zod';
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { SettingsPath } from '@/types/SettingsPath';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsHostnameEffect } from '~/pages/settings/workspace/SettingsHostnameEffect';
export const SettingsDomain = () => {
const navigate = useNavigateSettings();
@ -54,22 +39,21 @@ export const SettingsDomain = () => {
})
.required();
const domainConfiguration = useRecoilValue(domainConfigurationState);
const { enqueueSnackBar } = useSnackBar();
const [updateWorkspace] = useUpdateWorkspaceMutation();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const isCustomDomainEnabled = useIsFeatureEnabled(
FeatureFlagKey.IsCustomDomainEnabled,
);
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const {
control,
watch,
getValues,
formState: { isValid },
} = useForm<Form>({
const form = useForm<{
subdomain: string;
}>({
mode: 'onChange',
delayError: 500,
defaultValues: {
@ -78,12 +62,12 @@ export const SettingsDomain = () => {
resolver: zodResolver(validationSchema),
});
const subdomainValue = watch('subdomain');
const subdomainValue = form.watch('subdomain');
const handleSave = async () => {
const values = getValues();
const values = form.getValues();
if (!values || !isValid || !currentWorkspace) {
if (!values || !form.formState.isValid || !currentWorkspace) {
return enqueueSnackBar(t`Invalid form values`, {
variant: SnackBarVariant.Error,
});
@ -100,7 +84,7 @@ export const SettingsDomain = () => {
error instanceof ApolloError &&
error.graphQLErrors[0]?.extensions?.code === 'CONFLICT'
) {
return control.setError('subdomain', {
return form.control.setError('subdomain', {
type: 'manual',
message: t`Subdomain already taken`,
});
@ -137,7 +121,8 @@ export const SettingsDomain = () => {
actionButton={
<SaveAndCancelButtons
isSaveDisabled={
!isValid || subdomainValue === currentWorkspace?.subdomain
!form.formState.isValid ||
subdomainValue === currentWorkspace?.subdomain
}
onCancel={() => navigate(SettingsPath.Workspace)}
onSave={handleSave}
@ -145,39 +130,18 @@ export const SettingsDomain = () => {
}
>
<SettingsPageContainer>
<Section>
<H2Title
title={t`Domain`}
description={t`Set the name of your subdomain`}
/>
{currentWorkspace?.subdomain && (
<StyledDomainFromWrapper>
<Controller
name="subdomain"
control={control}
render={({
field: { onChange, value },
fieldState: { error },
}) => (
<>
<TextInputV2
value={value}
type="text"
onChange={onChange}
error={error?.message}
fullWidth
/>
{isDefined(domainConfiguration.frontDomain) && (
<StyledDomain>
.{domainConfiguration.frontDomain}
</StyledDomain>
)}
</>
)}
/>
</StyledDomainFromWrapper>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<FormProvider {...form}>
{isCustomDomainEnabled && (
<>
<SettingsHostnameEffect />
<SettingsHostname />
</>
)}
</Section>
{(!currentWorkspace?.hostname || !isCustomDomainEnabled) && (
<SettingsSubdomain />
)}
</FormProvider>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);

View File

@ -0,0 +1,150 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Button, H2Title, Section } from 'twenty-ui';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilState } from 'recoil';
import styled from '@emotion/styled';
import {
useUpdateWorkspaceMutation,
useGetHostnameDetailsQuery,
} from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { useLingui } from '@lingui/react/macro';
const validationSchema = z
.object({
hostname: z
.string()
.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:
"Invalid custom hostname. Custom hostnames 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)
.nullable(),
})
.required();
type Form = z.infer<typeof validationSchema>;
const StyledDomainFromWrapper = styled.div`
align-items: center;
display: flex;
`;
export const SettingsHostname = () => {
const [updateWorkspace] = useUpdateWorkspaceMutation();
const { data: getHostnameDetailsData } = useGetHostnameDetailsQuery();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { t } = useLingui();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const {
control,
getValues,
clearErrors,
handleSubmit,
formState: { isValid },
} = useForm<Form>({
mode: 'onSubmit',
defaultValues: {
hostname: currentWorkspace?.hostname ?? '',
},
resolver: zodResolver(validationSchema),
});
const handleDelete = async () => {
try {
if (!currentWorkspace) {
throw new Error('Invalid form values');
}
await updateWorkspace({
variables: {
input: {
hostname: null,
},
},
});
redirectToWorkspaceDomain(currentWorkspace.subdomain);
} catch (error) {
control.setError('hostname', {
type: 'manual',
message: (error as Error).message,
});
}
};
const handleSave = async () => {
const values = getValues();
try {
clearErrors();
if (!values || !isValid || !currentWorkspace) {
throw new Error('Invalid form values');
}
await updateWorkspace({
variables: {
input: {
hostname: values.hostname,
},
},
});
setCurrentWorkspace({
...currentWorkspace,
hostname: values.hostname,
});
// redirectToWorkspaceDomain(values.subdomain);
} catch (error) {
control.setError('hostname', {
type: 'manual',
message: (error as Error).message,
});
}
};
return (
<Section>
<H2Title title={t`Domain`} description={t`Set the name of your domain`} />
<StyledDomainFromWrapper>
<Controller
name="hostname"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<TextInputV2
value={value ?? undefined}
type="text"
onChange={onChange}
error={error?.message}
fullWidth
/>
)}
/>
</StyledDomainFromWrapper>
<Button onClick={handleSubmit(handleSave)} title={'save'}></Button>
<Button onClick={handleSubmit(handleDelete)} title={'delete'}></Button>
{isDefined(getHostnameDetailsData?.getHostnameDetails?.hostname) && (
<pre>
{getHostnameDetailsData.getHostnameDetails.hostname} CNAME
app.twenty-main.com
</pre>
)}
{getHostnameDetailsData && (
<pre>{JSON.stringify(getHostnameDetailsData, null, 4)}</pre>
)}
</Section>
);
};

View File

@ -0,0 +1,28 @@
import { isDefined } from '~/utils/isDefined';
import { useEffect } from 'react';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useRecoilValue } from 'recoil';
import { useGetHostnameDetailsQuery } from '~/generated/graphql';
export const SettingsHostnameEffect = () => {
const { refetch } = useGetHostnameDetailsQuery();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
useEffect(() => {
let pollIntervalFn: null | ReturnType<typeof setInterval> = null;
if (isDefined(currentWorkspace?.hostname)) {
pollIntervalFn = setInterval(async () => {
refetch();
}, 3000);
}
return () => {
if (isDefined(pollIntervalFn)) {
clearInterval(pollIntervalFn);
}
};
}, [currentWorkspace?.hostname, refetch]);
return <></>;
};

View File

@ -0,0 +1,63 @@
import { H2Title, Section } from 'twenty-ui';
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
import { Controller, useFormContext } from 'react-hook-form';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from '~/utils/isDefined';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { useRecoilValue } from 'recoil';
const StyledDomainFormWrapper = styled.div`
align-items: center;
display: flex;
`;
const StyledDomain = styled.h2`
align-self: flex-start;
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin: ${({ theme }) => theme.spacing(2)};
white-space: nowrap;
`;
export const SettingsSubdomain = () => {
const domainConfiguration = useRecoilValue(domainConfigurationState);
const { t } = useLingui();
const { control } = useFormContext<{
subdomain: string;
}>();
return (
<Section>
<H2Title
title={t`Subdomain`}
description={t`Set the name of your subdomain`}
/>
<StyledDomainFormWrapper>
<Controller
name="subdomain"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<>
<TextInputV2
value={value}
type="text"
onChange={onChange}
error={error?.message}
fullWidth
/>
{isDefined(domainConfiguration.frontDomain) && (
<StyledDomain>
{`.${domainConfiguration.frontDomain}`}
</StyledDomain>
)}
</>
)}
/>
</StyledDomainFormWrapper>
</Section>
);
};

View File

@ -77,4 +77,6 @@ FRONT_PORT=3001
# SESSION_STORE_SECRET=replace_me_with_a_random_string_session
# ENTERPRISE_KEY=replace_me_with_a_valid_enterprise_key
# SSL_KEY_PATH="./certs/your-cert.key"
# SSL_CERT_PATH="./certs/your-cert.crt"
# SSL_CERT_PATH="./certs/your-cert.crt"
# CLOUDFLARE_API_KEY=
# CLOUDFLARE_ZONE_ID=

View File

@ -30,6 +30,7 @@
"cache-manager": "^5.4.0",
"cache-manager-redis-yet": "^4.1.2",
"class-validator": "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch",
"cloudflare": "^3.5.0",
"connect-redis": "^7.1.1",
"express-session": "^1.18.1",
"graphql-middleware": "^6.1.35",

View File

@ -37,6 +37,9 @@ export class AvailableWorkspaceOutput {
@Field(() => String)
subdomain: string;
@Field(() => String, { nullable: true })
hostname?: string;
@Field(() => String, { nullable: true })
logo?: string;

View File

@ -7,5 +7,7 @@ export class DomainManagerException extends CustomException {
}
export enum DomainManagerExceptionCode {
CLOUDFLARE_CLIENT_NOT_INITIALIZED = 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
HOSTNAME_ALREADY_REGISTERED = 'HOSTNAME_ALREADY_REGISTERED',
SUBDOMAIN_REQUIRED = 'SUBDOMAIN_REQUIRED',
}

View File

@ -0,0 +1,75 @@
import { createUnionType, Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
class CustomHostnameOwnershipVerificationTxt {
@Field(() => String)
type: 'txt';
@Field(() => String)
name: string;
@Field(() => String)
value: string;
}
@ObjectType()
class CustomHostnameOwnershipVerificationHttp {
@Field()
type: 'http';
@Field(() => String)
body: string;
@Field(() => String)
url: string;
}
const CustomHostnameOwnershipVerification = createUnionType({
name: 'OwnershipVerification',
types: () =>
[
CustomHostnameOwnershipVerificationTxt,
CustomHostnameOwnershipVerificationHttp,
] as const,
resolveType(value) {
if ('type' in value && value.type === 'txt') {
return CustomHostnameOwnershipVerificationTxt;
}
if ('type' in value && value.type === 'http') {
return CustomHostnameOwnershipVerificationHttp;
}
return null;
},
});
@ObjectType()
export class CustomHostnameDetails {
@Field(() => String)
id: string;
@Field(() => String)
hostname: string;
@Field(() => [CustomHostnameOwnershipVerification])
ownershipVerifications: Array<typeof CustomHostnameOwnershipVerification>;
@Field(() => String, { nullable: true })
status?:
| 'active'
| 'pending'
| 'active_redeploying'
| 'moved'
| 'pending_deletion'
| 'deleted'
| 'pending_blocked'
| 'pending_migration'
| 'pending_provisioned'
| 'test_pending'
| 'test_active'
| 'test_active_apex'
| 'test_blocked'
| 'test_failed'
| 'provisioned'
| 'blocked';
}

View File

@ -2,24 +2,36 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import Cloudflare from 'cloudflare';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import {
WorkspaceException,
WorkspaceExceptionCode,
} from 'src/engine/core-modules/workspace/workspace.exception';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { isDefined } from 'src/utils/is-defined';
import { isWorkEmail } from 'src/utils/is-work-email';
import { domainManagerValidator } from 'src/engine/core-modules/domain-manager/validator/cloudflare.validate';
import {
DomainManagerException,
DomainManagerExceptionCode,
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
import { generateRandomSubdomain } from 'src/engine/core-modules/domain-manager/utils/generate-random-subdomain';
import { getSubdomainNameFromDisplayName } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name';
import { getSubdomainFromEmail } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-from-email';
import { CustomHostnameDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-hostname-details';
@Injectable()
export class DomainManagerService {
cloudflareClient?: Cloudflare;
constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly environmentService: EnvironmentService,
) {}
) {
if (this.environmentService.get('CLOUDFLARE_API_KEY')) {
this.cloudflareClient = new Cloudflare({
apiToken: this.environmentService.get('CLOUDFLARE_API_KEY'),
});
}
}
getFrontUrl() {
let baseUrl: URL;
@ -105,6 +117,7 @@ export class DomainManagerService {
return url;
}
// @Deprecated
getWorkspaceSubdomainFromUrl = (url: string) => {
const { hostname: originHostname } = new URL(url);
@ -119,6 +132,24 @@ export class DomainManagerService {
return this.isDefaultSubdomain(subdomain) ? null : subdomain;
};
getSubdomainAndHostnameFromUrl = (url: string) => {
const { hostname: originHostname } = new URL(url);
const frontDomain = this.getFrontUrl().hostname;
const isFrontdomain = originHostname.endsWith(`.${frontDomain}`);
const subdomain = originHostname.replace(`.${frontDomain}`, '');
return {
subdomain:
isFrontdomain && !this.isDefaultSubdomain(subdomain)
? subdomain
: undefined,
hostname: isFrontdomain ? undefined : originHostname,
};
};
async getWorkspaceBySubdomainOrDefaultWorkspace(subdomain?: string) {
return subdomain
? await this.workspaceRepository.findOne({
@ -180,121 +211,37 @@ export class DomainManagerService {
}
async getWorkspaceByOriginOrDefaultWorkspace(origin: string) {
try {
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
return this.getDefaultWorkspace();
}
const subdomain = this.getWorkspaceSubdomainFromUrl(origin);
if (!isDefined(subdomain)) return;
return (
(await this.workspaceRepository.findOne({
where: { subdomain },
relations: ['workspaceSSOIdentityProviders'],
})) ?? undefined
);
} catch (e) {
throw new WorkspaceException(
'Workspace not found',
WorkspaceExceptionCode.SUBDOMAIN_NOT_FOUND,
);
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
return this.getDefaultWorkspace();
}
const { subdomain, hostname } = this.getSubdomainAndHostnameFromUrl(origin);
if (!hostname && !subdomain) return;
const where = isDefined(hostname) ? { hostname } : { subdomain };
return (
(await this.workspaceRepository.findOne({
where,
relations: ['workspaceSSOIdentityProviders'],
})) ?? undefined
);
}
private generateRandomSubdomain(): string {
const prefixes = [
'cool',
'smart',
'fast',
'bright',
'shiny',
'happy',
'funny',
'clever',
'brave',
'kind',
'gentle',
'quick',
'sharp',
'calm',
'silent',
'lucky',
'fierce',
'swift',
'mighty',
'noble',
'bold',
'wise',
'eager',
'joyful',
'glad',
'zany',
'witty',
'bouncy',
'graceful',
'colorful',
];
const suffixes = [
'raccoon',
'panda',
'whale',
'tiger',
'dolphin',
'eagle',
'penguin',
'owl',
'fox',
'wolf',
'lion',
'bear',
'hawk',
'shark',
'sparrow',
'moose',
'lynx',
'falcon',
'rabbit',
'hedgehog',
'monkey',
'horse',
'koala',
'kangaroo',
'elephant',
'giraffe',
'panther',
'crocodile',
'seal',
'octopus',
];
private extractSubdomain(params?: { email?: string; displayName?: string }) {
if (params?.email) {
return getSubdomainFromEmail(params.email);
}
const randomPrefix = prefixes[Math.floor(Math.random() * prefixes.length)];
const randomSuffix = suffixes[Math.floor(Math.random() * suffixes.length)];
return `${randomPrefix}-${randomSuffix}`;
}
private getSubdomainNameByEmail(email?: string) {
if (!isDefined(email) || !isWorkEmail(email)) return;
return getDomainNameByEmail(email);
}
private getSubdomainNameByDisplayName(displayName?: string) {
if (!isDefined(displayName)) return;
const displayNameWords = displayName.match(/(\w| |\d)+/g);
if (displayNameWords) {
return displayNameWords.join('-').replace(/ /g, '').toLowerCase();
if (params?.displayName) {
return getSubdomainNameFromDisplayName(params.displayName);
}
}
async generateSubdomain(params?: { email?: string; displayName?: string }) {
const subdomain =
this.getSubdomainNameByEmail(params?.email) ??
this.getSubdomainNameByDisplayName(params?.displayName) ??
this.generateRandomSubdomain();
this.extractSubdomain(params) ?? generateRandomSubdomain();
const existingWorkspaceCount = await this.workspaceRepository.countBy({
subdomain,
@ -302,4 +249,133 @@ export class DomainManagerService {
return `${subdomain}${existingWorkspaceCount > 0 ? `-${Math.random().toString(36).substring(2, 10)}` : ''}`;
}
async registerCustomHostname(hostname: string) {
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
if (await this.getCustomHostnameDetails(hostname)) {
throw new DomainManagerException(
'Hostname already registered',
DomainManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED,
);
}
return await this.cloudflareClient.customHostnames.create({
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
hostname,
ssl: {
method: 'txt',
type: 'dv',
settings: {
http2: 'on',
min_tls_version: '1.2',
tls_1_3: 'on',
ciphers: ['ECDHE-RSA-AES128-GCM-SHA256', 'AES128-SHA'],
early_hints: 'on',
},
bundle_method: 'ubiquitous',
wildcard: false,
},
});
}
async getCustomHostnameDetails(
hostname: string,
): Promise<CustomHostnameDetails | undefined> {
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
const response = await this.cloudflareClient.customHostnames.list({
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
hostname,
});
if (response.result.length === 0) {
return undefined;
}
if (response.result.length === 1) {
return {
id: response.result[0].id,
hostname: response.result[0].hostname,
status: response.result[0].status,
ownershipVerifications: [
response.result[0].ownership_verification,
response.result[0].ownership_verification_http,
].reduce(
(acc, ownershipVerification) => {
if (!ownershipVerification) return acc;
if (
'http_body' in ownershipVerification &&
'http_url' in ownershipVerification &&
ownershipVerification.http_body &&
ownershipVerification.http_url
) {
acc.push({
type: 'http',
body: ownershipVerification.http_body,
url: ownershipVerification.http_url,
});
}
if (
'type' in ownershipVerification &&
ownershipVerification.type === 'txt' &&
ownershipVerification.value &&
ownershipVerification.name
) {
acc.push({
type: 'txt',
value: ownershipVerification.value,
name: ownershipVerification.name,
});
}
return acc;
},
[] as CustomHostnameDetails['ownershipVerifications'],
),
};
}
// should never append. error 5xx
throw new Error('More than one custom hostname found in cloudflare');
}
async updateCustomHostname(fromHostname: string, toHostname: string) {
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
const fromCustomHostname =
await this.getCustomHostnameDetails(fromHostname);
if (fromCustomHostname) {
await this.deleteCustomHostname(fromCustomHostname.id);
}
return await this.registerCustomHostname(toHostname);
}
async deleteCustomHostnameByHostnameSilently(hostname: string) {
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
try {
const customHostname = await this.getCustomHostnameDetails(hostname);
if (customHostname) {
await this.cloudflareClient.customHostnames.delete(customHostname.id, {
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
});
}
} catch (err) {
return;
}
}
async deleteCustomHostname(customHostnameId: string) {
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
return this.cloudflareClient.customHostnames.delete(customHostnameId, {
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
});
}
}

View File

@ -0,0 +1,16 @@
import { generateRandomSubdomain } from 'src/engine/core-modules/domain-manager/utils/generate-random-subdomain';
describe('generateRandomSubdomain', () => {
it('should return a string in the format "prefix-suffix"', () => {
const result = generateRandomSubdomain();
expect(result).toMatch(/^[a-z]+-[a-z]+$/);
});
it('should generate different results on consecutive calls', () => {
const result1 = generateRandomSubdomain();
const result2 = generateRandomSubdomain();
expect(result1).not.toEqual(result2);
});
});

View File

@ -0,0 +1,25 @@
import { getSubdomainFromEmail } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-from-email';
describe('getSubdomainFromEmail', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return undefined if email is not defined', () => {
const result = getSubdomainFromEmail(undefined);
expect(result).toBeUndefined();
});
it('should return undefined if email is not a work email', () => {
const result = getSubdomainFromEmail('test@gmail.com');
expect(result).toBeUndefined();
});
it('should return the domain name if email is valid and a work email', () => {
const result = getSubdomainFromEmail('test@twenty.com');
expect(result).toBe('twenty');
});
});

View File

@ -0,0 +1,41 @@
import { getSubdomainNameFromDisplayName } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name';
describe('getSubdomainNameFromDisplayName', () => {
it('should return a hyphen-separated, lowercase subdomain name without spaces for a valid display name', () => {
const result = getSubdomainNameFromDisplayName('My Display Name 123');
expect(result).toBe('my-display-name-123');
});
it('should return undefined if displayName is undefined', () => {
const result = getSubdomainNameFromDisplayName(undefined);
expect(result).toBeUndefined();
});
it('should handle display names with special characters by removing them but keeping words and numbers', () => {
const result = getSubdomainNameFromDisplayName('Hello!@# World$%^ 2023');
expect(result).toBe('hello-world-2023');
});
it('should return a single word in lowercase if displayName consists of one valid word', () => {
const result = getSubdomainNameFromDisplayName('SingleWord');
expect(result).toBe('singleword');
});
it('should return undefined when displayName contains only special characters', () => {
const result = getSubdomainNameFromDisplayName('!@#$%^&*()');
expect(result).toBeUndefined();
});
it('should handle display names with multiple spaces by removing them', () => {
const result = getSubdomainNameFromDisplayName(
' Spaced Out Name ',
);
expect(result).toBe('spaced-out-name');
});
});

View File

@ -0,0 +1,71 @@
export const generateRandomSubdomain = () => {
const prefixes = [
'cool',
'smart',
'fast',
'bright',
'shiny',
'happy',
'funny',
'clever',
'brave',
'kind',
'gentle',
'quick',
'sharp',
'calm',
'silent',
'lucky',
'fierce',
'swift',
'mighty',
'noble',
'bold',
'wise',
'eager',
'joyful',
'glad',
'zany',
'witty',
'bouncy',
'graceful',
'colorful',
];
const suffixes = [
'raccoon',
'panda',
'whale',
'tiger',
'dolphin',
'eagle',
'penguin',
'owl',
'fox',
'wolf',
'lion',
'bear',
'hawk',
'shark',
'sparrow',
'moose',
'lynx',
'falcon',
'rabbit',
'hedgehog',
'monkey',
'horse',
'koala',
'kangaroo',
'elephant',
'giraffe',
'panther',
'crocodile',
'seal',
'octopus',
];
const randomPrefix = prefixes[Math.floor(Math.random() * prefixes.length)];
const randomSuffix = suffixes[Math.floor(Math.random() * suffixes.length)];
return `${randomPrefix}-${randomSuffix}`;
};

View File

@ -0,0 +1,11 @@
import { isDefined } from 'src/utils/is-defined';
import { isWorkEmail } from 'src/utils/is-work-email';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
export const getSubdomainFromEmail = (email?: string) => {
if (!isDefined(email) || !isWorkEmail(email)) return;
const domain = getDomainNameByEmail(email);
return domain.split('.')[0];
};

View File

@ -0,0 +1,10 @@
import { isDefined } from 'src/utils/is-defined';
export const getSubdomainNameFromDisplayName = (displayName?: string) => {
if (!isDefined(displayName)) return;
const displayNameWords = displayName.match(/(\w|\d)+/g);
if (displayNameWords) {
return displayNameWords.join('-').replace(/ /g, '').toLowerCase();
}
};

View File

@ -0,0 +1,23 @@
import Cloudflare from 'cloudflare';
import {
DomainManagerException,
DomainManagerExceptionCode,
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
const isCloudflareInstanceDefined = (
cloudflareInstance: Cloudflare | undefined | null,
): asserts cloudflareInstance is Cloudflare => {
if (!cloudflareInstance) {
throw new DomainManagerException(
'Cloudflare instance is not defined',
DomainManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED,
);
}
};
export const domainManagerValidator: {
isCloudflareInstanceDefined: typeof isCloudflareInstanceDefined;
} = {
isCloudflareInstanceDefined,
};

View File

@ -242,6 +242,14 @@ export class EnvironmentVariables {
@IsBoolean()
IS_MULTIWORKSPACE_ENABLED = false;
@IsString()
@ValidateIf((env) => env.CLOUDFLARE_ZONE_ID)
CLOUDFLARE_API_KEY: string;
@IsString()
@ValidateIf((env) => env.CLOUDFLARE_API_KEY)
CLOUDFLARE_ZONE_ID: string;
// Custom Code Engine
@IsEnum(ServerlessDriverType)
@IsOptional()

View File

@ -58,4 +58,7 @@ export class PublicWorkspaceDataOutput {
@Field(() => String)
subdomain: Workspace['subdomain'];
@Field(() => String, { nullable: true })
hostname: Workspace['hostname'];
}

View File

@ -10,11 +10,6 @@ import {
@InputType()
export class UpdateWorkspaceInput {
@Field({ nullable: true })
@IsString()
@IsOptional()
domainName?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
@ -105,6 +100,14 @@ export class UpdateWorkspaceInput {
])
subdomain?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
@Matches(
/^(([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])$/,
)
hostname?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()

View File

@ -23,6 +23,8 @@ import {
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags';
import { isDefined } from 'src/utils/is-defined';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -40,43 +42,96 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private readonly billingService: BillingService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService,
) {
super(workspaceRepository);
}
private async validateSubdomainUpdate(newSubdomain: string) {
const subdomainAvailable = await this.isSubdomainAvailable(newSubdomain);
if (
!subdomainAvailable ||
this.environmentService.get('DEFAULT_SUBDOMAIN') === newSubdomain
) {
throw new WorkspaceException(
'Subdomain already taken',
WorkspaceExceptionCode.SUBDOMAIN_ALREADY_TAKEN,
);
}
}
private async setCustomDomain(workspace: Workspace, hostname: string) {
const existingWorkspace = await this.workspaceRepository.findOne({
where: { hostname },
});
if (existingWorkspace && existingWorkspace.id !== workspace.id) {
throw new WorkspaceException(
'Domain already taken',
WorkspaceExceptionCode.DOMAIN_ALREADY_TAKEN,
);
}
if (
hostname &&
workspace.hostname !== hostname &&
isDefined(workspace.hostname)
) {
await this.domainManagerService.updateCustomHostname(
workspace.hostname,
hostname,
);
}
if (
hostname &&
workspace.hostname !== hostname &&
!isDefined(workspace.hostname)
) {
await this.domainManagerService.registerCustomHostname(hostname);
}
}
async updateWorkspaceById(payload: Partial<Workspace> & { id: string }) {
const workspace = await this.workspaceRepository.findOneBy({
id: payload.id,
});
workspaceValidator.assertIsDefinedOrThrow(
workspace,
new WorkspaceException(
'Workspace not found',
WorkspaceExceptionCode.WORKSPACE_NOT_FOUND,
),
);
workspaceValidator.assertIsDefinedOrThrow(workspace);
if (payload.subdomain && workspace.subdomain !== payload.subdomain) {
const subdomainAvailable = await this.isSubdomainAvailable(
payload.subdomain,
);
if (
!subdomainAvailable ||
this.environmentService.get('DEFAULT_SUBDOMAIN') === payload.subdomain
) {
throw new WorkspaceException(
'Subdomain already taken',
WorkspaceExceptionCode.SUBDOMAIN_ALREADY_TAKEN,
);
}
await this.validateSubdomainUpdate(payload.subdomain);
}
return this.workspaceRepository.save({
...workspace,
...payload,
});
let customDomainRegistered = false;
if (payload.hostname === null && isDefined(workspace.hostname)) {
await this.domainManagerService.deleteCustomHostnameByHostnameSilently(
workspace.hostname,
);
}
if (payload.hostname && workspace.hostname !== payload.hostname) {
await this.setCustomDomain(workspace, payload.hostname);
customDomainRegistered = true;
}
try {
return await this.workspaceRepository.save({
...workspace,
...payload,
});
} catch (error) {
// revert custom domain registration on error
if (payload.hostname && customDomainRegistered) {
this.domainManagerService
.deleteCustomHostnameByHostnameSilently(payload.hostname)
.catch(() => {
// send to sentry
});
}
}
}
async activateWorkspace(

View File

@ -9,5 +9,6 @@ export class WorkspaceException extends CustomException {
export enum WorkspaceExceptionCode {
SUBDOMAIN_NOT_FOUND = 'SUBDOMAIN_NOT_FOUND',
SUBDOMAIN_ALREADY_TAKEN = 'SUBDOMAIN_ALREADY_TAKEN',
DOMAIN_ALREADY_TAKEN = 'DOMAIN_ALREADY_TAKEN',
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
}

View File

@ -33,10 +33,6 @@ import {
import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util';
import { workspaceGraphqlApiExceptionHandler } from 'src/engine/core-modules/workspace/utils/workspace-graphql-api-exception-handler.util';
import {
WorkspaceException,
WorkspaceExceptionCode,
} from 'src/engine/core-modules/workspace/workspace.exception';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
@ -48,6 +44,7 @@ import { GraphqlValidationExceptionFilter } from 'src/filters/graphql-validation
import { assert } from 'src/utils/assert';
import { isDefined } from 'src/utils/is-defined';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { CustomHostnameDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-hostname-details';
import { Workspace } from './workspace.entity';
@ -218,21 +215,27 @@ export class WorkspaceResolver {
return isDefined(this.environmentService.get('ENTERPRISE_KEY'));
}
@Query(() => CustomHostnameDetails, { nullable: true })
@UseGuards(WorkspaceAuthGuard)
async getHostnameDetails(
@AuthWorkspace() { hostname }: Workspace,
): Promise<CustomHostnameDetails | undefined> {
if (!hostname) return undefined;
return await this.domainManagerService.getCustomHostnameDetails(hostname);
}
@Query(() => PublicWorkspaceDataOutput)
async getPublicWorkspaceDataBySubdomain(@OriginHeader() origin: string) {
async getPublicWorkspaceDataBySubdomain(
@OriginHeader() origin: string,
): Promise<PublicWorkspaceDataOutput | undefined> {
try {
const workspace =
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
);
workspaceValidator.assertIsDefinedOrThrow(
workspace,
new WorkspaceException(
'Workspace not found',
WorkspaceExceptionCode.WORKSPACE_NOT_FOUND,
),
);
workspaceValidator.assertIsDefinedOrThrow(workspace);
let workspaceLogoWithToken = '';
@ -261,6 +264,7 @@ export class WorkspaceResolver {
logo: workspaceLogoWithToken,
displayName: workspace.displayName,
subdomain: workspace.subdomain,
hostname: workspace.hostname,
authProviders: getAuthProvidersByWorkspace({
workspace,
systemEnabledProviders,

View File

@ -17533,6 +17533,13 @@ __metadata:
languageName: node
linkType: hard
"@types/qs@npm:^6.9.7":
version: 6.9.17
resolution: "@types/qs@npm:6.9.17"
checksum: 10c0/a183fa0b3464267f8f421e2d66d960815080e8aab12b9aadab60479ba84183b1cdba8f4eff3c06f76675a8e42fe6a3b1313ea76c74f2885c3e25d32499c17d1b
languageName: node
linkType: hard
"@types/range-parser@npm:*":
version: 1.2.7
resolution: "@types/range-parser@npm:1.2.7"
@ -23089,6 +23096,24 @@ __metadata:
languageName: node
linkType: hard
"cloudflare@npm:^3.5.0":
version: 3.5.0
resolution: "cloudflare@npm:3.5.0"
dependencies:
"@types/node": "npm:^18.11.18"
"@types/node-fetch": "npm:^2.6.4"
"@types/qs": "npm:^6.9.7"
abort-controller: "npm:^3.0.0"
agentkeepalive: "npm:^4.2.1"
form-data-encoder: "npm:1.7.2"
formdata-node: "npm:^4.3.2"
node-fetch: "npm:^2.6.7"
qs: "npm:^6.10.3"
web-streams-polyfill: "npm:^3.2.1"
checksum: 10c0/bb48ff68a4f5b7e945ceec570e7e17251ad167d8d2dadf8099fd74df46582356382b8cd3f62a9da74cd3a208f8f5181a40c561bc5873592d6a4930458c0e7b86
languageName: node
linkType: hard
"clsx@npm:^1.1.1, clsx@npm:^1.2.1":
version: 1.2.1
resolution: "clsx@npm:1.2.1"
@ -40533,6 +40558,15 @@ __metadata:
languageName: node
linkType: hard
"qs@npm:^6.10.3":
version: 6.13.1
resolution: "qs@npm:6.13.1"
dependencies:
side-channel: "npm:^1.0.6"
checksum: 10c0/5ef527c0d62ffca5501322f0832d800ddc78eeb00da3b906f1b260ca0492721f8cdc13ee4b8fd8ac314a6ec37b948798c7b603ccc167e954088df392092f160c
languageName: node
linkType: hard
"qs@npm:~6.5.2":
version: 6.5.3
resolution: "qs@npm:6.5.3"
@ -45876,6 +45910,7 @@ __metadata:
cache-manager: "npm:^5.4.0"
cache-manager-redis-yet: "npm:^4.1.2"
class-validator: "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch"
cloudflare: "npm:^3.5.0"
connect-redis: "npm:^7.1.1"
express-session: "npm:^1.18.1"
graphql-middleware: "npm:^6.1.35"