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 = {
|
||||
__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']>;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -10,6 +10,7 @@ export const CHECK_USER_EXISTS = gql`
|
||||
id
|
||||
displayName
|
||||
subdomain
|
||||
hostname
|
||||
logo
|
||||
sso {
|
||||
type
|
||||
|
||||
@ -7,6 +7,7 @@ export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql`
|
||||
logo
|
||||
displayName
|
||||
subdomain
|
||||
hostname
|
||||
authProviders {
|
||||
sso {
|
||||
id
|
||||
|
||||
@ -20,6 +20,7 @@ export type CurrentWorkspace = Pick<
|
||||
| 'isPasswordAuthEnabled'
|
||||
| 'hasValidEnterpriseKey'
|
||||
| 'subdomain'
|
||||
| 'hostname'
|
||||
| 'metadataVersion'
|
||||
>;
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -37,6 +37,7 @@ export const USER_QUERY_FRAGMENT = gql`
|
||||
isPasswordAuthEnabled
|
||||
subdomain
|
||||
hasValidEnterpriseKey
|
||||
hostname
|
||||
featureFlags {
|
||||
id
|
||||
key
|
||||
|
||||
@ -4,6 +4,7 @@ export const UPDATE_WORKSPACE = gql`
|
||||
mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
|
||||
updateWorkspace(data: $input) {
|
||||
id
|
||||
hostname
|
||||
subdomain
|
||||
displayName
|
||||
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 { 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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
# 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=
|
||||
@ -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",
|
||||
|
||||
@ -37,6 +37,9 @@ export class AvailableWorkspaceOutput {
|
||||
@Field(() => String)
|
||||
subdomain: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
hostname?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
logo?: string;
|
||||
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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 { 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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
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()
|
||||
|
||||
@ -58,4 +58,7 @@ export class PublicWorkspaceDataOutput {
|
||||
|
||||
@Field(() => String)
|
||||
subdomain: Workspace['subdomain'];
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
hostname: Workspace['hostname'];
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
35
yarn.lock
35
yarn.lock
@ -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"
|
||||
|
||||
Reference in New Issue
Block a user