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 = {
/** Step to update in JSON format */
step: Scalars['JSON'];
/** Boolean to check if we need to update stepOutput */
shouldUpdateStepOutput?: InputMaybe<Scalars['Boolean']>;
/** 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<Array<BillingCustomer>>;
billingEntitlements?: Maybe<Array<BillingEntitlement>>;
billingSubscriptions?: Maybe<Array<BillingSubscription>>;
createdAt: Scalars['DateTime'];
@ -1430,6 +1431,12 @@ export type Workspace = {
};
export type WorkspaceBillingCustomersArgs = {
filter?: BillingCustomerFilter;
sorting?: Array<BillingCustomerSort>;
};
export type WorkspaceBillingEntitlementsArgs = {
filter?: BillingEntitlementFilter;
sorting?: Array<BillingEntitlementSort>;
@ -1517,6 +1524,27 @@ export type WorkspaceNameAndId = {
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 = {
__typename?: 'billingEntitlement';
id: Scalars['UUID'];
@ -4418,4 +4446,4 @@ export function useGetWorkspaceFromInviteHashLazyQuery(baseOptions?: Apollo.Lazy
}
export type GetWorkspaceFromInviteHashQueryHookResult = ReturnType<typeof useGetWorkspaceFromInviteHashQuery>;
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 = (
params: { workspaceId: string; subdomain: string } | null,
) => {
setLastAuthenticatedWorkspaceDomain(
params
? {
...params,
cookieAttributes: {
domain: `.${domainConfiguration.frontDomain}`,
},
}
: null,
);
setLastAuthenticatedWorkspaceDomain({
...(params ? params : {}),
cookieAttributes: {
domain: `.${domainConfiguration.frontDomain}`,
},
});
};
return {

View File

@ -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: [

View File

@ -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);
}

View File

@ -60,6 +60,22 @@ export const SettingsDomain = () => {
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 () => {
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<Form>({
mode: 'onChange',
delayError: 500,
defaultValues: {
subdomain: currentWorkspace?.subdomain ?? '',
},
resolver: zodResolver(validationSchema),
});
return (
<SubMenuTopBarContainer
title="General"
@ -118,7 +132,9 @@ export const SettingsDomain = () => {
]}
actionButton={
<SaveAndCancelButtons
isSaveDisabled={!isValid}
isSaveDisabled={
!isValid || subdomainValue === currentWorkspace?.subdomain
}
onCancel={() => navigate(getSettingsPagePath(SettingsPath.Workspace))}
onSave={handleSave}
/>

View File

@ -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'])),

View File

@ -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 })

View File

@ -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<Workspace> {
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) {
if (!data.displayName || !data.displayName.length) {
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,
@AuthWorkspace() workspace: Workspace,
) {
return this.workspaceService.updateOne(workspace.id, data);
return this.workspaceService.updateWorkspaceById({
...data,
id: workspace.id,
});
}
@Mutation(() => String)