diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 798f5582e..b1fe1e8e2 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1277,10 +1277,10 @@ export type UpdateServerlessFunctionInput = { }; export type UpdateWorkflowVersionStepInput = { - /** Step to update in JSON format */ - step: Scalars['JSON']; /** Boolean to check if we need to update stepOutput */ shouldUpdateStepOutput?: InputMaybe; + /** Step to update in JSON format */ + step: Scalars['JSON']; /** Workflow version ID */ workflowVersionId: Scalars['String']; }; @@ -1405,6 +1405,7 @@ export type Workspace = { __typename?: 'Workspace'; activationStatus: WorkspaceActivationStatus; allowImpersonation: Scalars['Boolean']; + billingCustomers?: Maybe>; billingEntitlements?: Maybe>; billingSubscriptions?: Maybe>; createdAt: Scalars['DateTime']; @@ -1430,6 +1431,12 @@ export type Workspace = { }; +export type WorkspaceBillingCustomersArgs = { + filter?: BillingCustomerFilter; + sorting?: Array; +}; + + export type WorkspaceBillingEntitlementsArgs = { filter?: BillingEntitlementFilter; sorting?: Array; @@ -1517,6 +1524,27 @@ export type WorkspaceNameAndId = { id: Scalars['String']; }; +export type BillingCustomer = { + __typename?: 'billingCustomer'; + id: Scalars['UUID']; +}; + +export type BillingCustomerFilter = { + and?: InputMaybe>; + id?: InputMaybe; + or?: InputMaybe>; +}; + +export type BillingCustomerSort = { + direction: SortDirection; + field: BillingCustomerSortFields; + nulls?: InputMaybe; +}; + +export enum BillingCustomerSortFields { + Id = 'id' +} + export type BillingEntitlement = { __typename?: 'billingEntitlement'; id: Scalars['UUID']; @@ -4418,4 +4446,4 @@ export function useGetWorkspaceFromInviteHashLazyQuery(baseOptions?: Apollo.Lazy } export type GetWorkspaceFromInviteHashQueryHookResult = ReturnType; export type GetWorkspaceFromInviteHashLazyQueryHookResult = ReturnType; -export type GetWorkspaceFromInviteHashQueryResult = Apollo.QueryResult; +export type GetWorkspaceFromInviteHashQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/packages/twenty-front/src/modules/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain.ts b/packages/twenty-front/src/modules/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain.ts index 4085de30b..99348f3fb 100644 --- a/packages/twenty-front/src/modules/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain.ts +++ b/packages/twenty-front/src/modules/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain.ts @@ -10,16 +10,12 @@ export const useLastAuthenticatedWorkspaceDomain = () => { const setLastAuthenticateWorkspaceDomainWithCookieAttributes = ( params: { workspaceId: string; subdomain: string } | null, ) => { - setLastAuthenticatedWorkspaceDomain( - params - ? { - ...params, - cookieAttributes: { - domain: `.${domainConfiguration.frontDomain}`, - }, - } - : null, - ); + setLastAuthenticatedWorkspaceDomain({ + ...(params ? params : {}), + cookieAttributes: { + domain: `.${domainConfiguration.frontDomain}`, + }, + }); }; return { diff --git a/packages/twenty-front/src/modules/domain-manager/states/lastAuthenticatedWorkspaceDomainState.ts b/packages/twenty-front/src/modules/domain-manager/states/lastAuthenticatedWorkspaceDomainState.ts index 4706032b7..6d8e458a3 100644 --- a/packages/twenty-front/src/modules/domain-manager/states/lastAuthenticatedWorkspaceDomainState.ts +++ b/packages/twenty-front/src/modules/domain-manager/states/lastAuthenticatedWorkspaceDomainState.ts @@ -1,11 +1,16 @@ import { cookieStorageEffect } from '~/utils/recoil-effects'; import { createState } from 'twenty-ui'; -export const lastAuthenticatedWorkspaceDomainState = createState<{ - subdomain: string; - workspaceId: string; - cookieAttributes?: Cookies.CookieAttributes; -} | null>({ +export const lastAuthenticatedWorkspaceDomainState = createState< + | { + subdomain: string; + workspaceId: string; + cookieAttributes?: Cookies.CookieAttributes; + } + | null + // this type is necessary to let the deletion of cookie. Without the domain the cookie is not deleted. + | { cookieAttributes?: Cookies.CookieAttributes } +>({ key: 'lastAuthenticateWorkspaceDomain', defaultValue: null, effects: [ diff --git a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx index 3d43fc268..f7c67ba62 100644 --- a/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx +++ b/packages/twenty-front/src/modules/workspace/components/WorkspaceProviderEffect.tsx @@ -41,8 +41,10 @@ export const WorkspaceProviderEffect = () => { useEffect(() => { if ( isMultiWorkspaceEnabled && - isDefined(lastAuthenticatedWorkspaceDomain?.subdomain) && - isDefaultDomain + isDefaultDomain && + isDefined(lastAuthenticatedWorkspaceDomain) && + 'subdomain' in lastAuthenticatedWorkspaceDomain && + isDefined(lastAuthenticatedWorkspaceDomain?.subdomain) ) { redirectToWorkspaceDomain(lastAuthenticatedWorkspaceDomain.subdomain); } diff --git a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx index 007a95910..2febb79f8 100644 --- a/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx +++ b/packages/twenty-front/src/pages/settings/workspace/SettingsDomain.tsx @@ -60,6 +60,22 @@ export const SettingsDomain = () => { currentWorkspaceState, ); + const { + control, + watch, + getValues, + formState: { isValid }, + } = useForm
({ + mode: 'onChange', + delayError: 500, + defaultValues: { + subdomain: currentWorkspace?.subdomain ?? '', + }, + resolver: zodResolver(validationSchema), + }); + + const subdomainValue = watch('subdomain'); + const handleSave = async () => { try { const values = getValues(); @@ -83,25 +99,23 @@ export const SettingsDomain = () => { window.location.href = buildWorkspaceUrl(values.subdomain); } catch (error) { + if ( + error instanceof Error && + error.message === 'Subdomain already taken' + ) { + control.setError('subdomain', { + type: 'manual', + message: (error as Error).message, + }); + return; + } + enqueueSnackBar((error as Error).message, { variant: SnackBarVariant.Error, }); } }; - const { - control, - getValues, - formState: { isValid }, - } = useForm({ - mode: 'onChange', - delayError: 500, - defaultValues: { - subdomain: currentWorkspace?.subdomain ?? '', - }, - resolver: zodResolver(validationSchema), - }); - return ( { ]} actionButton={ navigate(getSettingsPagePath(SettingsPath.Workspace))} onSave={handleSave} /> diff --git a/packages/twenty-front/src/utils/recoil-effects.ts b/packages/twenty-front/src/utils/recoil-effects.ts index 5d77b27b0..5c409e7dc 100644 --- a/packages/twenty-front/src/utils/recoil-effects.ts +++ b/packages/twenty-front/src/utils/recoil-effects.ts @@ -60,20 +60,23 @@ export const cookieStorageEffect = }; onSet((newValue, _, isReset) => { - if (!newValue) { - cookieStorage.removeItem(key, defaultAttributes); - return; - } - const cookieAttributes = { ...defaultAttributes, ...(isCustomCookiesAttributesValue(newValue) ? newValue.cookieAttributes : {}), }; + if ( + !newValue || + (Object.keys(newValue).length === 1 && + isCustomCookiesAttributesValue(newValue)) + ) { + cookieStorage.removeItem(key, cookieAttributes); + return; + } isReset - ? cookieStorage.removeItem(key, defaultAttributes) + ? cookieStorage.removeItem(key, cookieAttributes) : cookieStorage.setItem( key, JSON.stringify(omit(newValue, ['cookieAttributes'])), diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts index 7c2ef7556..e86a58202 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts @@ -1,6 +1,6 @@ import { Field, InputType } from '@nestjs/graphql'; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsOptional, IsString, Matches } from 'class-validator'; @InputType() export class UpdateWorkspaceInput { @@ -12,6 +12,7 @@ export class UpdateWorkspaceInput { @Field({ nullable: true }) @IsString() @IsOptional() + @Matches(/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/) subdomain?: string; @Field({ nullable: true }) diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 0ae78f95e..f801f7bfb 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -18,6 +18,12 @@ import { } from 'src/engine/core-modules/workspace/workspace.entity'; 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 { + WorkspaceException, + WorkspaceExceptionCode, +} from 'src/engine/core-modules/workspace/workspace.exception'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { ConflictError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; @Injectable() // eslint-disable-next-line @nx/workspace-inject-workspace-repository @@ -37,6 +43,35 @@ export class WorkspaceService extends TypeOrmQueryService { super(workspaceRepository); } + async updateWorkspaceById(payload: Partial & { id: string }) { + const workspace = await this.workspaceRepository.findOneBy({ + id: payload.id, + }); + + workspaceValidator.assertIsExist( + workspace, + new WorkspaceException( + 'Workspace not found', + WorkspaceExceptionCode.WORKSPACE_NOT_FOUND, + ), + ); + + if (payload.subdomain && workspace.subdomain !== payload.subdomain) { + const subdomainAvailable = await this.isSubdomainAvailable( + payload.subdomain, + ); + + if (!subdomainAvailable) { + throw new ConflictError('Subdomain already taken'); + } + } + + return this.workspaceRepository.save({ + ...workspace, + ...payload, + }); + } + async activateWorkspace(user: User, data: ActivateWorkspaceInput) { if (!data.displayName || !data.displayName.length) { throw new BadRequestException("'displayName' not provided"); @@ -159,4 +194,12 @@ export class WorkspaceService extends TypeOrmQueryService { ); } } + + async isSubdomainAvailable(subdomain: string) { + const existingWorkspace = await this.workspaceRepository.findOne({ + where: { subdomain: subdomain }, + }); + + return !existingWorkspace; + } } diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts index d7f9cfe04..10f79331a 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.resolver.ts @@ -91,7 +91,10 @@ export class WorkspaceResolver { @Args('data') data: UpdateWorkspaceInput, @AuthWorkspace() workspace: Workspace, ) { - return this.workspaceService.updateOne(workspace.id, data); + return this.workspaceService.updateWorkspaceById({ + ...data, + id: workspace.id, + }); } @Mutation(() => String)