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