feat(workspace): Add subdomain availability check (#8906)

Implemented a feature to check the availability of subdomains when
updating workspace settings. This includes a new mutation,
`isSubdomainAvailable`, to validate subdomain availability through
GraphQL. The frontend now verifies if a subdomain is available to
prevent duplicates during updates.

---------

Co-authored-by: Weiko <corentin@twenty.com>
This commit is contained in:
Antoine Moreaux
2024-12-06 14:28:30 +01:00
committed by GitHub
parent 5c565345ae
commit 36fb14179b
9 changed files with 139 additions and 42 deletions

View File

@ -1277,10 +1277,10 @@ export type UpdateServerlessFunctionInput = {
}; };
export type UpdateWorkflowVersionStepInput = { export type UpdateWorkflowVersionStepInput = {
/** Step to update in JSON format */
step: Scalars['JSON'];
/** Boolean to check if we need to update stepOutput */ /** Boolean to check if we need to update stepOutput */
shouldUpdateStepOutput?: InputMaybe<Scalars['Boolean']>; shouldUpdateStepOutput?: InputMaybe<Scalars['Boolean']>;
/** Step to update in JSON format */
step: Scalars['JSON'];
/** Workflow version ID */ /** Workflow version ID */
workflowVersionId: Scalars['String']; workflowVersionId: Scalars['String'];
}; };
@ -1405,6 +1405,7 @@ export type Workspace = {
__typename?: 'Workspace'; __typename?: 'Workspace';
activationStatus: WorkspaceActivationStatus; activationStatus: WorkspaceActivationStatus;
allowImpersonation: Scalars['Boolean']; allowImpersonation: Scalars['Boolean'];
billingCustomers?: Maybe<Array<BillingCustomer>>;
billingEntitlements?: Maybe<Array<BillingEntitlement>>; billingEntitlements?: Maybe<Array<BillingEntitlement>>;
billingSubscriptions?: Maybe<Array<BillingSubscription>>; billingSubscriptions?: Maybe<Array<BillingSubscription>>;
createdAt: Scalars['DateTime']; createdAt: Scalars['DateTime'];
@ -1430,6 +1431,12 @@ export type Workspace = {
}; };
export type WorkspaceBillingCustomersArgs = {
filter?: BillingCustomerFilter;
sorting?: Array<BillingCustomerSort>;
};
export type WorkspaceBillingEntitlementsArgs = { export type WorkspaceBillingEntitlementsArgs = {
filter?: BillingEntitlementFilter; filter?: BillingEntitlementFilter;
sorting?: Array<BillingEntitlementSort>; sorting?: Array<BillingEntitlementSort>;
@ -1517,6 +1524,27 @@ export type WorkspaceNameAndId = {
id: Scalars['String']; id: Scalars['String'];
}; };
export type BillingCustomer = {
__typename?: 'billingCustomer';
id: Scalars['UUID'];
};
export type BillingCustomerFilter = {
and?: InputMaybe<Array<BillingCustomerFilter>>;
id?: InputMaybe<UuidFilterComparison>;
or?: InputMaybe<Array<BillingCustomerFilter>>;
};
export type BillingCustomerSort = {
direction: SortDirection;
field: BillingCustomerSortFields;
nulls?: InputMaybe<SortNulls>;
};
export enum BillingCustomerSortFields {
Id = 'id'
}
export type BillingEntitlement = { export type BillingEntitlement = {
__typename?: 'billingEntitlement'; __typename?: 'billingEntitlement';
id: Scalars['UUID']; id: Scalars['UUID'];
@ -4418,4 +4446,4 @@ export function useGetWorkspaceFromInviteHashLazyQuery(baseOptions?: Apollo.Lazy
} }
export type GetWorkspaceFromInviteHashQueryHookResult = ReturnType<typeof useGetWorkspaceFromInviteHashQuery>; export type GetWorkspaceFromInviteHashQueryHookResult = ReturnType<typeof useGetWorkspaceFromInviteHashQuery>;
export type GetWorkspaceFromInviteHashLazyQueryHookResult = ReturnType<typeof useGetWorkspaceFromInviteHashLazyQuery>; export type GetWorkspaceFromInviteHashLazyQueryHookResult = ReturnType<typeof useGetWorkspaceFromInviteHashLazyQuery>;
export type GetWorkspaceFromInviteHashQueryResult = Apollo.QueryResult<GetWorkspaceFromInviteHashQuery, GetWorkspaceFromInviteHashQueryVariables>; export type GetWorkspaceFromInviteHashQueryResult = Apollo.QueryResult<GetWorkspaceFromInviteHashQuery, GetWorkspaceFromInviteHashQueryVariables>;

View File

@ -10,16 +10,12 @@ export const useLastAuthenticatedWorkspaceDomain = () => {
const setLastAuthenticateWorkspaceDomainWithCookieAttributes = ( const setLastAuthenticateWorkspaceDomainWithCookieAttributes = (
params: { workspaceId: string; subdomain: string } | null, params: { workspaceId: string; subdomain: string } | null,
) => { ) => {
setLastAuthenticatedWorkspaceDomain( setLastAuthenticatedWorkspaceDomain({
params ...(params ? params : {}),
? { cookieAttributes: {
...params, domain: `.${domainConfiguration.frontDomain}`,
cookieAttributes: { },
domain: `.${domainConfiguration.frontDomain}`, });
},
}
: null,
);
}; };
return { return {

View File

@ -1,11 +1,16 @@
import { cookieStorageEffect } from '~/utils/recoil-effects'; import { cookieStorageEffect } from '~/utils/recoil-effects';
import { createState } from 'twenty-ui'; import { createState } from 'twenty-ui';
export const lastAuthenticatedWorkspaceDomainState = createState<{ export const lastAuthenticatedWorkspaceDomainState = createState<
subdomain: string; | {
workspaceId: string; subdomain: string;
cookieAttributes?: Cookies.CookieAttributes; workspaceId: string;
} | null>({ 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', key: 'lastAuthenticateWorkspaceDomain',
defaultValue: null, defaultValue: null,
effects: [ effects: [

View File

@ -41,8 +41,10 @@ export const WorkspaceProviderEffect = () => {
useEffect(() => { useEffect(() => {
if ( if (
isMultiWorkspaceEnabled && isMultiWorkspaceEnabled &&
isDefined(lastAuthenticatedWorkspaceDomain?.subdomain) && isDefaultDomain &&
isDefaultDomain isDefined(lastAuthenticatedWorkspaceDomain) &&
'subdomain' in lastAuthenticatedWorkspaceDomain &&
isDefined(lastAuthenticatedWorkspaceDomain?.subdomain)
) { ) {
redirectToWorkspaceDomain(lastAuthenticatedWorkspaceDomain.subdomain); redirectToWorkspaceDomain(lastAuthenticatedWorkspaceDomain.subdomain);
} }

View File

@ -60,6 +60,22 @@ export const SettingsDomain = () => {
currentWorkspaceState, currentWorkspaceState,
); );
const {
control,
watch,
getValues,
formState: { isValid },
} = useForm<Form>({
mode: 'onChange',
delayError: 500,
defaultValues: {
subdomain: currentWorkspace?.subdomain ?? '',
},
resolver: zodResolver(validationSchema),
});
const subdomainValue = watch('subdomain');
const handleSave = async () => { const handleSave = async () => {
try { try {
const values = getValues(); const values = getValues();
@ -83,25 +99,23 @@ export const SettingsDomain = () => {
window.location.href = buildWorkspaceUrl(values.subdomain); window.location.href = buildWorkspaceUrl(values.subdomain);
} catch (error) { } 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, { enqueueSnackBar((error as Error).message, {
variant: SnackBarVariant.Error, variant: SnackBarVariant.Error,
}); });
} }
}; };
const {
control,
getValues,
formState: { isValid },
} = useForm<Form>({
mode: 'onChange',
delayError: 500,
defaultValues: {
subdomain: currentWorkspace?.subdomain ?? '',
},
resolver: zodResolver(validationSchema),
});
return ( return (
<SubMenuTopBarContainer <SubMenuTopBarContainer
title="General" title="General"
@ -118,7 +132,9 @@ export const SettingsDomain = () => {
]} ]}
actionButton={ actionButton={
<SaveAndCancelButtons <SaveAndCancelButtons
isSaveDisabled={!isValid} isSaveDisabled={
!isValid || subdomainValue === currentWorkspace?.subdomain
}
onCancel={() => navigate(getSettingsPagePath(SettingsPath.Workspace))} onCancel={() => navigate(getSettingsPagePath(SettingsPath.Workspace))}
onSave={handleSave} onSave={handleSave}
/> />

View File

@ -60,20 +60,23 @@ export const cookieStorageEffect =
}; };
onSet((newValue, _, isReset) => { onSet((newValue, _, isReset) => {
if (!newValue) {
cookieStorage.removeItem(key, defaultAttributes);
return;
}
const cookieAttributes = { const cookieAttributes = {
...defaultAttributes, ...defaultAttributes,
...(isCustomCookiesAttributesValue(newValue) ...(isCustomCookiesAttributesValue(newValue)
? newValue.cookieAttributes ? newValue.cookieAttributes
: {}), : {}),
}; };
if (
!newValue ||
(Object.keys(newValue).length === 1 &&
isCustomCookiesAttributesValue(newValue))
) {
cookieStorage.removeItem(key, cookieAttributes);
return;
}
isReset isReset
? cookieStorage.removeItem(key, defaultAttributes) ? cookieStorage.removeItem(key, cookieAttributes)
: cookieStorage.setItem( : cookieStorage.setItem(
key, key,
JSON.stringify(omit(newValue, ['cookieAttributes'])), JSON.stringify(omit(newValue, ['cookieAttributes'])),

View File

@ -1,6 +1,6 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql';
import { IsBoolean, IsOptional, IsString } from 'class-validator'; import { IsBoolean, IsOptional, IsString, Matches } from 'class-validator';
@InputType() @InputType()
export class UpdateWorkspaceInput { export class UpdateWorkspaceInput {
@ -12,6 +12,7 @@ export class UpdateWorkspaceInput {
@Field({ nullable: true }) @Field({ nullable: true })
@IsString() @IsString()
@IsOptional() @IsOptional()
@Matches(/^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/)
subdomain?: string; subdomain?: string;
@Field({ nullable: true }) @Field({ nullable: true })

View File

@ -18,6 +18,12 @@ import {
} from 'src/engine/core-modules/workspace/workspace.entity'; } from 'src/engine/core-modules/workspace/workspace.entity';
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 {
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() @Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository // eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -37,6 +43,35 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
super(workspaceRepository); super(workspaceRepository);
} }
async updateWorkspaceById(payload: Partial<Workspace> & { 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) { async activateWorkspace(user: User, data: ActivateWorkspaceInput) {
if (!data.displayName || !data.displayName.length) { if (!data.displayName || !data.displayName.length) {
throw new BadRequestException("'displayName' not provided"); throw new BadRequestException("'displayName' not provided");
@ -159,4 +194,12 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
); );
} }
} }
async isSubdomainAvailable(subdomain: string) {
const existingWorkspace = await this.workspaceRepository.findOne({
where: { subdomain: subdomain },
});
return !existingWorkspace;
}
} }

View File

@ -91,7 +91,10 @@ export class WorkspaceResolver {
@Args('data') data: UpdateWorkspaceInput, @Args('data') data: UpdateWorkspaceInput,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
) { ) {
return this.workspaceService.updateOne(workspace.id, data); return this.workspaceService.updateWorkspaceById({
...data,
id: workspace.id,
});
} }
@Mutation(() => String) @Mutation(() => String)