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:
@ -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>;
|
||||
@ -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 {
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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'])),
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user