feat(): enable custom domain usage (#9911)

# Content
- Introduce the `workspaceUrls` property. It contains two
sub-properties: `customUrl, subdomainUrl`. These endpoints are used to
access the workspace. Even if the `workspaceUrls` is invalid for
multiple reasons, the `subdomainUrl` remains valid.
- Introduce `ResolveField` workspaceEndpoints to avoid unnecessary URL
computation on the frontend part.
- Add a `forceSubdomainUrl` to avoid custom URL using a query parameter
This commit is contained in:
Antoine Moreaux
2025-02-07 14:34:26 +01:00
committed by GitHub
parent 3cc66fe712
commit 68183b7c85
87 changed files with 645 additions and 373 deletions

View File

@ -5,7 +5,10 @@ export const IMPERSONATE = gql`
mutation Impersonate($userId: String!, $workspaceId: String!) {
impersonate(userId: $userId, workspaceId: $workspaceId) {
workspace {
subdomain
workspaceUrls {
subdomainUrl
customUrl
}
id
}
loginToken {

View File

@ -22,7 +22,10 @@ export const SIGN_UP = gql`
}
workspace {
id
subdomain
workspaceUrls {
subdomainUrl
customUrl
}
}
}
}

View File

@ -9,8 +9,10 @@ export const CHECK_USER_EXISTS = gql`
availableWorkspaces {
id
displayName
subdomain
hostname
workspaceUrls {
subdomainUrl
customUrl
}
logo
sso {
type

View File

@ -1,13 +1,15 @@
import { gql } from '@apollo/client';
export const GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN = gql`
query GetPublicWorkspaceDataBySubdomain {
getPublicWorkspaceDataBySubdomain {
export const GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN = gql`
query GetPublicWorkspaceDataByDomain {
getPublicWorkspaceDataByDomain {
id
logo
displayName
subdomain
hostname
workspaceUrls {
subdomainUrl
customUrl
}
authProviders {
sso {
id

View File

@ -53,7 +53,7 @@ import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type
import { captchaState } from '@/client-config/states/captchaState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
@ -62,6 +62,7 @@ import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/state
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { useSearchParams } from 'react-router-dom';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
@ -96,8 +97,7 @@ export const useAuth = () => {
useGetLoginTokenFromEmailVerificationTokenMutation();
const [getCurrentUser] = useGetCurrentUserLazyQuery();
const { isOnAWorkspaceSubdomain } =
useIsCurrentLocationOnAWorkspaceSubdomain();
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const workspacePublicData = useRecoilValue(workspacePublicDataState);
@ -289,10 +289,10 @@ export const useAuth = () => {
setCurrentWorkspace(workspace);
if (isDefined(workspace) && isOnAWorkspaceSubdomain) {
if (isDefined(workspace) && isOnAWorkspace) {
setLastAuthenticateWorkspaceDomain({
workspaceId: workspace.id,
subdomain: workspace.subdomain,
workspaceUrl: getWorkspaceUrl(workspace.workspaceUrls),
});
}
@ -315,7 +315,7 @@ export const useAuth = () => {
};
}, [
getCurrentUser,
isOnAWorkspaceSubdomain,
isOnAWorkspace,
setCurrentUser,
setCurrentWorkspace,
setCurrentWorkspaceMember,
@ -413,7 +413,8 @@ export const useAuth = () => {
if (isMultiWorkspaceEnabled) {
return redirectToWorkspaceDomain(
signUpResult.data.signUp.workspace.subdomain,
getWorkspaceUrl(signUpResult.data.signUp.workspace.workspaceUrls),
isEmailVerificationRequired ? AppPath.SignInUp : AppPath.Verify,
{
...(!isEmailVerificationRequired && {

View File

@ -27,6 +27,7 @@ import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirect
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { isDefined } from 'twenty-shared';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
const StyledContentContainer = styled(motion.div)`
margin-bottom: ${({ theme }) => theme.spacing(8)};
@ -92,9 +93,13 @@ export const SignInUpGlobalScopeForm = () => {
if (response.__typename === 'UserExists') {
if (response.availableWorkspaces.length >= 1) {
const workspace = response.availableWorkspaces[0];
return redirectToWorkspaceDomain(workspace.subdomain, pathname, {
email: form.getValues('email'),
});
return redirectToWorkspaceDomain(
getWorkspaceUrl(workspace.workspaceUrls),
pathname,
{
email: form.getValues('email'),
},
);
}
}
if (response.__typename === 'UserNotExists') {

View File

@ -3,6 +3,7 @@ import { useSSO } from '@/auth/sign-in-up/hooks/useSSO';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { MockedProvider } from '@apollo/client/testing';
import { MemoryRouter } from 'react-router-dom';
import { renderHook } from '@testing-library/react';
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar');
@ -52,9 +53,11 @@ const apolloMocks = [
];
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<MockedProvider mocks={apolloMocks} addTypename={false}>
{children}
</MockedProvider>
<MemoryRouter>
<MockedProvider mocks={apolloMocks} addTypename={false}>
{children}
</MockedProvider>
</MemoryRouter>
);
describe('useSSO', () => {

View File

@ -13,14 +13,16 @@ export const useSSO = () => {
const { enqueueSnackBar } = useSnackBar();
const { redirect } = useRedirect();
const redirectToSSOLoginPage = async (identityProviderId: string) => {
let authorizationUrlForSSOResult;
try {
authorizationUrlForSSOResult = await apolloClient.mutate({
mutation: GET_AUTHORIZATION_URL,
variables: {
input: { identityProviderId, workspaceInviteHash },
input: {
identityProviderId,
workspaceInviteHash,
},
},
});
} catch (error: any) {

View File

@ -21,6 +21,7 @@ export type CurrentWorkspace = Pick<
| 'hasValidEnterpriseKey'
| 'subdomain'
| 'hostname'
| 'workspaceUrls'
| 'metadataVersion'
>;

View File

@ -1,10 +1,9 @@
import { createState } from '@ui/utilities/state/utils/createState';
import { Workspace } from '~/generated/graphql';
export type Workspaces = Pick<
Workspace,
'id' | 'logo' | 'displayName' | 'subdomain'
'id' | 'logo' | 'displayName' | 'workspaceUrls'
>[];
export const workspacesState = createState<Workspaces>({

View File

@ -1,20 +1,12 @@
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const useBuildWorkspaceUrl = () => {
const domainConfiguration = useRecoilValue(domainConfigurationState);
const buildWorkspaceUrl = (
subdomain: string,
endpoint: string,
pathname?: string,
searchParams?: Record<string, string>,
searchParams?: Record<string, string | boolean>,
) => {
const url = new URL(window.location.href);
if (subdomain.length !== 0) {
url.hostname = `${subdomain}.${domainConfiguration.frontDomain}`;
}
const url = new URL(endpoint);
if (isDefined(pathname)) {
url.pathname = pathname;
@ -22,7 +14,7 @@ export const useBuildWorkspaceUrl = () => {
if (isDefined(searchParams)) {
Object.entries(searchParams).forEach(([key, value]) =>
url.searchParams.set(key, value),
url.searchParams.set(key, value.toString()),
);
}
return url.toString();

View File

@ -5,9 +5,9 @@ import { useRedirectToDefaultDomain } from '@/domain-manager/hooks/useRedirectTo
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { useGetPublicWorkspaceDataBySubdomainQuery } from '~/generated/graphql';
import { useGetPublicWorkspaceDataByDomainQuery } from '~/generated/graphql';
export const useGetPublicWorkspaceDataBySubdomain = () => {
export const useGetPublicWorkspaceDataByDomain = () => {
const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const setWorkspaceAuthProviders = useSetRecoilState(
@ -19,15 +19,15 @@ export const useGetPublicWorkspaceDataBySubdomain = () => {
workspacePublicDataState,
);
const { loading, data, error } = useGetPublicWorkspaceDataBySubdomainQuery({
const { loading, data, error } = useGetPublicWorkspaceDataByDomainQuery({
skip:
(isMultiWorkspaceEnabled && isDefaultDomain) ||
isDefined(workspacePublicData),
onCompleted: (data) => {
setWorkspaceAuthProviders(
data.getPublicWorkspaceDataBySubdomain.authProviders,
data.getPublicWorkspaceDataByDomain.authProviders,
);
setWorkspacePublicDataState(data.getPublicWorkspaceDataBySubdomain);
setWorkspacePublicDataState(data.getPublicWorkspaceDataByDomain);
},
onError: (error) => {
// eslint-disable-next-line no-console
@ -38,7 +38,7 @@ export const useGetPublicWorkspaceDataBySubdomain = () => {
return {
loading,
data: data?.getPublicWorkspaceDataBySubdomain,
data: data?.getPublicWorkspaceDataByDomain,
error,
};
};

View File

@ -4,7 +4,7 @@ import { domainConfigurationState } from '@/domain-manager/states/domainConfigur
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const useIsCurrentLocationOnAWorkspaceSubdomain = () => {
export const useIsCurrentLocationOnAWorkspace = () => {
const { defaultDomain } = useReadDefaultDomainFromConfiguration();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
@ -18,10 +18,10 @@ export const useIsCurrentLocationOnAWorkspaceSubdomain = () => {
throw new Error('frontDomain and defaultSubdomain are required');
}
const isOnAWorkspaceSubdomain =
const isOnAWorkspace =
isMultiWorkspaceEnabled && window.location.hostname !== defaultDomain;
return {
isOnAWorkspaceSubdomain,
isOnAWorkspace,
};
};

View File

@ -8,7 +8,7 @@ export const useLastAuthenticatedWorkspaceDomain = () => {
lastAuthenticatedWorkspaceDomainState,
);
const setLastAuthenticateWorkspaceDomainWithCookieAttributes = (
params: { workspaceId: string; subdomain: string } | null,
params: { workspaceId: string; workspaceUrl: string } | null,
) => {
setLastAuthenticatedWorkspaceDomain({
...(params ? params : {}),

View File

@ -1,25 +0,0 @@
import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const useReadWorkspaceSubdomainFromCurrentLocation = () => {
const domainConfiguration = useRecoilValue(domainConfigurationState);
const { isOnAWorkspaceSubdomain } =
useIsCurrentLocationOnAWorkspaceSubdomain();
if (!isDefined(domainConfiguration.frontDomain)) {
throw new Error('frontDomain is not defined');
}
const workspaceSubdomain = isOnAWorkspaceSubdomain
? window.location.hostname.replace(
`.${domainConfiguration.frontDomain}`,
'',
)
: null;
return {
workspaceSubdomain,
};
};

View File

@ -0,0 +1,11 @@
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
export const useReadWorkspaceUrlFromCurrentLocation = () => {
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
return {
currentLocationHostname: isOnAWorkspace
? window.location.hostname
: undefined,
};
};

View File

@ -9,12 +9,12 @@ export const useRedirectToWorkspaceDomain = () => {
const { redirect } = useRedirect();
const redirectToWorkspaceDomain = (
subdomain: string,
baseUrl: string,
pathname?: string,
searchParams?: Record<string, string>,
searchParams?: Record<string, string | boolean>,
) => {
if (!isMultiWorkspaceEnabled) return;
redirect(buildWorkspaceUrl(subdomain, pathname, searchParams));
redirect(buildWorkspaceUrl(baseUrl, pathname, searchParams));
};
return {

View File

@ -3,7 +3,7 @@ import { cookieStorageEffect } from '~/utils/recoil-effects';
export const lastAuthenticatedWorkspaceDomainState = createState<
| {
subdomain: string;
workspaceUrl: string;
workspaceId: string;
cookieAttributes?: Cookies.CookieAttributes;
}

View File

@ -159,6 +159,10 @@ export const queries = {
subdomain
hasValidEnterpriseKey
hostname
workspaceUrls {
subdomainUrl
customUrl
}
featureFlags {
id
key
@ -183,6 +187,11 @@ export const queries = {
logo
displayName
subdomain
hostname
workspaceUrls {
subdomainUrl
customUrl
}
}
}
userVars
@ -309,6 +318,10 @@ export const responseData = {
isPasswordAuthEnabled: true,
subdomain: 'test',
hostname: null,
workspaceUrls: {
customUrl: undefined,
subdomainUrl: 'https://test.twenty.com/',
},
featureFlags: [],
metadataVersion: 1,
currentBillingSubscription: null,

View File

@ -27,6 +27,10 @@ const Wrapper = getJestMetadataAndApolloMocksWrapper({
isGoogleAuthEnabled: true,
isMicrosoftAuthEnabled: false,
isPasswordAuthEnabled: true,
workspaceUrls: {
subdomainUrl: 'https://twenty.twenty.com',
customUrl: 'https://my-custom-domain.com',
},
currentBillingSubscription: {
id: '1',
interval: SubscriptionInterval.Month,

View File

@ -8,6 +8,7 @@ import { useState } from 'react';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { useImpersonateMutation } from '~/generated/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
export const useImpersonate = () => {
const [currentUser] = useRecoilState(currentUserState);
@ -55,9 +56,13 @@ export const useImpersonate = () => {
return;
}
return redirectToWorkspaceDomain(workspace.subdomain, AppPath.Verify, {
loginToken: loginToken.token,
});
return redirectToWorkspaceDomain(
getWorkspaceUrl(workspace.workspaceUrls),
AppPath.Verify,
{
loginToken: loginToken.token,
},
);
} catch (error) {
setError('Failed to impersonate user. Please try again.');
setIsLoading(false);

View File

@ -18,6 +18,7 @@ import {
UndecoratedLink,
} from 'twenty-ui';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
const StyledContainer = styled.div<{ isNavigationDrawerExpanded: boolean }>`
align-items: center;
@ -67,7 +68,7 @@ export const MultiWorkspaceDropdownButton = ({
const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const handleChange = async (workspace: Workspaces[0]) => {
redirectToWorkspaceDomain(workspace.subdomain);
redirectToWorkspaceDomain(getWorkspaceUrl(workspace.workspaceUrls));
};
const [isNavigationDrawerExpanded] = useRecoilState(
isNavigationDrawerExpandedState,
@ -104,7 +105,7 @@ export const MultiWorkspaceDropdownButton = ({
{workspaces.map((workspace) => (
<UndecoratedLink
key={workspace.id}
to={buildWorkspaceUrl(workspace.subdomain)}
to={buildWorkspaceUrl(getWorkspaceUrl(workspace.workspaceUrls))}
onClick={(event) => {
event?.preventDefault();
handleChange(workspace);

View File

@ -38,6 +38,10 @@ export const USER_QUERY_FRAGMENT = gql`
subdomain
hasValidEnterpriseKey
hostname
workspaceUrls {
subdomainUrl
customUrl
}
featureFlags {
id
key
@ -62,6 +66,11 @@ export const USER_QUERY_FRAGMENT = gql`
logo
displayName
subdomain
hostname
workspaceUrls {
subdomainUrl
customUrl
}
}
}
userVars

View File

@ -1,17 +1,18 @@
import { useRecoilValue } from 'recoil';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useReadWorkspaceSubdomainFromCurrentLocation } from '@/domain-manager/hooks/useReadWorkspaceSubdomainFromCurrentLocation';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { lastAuthenticatedWorkspaceDomainState } from '@/domain-manager/states/lastAuthenticatedWorkspaceDomainState';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { lastAuthenticatedWorkspaceDomainState } from '@/domain-manager/states/lastAuthenticatedWorkspaceDomainState';
import { useReadWorkspaceUrlFromCurrentLocation } from '@/domain-manager/hooks/useReadWorkspaceUrlFromCurrentLocation';
import { useGetPublicWorkspaceDataBySubdomain } from '@/domain-manager/hooks/useGetPublicWorkspaceDataBySubdomain';
import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain';
import { useGetPublicWorkspaceDataByDomain } from '@/domain-manager/hooks/useGetPublicWorkspaceDataByDomain';
import { WorkspaceUrls } from '~/generated/graphql';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
export const WorkspaceProviderEffect = () => {
const { data: getPublicWorkspaceData } =
useGetPublicWorkspaceDataBySubdomain();
const { data: getPublicWorkspaceData } = useGetPublicWorkspaceDataByDomain();
const lastAuthenticatedWorkspaceDomain = useRecoilValue(
lastAuthenticatedWorkspaceDomainState,
@ -20,23 +21,38 @@ export const WorkspaceProviderEffect = () => {
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { isDefaultDomain } = useIsCurrentLocationOnDefaultDomain();
const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation();
const { currentLocationHostname } = useReadWorkspaceUrlFromCurrentLocation();
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const getHostnamesFromWorkspaceUrls = (workspaceUrls: WorkspaceUrls) => {
return {
customUrlHostname: workspaceUrls.customUrl
? new URL(workspaceUrls.customUrl).hostname
: undefined,
subdomainUrlHostname: new URL(workspaceUrls.subdomainUrl).hostname,
};
};
useEffect(() => {
const hostnames = getPublicWorkspaceData
? getHostnamesFromWorkspaceUrls(getPublicWorkspaceData?.workspaceUrls)
: null;
if (
isMultiWorkspaceEnabled &&
isDefined(getPublicWorkspaceData?.subdomain) &&
getPublicWorkspaceData.subdomain !== workspaceSubdomain
isDefined(getPublicWorkspaceData) &&
currentLocationHostname !== hostnames?.customUrlHostname &&
currentLocationHostname !== hostnames?.subdomainUrlHostname
) {
redirectToWorkspaceDomain(getPublicWorkspaceData.subdomain);
redirectToWorkspaceDomain(
getWorkspaceUrl(getPublicWorkspaceData.workspaceUrls),
);
}
}, [
workspaceSubdomain,
isMultiWorkspaceEnabled,
redirectToWorkspaceDomain,
getPublicWorkspaceData,
currentLocationHostname,
]);
useEffect(() => {
@ -44,10 +60,10 @@ export const WorkspaceProviderEffect = () => {
isMultiWorkspaceEnabled &&
isDefaultDomain &&
isDefined(lastAuthenticatedWorkspaceDomain) &&
'subdomain' in lastAuthenticatedWorkspaceDomain &&
isDefined(lastAuthenticatedWorkspaceDomain?.subdomain)
'workspaceUrl' in lastAuthenticatedWorkspaceDomain &&
isDefined(lastAuthenticatedWorkspaceDomain?.workspaceUrl)
) {
redirectToWorkspaceDomain(lastAuthenticatedWorkspaceDomain.subdomain);
redirectToWorkspaceDomain(lastAuthenticatedWorkspaceDomain.workspaceUrl);
}
}, [
isMultiWorkspaceEnabled,

View File

@ -4,7 +4,6 @@ export const ACTIVATE_WORKSPACE = gql`
mutation ActivateWorkspace($input: ActivateWorkspaceInput!) {
activateWorkspace(data: $input) {
id
subdomain
}
}
`;

View File

@ -7,7 +7,6 @@ export const GET_WORKSPACE_FROM_INVITE_HASH = gql`
displayName
logo
allowImpersonation
subdomain
}
}
`;