Compare commits

...
Author SHA1 Message Date
Félix Malfait 48b2b7b0ff Fix 2026-03-31 17:11:39 +02:00
Félix Malfait 94d5348595 Fix 2026-03-31 17:03:06 +02:00
Félix Malfait 86423104f7 Further simplification 2026-03-31 13:22:19 +02:00
Félix Malfait e4a073be56 Fix auth token flow: stop revoking all tokens on stale refresh, simplify login
- Stop revoking all user tokens when a stale refresh token is reused.
  The most common cause is a lost renewal response (e.g. page navigation),
  not actual theft. Now only the specific stale token is rejected.
- Fix useAuth.getAuthTokensFromLoginToken silently swallowing non-2FA
  errors — callers like useVerifyLogin can now properly show error feedback.
- Remove clientConfigLoaded gate from VerifyLoginTokenEffect so the login
  token exchange runs in parallel with client config fetch.
- Clear stale tokens before login token exchange to prevent background
  queries from firing with revoked credentials.
- Unify user-loading logic: useLoadCurrentUser is now the single source
  of truth (deleted members, defaultRole, colorScheme, locale catalog,
  isCurrentUserLoaded), and UserMetadataProviderInitialEffect delegates
  to it instead of duplicating the hydration.

Made-with: Cursor
2026-03-31 12:45:42 +02:00
26 changed files with 230 additions and 329 deletions
@@ -7,7 +7,7 @@ import {
import { setContext } from '@apollo/client/link/context';
import { ErrorLink } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import { from, switchMap } from 'rxjs';
import { EMPTY, from, switchMap } from 'rxjs';
import { RestLink } from 'apollo-link-rest';
import UploadHttpLink from 'apollo-upload-client/UploadHttpLink.mjs';
@@ -41,7 +41,7 @@ const logger = loggerLink(() => 'Twenty');
// Shared across all ApolloFactory instances so concurrent
// UNAUTHENTICATED errors from /graphql and /metadata clients
// deduplicate into a single renewal request.
let renewalPromise: Promise<void> | null = null;
let renewalPromise: Promise<boolean> | null = null;
const TOKEN_RENEWAL_MAX_RETRIES = 3;
const TOKEN_RENEWAL_RETRY_DELAY_MS = 1000;
@@ -181,21 +181,32 @@ export class ApolloFactory implements ApolloManager {
operation: ApolloLink.Operation,
forward: ApolloLink.ForwardFunction,
) => {
if (!getTokenPair()) {
onUnauthenticatedError?.();
return EMPTY;
}
if (!renewalPromise) {
renewalPromise = attemptTokenRenewal()
.then(() => true)
.catch(() => {
// oxlint-disable-next-line no-console
console.log(
'Failed to renew token after retries, triggering unauthenticated error',
);
onUnauthenticatedError?.();
return false;
})
.finally(() => {
renewalPromise = null;
});
}
return from(renewalPromise).pipe(switchMap(() => forward(operation)));
return from(renewalPromise).pipe(
switchMap((succeeded) => (succeeded ? forward(operation) : EMPTY)),
);
};
const sendToSentry = ({
@@ -9,7 +9,7 @@ import { PageChangeEffect } from '@/app/effect-components/PageChangeEffect';
import { SignOutOnOtherTabSignOutEffect } from '@/auth/effect-components/SignOutOnOtherTabSignOutEffect';
import { AuthProvider } from '@/auth/components/AuthProvider';
import { CaptchaProvider } from '@/captcha/components/CaptchaProvider';
import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider';
import { ClientConfigErrorGate } from '@/client-config/components/ClientConfigErrorGate';
import { ClientConfigProviderEffect } from '@/client-config/components/ClientConfigProviderEffect';
import { MainContextStoreProvider } from '@/context-store/components/MainContextStoreProvider';
import { ErrorMessageEffect } from '@/error-handler/components/ErrorMessageEffect';
@@ -47,7 +47,7 @@ export const AppRouterProviders = () => {
<MinimalMetadataLoadEffect />
<IsMinimalMetadataReadyEffect />
<WorkspaceProviderEffect />
<ClientConfigProvider>
<ClientConfigErrorGate>
<CaptchaProvider>
<MinimalMetadataGater>
<AuthProvider>
@@ -86,7 +86,7 @@ export const AppRouterProviders = () => {
</AuthProvider>
</MinimalMetadataGater>
</CaptchaProvider>
</ClientConfigProvider>
</ClientConfigErrorGate>
</BaseThemeProvider>
</ApolloProvider>
);
@@ -42,7 +42,9 @@ export const VerifyEmailEffect = () => {
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const { verifyLoginToken } = useVerifyLogin();
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const clientConfigApiStatus = useAtomStateValue(clientConfigApiStatusState);
const { isLoaded: isClientConfigLoaded } = useAtomStateValue(
clientConfigApiStatusState,
);
const { t } = useLingui();
useEffect(() => {
@@ -113,7 +115,7 @@ export const VerifyEmailEffect = () => {
}
};
if (!clientConfigApiStatus.isLoadedOnce) {
if (!isClientConfigLoaded) {
return;
}
@@ -121,7 +123,7 @@ export const VerifyEmailEffect = () => {
// Verify email only needs to run once at mount
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [clientConfigApiStatus.isLoadedOnce]);
}, [isClientConfigLoaded]);
if (isError) {
return (
@@ -1,12 +1,13 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
import { useVerifyLogin } from '@/auth/hooks/useVerifyLogin';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { useInvalidateMetadataStore } from '@/metadata-store/hooks/useInvalidateMetadataStore';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const VerifyLoginTokenEffect = () => {
@@ -15,25 +16,20 @@ export const VerifyLoginTokenEffect = () => {
const hasAccessTokenPair = useHasAccessTokenPair();
const navigate = useNavigateApp();
const setTokenPair = useSetAtomState(tokenPairState);
const { verifyLoginToken } = useVerifyLogin();
const { isSaved: clientConfigLoaded } = useAtomStateValue(
clientConfigApiStatusState,
);
const { resetMetadataStore } = useInvalidateMetadataStore();
useEffect(() => {
if (!clientConfigLoaded) {
return;
}
if (isDefined(loginToken)) {
resetMetadataStore();
setTokenPair(null);
verifyLoginToken(loginToken);
} else if (!hasAccessTokenPair) {
navigate(AppPath.SignInUp);
}
// Verify only needs to run once at mount
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [clientConfigLoaded]);
}, []);
return <></>;
return null;
};
@@ -341,6 +341,8 @@ export const useAuth = () => {
handleSetLoginToken(loginToken);
navigate(AppPath.SignInUp);
setSignInUpStep(SignInUpStep.TwoFactorAuthenticationProvision);
return;
}
if (
@@ -351,7 +353,11 @@ export const useAuth = () => {
handleSetLoginToken(loginToken);
navigate(AppPath.SignInUp);
setSignInUpStep(SignInUpStep.TwoFactorAuthenticationVerification);
return;
}
throw error;
}
},
[
@@ -0,0 +1,15 @@
import { tokenPairState } from '@/auth/states/tokenPairState';
import { isWorkspaceSpecificAccessToken } from '@/auth/utils/isWorkspaceSpecificAccessToken';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useHasWorkspaceSpecificToken = (): boolean => {
const tokenPair = useAtomStateValue(tokenPairState);
if (!tokenPair?.accessOrWorkspaceAgnosticToken?.token) {
return false;
}
return isWorkspaceSpecificAccessToken(
tokenPair.accessOrWorkspaceAgnosticToken.token,
);
};
@@ -0,0 +1,13 @@
export const isWorkspaceSpecificAccessToken = (
tokenString: string,
): boolean => {
try {
const base64Url = tokenString.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(base64));
return payload.type === 'ACCESS';
} catch {
return false;
}
};
@@ -2,14 +2,15 @@ import { clientConfigApiStatusState } from '@/client-config/states/clientConfigA
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { AppFullScreenErrorFallback } from '@/error-handler/components/AppFullScreenErrorFallback';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
export const ClientConfigProvider: React.FC<React.PropsWithChildren> = ({
export const ClientConfigErrorGate: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const { isErrored, error } = useAtomStateValue(clientConfigApiStatusState);
const { error } = useAtomStateValue(clientConfigApiStatusState);
const { t } = useLingui();
return isErrored && error instanceof Error ? (
return isDefined(error) ? (
<AppFullScreenErrorFallback
error={error}
resetErrorBoundary={() => {
@@ -1,45 +1,17 @@
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const ClientConfigProviderEffect = () => {
const [clientConfigApiStatus, setClientConfigApiStatus] = useAtomState(
clientConfigApiStatusState,
);
const { data, loading, error, fetchClientConfig } = useClientConfig();
const { isLoaded } = useAtomStateValue(clientConfigApiStatusState);
const { fetchClientConfig } = useClientConfig();
useEffect(() => {
if (
!clientConfigApiStatus.isLoadedOnce &&
!clientConfigApiStatus.isLoading
) {
if (!isLoaded) {
fetchClientConfig();
}
}, [
clientConfigApiStatus.isLoadedOnce,
clientConfigApiStatus.isLoading,
fetchClientConfig,
]);
useEffect(() => {
if (loading) return;
if (error instanceof Error) {
setClientConfigApiStatus((currentStatus) => ({
...currentStatus,
isErrored: true,
error,
}));
return;
}
if (!isDefined(data?.clientConfig)) {
return;
}
}, [data?.clientConfig, error, loading, setClientConfigApiStatus]);
}, [isLoaded, fetchClientConfig]);
return <></>;
};
@@ -9,10 +9,10 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useCaptcha = () => {
const captcha = useAtomStateValue(captchaState);
const captchaToken = useAtomStateValue(captchaTokenState);
const clientConfigApiStatus = useAtomStateValue(clientConfigApiStatusState);
const { isLoaded: isClientConfigLoaded } = useAtomStateValue(
clientConfigApiStatusState,
);
const isCaptchaScriptLoaded = useAtomStateValue(isCaptchaScriptLoadedState);
const isClientConfigLoaded = clientConfigApiStatus.isLoadedOnce;
const isSiteKeyDefined = isDefined(captcha?.siteKey);
const isTokenAvailable = isDefined(captchaToken);
@@ -23,24 +23,14 @@ import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWork
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
import { sentryConfigState } from '@/client-config/states/sentryConfigState';
import { supportChatState } from '@/client-config/states/supportChatState';
import { type ClientConfig } from '@/client-config/types/ClientConfig';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { useCallback } from 'react';
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
import { getClientConfig } from '@/client-config/utils/getClientConfig';
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
type UseClientConfigResult = {
data: { clientConfig: ClientConfig } | undefined;
loading: boolean;
error: Error | undefined;
fetchClientConfig: () => Promise<void>;
refetch: () => Promise<void>;
};
export const useClientConfig = (): UseClientConfigResult => {
export const useClientConfig = () => {
const setIsAnalyticsEnabled = useSetAtomState(isAnalyticsEnabledState);
const setDomainConfiguration = useSetAtomState(domainConfigurationState);
const setAuthProviders = useSetAtomState(authProvidersState);
@@ -60,9 +50,7 @@ export const useClientConfig = (): UseClientConfigResult => {
const setSupportChat = useSetAtomState(supportChatState);
const setSentryConfig = useSetAtomState(sentryConfigState);
const [clientConfigApiStatus, setClientConfigApiStatus] = useAtomState(
clientConfigApiStatusState,
);
const setClientConfigApiStatus = useSetAtomState(clientConfigApiStatusState);
const setCaptcha = useSetAtomState(captchaState);
@@ -120,26 +108,9 @@ export const useClientConfig = (): UseClientConfigResult => {
const setAppVersion = useSetAtomState(appVersionState);
const fetchClientConfig = useCallback(async () => {
setClientConfigApiStatus((prev) => ({
...prev,
isLoading: true,
}));
try {
const clientConfig = await getClientConfig();
setClientConfigApiStatus((prev) => ({
...prev,
isLoading: false,
isLoadedOnce: true,
isErrored: false,
error: undefined,
data: { clientConfig },
}));
setClientConfigApiStatus((currentStatus) => ({
...currentStatus,
isErrored: false,
error: undefined,
}));
setAppVersion(clientConfig.appVersion);
setAuthProviders({
google: clientConfig.authProviders.google,
@@ -155,18 +126,15 @@ export const useClientConfig = (): UseClientConfigResult => {
setIsEmailVerificationRequired(clientConfig.isEmailVerificationRequired);
setBilling(clientConfig.billing);
setSupportChat(clientConfig.support);
setSentryConfig({
dsn: clientConfig?.sentry?.dsn,
release: clientConfig?.sentry?.release,
environment: clientConfig?.sentry?.environment,
});
setCaptcha({
provider: clientConfig?.captcha?.provider,
siteKey: clientConfig?.captcha?.siteKey,
});
setApiConfig(clientConfig?.api);
setDomainConfiguration({
defaultSubdomain: clientConfig?.defaultSubdomain,
@@ -182,11 +150,6 @@ export const useClientConfig = (): UseClientConfigResult => {
setIsConfigVariablesInDbEnabled(
clientConfig?.isConfigVariablesInDbEnabled,
);
setClientConfigApiStatus((currentStatus) => ({
...currentStatus,
isSaved: true,
}));
setCalendarBookingPageId(clientConfig?.calendarBookingPageId ?? null);
setIsImapSmtpCaldavEnabled(clientConfig?.isImapSmtpCaldavEnabled);
setIsEmailingDomainsEnabled(clientConfig?.isEmailingDomainsEnabled);
@@ -195,16 +158,12 @@ export const useClientConfig = (): UseClientConfigResult => {
clientConfig?.isCloudflareIntegrationEnabled,
);
setIsClickHouseConfigured(clientConfig?.isClickHouseConfigured ?? false);
setClientConfigApiStatus({ isLoaded: true, error: undefined });
} catch (err) {
const error =
err instanceof Error ? err : new Error('Failed to fetch client config');
setClientConfigApiStatus((prev) => ({
...prev,
isLoading: false,
isLoadedOnce: true,
isErrored: true,
error,
}));
setClientConfigApiStatus({ isLoaded: true, error });
}
}, [
setAiModels,
@@ -237,11 +196,5 @@ export const useClientConfig = (): UseClientConfigResult => {
setAllowRequestsToTwentyIcons,
]);
return {
data: clientConfigApiStatus.data,
loading: clientConfigApiStatus.isLoading || false,
error: clientConfigApiStatus.error,
fetchClientConfig,
refetch: fetchClientConfig,
};
return { fetchClientConfig };
};
@@ -1,24 +1,15 @@
import { type ClientConfig } from '@/client-config/types/ClientConfig';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
type ClientConfigApiStatus = {
isLoadedOnce: boolean;
isLoading: boolean;
isErrored: boolean;
isSaved: boolean;
isLoaded: boolean;
error?: Error;
data?: { clientConfig: ClientConfig };
};
export const clientConfigApiStatusState =
createAtomState<ClientConfigApiStatus>({
key: 'clientConfigApiStatus',
defaultValue: {
isLoadedOnce: false,
isLoading: false,
isErrored: false,
isSaved: false,
isLoaded: false,
error: undefined,
data: undefined,
},
});
@@ -29,7 +29,9 @@ export const useGetPublicWorkspaceDataByDomain = () => {
const workspacePublicData = useAtomStateValue(workspacePublicDataState);
const { redirectToDefaultDomain } = useRedirectToDefaultDomain();
const setWorkspacePublicData = useSetAtomState(workspacePublicDataState);
const clientConfigApiStatus = useAtomStateValue(clientConfigApiStatusState);
const { isLoaded: isClientConfigLoaded } = useAtomStateValue(
clientConfigApiStatusState,
);
const { loading, data, error } = useQuery(
GetPublicWorkspaceDataByDomainDocument,
@@ -38,7 +40,7 @@ export const useGetPublicWorkspaceDataByDomain = () => {
origin,
},
skip:
!clientConfigApiStatus.isSaved ||
!isClientConfigLoaded ||
(isMultiWorkspaceEnabled && isDefaultDomain) ||
isDefined(workspacePublicData),
},
@@ -1,4 +1,4 @@
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
import { useHasWorkspaceSpecificToken } from '@/auth/hooks/useHasWorkspaceSpecificToken';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import { useLoadMinimalMetadata } from '@/metadata-store/hooks/useLoadMinimalMetadata';
@@ -12,10 +12,10 @@ import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
type LoadedState = 'none' | 'mocked' | 'real';
const computeDesiredLoadState = (
hasAccessTokenPair: boolean,
hasWorkspaceSpecificToken: boolean,
isActiveWorkspace: boolean,
): LoadedState => {
if (hasAccessTokenPair && isActiveWorkspace) {
if (hasWorkspaceSpecificToken && isActiveWorkspace) {
return 'real';
}
@@ -23,7 +23,7 @@ const computeDesiredLoadState = (
};
export const MinimalMetadataLoadEffect = () => {
const hasAccessTokenPair = useHasAccessTokenPair();
const hasWorkspaceSpecificToken = useHasWorkspaceSpecificToken();
const isCurrentUserLoaded = useAtomStateValue(isCurrentUserLoadedState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const metadataLoadedVersion = useAtomStateValue(metadataLoadedVersionState);
@@ -39,7 +39,7 @@ export const MinimalMetadataLoadEffect = () => {
const isActiveWorkspace = isWorkspaceActiveOrSuspended(currentWorkspace);
const desiredLoadState = computeDesiredLoadState(
hasAccessTokenPair,
hasWorkspaceSpecificToken,
isActiveWorkspace,
);
@@ -76,7 +76,7 @@ export const MinimalMetadataLoadEffect = () => {
performLoad();
}, [
isCurrentUserLoaded,
hasAccessTokenPair,
hasWorkspaceSpecificToken,
isActiveWorkspace,
desiredLoadState,
lastMetadataLoadData,
@@ -1,182 +1,49 @@
import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair';
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { currentWorkspaceDeletedMembersState } from '@/auth/states/currentWorkspaceDeletedMembersState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import { useInitializeFormatPreferences } from '@/localization/hooks/useInitializeFormatPreferences';
import { getDateFnsLocale } from '@/ui/field/display/utils/getDateFnsLocale';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { type ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { enUS } from 'date-fns/locale';
import { useStore } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { type ObjectPermissions } from 'twenty-shared/types';
import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser';
import { useEffect } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useQuery } from '@apollo/client/react';
import {
type WorkspaceMember,
GetCurrentUserDocument,
} from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
export const UserMetadataProviderInitialEffect = () => {
const hasAccessTokenPair = useHasAccessTokenPair();
const currentUser = useAtomStateValue(currentUserState);
const store = useStore();
const [isInitialized, setIsInitialized] = useState(false);
const location = useLocation();
const [searchParams] = useSearchParams();
const setCurrentUser = useSetAtomState(currentUserState);
const setCurrentWorkspace = useSetAtomState(currentWorkspaceState);
const setCurrentUserWorkspace = useSetAtomState(currentUserWorkspaceState);
const setAvailableWorkspaces = useSetAtomState(availableWorkspacesState);
const setCurrentWorkspaceMember = useSetAtomState(
currentWorkspaceMemberState,
);
const setCurrentWorkspaceMembers = useSetAtomState(
currentWorkspaceMembersState,
);
const setCurrentWorkspaceDeletedMembers = useSetAtomState(
currentWorkspaceDeletedMembersState,
);
const setIsCurrentUserLoaded = useSetAtomState(isCurrentUserLoadedState);
const { loadCurrentUser } = useLoadCurrentUser();
const { initializeFormatPreferences } = useInitializeFormatPreferences();
const updateLocaleCatalog = useCallback(
async (newLocale: keyof typeof APP_LOCALES) => {
const localeValue = store.get(dateLocaleState.atom);
if (localeValue.locale !== newLocale) {
getDateFnsLocale(newLocale).then((localeCatalog) => {
const newValue = {
locale: newLocale,
localeCatalog: localeCatalog || enUS,
};
store.set(dateLocaleState.atom, newValue);
});
}
},
[store],
);
const shouldSkipUserQuery = !hasAccessTokenPair || isDefined(currentUser);
const { data: userQueryData, loading: userQueryLoading } = useQuery(
GetCurrentUserDocument,
{
skip: shouldSkipUserQuery,
},
);
const isLoginFlowInProgress =
isMatchingLocation(location, AppPath.Verify) &&
searchParams.has('loginToken');
useEffect(() => {
if (isInitialized) {
if (isLoginFlowInProgress) {
return;
}
if (!hasAccessTokenPair) {
setIsCurrentUserLoaded(true);
setIsInitialized(true);
return;
}
if (userQueryLoading || !isDefined(userQueryData?.currentUser)) {
if (isDefined(currentUser)) {
return;
}
setCurrentUser(userQueryData.currentUser);
if (isDefined(userQueryData.currentUser.currentWorkspace)) {
setCurrentWorkspace({
...userQueryData.currentUser.currentWorkspace,
defaultRole:
userQueryData.currentUser.currentWorkspace.defaultRole ?? null,
workspaceCustomApplication:
userQueryData.currentUser.currentWorkspace
.workspaceCustomApplication ?? null,
});
}
if (isDefined(userQueryData.currentUser.currentUserWorkspace)) {
setCurrentUserWorkspace({
permissionFlags:
userQueryData.currentUser.currentUserWorkspace.permissionFlags ?? [],
twoFactorAuthenticationMethodSummary:
userQueryData.currentUser.currentUserWorkspace
.twoFactorAuthenticationMethodSummary ?? [],
objectsPermissions:
(userQueryData.currentUser.currentUserWorkspace
.objectsPermissions as Array<
ObjectPermissions & { objectMetadataId: string }
>) ?? [],
});
}
const {
workspaceMember,
workspaceMembers,
deletedWorkspaceMembers,
availableWorkspaces,
} = userQueryData.currentUser;
const affectDefaultValuesOnEmptyWorkspaceMemberFields = (
workspaceMember: WorkspaceMember,
) => {
return {
...workspaceMember,
colorScheme: (workspaceMember.colorScheme as ColorScheme) ?? 'System',
locale:
(workspaceMember.locale as keyof typeof APP_LOCALES) ?? SOURCE_LOCALE,
};
};
if (isDefined(workspaceMember)) {
const updatedWorkspaceMember =
affectDefaultValuesOnEmptyWorkspaceMemberFields(workspaceMember);
setCurrentWorkspaceMember(updatedWorkspaceMember);
updateLocaleCatalog(updatedWorkspaceMember.locale);
initializeFormatPreferences(updatedWorkspaceMember);
dynamicActivate(
(workspaceMember.locale as keyof typeof APP_LOCALES) ?? SOURCE_LOCALE,
);
}
if (isDefined(workspaceMembers)) {
setCurrentWorkspaceMembers(workspaceMembers);
}
if (isDefined(deletedWorkspaceMembers)) {
setCurrentWorkspaceDeletedMembers(deletedWorkspaceMembers);
}
if (isDefined(availableWorkspaces)) {
setAvailableWorkspaces(availableWorkspaces);
}
setIsCurrentUserLoaded(true);
setIsInitialized(true);
loadCurrentUser();
}, [
isInitialized,
hasAccessTokenPair,
userQueryLoading,
userQueryData?.currentUser,
setCurrentUser,
setCurrentUserWorkspace,
setCurrentWorkspaceMembers,
setAvailableWorkspaces,
setCurrentWorkspace,
setCurrentWorkspaceMember,
initializeFormatPreferences,
setCurrentWorkspaceDeletedMembers,
updateLocaleCatalog,
isLoginFlowInProgress,
currentUser,
loadCurrentUser,
setIsCurrentUserLoaded,
]);
@@ -19,5 +19,19 @@ export const useInvalidateMetadataStore = () => {
store.set(metadataLoadedVersionState.atom, (prev) => prev + 1);
}, [store]);
return { invalidateMetadataStore };
// Resets statuses to 'empty' so isMinimalMetadataReady becomes false
// and loadMinimalMetadata writes fresh server data. Use during login
// flows where stale localStorage metadata must not gate rendering.
const resetMetadataStore = useCallback(() => {
for (const key of ALL_METADATA_ENTITY_KEYS) {
store.set(metadataStoreState.atomFamily(key), (prev) => ({
...prev,
status: 'empty',
currentCollectionHash: undefined,
}));
}
store.set(metadataLoadedVersionState.atom, (prev) => prev + 1);
}, [store]);
return { invalidateMetadataStore, resetMetadataStore };
};
@@ -51,7 +51,7 @@ type UsageBreakdownItem = {
export const SettingsAdminAI = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const { refetch: refetchClientConfig } = useClientConfig();
const { fetchClientConfig: refetchClientConfig } = useClientConfig();
const { formatUsageValue } = useUsageValueFormatter();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const billing = useAtomStateValue(billingState);
@@ -15,7 +15,7 @@ import {
export const useConfigVariableActions = (variableName: string) => {
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { refetch: refetchClientConfig } = useClientConfig();
const { fetchClientConfig: refetchClientConfig } = useClientConfig();
const [updateDatabaseConfigVariable] = useMutation(
UpdateDatabaseConfigVariableDocument,
@@ -0,0 +1,34 @@
import { ApolloCoreClientContext } from '@/object-metadata/contexts/ApolloCoreClientContext';
import {
ApolloClient,
ApolloLink,
InMemoryCache,
Observable,
} from '@apollo/client';
import { ApolloProvider } from '@apollo/client/react';
import { type ReactNode } from 'react';
const noOpLink = new ApolloLink(
() =>
new Observable((observer) => {
observer.next({ data: {} });
observer.complete();
}),
);
const noOpClient = new ApolloClient({
link: noOpLink,
cache: new InMemoryCache(),
});
export const NoOpApolloCoreProvider = ({
children,
}: {
children: ReactNode;
}) => (
<ApolloProvider client={noOpClient}>
<ApolloCoreClientContext.Provider value={noOpClient}>
{children}
</ApolloCoreClientContext.Provider>
</ApolloProvider>
);
@@ -11,6 +11,7 @@ import { MobileNavigationBar } from '@/navigation/components/MobileNavigationBar
import { PageDragDropProvider } from '@/navigation-menu-item/display/dnd/providers/PageDragDropProvider';
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { NoOpApolloCoreProvider } from '@/sign-in-background-mock/components/NoOpApolloCoreProvider';
import { SignInAppNavigationDrawerMock } from '@/sign-in-background-mock/components/SignInAppNavigationDrawerMock';
import { Suspense, lazy, useContext } from 'react';
@@ -105,9 +106,11 @@ export const DefaultLayout = () => {
{showAuthModal ? (
<>
<StyledMainContainer>
<Suspense fallback={null}>
<SignInBackgroundMockPage />
</Suspense>
<NoOpApolloCoreProvider>
<Suspense fallback={null}>
<SignInBackgroundMockPage />
</Suspense>
</NoOpApolloCoreProvider>
</StyledMainContainer>
<AnimatePresence mode="wait">
<LayoutGroup>
@@ -1,23 +1,29 @@
import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { currentWorkspaceDeletedMembersState } from '@/auth/states/currentWorkspaceDeletedMembersState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import { authProvidersState } from '@/client-config/states/authProvidersState';
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
import { useInitializeFormatPreferences } from '@/localization/hooks/useInitializeFormatPreferences';
import { getDateFnsLocale } from '@/ui/field/display/utils/getDateFnsLocale';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { workspaceAuthBypassProvidersState } from '@/workspace/states/workspaceAuthBypassProvidersState';
import { useApolloClient } from '@apollo/client/react';
import { enUS } from 'date-fns/locale';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { SOURCE_LOCALE, type APP_LOCALES } from 'twenty-shared/translations';
import { type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ColorScheme } from 'twenty-ui/input';
import { useApolloClient } from '@apollo/client/react';
import { GetCurrentUserDocument } from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
@@ -33,7 +39,11 @@ export const useLoadCurrentUser = () => {
const setCurrentWorkspaceMembers = useSetAtomState(
currentWorkspaceMembersState,
);
const setCurrentWorkspaceDeletedMembers = useSetAtomState(
currentWorkspaceDeletedMembersState,
);
const setCurrentWorkspace = useSetAtomState(currentWorkspaceState);
const setIsCurrentUserLoaded = useSetAtomState(isCurrentUserLoadedState);
const { initializeFormatPreferences } = useInitializeFormatPreferences();
const setWorkspaceAuthBypassProviders = useSetAtomState(
workspaceAuthBypassProvidersState,
@@ -43,6 +53,7 @@ export const useLoadCurrentUser = () => {
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
const client = useApolloClient();
const store = useStore();
const loadCurrentUser = useCallback(async () => {
const currentUserResult = await client.query({
@@ -84,25 +95,42 @@ export const useLoadCurrentUser = () => {
});
}
if (isDefined(user.deletedWorkspaceMembers)) {
setCurrentWorkspaceDeletedMembers(user.deletedWorkspaceMembers);
}
if (isDefined(user.workspaceMember)) {
const memberLocale =
(user.workspaceMember.locale as keyof typeof APP_LOCALES) ??
SOURCE_LOCALE;
workspaceMember = {
...user.workspaceMember,
colorScheme: user.workspaceMember?.colorScheme as ColorScheme,
locale: user.workspaceMember?.locale ?? SOURCE_LOCALE,
colorScheme:
(user.workspaceMember.colorScheme as ColorScheme) ?? 'System',
locale: memberLocale,
};
setCurrentWorkspaceMember(workspaceMember);
// Initialize unified format preferences state
initializeFormatPreferences(workspaceMember);
dynamicActivate(
(workspaceMember.locale as keyof typeof APP_LOCALES) ?? SOURCE_LOCALE,
);
dynamicActivate(memberLocale);
const currentLocale = store.get(dateLocaleState.atom);
if (currentLocale.locale !== memberLocale) {
getDateFnsLocale(memberLocale).then((localeCatalog) => {
store.set(dateLocaleState.atom, {
locale: memberLocale,
localeCatalog: localeCatalog || enUS,
});
});
}
}
const workspace = isDefined(user.currentWorkspace)
? {
...user.currentWorkspace,
defaultRole: user.currentWorkspace.defaultRole ?? null,
workspaceCustomApplication:
user.currentWorkspace.workspaceCustomApplication ?? null,
}
@@ -127,6 +155,8 @@ export const useLoadCurrentUser = () => {
});
}
setIsCurrentUserLoaded(true);
return {
user,
workspaceMember,
@@ -134,13 +164,16 @@ export const useLoadCurrentUser = () => {
};
}, [
client,
store,
setCurrentUser,
setCurrentWorkspace,
isOnAWorkspace,
setCurrentWorkspaceMembers,
setCurrentWorkspaceDeletedMembers,
setAvailableWorkspaces,
setCurrentUserWorkspace,
setCurrentWorkspaceMember,
setIsCurrentUserLoaded,
initializeFormatPreferences,
setLastAuthenticateWorkspaceDomain,
authProviders,
@@ -84,7 +84,9 @@ const StandardContent = ({
export const SignInUp = () => {
const { t } = useLingui();
const setSignInUpStep = useSetAtomState(signInUpStepState);
const clientConfigApiStatus = useAtomStateValue(clientConfigApiStatusState);
const { isLoaded: isClientConfigLoaded } = useAtomStateValue(
clientConfigApiStatusState,
);
const { form } = useSignInUpForm();
const { signInUpStep } = useSignInUp(form);
@@ -146,7 +148,7 @@ export const SignInUp = () => {
]);
const signInUpForm = useMemo(() => {
if (getPublicWorkspaceDataLoading || !clientConfigApiStatus.isLoadedOnce) {
if (getPublicWorkspaceDataLoading || !isClientConfigLoaded) {
return (
<StyledLoaderContainer>
<Loader color="gray" />
@@ -194,7 +196,7 @@ export const SignInUp = () => {
</>
);
}, [
clientConfigApiStatus.isLoadedOnce,
isClientConfigLoaded,
isDefaultDomain,
isMultiWorkspaceEnabled,
isOnAWorkspace,
@@ -51,7 +51,7 @@ export const SettingsAdminAiProviderDetail = () => {
const { providerName } = useParams<{ providerName: string }>();
const navigate = useNavigate();
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const { refetch: refetchClientConfig } = useClientConfig();
const { fetchClientConfig: refetchClientConfig } = useClientConfig();
const { openModal } = useModal();
const [searchQuery, setSearchQuery] = useState('');
const [modelToRemove, setModelToRemove] = useState<{
@@ -20,7 +20,7 @@ import { IsMinimalMetadataReadyEffect } from '@/metadata-store/effect-components
import { MinimalMetadataLoadEffect } from '@/metadata-store/effect-components/MinimalMetadataLoadEffect';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { useState } from 'react';
import { ClientConfigProvider } from '~/modules/client-config/components/ClientConfigProvider';
import { ClientConfigErrorGate } from '~/modules/client-config/components/ClientConfigErrorGate';
import { mockedApolloClient } from '~/testing/mockedApolloClient';
import { MainContextStoreProvider } from '@/context-store/components/MainContextStoreProvider';
@@ -85,7 +85,7 @@ const Providers = () => {
<I18nProvider i18n={i18n}>
<ApolloStorybookDevLogEffect />
<ClientConfigProviderEffect />
<ClientConfigProvider>
<ClientConfigErrorGate>
<UserMetadataProviderInitialEffect />
<MinimalMetadataLoadEffect />
<IsMinimalMetadataReadyEffect />
@@ -106,7 +106,7 @@ const Providers = () => {
<MainContextStoreProvider />
</ApolloCoreClientMockedProvider>
</MinimalMetadataGater>
</ClientConfigProvider>
</ClientConfigErrorGate>
</I18nProvider>
</ApolloProvider>
</SnackBarComponentInstanceContext.Provider>
@@ -95,7 +95,7 @@ describe('RefreshTokenService', () => {
jest
.spyOn(appTokenRepository, 'findOneBy')
.mockResolvedValue(mockAppToken);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser);
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser);
jest.spyOn(twentyConfigService, 'get').mockReturnValue('1h');
const result = await service.verifyRefreshToken(mockToken);
@@ -204,7 +204,7 @@ describe('RefreshTokenService', () => {
const user = { id: userId } as UserEntity;
jest.spyOn(userRepository, 'findOne').mockResolvedValue(user);
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(user);
const out = await service.verifyRefreshToken(refreshToken);
@@ -67,9 +67,8 @@ export class RefreshTokenService {
);
}
const user = await this.userRepository.findOne({
where: { id: jwtPayload.sub },
relations: ['appTokens'],
const user = await this.userRepository.findOneBy({
id: jwtPayload.sub,
});
if (!user) {
@@ -84,23 +83,10 @@ export class RefreshTokenService {
token.revokedAt.getTime() <= Date.now() - ms(reuseGracePeriod);
if (wasRevokedBeforeGracePeriod) {
// Token was revoked long ago and is being reused -- suspicious.
// Revoke all user refresh tokens as a safety measure.
await Promise.all(
user.appTokens.map(async ({ id, type }) => {
if (type === AppTokenType.RefreshToken) {
await this.appTokenRepository.update(
{ id },
{
revokedAt: new Date(),
},
);
}
}),
);
// Reject the stale token but don't revoke all tokens — the most
// common cause is a lost renewal response, not actual token theft.
throw new AuthException(
'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.',
'This refresh token has been revoked.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}