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