fix(): sleep before redirect (#9079)

## Summary
This Pull Request centralizes the redirection logic by introducing a
reusable `useRedirect` hook, which replaces direct usage of
`window.location.href` with more standardized and testable functionality
across multiple modules.

- Introduced a new `useRedirect` hook for handling redirection logic
with optional controlled delays.
- Refactored redirection implementations in various modules (`useAuth`,
workspace, and settings-related hooks, etc.) to use the newly introduced
`useRedirect` or related high-level hooks.
- Updated API and documentation to include or improve support for SSO,
particularly OIDC and SAML setup processes in server logic.
- Enhanced frontend and backend configurability with new environment
variable settings for SSO.

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Antoine Moreaux
2024-12-16 15:15:55 +01:00
committed by GitHub
parent 9e9c1bdff1
commit f8f3945680
11 changed files with 45 additions and 22 deletions

View File

@ -48,6 +48,7 @@ import { useReadWorkspaceSubdomainFromCurrentLocation } from '@/domain-manager/h
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState'; import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
export const useAuth = () => { export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState); const setTokenPair = useSetRecoilState(tokenPairState);
@ -65,6 +66,7 @@ export const useAuth = () => {
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState); const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState);
const setWorkspaces = useSetRecoilState(workspacesState); const setWorkspaces = useSetRecoilState(workspacesState);
const { redirect } = useRedirect();
const [challenge] = useChallengeMutation(); const [challenge] = useChallengeMutation();
const [signUp] = useSignUpMutation(); const [signUp] = useSignUpMutation();
@ -367,9 +369,9 @@ export const useAuth = () => {
workspacePersonalInviteToken?: string; workspacePersonalInviteToken?: string;
workspaceInviteHash?: string; workspaceInviteHash?: string;
}) => { }) => {
window.location.href = buildRedirectUrl('/auth/google', params); redirect(buildRedirectUrl('/auth/google', params));
}, },
[buildRedirectUrl], [buildRedirectUrl, redirect],
); );
const handleMicrosoftLogin = useCallback( const handleMicrosoftLogin = useCallback(
@ -377,9 +379,9 @@ export const useAuth = () => {
workspacePersonalInviteToken?: string; workspacePersonalInviteToken?: string;
workspaceInviteHash?: string; workspaceInviteHash?: string;
}) => { }) => {
window.location.href = buildRedirectUrl('/auth/microsoft', params); redirect(buildRedirectUrl('/auth/microsoft', params));
}, },
[buildRedirectUrl], [buildRedirectUrl, redirect],
); );
return { return {

View File

@ -1,7 +1,6 @@
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState'; import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain'; import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useIsCurrentLocationOnDefaultDomain';
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
import { useRedirectToDefaultDomain } from '@/domain-manager/hooks/useRedirectToDefaultDomain'; import { useRedirectToDefaultDomain } from '@/domain-manager/hooks/useRedirectToDefaultDomain';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue, useSetRecoilState } from 'recoil';
@ -19,8 +18,6 @@ export const useGetPublicWorkspaceDataBySubdomain = () => {
const setWorkspacePublicDataState = useSetRecoilState( const setWorkspacePublicDataState = useSetRecoilState(
workspacePublicDataState, workspacePublicDataState,
); );
const { setLastAuthenticateWorkspaceDomain } =
useLastAuthenticatedWorkspaceDomain();
const { loading } = useGetPublicWorkspaceDataBySubdomainQuery({ const { loading } = useGetPublicWorkspaceDataBySubdomainQuery({
skip: skip:
@ -35,7 +32,6 @@ export const useGetPublicWorkspaceDataBySubdomain = () => {
onError: (error) => { onError: (error) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(error); console.error(error);
setLastAuthenticateWorkspaceDomain(null);
redirectToDefaultDomain(); redirectToDefaultDomain();
}, },
}); });

View File

@ -0,0 +1,14 @@
// Don't use this hook directly! Prefer the high level hooks like:
// useRedirectToDefaultDomain and useRedirectToWorkspaceDomain
import { useDebouncedCallback } from 'use-debounce';
export const useRedirect = () => {
const redirect = useDebouncedCallback((url: string) => {
window.location.href = url;
}, 1);
return {
redirect,
};
};

View File

@ -1,12 +1,19 @@
import { useReadDefaultDomainFromConfiguration } from '@/domain-manager/hooks/useReadDefaultDomainFromConfiguration'; import { useReadDefaultDomainFromConfiguration } from '@/domain-manager/hooks/useReadDefaultDomainFromConfiguration';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
export const useRedirectToDefaultDomain = () => { export const useRedirectToDefaultDomain = () => {
const { defaultDomain } = useReadDefaultDomainFromConfiguration(); const { defaultDomain } = useReadDefaultDomainFromConfiguration();
const { setLastAuthenticateWorkspaceDomain } =
useLastAuthenticatedWorkspaceDomain();
const { redirect } = useRedirect();
const redirectToDefaultDomain = () => { const redirectToDefaultDomain = () => {
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (url.hostname !== defaultDomain) { if (url.hostname !== defaultDomain) {
setLastAuthenticateWorkspaceDomain(null);
url.hostname = defaultDomain; url.hostname = defaultDomain;
window.location.href = url.toString(); redirect(url.toString());
} }
}; };

View File

@ -1,10 +1,12 @@
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl'; import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
export const useRedirectToWorkspaceDomain = () => { export const useRedirectToWorkspaceDomain = () => {
const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState); const isMultiWorkspaceEnabled = useRecoilValue(isMultiWorkspaceEnabledState);
const { buildWorkspaceUrl } = useBuildWorkspaceUrl(); const { buildWorkspaceUrl } = useBuildWorkspaceUrl();
const { redirect } = useRedirect();
const redirectToWorkspaceDomain = ( const redirectToWorkspaceDomain = (
subdomain: string, subdomain: string,
@ -12,7 +14,7 @@ export const useRedirectToWorkspaceDomain = () => {
searchParams?: Record<string, string>, searchParams?: Record<string, string>,
) => { ) => {
if (!isMultiWorkspaceEnabled) return; if (!isMultiWorkspaceEnabled) return;
window.location.href = buildWorkspaceUrl(subdomain, pathname, searchParams); redirect(buildWorkspaceUrl(subdomain, pathname, searchParams));
}; };
return { return {

View File

@ -7,6 +7,7 @@ import {
MessageChannelVisibility, MessageChannelVisibility,
useGenerateTransientTokenMutation, useGenerateTransientTokenMutation,
} from '~/generated/graphql'; } from '~/generated/graphql';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
const getProviderUrl = (provider: string) => { const getProviderUrl = (provider: string) => {
switch (provider) { switch (provider) {
@ -21,6 +22,7 @@ const getProviderUrl = (provider: string) => {
export const useTriggerApisOAuth = () => { export const useTriggerApisOAuth = () => {
const [generateTransientToken] = useGenerateTransientTokenMutation(); const [generateTransientToken] = useGenerateTransientTokenMutation();
const { redirect } = useRedirect();
const triggerApisOAuth = useCallback( const triggerApisOAuth = useCallback(
async ( async (
@ -60,9 +62,9 @@ export const useTriggerApisOAuth = () => {
params += loginHint ? `&loginHint=${loginHint}` : ''; params += loginHint ? `&loginHint=${loginHint}` : '';
window.location.href = `${authServerUrl}/auth/${getProviderUrl(provider)}?${params}`; redirect(`${authServerUrl}/auth/${getProviderUrl(provider)}?${params}`);
}, },
[generateTransientToken], [generateTransientToken, redirect],
); );
return { triggerApisOAuth }; return { triggerApisOAuth };

View File

@ -6,10 +6,11 @@ import { useState } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil'; import { useRecoilState, useSetRecoilState } from 'recoil';
import { useImpersonateMutation } from '~/generated/graphql'; import { useImpersonateMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { sleep } from '~/utils/sleep'; import { useRedirect } from '@/domain-manager/hooks/useRedirect';
export const useImpersonate = () => { export const useImpersonate = () => {
const { clearSession } = useAuth(); const { clearSession } = useAuth();
const { redirect } = useRedirect();
const [currentUser, setCurrentUser] = useRecoilState(currentUserState); const [currentUser, setCurrentUser] = useRecoilState(currentUserState);
const setTokenPair = useSetRecoilState(tokenPairState); const setTokenPair = useSetRecoilState(tokenPairState);
const [impersonate] = useImpersonateMutation(); const [impersonate] = useImpersonateMutation();
@ -43,8 +44,7 @@ export const useImpersonate = () => {
await clearSession(); await clearSession();
setCurrentUser(user); setCurrentUser(user);
setTokenPair(tokens); setTokenPair(tokens);
await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly. redirect(AppPath.Index);
window.location.href = AppPath.Index;
} catch (error) { } catch (error) {
setError('Failed to impersonate user. Please try again.'); setError('Failed to impersonate user. Please try again.');
setIsLoading(false); setIsLoading(false);

View File

@ -6,6 +6,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { MainButton, UndecoratedLink } from 'twenty-ui'; import { MainButton, UndecoratedLink } from 'twenty-ui';
import { useAuthorizeAppMutation } from '~/generated/graphql'; import { useAuthorizeAppMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
type App = { id: string; name: string; logo: string }; type App = { id: string; name: string; logo: string };
@ -55,6 +56,7 @@ const StyledButtonContainer = styled.div`
export const Authorize = () => { export const Authorize = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParam] = useSearchParams(); const [searchParam] = useSearchParams();
const { redirect } = useRedirect();
//TODO: Replace with db call for registered third party apps //TODO: Replace with db call for registered third party apps
const [apps] = useState<App[]>([ const [apps] = useState<App[]>([
{ {
@ -89,7 +91,7 @@ export const Authorize = () => {
redirectUrl, redirectUrl,
}, },
onCompleted: (data) => { onCompleted: (data) => {
window.location.href = data.authorizeApp.redirectUrl; redirect(data.authorizeApp.redirectUrl);
}, },
}); });
} }

View File

@ -17,7 +17,7 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useUpdateWorkspaceMutation } from '~/generated/graphql'; import { useUpdateWorkspaceMutation } from '~/generated/graphql';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
import { useBuildWorkspaceUrl } from '@/domain-manager/hooks/useBuildWorkspaceUrl'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
const validationSchema = z const validationSchema = z
.object({ .object({
@ -54,7 +54,7 @@ export const SettingsDomain = () => {
const { enqueueSnackBar } = useSnackBar(); const { enqueueSnackBar } = useSnackBar();
const [updateWorkspace] = useUpdateWorkspaceMutation(); const [updateWorkspace] = useUpdateWorkspaceMutation();
const { buildWorkspaceUrl } = useBuildWorkspaceUrl(); const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState( const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState, currentWorkspaceState,
@ -97,7 +97,7 @@ export const SettingsDomain = () => {
subdomain: values.subdomain, subdomain: values.subdomain,
}); });
window.location.href = buildWorkspaceUrl(values.subdomain); redirectToWorkspaceDomain(values.subdomain);
} catch (error) { } catch (error) {
if ( if (
error instanceof Error && error instanceof Error &&

View File

@ -25,7 +25,6 @@ import {
OIDCResponseType, OIDCResponseType,
WorkspaceSSOIdentityProvider, WorkspaceSSOIdentityProvider,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
@Injectable() @Injectable()
export class SSOService { export class SSOService {
@ -35,8 +34,6 @@ export class SSOService {
private readonly featureFlagRepository: Repository<FeatureFlagEntity>, private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
@InjectRepository(WorkspaceSSOIdentityProvider, 'core') @InjectRepository(WorkspaceSSOIdentityProvider, 'core')
private readonly workspaceSSOIdentityProviderRepository: Repository<WorkspaceSSOIdentityProvider>, private readonly workspaceSSOIdentityProviderRepository: Repository<WorkspaceSSOIdentityProvider>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly billingService: BillingService, private readonly billingService: BillingService,
) {} ) {}

View File

@ -116,6 +116,7 @@ yarn command:prod cron:calendar:ongoing-stale
['AUTH_GOOGLE_CLIENT_SECRET', '', 'Google client secret'], ['AUTH_GOOGLE_CLIENT_SECRET', '', 'Google client secret'],
['AUTH_GOOGLE_CALLBACK_URL', 'https://[YourDomain]/auth/google/redirect', 'Google auth callback'], ['AUTH_GOOGLE_CALLBACK_URL', 'https://[YourDomain]/auth/google/redirect', 'Google auth callback'],
['AUTH_MICROSOFT_ENABLED', 'false', 'Enable Microsoft SSO login'], ['AUTH_MICROSOFT_ENABLED', 'false', 'Enable Microsoft SSO login'],
['AUTH_SSO_ENABLED', 'false', 'Enable SSO with SAML or OIDC'],
['AUTH_MICROSOFT_CLIENT_ID', '', 'Microsoft client ID'], ['AUTH_MICROSOFT_CLIENT_ID', '', 'Microsoft client ID'],
['AUTH_MICROSOFT_TENANT_ID', '', 'Microsoft tenant ID'], ['AUTH_MICROSOFT_TENANT_ID', '', 'Microsoft tenant ID'],
['AUTH_MICROSOFT_CLIENT_SECRET', '', 'Microsoft client secret'], ['AUTH_MICROSOFT_CLIENT_SECRET', '', 'Microsoft client secret'],