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:
@ -109,6 +109,7 @@ export type AuthorizeApp = {
|
|||||||
export type AvailableWorkspaceOutput = {
|
export type AvailableWorkspaceOutput = {
|
||||||
__typename?: 'AvailableWorkspaceOutput';
|
__typename?: 'AvailableWorkspaceOutput';
|
||||||
displayName?: Maybe<Scalars['String']['output']>;
|
displayName?: Maybe<Scalars['String']['output']>;
|
||||||
|
hostname?: Maybe<Scalars['String']['output']>;
|
||||||
id: Scalars['String']['output'];
|
id: Scalars['String']['output'];
|
||||||
logo?: Maybe<Scalars['String']['output']>;
|
logo?: Maybe<Scalars['String']['output']>;
|
||||||
sso: Array<SsoConnection>;
|
sso: Array<SsoConnection>;
|
||||||
@ -375,6 +376,28 @@ export type CursorPaging = {
|
|||||||
last?: InputMaybe<Scalars['Int']['input']>;
|
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 = {
|
export type DeleteOneFieldInput = {
|
||||||
/** The id of the field to delete. */
|
/** The id of the field to delete. */
|
||||||
id: Scalars['UUID']['input'];
|
id: Scalars['UUID']['input'];
|
||||||
@ -1209,6 +1232,8 @@ export type OnboardingStepSuccess = {
|
|||||||
success: Scalars['Boolean']['output'];
|
success: Scalars['Boolean']['output'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OwnershipVerification = CustomHostnameOwnershipVerificationHttp | CustomHostnameOwnershipVerificationTxt;
|
||||||
|
|
||||||
export type PageInfo = {
|
export type PageInfo = {
|
||||||
__typename?: 'PageInfo';
|
__typename?: 'PageInfo';
|
||||||
/** The cursor of the last returned record. */
|
/** The cursor of the last returned record. */
|
||||||
@ -1246,6 +1271,7 @@ export type PublicWorkspaceDataOutput = {
|
|||||||
__typename?: 'PublicWorkspaceDataOutput';
|
__typename?: 'PublicWorkspaceDataOutput';
|
||||||
authProviders: AuthProviders;
|
authProviders: AuthProviders;
|
||||||
displayName?: Maybe<Scalars['String']['output']>;
|
displayName?: Maybe<Scalars['String']['output']>;
|
||||||
|
hostname?: Maybe<Scalars['String']['output']>;
|
||||||
id: Scalars['String']['output'];
|
id: Scalars['String']['output'];
|
||||||
logo?: Maybe<Scalars['String']['output']>;
|
logo?: Maybe<Scalars['String']['output']>;
|
||||||
subdomain: Scalars['String']['output'];
|
subdomain: Scalars['String']['output'];
|
||||||
@ -1275,6 +1301,7 @@ export type Query = {
|
|||||||
findWorkspaceFromInviteHash: Workspace;
|
findWorkspaceFromInviteHash: Workspace;
|
||||||
findWorkspaceInvitations: Array<WorkspaceInvitation>;
|
findWorkspaceInvitations: Array<WorkspaceInvitation>;
|
||||||
getAvailablePackages: Scalars['JSON']['output'];
|
getAvailablePackages: Scalars['JSON']['output'];
|
||||||
|
getHostnameDetails?: Maybe<CustomHostnameDetails>;
|
||||||
getPostgresCredentials?: Maybe<PostgresCredentials>;
|
getPostgresCredentials?: Maybe<PostgresCredentials>;
|
||||||
getProductPrices: BillingProductPricesOutput;
|
getProductPrices: BillingProductPricesOutput;
|
||||||
getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput;
|
getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput;
|
||||||
@ -1850,7 +1877,7 @@ export type UpdateWorkflowVersionStepInput = {
|
|||||||
export type UpdateWorkspaceInput = {
|
export type UpdateWorkspaceInput = {
|
||||||
allowImpersonation?: InputMaybe<Scalars['Boolean']['input']>;
|
allowImpersonation?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
displayName?: InputMaybe<Scalars['String']['input']>;
|
displayName?: InputMaybe<Scalars['String']['input']>;
|
||||||
domainName?: InputMaybe<Scalars['String']['input']>;
|
hostname?: InputMaybe<Scalars['String']['input']>;
|
||||||
inviteHash?: InputMaybe<Scalars['String']['input']>;
|
inviteHash?: InputMaybe<Scalars['String']['input']>;
|
||||||
isGoogleAuthEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
isGoogleAuthEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
isMicrosoftAuthEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
isMicrosoftAuthEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
|
|||||||
@ -102,6 +102,7 @@ export type AuthorizeApp = {
|
|||||||
export type AvailableWorkspaceOutput = {
|
export type AvailableWorkspaceOutput = {
|
||||||
__typename?: 'AvailableWorkspaceOutput';
|
__typename?: 'AvailableWorkspaceOutput';
|
||||||
displayName?: Maybe<Scalars['String']>;
|
displayName?: Maybe<Scalars['String']>;
|
||||||
|
hostname?: Maybe<Scalars['String']>;
|
||||||
id: Scalars['String'];
|
id: Scalars['String'];
|
||||||
logo?: Maybe<Scalars['String']>;
|
logo?: Maybe<Scalars['String']>;
|
||||||
sso: Array<SsoConnection>;
|
sso: Array<SsoConnection>;
|
||||||
@ -312,6 +313,28 @@ export type CursorPaging = {
|
|||||||
last?: InputMaybe<Scalars['Int']>;
|
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 = {
|
export type DeleteOneFieldInput = {
|
||||||
/** The id of the field to delete. */
|
/** The id of the field to delete. */
|
||||||
id: Scalars['UUID'];
|
id: Scalars['UUID'];
|
||||||
@ -1076,6 +1099,8 @@ export type OnboardingStepSuccess = {
|
|||||||
success: Scalars['Boolean'];
|
success: Scalars['Boolean'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type OwnershipVerification = CustomHostnameOwnershipVerificationHttp | CustomHostnameOwnershipVerificationTxt;
|
||||||
|
|
||||||
export type PageInfo = {
|
export type PageInfo = {
|
||||||
__typename?: 'PageInfo';
|
__typename?: 'PageInfo';
|
||||||
/** The cursor of the last returned record. */
|
/** The cursor of the last returned record. */
|
||||||
@ -1113,6 +1138,7 @@ export type PublicWorkspaceDataOutput = {
|
|||||||
__typename?: 'PublicWorkspaceDataOutput';
|
__typename?: 'PublicWorkspaceDataOutput';
|
||||||
authProviders: AuthProviders;
|
authProviders: AuthProviders;
|
||||||
displayName?: Maybe<Scalars['String']>;
|
displayName?: Maybe<Scalars['String']>;
|
||||||
|
hostname?: Maybe<Scalars['String']>;
|
||||||
id: Scalars['String'];
|
id: Scalars['String'];
|
||||||
logo?: Maybe<Scalars['String']>;
|
logo?: Maybe<Scalars['String']>;
|
||||||
subdomain: Scalars['String'];
|
subdomain: Scalars['String'];
|
||||||
@ -1139,6 +1165,7 @@ export type Query = {
|
|||||||
findWorkspaceFromInviteHash: Workspace;
|
findWorkspaceFromInviteHash: Workspace;
|
||||||
findWorkspaceInvitations: Array<WorkspaceInvitation>;
|
findWorkspaceInvitations: Array<WorkspaceInvitation>;
|
||||||
getAvailablePackages: Scalars['JSON'];
|
getAvailablePackages: Scalars['JSON'];
|
||||||
|
getHostnameDetails?: Maybe<CustomHostnameDetails>;
|
||||||
getPostgresCredentials?: Maybe<PostgresCredentials>;
|
getPostgresCredentials?: Maybe<PostgresCredentials>;
|
||||||
getProductPrices: BillingProductPricesOutput;
|
getProductPrices: BillingProductPricesOutput;
|
||||||
getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput;
|
getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput;
|
||||||
@ -1638,7 +1665,7 @@ export type UpdateWorkflowVersionStepInput = {
|
|||||||
export type UpdateWorkspaceInput = {
|
export type UpdateWorkspaceInput = {
|
||||||
allowImpersonation?: InputMaybe<Scalars['Boolean']>;
|
allowImpersonation?: InputMaybe<Scalars['Boolean']>;
|
||||||
displayName?: InputMaybe<Scalars['String']>;
|
displayName?: InputMaybe<Scalars['String']>;
|
||||||
domainName?: InputMaybe<Scalars['String']>;
|
hostname?: InputMaybe<Scalars['String']>;
|
||||||
inviteHash?: InputMaybe<Scalars['String']>;
|
inviteHash?: InputMaybe<Scalars['String']>;
|
||||||
isGoogleAuthEnabled?: InputMaybe<Scalars['Boolean']>;
|
isGoogleAuthEnabled?: InputMaybe<Scalars['Boolean']>;
|
||||||
isMicrosoftAuthEnabled?: 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 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<{
|
export type ValidatePasswordResetTokenQueryVariables = Exact<{
|
||||||
token: Scalars['String'];
|
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 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; }>;
|
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 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<{
|
export type ActivateWorkflowVersionMutationVariables = Exact<{
|
||||||
workflowVersionId: Scalars['String'];
|
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<{
|
export type UploadWorkspaceLogoMutationVariables = Exact<{
|
||||||
file: Scalars['Upload'];
|
file: Scalars['Upload'];
|
||||||
@ -2279,6 +2306,11 @@ export type UploadWorkspaceLogoMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type UploadWorkspaceLogoMutation = { __typename?: 'Mutation', uploadWorkspaceLogo: string };
|
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<{
|
export type GetWorkspaceFromInviteHashQueryVariables = Exact<{
|
||||||
inviteHash: Scalars['String'];
|
inviteHash: Scalars['String'];
|
||||||
}>;
|
}>;
|
||||||
@ -2436,6 +2468,7 @@ export const UserQueryFragmentFragmentDoc = gql`
|
|||||||
isPasswordAuthEnabled
|
isPasswordAuthEnabled
|
||||||
subdomain
|
subdomain
|
||||||
hasValidEnterpriseKey
|
hasValidEnterpriseKey
|
||||||
|
hostname
|
||||||
featureFlags {
|
featureFlags {
|
||||||
id
|
id
|
||||||
key
|
key
|
||||||
@ -3223,6 +3256,7 @@ export const CheckUserExistsDocument = gql`
|
|||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
subdomain
|
subdomain
|
||||||
|
hostname
|
||||||
logo
|
logo
|
||||||
sso {
|
sso {
|
||||||
type
|
type
|
||||||
@ -3276,6 +3310,7 @@ export const GetPublicWorkspaceDataBySubdomainDocument = gql`
|
|||||||
logo
|
logo
|
||||||
displayName
|
displayName
|
||||||
subdomain
|
subdomain
|
||||||
|
hostname
|
||||||
authProviders {
|
authProviders {
|
||||||
sso {
|
sso {
|
||||||
id
|
id
|
||||||
@ -4512,6 +4547,7 @@ export const UpdateWorkspaceDocument = gql`
|
|||||||
mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
|
mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
|
||||||
updateWorkspace(data: $input) {
|
updateWorkspace(data: $input) {
|
||||||
id
|
id
|
||||||
|
hostname
|
||||||
subdomain
|
subdomain
|
||||||
displayName
|
displayName
|
||||||
logo
|
logo
|
||||||
@ -4580,6 +4616,53 @@ export function useUploadWorkspaceLogoMutation(baseOptions?: Apollo.MutationHook
|
|||||||
export type UploadWorkspaceLogoMutationHookResult = ReturnType<typeof useUploadWorkspaceLogoMutation>;
|
export type UploadWorkspaceLogoMutationHookResult = ReturnType<typeof useUploadWorkspaceLogoMutation>;
|
||||||
export type UploadWorkspaceLogoMutationResult = Apollo.MutationResult<UploadWorkspaceLogoMutation>;
|
export type UploadWorkspaceLogoMutationResult = Apollo.MutationResult<UploadWorkspaceLogoMutation>;
|
||||||
export type UploadWorkspaceLogoMutationOptions = Apollo.BaseMutationOptions<UploadWorkspaceLogoMutation, UploadWorkspaceLogoMutationVariables>;
|
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`
|
export const GetWorkspaceFromInviteHashDocument = gql`
|
||||||
query GetWorkspaceFromInviteHash($inviteHash: String!) {
|
query GetWorkspaceFromInviteHash($inviteHash: String!) {
|
||||||
findWorkspaceFromInviteHash(inviteHash: $inviteHash) {
|
findWorkspaceFromInviteHash(inviteHash: $inviteHash) {
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export const CHECK_USER_EXISTS = gql`
|
|||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
subdomain
|
subdomain
|
||||||
|
hostname
|
||||||
logo
|
logo
|
||||||
sso {
|
sso {
|
||||||
type
|
type
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql`
|
|||||||
logo
|
logo
|
||||||
displayName
|
displayName
|
||||||
subdomain
|
subdomain
|
||||||
|
hostname
|
||||||
authProviders {
|
authProviders {
|
||||||
sso {
|
sso {
|
||||||
id
|
id
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export type CurrentWorkspace = Pick<
|
|||||||
| 'isPasswordAuthEnabled'
|
| 'isPasswordAuthEnabled'
|
||||||
| 'hasValidEnterpriseKey'
|
| 'hasValidEnterpriseKey'
|
||||||
| 'subdomain'
|
| 'subdomain'
|
||||||
|
| 'hostname'
|
||||||
| 'metadataVersion'
|
| 'metadataVersion'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|||||||
@ -158,6 +158,7 @@ export const queries = {
|
|||||||
isPasswordAuthEnabled
|
isPasswordAuthEnabled
|
||||||
subdomain
|
subdomain
|
||||||
hasValidEnterpriseKey
|
hasValidEnterpriseKey
|
||||||
|
hostname
|
||||||
featureFlags {
|
featureFlags {
|
||||||
id
|
id
|
||||||
key
|
key
|
||||||
@ -307,6 +308,7 @@ export const responseData = {
|
|||||||
isMicrosoftAuthEnabled: false,
|
isMicrosoftAuthEnabled: false,
|
||||||
isPasswordAuthEnabled: true,
|
isPasswordAuthEnabled: true,
|
||||||
subdomain: 'test',
|
subdomain: 'test',
|
||||||
|
hostname: null,
|
||||||
featureFlags: [],
|
featureFlags: [],
|
||||||
metadataVersion: 1,
|
metadataVersion: 1,
|
||||||
currentBillingSubscription: null,
|
currentBillingSubscription: null,
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export const USER_QUERY_FRAGMENT = gql`
|
|||||||
isPasswordAuthEnabled
|
isPasswordAuthEnabled
|
||||||
subdomain
|
subdomain
|
||||||
hasValidEnterpriseKey
|
hasValidEnterpriseKey
|
||||||
|
hostname
|
||||||
featureFlags {
|
featureFlags {
|
||||||
id
|
id
|
||||||
key
|
key
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export const UPDATE_WORKSPACE = gql`
|
|||||||
mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
|
mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
|
||||||
updateWorkspace(data: $input) {
|
updateWorkspace(data: $input) {
|
||||||
id
|
id
|
||||||
|
hostname
|
||||||
subdomain
|
subdomain
|
||||||
displayName
|
displayName
|
||||||
logo
|
logo
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@ -1,42 +1,27 @@
|
|||||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||||
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
import { useRecoilState } from 'recoil';
|
||||||
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
|
|
||||||
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
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 { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
import { TextInputV2 } from '@/ui/input/components/TextInputV2';
|
import {
|
||||||
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
FeatureFlagKey,
|
||||||
import { ApolloError } from '@apollo/client';
|
useUpdateWorkspaceMutation,
|
||||||
import styled from '@emotion/styled';
|
} from '~/generated/graphql';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { SettingsHostname } from '~/pages/settings/workspace/SettingsHostname';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { SettingsSubdomain } from '~/pages/settings/workspace/SettingsSubdomain';
|
||||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
import { H2Title, Section } from 'twenty-ui';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { useUpdateWorkspaceMutation } from '~/generated/graphql';
|
|
||||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
|
||||||
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
|
||||||
|
import { ApolloError } from '@apollo/client';
|
||||||
type Form = {
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
subdomain: string;
|
import { z } from 'zod';
|
||||||
};
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
const StyledDomainFromWrapper = styled.div`
|
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
|
||||||
align-items: center;
|
import { SettingsPath } from '@/types/SettingsPath';
|
||||||
display: flex;
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
`;
|
import { SettingsHostnameEffect } from '~/pages/settings/workspace/SettingsHostnameEffect';
|
||||||
|
|
||||||
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)};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const SettingsDomain = () => {
|
export const SettingsDomain = () => {
|
||||||
const navigate = useNavigateSettings();
|
const navigate = useNavigateSettings();
|
||||||
@ -54,22 +39,21 @@ export const SettingsDomain = () => {
|
|||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|
||||||
const domainConfiguration = useRecoilValue(domainConfigurationState);
|
|
||||||
|
|
||||||
const { enqueueSnackBar } = useSnackBar();
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
const [updateWorkspace] = useUpdateWorkspaceMutation();
|
const [updateWorkspace] = useUpdateWorkspaceMutation();
|
||||||
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
|
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
|
||||||
|
|
||||||
|
const isCustomDomainEnabled = useIsFeatureEnabled(
|
||||||
|
FeatureFlagKey.IsCustomDomainEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
|
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
|
||||||
currentWorkspaceState,
|
currentWorkspaceState,
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const form = useForm<{
|
||||||
control,
|
subdomain: string;
|
||||||
watch,
|
}>({
|
||||||
getValues,
|
|
||||||
formState: { isValid },
|
|
||||||
} = useForm<Form>({
|
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
delayError: 500,
|
delayError: 500,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -78,12 +62,12 @@ export const SettingsDomain = () => {
|
|||||||
resolver: zodResolver(validationSchema),
|
resolver: zodResolver(validationSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const subdomainValue = watch('subdomain');
|
const subdomainValue = form.watch('subdomain');
|
||||||
|
|
||||||
const handleSave = async () => {
|
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`, {
|
return enqueueSnackBar(t`Invalid form values`, {
|
||||||
variant: SnackBarVariant.Error,
|
variant: SnackBarVariant.Error,
|
||||||
});
|
});
|
||||||
@ -100,7 +84,7 @@ export const SettingsDomain = () => {
|
|||||||
error instanceof ApolloError &&
|
error instanceof ApolloError &&
|
||||||
error.graphQLErrors[0]?.extensions?.code === 'CONFLICT'
|
error.graphQLErrors[0]?.extensions?.code === 'CONFLICT'
|
||||||
) {
|
) {
|
||||||
return control.setError('subdomain', {
|
return form.control.setError('subdomain', {
|
||||||
type: 'manual',
|
type: 'manual',
|
||||||
message: t`Subdomain already taken`,
|
message: t`Subdomain already taken`,
|
||||||
});
|
});
|
||||||
@ -137,7 +121,8 @@ export const SettingsDomain = () => {
|
|||||||
actionButton={
|
actionButton={
|
||||||
<SaveAndCancelButtons
|
<SaveAndCancelButtons
|
||||||
isSaveDisabled={
|
isSaveDisabled={
|
||||||
!isValid || subdomainValue === currentWorkspace?.subdomain
|
!form.formState.isValid ||
|
||||||
|
subdomainValue === currentWorkspace?.subdomain
|
||||||
}
|
}
|
||||||
onCancel={() => navigate(SettingsPath.Workspace)}
|
onCancel={() => navigate(SettingsPath.Workspace)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
@ -145,39 +130,18 @@ export const SettingsDomain = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SettingsPageContainer>
|
<SettingsPageContainer>
|
||||||
<Section>
|
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||||
<H2Title
|
<FormProvider {...form}>
|
||||||
title={t`Domain`}
|
{isCustomDomainEnabled && (
|
||||||
description={t`Set the name of your subdomain`}
|
<>
|
||||||
/>
|
<SettingsHostnameEffect />
|
||||||
{currentWorkspace?.subdomain && (
|
<SettingsHostname />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</Section>
|
{(!currentWorkspace?.hostname || !isCustomDomainEnabled) && (
|
||||||
|
<SettingsSubdomain />
|
||||||
|
)}
|
||||||
|
</FormProvider>
|
||||||
</SettingsPageContainer>
|
</SettingsPageContainer>
|
||||||
</SubMenuTopBarContainer>
|
</SubMenuTopBarContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 <></>;
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -77,4 +77,6 @@ FRONT_PORT=3001
|
|||||||
# SESSION_STORE_SECRET=replace_me_with_a_random_string_session
|
# SESSION_STORE_SECRET=replace_me_with_a_random_string_session
|
||||||
# ENTERPRISE_KEY=replace_me_with_a_valid_enterprise_key
|
# ENTERPRISE_KEY=replace_me_with_a_valid_enterprise_key
|
||||||
# SSL_KEY_PATH="./certs/your-cert.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=
|
||||||
@ -30,6 +30,7 @@
|
|||||||
"cache-manager": "^5.4.0",
|
"cache-manager": "^5.4.0",
|
||||||
"cache-manager-redis-yet": "^4.1.2",
|
"cache-manager-redis-yet": "^4.1.2",
|
||||||
"class-validator": "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch",
|
"class-validator": "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch",
|
||||||
|
"cloudflare": "^3.5.0",
|
||||||
"connect-redis": "^7.1.1",
|
"connect-redis": "^7.1.1",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"graphql-middleware": "^6.1.35",
|
"graphql-middleware": "^6.1.35",
|
||||||
|
|||||||
@ -37,6 +37,9 @@ export class AvailableWorkspaceOutput {
|
|||||||
@Field(() => String)
|
@Field(() => String)
|
||||||
subdomain: string;
|
subdomain: string;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
hostname?: string;
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
logo?: string;
|
logo?: string;
|
||||||
|
|
||||||
|
|||||||
@ -7,5 +7,7 @@ export class DomainManagerException extends CustomException {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum DomainManagerExceptionCode {
|
export enum DomainManagerExceptionCode {
|
||||||
|
CLOUDFLARE_CLIENT_NOT_INITIALIZED = 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
|
||||||
|
HOSTNAME_ALREADY_REGISTERED = 'HOSTNAME_ALREADY_REGISTERED',
|
||||||
SUBDOMAIN_REQUIRED = 'SUBDOMAIN_REQUIRED',
|
SUBDOMAIN_REQUIRED = 'SUBDOMAIN_REQUIRED',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
|
}
|
||||||
@ -2,24 +2,36 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
import Cloudflare from 'cloudflare';
|
||||||
|
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
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 { 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()
|
@Injectable()
|
||||||
export class DomainManagerService {
|
export class DomainManagerService {
|
||||||
|
cloudflareClient?: Cloudflare;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Workspace, 'core')
|
@InjectRepository(Workspace, 'core')
|
||||||
private readonly workspaceRepository: Repository<Workspace>,
|
private readonly workspaceRepository: Repository<Workspace>,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
) {}
|
) {
|
||||||
|
if (this.environmentService.get('CLOUDFLARE_API_KEY')) {
|
||||||
|
this.cloudflareClient = new Cloudflare({
|
||||||
|
apiToken: this.environmentService.get('CLOUDFLARE_API_KEY'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getFrontUrl() {
|
getFrontUrl() {
|
||||||
let baseUrl: URL;
|
let baseUrl: URL;
|
||||||
@ -105,6 +117,7 @@ export class DomainManagerService {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @Deprecated
|
||||||
getWorkspaceSubdomainFromUrl = (url: string) => {
|
getWorkspaceSubdomainFromUrl = (url: string) => {
|
||||||
const { hostname: originHostname } = new URL(url);
|
const { hostname: originHostname } = new URL(url);
|
||||||
|
|
||||||
@ -119,6 +132,24 @@ export class DomainManagerService {
|
|||||||
return this.isDefaultSubdomain(subdomain) ? null : subdomain;
|
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) {
|
async getWorkspaceBySubdomainOrDefaultWorkspace(subdomain?: string) {
|
||||||
return subdomain
|
return subdomain
|
||||||
? await this.workspaceRepository.findOne({
|
? await this.workspaceRepository.findOne({
|
||||||
@ -180,121 +211,37 @@ export class DomainManagerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getWorkspaceByOriginOrDefaultWorkspace(origin: string) {
|
async getWorkspaceByOriginOrDefaultWorkspace(origin: string) {
|
||||||
try {
|
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
|
||||||
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
|
return this.getDefaultWorkspace();
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
private extractSubdomain(params?: { email?: string; displayName?: string }) {
|
||||||
const prefixes = [
|
if (params?.email) {
|
||||||
'cool',
|
return getSubdomainFromEmail(params.email);
|
||||||
'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)];
|
if (params?.displayName) {
|
||||||
const randomSuffix = suffixes[Math.floor(Math.random() * suffixes.length)];
|
return getSubdomainNameFromDisplayName(params.displayName);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateSubdomain(params?: { email?: string; displayName?: string }) {
|
async generateSubdomain(params?: { email?: string; displayName?: string }) {
|
||||||
const subdomain =
|
const subdomain =
|
||||||
this.getSubdomainNameByEmail(params?.email) ??
|
this.extractSubdomain(params) ?? generateRandomSubdomain();
|
||||||
this.getSubdomainNameByDisplayName(params?.displayName) ??
|
|
||||||
this.generateRandomSubdomain();
|
|
||||||
|
|
||||||
const existingWorkspaceCount = await this.workspaceRepository.countBy({
|
const existingWorkspaceCount = await this.workspaceRepository.countBy({
|
||||||
subdomain,
|
subdomain,
|
||||||
@ -302,4 +249,133 @@ export class DomainManagerService {
|
|||||||
|
|
||||||
return `${subdomain}${existingWorkspaceCount > 0 ? `-${Math.random().toString(36).substring(2, 10)}` : ''}`;
|
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'),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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}`;
|
||||||
|
};
|
||||||
@ -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];
|
||||||
|
};
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
};
|
||||||
@ -242,6 +242,14 @@ export class EnvironmentVariables {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
IS_MULTIWORKSPACE_ENABLED = false;
|
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
|
// Custom Code Engine
|
||||||
@IsEnum(ServerlessDriverType)
|
@IsEnum(ServerlessDriverType)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -58,4 +58,7 @@ export class PublicWorkspaceDataOutput {
|
|||||||
|
|
||||||
@Field(() => String)
|
@Field(() => String)
|
||||||
subdomain: Workspace['subdomain'];
|
subdomain: Workspace['subdomain'];
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
hostname: Workspace['hostname'];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,11 +10,6 @@ import {
|
|||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
export class UpdateWorkspaceInput {
|
export class UpdateWorkspaceInput {
|
||||||
@Field({ nullable: true })
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
domainName?: string;
|
|
||||||
|
|
||||||
@Field({ nullable: true })
|
@Field({ nullable: true })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ -105,6 +100,14 @@ export class UpdateWorkspaceInput {
|
|||||||
])
|
])
|
||||||
subdomain?: string;
|
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 })
|
@Field({ nullable: true })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import {
|
|||||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||||
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
|
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 { 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()
|
@Injectable()
|
||||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
// 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 billingService: BillingService,
|
||||||
private readonly userWorkspaceService: UserWorkspaceService,
|
private readonly userWorkspaceService: UserWorkspaceService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
|
private readonly domainManagerService: DomainManagerService,
|
||||||
) {
|
) {
|
||||||
super(workspaceRepository);
|
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 }) {
|
async updateWorkspaceById(payload: Partial<Workspace> & { id: string }) {
|
||||||
const workspace = await this.workspaceRepository.findOneBy({
|
const workspace = await this.workspaceRepository.findOneBy({
|
||||||
id: payload.id,
|
id: payload.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
workspaceValidator.assertIsDefinedOrThrow(
|
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
||||||
workspace,
|
|
||||||
new WorkspaceException(
|
|
||||||
'Workspace not found',
|
|
||||||
WorkspaceExceptionCode.WORKSPACE_NOT_FOUND,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (payload.subdomain && workspace.subdomain !== payload.subdomain) {
|
if (payload.subdomain && workspace.subdomain !== payload.subdomain) {
|
||||||
const subdomainAvailable = await this.isSubdomainAvailable(
|
await this.validateSubdomainUpdate(payload.subdomain);
|
||||||
payload.subdomain,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!subdomainAvailable ||
|
|
||||||
this.environmentService.get('DEFAULT_SUBDOMAIN') === payload.subdomain
|
|
||||||
) {
|
|
||||||
throw new WorkspaceException(
|
|
||||||
'Subdomain already taken',
|
|
||||||
WorkspaceExceptionCode.SUBDOMAIN_ALREADY_TAKEN,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.workspaceRepository.save({
|
let customDomainRegistered = false;
|
||||||
...workspace,
|
|
||||||
...payload,
|
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(
|
async activateWorkspace(
|
||||||
|
|||||||
@ -9,5 +9,6 @@ export class WorkspaceException extends CustomException {
|
|||||||
export enum WorkspaceExceptionCode {
|
export enum WorkspaceExceptionCode {
|
||||||
SUBDOMAIN_NOT_FOUND = 'SUBDOMAIN_NOT_FOUND',
|
SUBDOMAIN_NOT_FOUND = 'SUBDOMAIN_NOT_FOUND',
|
||||||
SUBDOMAIN_ALREADY_TAKEN = 'SUBDOMAIN_ALREADY_TAKEN',
|
SUBDOMAIN_ALREADY_TAKEN = 'SUBDOMAIN_ALREADY_TAKEN',
|
||||||
|
DOMAIN_ALREADY_TAKEN = 'DOMAIN_ALREADY_TAKEN',
|
||||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,10 +33,6 @@ import {
|
|||||||
import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
|
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 { 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 { 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 { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.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 { assert } from 'src/utils/assert';
|
||||||
import { isDefined } from 'src/utils/is-defined';
|
import { isDefined } from 'src/utils/is-defined';
|
||||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
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';
|
import { Workspace } from './workspace.entity';
|
||||||
|
|
||||||
@ -218,21 +215,27 @@ export class WorkspaceResolver {
|
|||||||
return isDefined(this.environmentService.get('ENTERPRISE_KEY'));
|
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)
|
@Query(() => PublicWorkspaceDataOutput)
|
||||||
async getPublicWorkspaceDataBySubdomain(@OriginHeader() origin: string) {
|
async getPublicWorkspaceDataBySubdomain(
|
||||||
|
@OriginHeader() origin: string,
|
||||||
|
): Promise<PublicWorkspaceDataOutput | undefined> {
|
||||||
try {
|
try {
|
||||||
const workspace =
|
const workspace =
|
||||||
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
|
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
|
||||||
origin,
|
origin,
|
||||||
);
|
);
|
||||||
|
|
||||||
workspaceValidator.assertIsDefinedOrThrow(
|
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
||||||
workspace,
|
|
||||||
new WorkspaceException(
|
|
||||||
'Workspace not found',
|
|
||||||
WorkspaceExceptionCode.WORKSPACE_NOT_FOUND,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let workspaceLogoWithToken = '';
|
let workspaceLogoWithToken = '';
|
||||||
|
|
||||||
@ -261,6 +264,7 @@ export class WorkspaceResolver {
|
|||||||
logo: workspaceLogoWithToken,
|
logo: workspaceLogoWithToken,
|
||||||
displayName: workspace.displayName,
|
displayName: workspace.displayName,
|
||||||
subdomain: workspace.subdomain,
|
subdomain: workspace.subdomain,
|
||||||
|
hostname: workspace.hostname,
|
||||||
authProviders: getAuthProvidersByWorkspace({
|
authProviders: getAuthProvidersByWorkspace({
|
||||||
workspace,
|
workspace,
|
||||||
systemEnabledProviders,
|
systemEnabledProviders,
|
||||||
|
|||||||
35
yarn.lock
35
yarn.lock
@ -17533,6 +17533,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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:*":
|
"@types/range-parser@npm:*":
|
||||||
version: 1.2.7
|
version: 1.2.7
|
||||||
resolution: "@types/range-parser@npm:1.2.7"
|
resolution: "@types/range-parser@npm:1.2.7"
|
||||||
@ -23089,6 +23096,24 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"clsx@npm:^1.1.1, clsx@npm:^1.2.1":
|
||||||
version: 1.2.1
|
version: 1.2.1
|
||||||
resolution: "clsx@npm:1.2.1"
|
resolution: "clsx@npm:1.2.1"
|
||||||
@ -40533,6 +40558,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"qs@npm:~6.5.2":
|
||||||
version: 6.5.3
|
version: 6.5.3
|
||||||
resolution: "qs@npm:6.5.3"
|
resolution: "qs@npm:6.5.3"
|
||||||
@ -45876,6 +45910,7 @@ __metadata:
|
|||||||
cache-manager: "npm:^5.4.0"
|
cache-manager: "npm:^5.4.0"
|
||||||
cache-manager-redis-yet: "npm:^4.1.2"
|
cache-manager-redis-yet: "npm:^4.1.2"
|
||||||
class-validator: "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch"
|
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"
|
connect-redis: "npm:^7.1.1"
|
||||||
express-session: "npm:^1.18.1"
|
express-session: "npm:^1.18.1"
|
||||||
graphql-middleware: "npm:^6.1.35"
|
graphql-middleware: "npm:^6.1.35"
|
||||||
|
|||||||
Reference in New Issue
Block a user