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