diff --git a/apps/studio/src/components/auth-provider.tsx b/apps/studio/src/components/auth-provider.tsx index 8afb587e36..4f614630e9 100644 --- a/apps/studio/src/components/auth-provider.tsx +++ b/apps/studio/src/components/auth-provider.tsx @@ -7,7 +7,8 @@ import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { useOffline } from 'src/hooks/use-offline'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { isInvalidTokenError } from 'src/lib/is-invalid-oauth-token-error'; -import { useI18nLocale } from 'src/stores'; +import { useAppDispatch, useI18nLocale } from 'src/stores'; +import { userLoggedOut } from 'src/stores/auth-actions'; import { setWpcomClient } from 'src/stores/wpcom-api'; import type { WPCOM } from 'wpcom/types'; @@ -45,21 +46,22 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { const { __ } = useI18n(); const isOffline = useOffline(); + const dispatch = useAppDispatch(); const authenticate = useCallback( () => getIpcApi().authenticate(), [] ); const handleInvalidToken = useCallback( async () => { try { void getIpcApi().logRendererMessage( 'info', 'Detected invalid token. Logging out.' ); + dispatch( userLoggedOut() ); await getIpcApi().clearAuthenticationToken(); setIsAuthenticated( false ); setClient( undefined ); - setWpcomClient( undefined ); setUser( undefined ); } catch ( err ) { console.error( 'Failed to handle invalid token:', err ); Sentry.captureException( err ); } - }, [] ); + }, [ dispatch ] ); useIpcListener( 'auth-updated', ( _event, payload ) => { if ( 'error' in payload ) { @@ -110,16 +112,16 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { } try { + dispatch( userLoggedOut() ); await getIpcApi().clearAuthenticationToken(); setIsAuthenticated( false ); setClient( undefined ); - setWpcomClient( undefined ); setUser( undefined ); } catch ( err ) { console.error( err ); Sentry.captureException( err ); } - }, [ client, isOffline ] ); + }, [ client, dispatch, isOffline ] ); useEffect( () => { async function run() { diff --git a/apps/studio/src/hooks/use-import-export.tsx b/apps/studio/src/hooks/use-import-export.tsx index af7c321726..7fc57eacf5 100644 --- a/apps/studio/src/hooks/use-import-export.tsx +++ b/apps/studio/src/hooks/use-import-export.tsx @@ -1,7 +1,8 @@ import * as Sentry from '@sentry/electron/renderer'; import { __, sprintf } from '@wordpress/i18n'; -import { createContext, useMemo, useState, useCallback, useContext } from 'react'; +import { createContext, useEffect, useMemo, useState, useCallback, useContext } from 'react'; import { WP_CLI_IMPORT_EXPORT_RESPONSE_TIMEOUT_IN_HRS } from 'src/constants'; +import { useAuth } from 'src/hooks/use-auth'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { simplifyErrorForDisplay } from 'src/lib/error-formatting'; @@ -26,6 +27,7 @@ export type ImportProgressState = { statusMessage: string; progress: number; isNewSite?: boolean; + isError?: boolean; }; }; @@ -76,6 +78,14 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode const [ importState, setImportState ] = useState< ImportProgressState >( {} ); const [ exportState, setExportState ] = useState< ExportProgressState >( {} ); const { startServer, stopServer, updateSite } = useSiteDetails(); + const { isAuthenticated } = useAuth(); + + useEffect( () => { + if ( ! isAuthenticated ) { + setImportState( {} ); + setExportState( {} ); + } + }, [ isAuthenticated ] ); const importFile = useCallback( async ( @@ -339,6 +349,7 @@ export const ImportExportProvider = ( { children }: { children: React.ReactNode [ siteId ]: { ...currentProgress, statusMessage: __( 'Import failed. Please try again.' ), + isError: true, }, } ) ); break; diff --git a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx index 17b26193db..47d3eb03e1 100644 --- a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx +++ b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx @@ -225,11 +225,15 @@ const SyncConnectedSitesSectionItem = ( { const sitePullState = useRootSelector( syncOperationsSelectors.selectPullState( selectedSite.id, connectedSite.id ) ); + const pullImportFailed = + sitePullState?.status.key === 'importing' && + importState[ connectedSite.localSiteId ]?.isError === true; const isPulling = - sitePullState?.status.key === 'in-progress' || - sitePullState?.status.key === 'downloading' || - sitePullState?.status.key === 'importing'; - const isPullError = sitePullState?.status.key === 'failed'; + ( sitePullState?.status.key === 'in-progress' || + sitePullState?.status.key === 'downloading' || + sitePullState?.status.key === 'importing' ) && + ! pullImportFailed; + const isPullError = sitePullState?.status.key === 'failed' || pullImportFailed; const hasPullFinished = sitePullState?.status.key === 'finished'; const hasPullCancelled = sitePullState?.status.key === 'cancelled'; const pullImportState = importState[ connectedSite.localSiteId ]; diff --git a/apps/studio/src/stores/auth-actions.ts b/apps/studio/src/stores/auth-actions.ts new file mode 100644 index 0000000000..bb746957ae --- /dev/null +++ b/apps/studio/src/stores/auth-actions.ts @@ -0,0 +1,3 @@ +import { createAction } from '@reduxjs/toolkit'; + +export const userLoggedOut = createAction( 'auth/userLoggedOut' ); diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index eed7e4a25b..0eee72d75f 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -14,6 +14,7 @@ import { } from 'src/hooks/use-sync-states-progress-info'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { appVersionApi } from 'src/stores/app-version-api'; +import { userLoggedOut } from 'src/stores/auth-actions'; import { betaFeaturesReducer, loadBetaFeatures } from 'src/stores/beta-features-slice'; import { certificateTrustApi } from 'src/stores/certificate-trust-api'; import { reducer as chatReducer } from 'src/stores/chat-slice'; @@ -32,9 +33,15 @@ import { syncOperationsSelectors, syncOperationsThunks, } from 'src/stores/sync/sync-operations-slice'; +import { + PUSH_POLLERS, + PULL_POLLERS, + stopPushPoller, + stopPullPoller, +} from 'src/stores/sync/sync-pollers'; import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites'; import uiReducer from 'src/stores/ui-slice'; -import { getWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; +import { getWpcomClient, setWpcomClient, wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; import { wordpressVersionsApi } from './wordpress-versions-api'; import type { SupportedLocale } from '@studio/common/lib/locale'; @@ -182,12 +189,44 @@ startAppListening( { }, } ); +// Clear all sync state when user logs out +startAppListening( { + actionCreator: userLoggedOut, + effect( action, listenerApi ) { + setWpcomClient( undefined ); + + // Use getOriginalState() to read state BEFORE the reducer cleared pullStates/pushStates + const state = listenerApi.getOriginalState(); + + for ( const pullState of Object.values( state.syncOperations.pullStates ) ) { + void store.dispatch( + syncOperationsThunks.cancelPull( { + selectedSiteId: pullState.selectedSite.id, + remoteSiteId: pullState.remoteSiteId, + displayNotification: false, + } ) + ); + } + + for ( const pushState of Object.values( state.syncOperations.pushStates ) ) { + void store.dispatch( + syncOperationsThunks.cancelPush( { + selectedSiteId: pushState.selectedSite.id, + remoteSiteId: pushState.remoteSiteId, + displayNotification: false, + } ) + ); + } + + // Reset authenticated RTK Query caches + listenerApi.dispatch( wpcomSitesApi.util.resetApiState() ); + listenerApi.dispatch( wpcomApi.util.resetApiState() ); + }, +} ); + const PUSH_POLLING_KEYS = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; const SYNC_POLLING_INTERVAL = 3000; -const PUSH_POLLERS = new Map< string, AbortController >(); -const PULL_POLLERS = new Map< string, AbortController >(); - function isPushPollable( selectedSiteId: string, remoteSiteId: number ) { const pushState = syncOperationsSelectors.selectPushState( selectedSiteId, @@ -204,16 +243,6 @@ function isPullPollable( selectedSiteId: string, remoteSiteId: number ) { return pullState?.status.key === 'in-progress' && !! pullState.backupId; } -function stopPushPoller( stateId: string ) { - PUSH_POLLERS.get( stateId )?.abort(); - PUSH_POLLERS.delete( stateId ); -} - -function stopPullPoller( stateId: string ) { - PULL_POLLERS.get( stateId )?.abort(); - PULL_POLLERS.delete( stateId ); -} - async function startPushPoller( selectedSiteId: string, remoteSiteId: number ) { const stateId = generateStateId( selectedSiteId, remoteSiteId ); if ( PUSH_POLLERS.has( stateId ) ) { diff --git a/apps/studio/src/stores/sync/connected-sites.ts b/apps/studio/src/stores/sync/connected-sites.ts index 4342eafc2c..e8cba1ecc7 100644 --- a/apps/studio/src/stores/sync/connected-sites.ts +++ b/apps/studio/src/stores/sync/connected-sites.ts @@ -2,6 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { RootState } from 'src/stores'; +import { userLoggedOut } from 'src/stores/auth-actions'; import type { SyncSite, SyncModalMode } from 'src/modules/sync/types'; type ConnectedSitesState = { @@ -60,6 +61,9 @@ const connectedSitesSlice = createSlice( { delete state.loadingSiteIds[ action.payload ]; }, }, + extraReducers: ( builder ) => { + builder.addCase( userLoggedOut, () => getInitialState() ); + }, } ); export const connectedSitesActions = connectedSitesSlice.actions; diff --git a/apps/studio/src/stores/sync/sync-operations-slice.ts b/apps/studio/src/stores/sync/sync-operations-slice.ts index 2e7dd736ed..84fc4a3b2e 100644 --- a/apps/studio/src/stores/sync/sync-operations-slice.ts +++ b/apps/studio/src/stores/sync/sync-operations-slice.ts @@ -7,7 +7,9 @@ import { generateStateId } from 'src/hooks/sync-sites/use-pull-push-states'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getHostnameFromUrl } from 'src/lib/url-utils'; import { store } from 'src/stores'; +import { userLoggedOut } from 'src/stores/auth-actions'; import { connectedSitesApi } from 'src/stores/sync/connected-sites'; +import { stopPullPoller, stopPushPoller } from 'src/stores/sync/sync-pollers'; import type { PullStateProgressInfo, PushStateProgressInfo, @@ -182,6 +184,7 @@ const syncOperationsSlice = createSlice( { }, extraReducers: ( builder ) => { builder + .addCase( userLoggedOut, () => initialState ) .addCase( pushSiteThunk.rejected, ( state, action ) => { const { connectedSite, selectedSite } = action.meta.arg; const stateId = generateStateId( selectedSite.id, connectedSite.id ); @@ -297,14 +300,19 @@ const createTypedAsyncThunk = createAsyncThunk.withTypes< { type CancelOperationPayload = { selectedSiteId: string; remoteSiteId: number; + displayNotification?: boolean; }; const cancelPushThunk = createTypedAsyncThunk( 'syncOperations/cancelPush', - async ( { selectedSiteId, remoteSiteId }: CancelOperationPayload, { dispatch } ) => { + async ( + { selectedSiteId, remoteSiteId, displayNotification = true }: CancelOperationPayload, + { dispatch } + ) => { const operationId = generateStateId( selectedSiteId, remoteSiteId ); const abortCallback = PUSH_SITE_ABORT_CALLBACKS.get( operationId ); + stopPushPoller( operationId ); abortCallback?.(); getIpcApi().cancelSyncOperation( operationId ); @@ -316,17 +324,23 @@ const cancelPushThunk = createTypedAsyncThunk( } ) ); - getIpcApi().showNotification( { - title: __( 'Push cancelled' ), - body: __( 'The push operation has been cancelled.' ), - } ); + if ( displayNotification ) { + getIpcApi().showNotification( { + title: __( 'Push cancelled' ), + body: __( 'The push operation has been cancelled.' ), + } ); + } } ); const cancelPullThunk = createTypedAsyncThunk( 'syncOperations/cancelPull', - async ( { selectedSiteId, remoteSiteId }: CancelOperationPayload, { dispatch } ) => { + async ( + { selectedSiteId, remoteSiteId, displayNotification = true }: CancelOperationPayload, + { dispatch } + ) => { const operationId = generateStateId( selectedSiteId, remoteSiteId ); + stopPullPoller( operationId ); getIpcApi().cancelSyncOperation( operationId ); dispatch( @@ -343,10 +357,12 @@ const cancelPullThunk = createTypedAsyncThunk( // Ignore errors if file doesn't exist } ); - getIpcApi().showNotification( { - title: __( 'Pull cancelled' ), - body: __( 'The pull operation has been cancelled.' ), - } ); + if ( displayNotification ) { + getIpcApi().showNotification( { + title: __( 'Pull cancelled' ), + body: __( 'The pull operation has been cancelled.' ), + } ); + } } ); diff --git a/apps/studio/src/stores/sync/sync-pollers.ts b/apps/studio/src/stores/sync/sync-pollers.ts new file mode 100644 index 0000000000..b924ccf181 --- /dev/null +++ b/apps/studio/src/stores/sync/sync-pollers.ts @@ -0,0 +1,20 @@ +/** + * Shared poller abort controllers for sync push/pull operations. + * + * Extracted into its own module so both the poller loops (stores/index.ts) + * and the cancel thunks (sync-operations-slice.ts) can abort in-flight + * poll requests without circular imports. + */ + +export const PUSH_POLLERS = new Map< string, AbortController >(); +export const PULL_POLLERS = new Map< string, AbortController >(); + +export function stopPushPoller( stateId: string ) { + PUSH_POLLERS.get( stateId )?.abort(); + PUSH_POLLERS.delete( stateId ); +} + +export function stopPullPoller( stateId: string ) { + PULL_POLLERS.get( stateId )?.abort(); + PULL_POLLERS.delete( stateId ); +} diff --git a/apps/studio/src/stores/sync/sync-slice.ts b/apps/studio/src/stores/sync/sync-slice.ts index cc450453c8..7abe56e033 100644 --- a/apps/studio/src/stores/sync/sync-slice.ts +++ b/apps/studio/src/stores/sync/sync-slice.ts @@ -1,5 +1,6 @@ import { createSlice } from '@reduxjs/toolkit'; import { TreeNode } from 'src/components/tree-view'; +import { userLoggedOut } from 'src/stores/auth-actions'; import { fetchRemoteFileTree } from './sync-api'; type RemoteFileTreeState = { @@ -33,6 +34,7 @@ const syncSlice = createSlice( { }, extraReducers: ( builder ) => { builder + .addCase( userLoggedOut, () => initialState ) .addCase( fetchRemoteFileTree.pending, ( state ) => { state.remoteFileTrees.loading = true; state.remoteFileTrees.error = null;