Skip to content

Commit

Permalink
feat(): enable custom domain usage (#9911)
Browse files Browse the repository at this point in the history
# 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
  • Loading branch information
AMoreaux authored Feb 7, 2025
1 parent 3cc66fe commit 68183b7
Show file tree
Hide file tree
Showing 87 changed files with 644 additions and 372 deletions.
1 change: 0 additions & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,6 @@ module.exports = {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-empty-interface': [
'error',
{
Expand Down
2 changes: 1 addition & 1 deletion packages/twenty-front/codegen-metadata.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ module.exports = {
},
config: {
namingConvention: { enumValues: 'keep' },
}
},
},
},
};
34 changes: 16 additions & 18 deletions packages/twenty-front/src/generated-metadata/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export type AvailableWorkspaceOutput = {
id: Scalars['String']['output'];
logo?: Maybe<Scalars['String']['output']>;
sso: Array<SsoConnection>;
subdomain: Scalars['String']['output'];
workspaceUrls: WorkspaceUrls;
};

export type Billing = {
Expand Down Expand Up @@ -475,19 +475,11 @@ export type EnvironmentVariable = {
};

export enum EnvironmentVariablesGroup {
Analytics = 'Analytics',
Authentication = 'Authentication',
Billing = 'Billing',
Cache = 'Cache',
Database = 'Database',
Email = 'Email',
Frontend = 'Frontend',
Logging = 'Logging',
Other = 'Other',
QueueConfig = 'QueueConfig',
ServerConfig = 'ServerConfig',
Storage = 'Storage',
Support = 'Support',
Workspace = 'Workspace'
}

Expand Down Expand Up @@ -706,7 +698,7 @@ export enum IdentityProviderType {
export type ImpersonateOutput = {
__typename?: 'ImpersonateOutput';
loginToken: AuthToken;
workspace: WorkspaceSubdomainAndId;
workspace: WorkspaceUrlsAndId;
};

export type Index = {
Expand Down Expand Up @@ -1360,10 +1352,9 @@ export type PublicWorkspaceDataOutput = {
__typename?: 'PublicWorkspaceDataOutput';
authProviders: AuthProviders;
displayName?: Maybe<Scalars['String']['output']>;
hostname?: Maybe<Scalars['String']['output']>;
id: Scalars['String']['output'];
logo?: Maybe<Scalars['String']['output']>;
subdomain: Scalars['String']['output'];
workspaceUrls: WorkspaceUrls;
};

export type PublishServerlessFunctionInput = {
Expand Down Expand Up @@ -1394,7 +1385,7 @@ export type Query = {
getHostnameDetails?: Maybe<CustomHostnameDetails>;
getPostgresCredentials?: Maybe<PostgresCredentials>;
getProductPrices: BillingProductPricesOutput;
getPublicWorkspaceDataBySubdomain: PublicWorkspaceDataOutput;
getPublicWorkspaceDataByDomain: PublicWorkspaceDataOutput;
getRoles: Array<Role>;
getServerlessFunctionSourceCode?: Maybe<Scalars['JSON']['output']>;
getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal;
Expand Down Expand Up @@ -1792,7 +1783,7 @@ export type SetupSsoOutput = {
export type SignUpOutput = {
__typename?: 'SignUpOutput';
loginToken: AuthToken;
workspace: WorkspaceSubdomainAndId;
workspace: WorkspaceUrlsAndId;
};

export enum SubscriptionInterval {
Expand Down Expand Up @@ -1994,7 +1985,7 @@ export type User = {
analyticsTinybirdJwts?: Maybe<AnalyticsTinybirdJwtMap>;
canImpersonate: Scalars['Boolean']['output'];
createdAt: Scalars['DateTime']['output'];
currentWorkspace?: Maybe<Workspace>;
currentWorkspace: Workspace;
defaultAvatarUrl?: Maybe<Scalars['String']['output']>;
deletedAt?: Maybe<Scalars['DateTime']['output']>;
disabled?: Maybe<Scalars['Boolean']['output']>;
Expand Down Expand Up @@ -2126,6 +2117,7 @@ export type Workspace = {
subdomain: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output'];
workspaceMembersCount?: Maybe<Scalars['Float']['output']>;
workspaceUrls: WorkspaceUrls;
};

export enum WorkspaceActivationStatus {
Expand Down Expand Up @@ -2202,10 +2194,16 @@ export type WorkspaceNameAndId = {
id: Scalars['String']['output'];
};

export type WorkspaceSubdomainAndId = {
__typename?: 'WorkspaceSubdomainAndId';
export type WorkspaceUrls = {
__typename?: 'workspaceUrls';
customUrl?: Maybe<Scalars['String']['output']>;
subdomainUrl: Scalars['String']['output'];
};

export type WorkspaceUrlsAndId = {
__typename?: 'workspaceUrlsAndId';
id: Scalars['String']['output'];
subdomain: Scalars['String']['output'];
workspaceUrls: WorkspaceUrls;
};

export type RemoteServerFieldsFragment = { __typename?: 'RemoteServer', id: string, createdAt: any, foreignDataWrapperId: string, foreignDataWrapperOptions?: any | null, foreignDataWrapperType: string, updatedAt: any, schema?: string | null, label: string, userMappingOptions?: { __typename?: 'UserMappingOptionsUser', user?: string | null } | null };
Expand Down
113 changes: 64 additions & 49 deletions packages/twenty-front/src/generated/graphql.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/test';
import { HttpResponse, graphql } from 'msw';

import { GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataBySubdomain';
import { GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataByDomain';
import { GET_CLIENT_CONFIG } from '@/client-config/graphql/queries/getClientConfig';
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries';
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
Expand Down Expand Up @@ -36,7 +36,7 @@ const userMetadataLoaderMocks = {
});
}),
graphql.query(
getOperationName(GET_PUBLIC_WORKSPACE_DATA_BY_SUBDOMAIN) ?? '',
getOperationName(GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN) ?? '',
() => {
return HttpResponse.json({
data: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ export const SIGN_UP = gql`
}
workspace {
id
subdomain
workspaceUrls {
subdomainUrl
customUrl
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ export const CHECK_USER_EXISTS = gql`
availableWorkspaces {
id
displayName
subdomain
hostname
workspaceUrls {
subdomainUrl
customUrl
}
logo
sso {
type
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 8 additions & 7 deletions packages/twenty-front/src/modules/auth/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -96,8 +97,7 @@ export const useAuth = () => {
useGetLoginTokenFromEmailVerificationTokenMutation();
const [getCurrentUser] = useGetCurrentUserLazyQuery();

const { isOnAWorkspaceSubdomain } =
useIsCurrentLocationOnAWorkspaceSubdomain();
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();

const workspacePublicData = useRecoilValue(workspacePublicDataState);

Expand Down Expand Up @@ -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),
});
}

Expand All @@ -315,7 +315,7 @@ export const useAuth = () => {
};
}, [
getCurrentUser,
isOnAWorkspaceSubdomain,
isOnAWorkspace,
setCurrentUser,
setCurrentWorkspace,
setCurrentWorkspaceMember,
Expand Down Expand Up @@ -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 && {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)};
Expand Down Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type CurrentWorkspace = Pick<
| 'hasValidEnterpriseKey'
| 'subdomain'
| 'hostname'
| 'workspaceUrls'
| 'metadataVersion'
>;

Expand Down
3 changes: 1 addition & 2 deletions packages/twenty-front/src/modules/auth/states/workspaces.ts
Original file line number Diff line number Diff line change
@@ -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>({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,20 @@
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;
}

if (isDefined(searchParams)) {
Object.entries(searchParams).forEach(([key, value]) =>
url.searchParams.set(key, value),
url.searchParams.set(key, value.toString()),
);
}
return url.toString();
Expand Down
Loading

0 comments on commit 68183b7

Please sign in to comment.