From d4a35d34e101aa498eeb6e7c3bb41af7a76098b0 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 12 Mar 2026 14:18:48 +0100 Subject: [PATCH 01/26] Add Auth Slice --- apps/studio/src/components/auth-provider.tsx | 181 +++-------------- apps/studio/src/hooks/use-auth.ts | 25 ++- apps/studio/src/stores/auth-slice.ts | 196 +++++++++++++++++++ apps/studio/src/stores/index.ts | 3 + 4 files changed, 241 insertions(+), 164 deletions(-) create mode 100644 apps/studio/src/stores/auth-slice.ts diff --git a/apps/studio/src/components/auth-provider.tsx b/apps/studio/src/components/auth-provider.tsx index 8afb587e36..c0924b5e3a 100644 --- a/apps/studio/src/components/auth-provider.tsx +++ b/apps/studio/src/components/auth-provider.tsx @@ -1,21 +1,24 @@ -import * as Sentry from '@sentry/electron/renderer'; -import wpcomFactory from '@studio/common/lib/wpcom-factory'; -import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory'; import { useI18n } from '@wordpress/react-i18n'; -import { createContext, useState, useEffect, useMemo, useCallback, ReactNode } from 'react'; +import { createContext, useCallback, useEffect, useMemo, ReactNode } from 'react'; 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 { setWpcomClient } from 'src/stores/wpcom-api'; +import { useAppDispatch, useRootSelector, useI18nLocale } from 'src/stores'; +import { + authLogout, + authTokenReceived, + initializeAuth, + selectIsAuthenticated, + selectUser, +} from 'src/stores/auth-slice'; +import { getWpcomClient } from 'src/stores/wpcom-api'; import type { WPCOM } from 'wpcom/types'; export interface AuthContextType { client: WPCOM | undefined; isAuthenticated: boolean; - authenticate: () => void; // Adjust based on the actual implementation - logout: () => Promise< void >; // Adjust based on the actual implementation + authenticate: () => void; + logout: () => Promise< void >; user?: { id: number; email: string; displayName: string }; } @@ -23,11 +26,6 @@ interface AuthProviderProps { children: ReactNode; } -interface WpcomParams extends Record< string, unknown > { - query?: string; - apiNamespace?: string; -} - export const AuthContext = createContext< AuthContextType >( { client: undefined, isAuthenticated: false, @@ -38,35 +36,25 @@ export const AuthContext = createContext< AuthContextType >( { } ); const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { - const [ isAuthenticated, setIsAuthenticated ] = useState( false ); - const [ client, setClient ] = useState< WPCOM | undefined >( undefined ); - const [ user, setUser ] = useState< AuthContextType[ 'user' ] >( undefined ); + const dispatch = useAppDispatch(); const locale = useI18nLocale(); const { __ } = useI18n(); const isOffline = useOffline(); + const isAuthenticated = useRootSelector( selectIsAuthenticated ); + const user = useRootSelector( selectUser ); + const authenticate = useCallback( () => getIpcApi().authenticate(), [] ); - const handleInvalidToken = useCallback( async () => { - try { - void getIpcApi().logRendererMessage( 'info', 'Detected invalid token. Logging out.' ); - await getIpcApi().clearAuthenticationToken(); - setIsAuthenticated( false ); - setClient( undefined ); - setWpcomClient( undefined ); - setUser( undefined ); - } catch ( err ) { - console.error( 'Failed to handle invalid token:', err ); - Sentry.captureException( err ); - } - }, [] ); + useEffect( () => { + void dispatch( initializeAuth( { locale } ) ); + }, [ dispatch, locale ] ); useIpcListener( 'auth-updated', ( _event, payload ) => { if ( 'error' in payload ) { let title: string = __( 'Authentication error' ); let message: string = __( 'Please try again.' ); - // User has denied access to the authorization dialog. if ( payload.error instanceof Error && payload.error.message.includes( 'access_denied' ) ) { title = __( 'Authorization denied' ); message = __( @@ -78,146 +66,25 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { return; } - const { token } = payload; - const newClient = createWpcomClient( token.accessToken, locale, handleInvalidToken ); - - setIsAuthenticated( true ); - setClient( newClient ); - setWpcomClient( newClient ); - setUser( { - id: token.id, - email: token.email, - displayName: token.displayName || '', - } ); + void dispatch( authTokenReceived( { token: payload.token, locale } ) ); } ); const logout = useCallback( async () => { - if ( ! isOffline && client ) { - try { - await client.req.del( { - apiNamespace: 'wpcom/v2', - path: '/studio-app/token', - // wpcom.req.del defaults to POST; explicitly send HTTP DELETE for v2 - method: 'DELETE', - } ); - console.log( 'Token revoked' ); - } catch ( err ) { - console.error( 'Failed to revoke token:', err ); - Sentry.captureException( err ); - } - } else if ( isOffline ) { - console.log( 'Offline: Skipping token revocation request' ); - } - - try { - await getIpcApi().clearAuthenticationToken(); - setIsAuthenticated( false ); - setClient( undefined ); - setWpcomClient( undefined ); - setUser( undefined ); - } catch ( err ) { - console.error( err ); - Sentry.captureException( err ); - } - }, [ client, isOffline ] ); - - useEffect( () => { - async function run() { - try { - const token = await getIpcApi().getAuthenticationToken(); - - if ( ! token ) { - setIsAuthenticated( false ); - return; - } - - const newClient = createWpcomClient( token.accessToken, locale, handleInvalidToken ); + await dispatch( authLogout( { isOffline } ) ); + }, [ dispatch, isOffline ] ); - setIsAuthenticated( true ); - setClient( newClient ); - setWpcomClient( newClient ); - setUser( { - id: token.id, - email: token.email, - displayName: token.displayName || '', - } ); - } catch ( err ) { - console.error( err ); - Sentry.captureException( err ); - } - } - void run(); - }, [ locale, handleInvalidToken ] ); - - // Memoize the context value to avoid unnecessary renders const contextValue: AuthContextType = useMemo( () => ( { - client, + client: getWpcomClient(), isAuthenticated, authenticate, logout, user, } ), - [ client, isAuthenticated, authenticate, logout, user ] + [ isAuthenticated, authenticate, logout, user ] ); return { children }; }; -function createWpcomClient( - token?: string, - locale?: string, - onInvalidToken?: () => Promise< void > -): WPCOM { - let isAuthErrorDialogOpen = false; - const handleInvalidTokenError = async ( response: unknown ) => { - if ( isInvalidTokenError( response ) && onInvalidToken && ! isAuthErrorDialogOpen ) { - isAuthErrorDialogOpen = true; - await onInvalidToken(); - await getIpcApi().showMessageBox( { - type: 'error', - message: 'Session Expired', - detail: 'Your session has expired. Please log in again.', - } ); - isAuthErrorDialogOpen = false; - } - }; - - const addLocaleToParams = ( params: WpcomParams ) => { - if ( locale && locale !== 'en' ) { - const queryParams = new URLSearchParams( - 'query' in params && typeof params.query === 'string' ? params.query : '' - ); - const localeParamName = - 'apiNamespace' in params && typeof params.apiNamespace === 'string' ? '_locale' : 'locale'; - queryParams.set( localeParamName, locale ); - - Object.assign( params, { - query: queryParams.toString(), - } ); - } - return params; - }; - - // Wrap the request handler to add locale and error handling before passing to wpcomFactory - const wrappedRequestHandler = ( - params: object, - callback: ( err: unknown, response?: unknown, headers?: unknown ) => void - ) => { - const modifiedParams = addLocaleToParams( params as WpcomParams ); - const wrappedCallback = ( err: unknown, response: unknown, headers: unknown ) => { - if ( err ) { - void handleInvalidTokenError( err ); - } - if ( typeof callback === 'function' ) { - callback( err, response, headers ); - } - }; - - return wpcomXhrRequest( modifiedParams, wrappedCallback ); - }; - - return wpcomFactory( token, wrappedRequestHandler ); -} - export default AuthProvider; diff --git a/apps/studio/src/hooks/use-auth.ts b/apps/studio/src/hooks/use-auth.ts index 6ed814d1d5..02009d4200 100644 --- a/apps/studio/src/hooks/use-auth.ts +++ b/apps/studio/src/hooks/use-auth.ts @@ -1,12 +1,23 @@ -import { useContext } from 'react'; -import { AuthContext, type AuthContextType } from 'src/components/auth-provider'; +import { useCallback } from 'react'; +import { useOffline } from 'src/hooks/use-offline'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useAppDispatch, useRootSelector } from 'src/stores'; +import { authLogout, selectIsAuthenticated, selectUser } from 'src/stores/auth-slice'; +import { getWpcomClient } from 'src/stores/wpcom-api'; +import type { AuthContextType } from 'src/components/auth-provider'; export const useAuth = (): AuthContextType => { - const context = useContext( AuthContext ); + const dispatch = useAppDispatch(); + const isOffline = useOffline(); - if ( ! context ) { - throw new Error( 'useAuth must be used within an AuthProvider' ); - } + const isAuthenticated = useRootSelector( selectIsAuthenticated ); + const user = useRootSelector( selectUser ); + const client = getWpcomClient(); - return context; + const authenticate = useCallback( () => getIpcApi().authenticate(), [] ); + const logout = useCallback( async () => { + await dispatch( authLogout( { isOffline } ) ); + }, [ dispatch, isOffline ] ); + + return { isAuthenticated, user, client, authenticate, logout }; }; diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts new file mode 100644 index 0000000000..88abe590ce --- /dev/null +++ b/apps/studio/src/stores/auth-slice.ts @@ -0,0 +1,196 @@ +import { createAsyncThunk, createSlice, isAnyOf } from '@reduxjs/toolkit'; +import * as Sentry from '@sentry/electron/renderer'; +import wpcomFactory from '@studio/common/lib/wpcom-factory'; +import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { isInvalidTokenError } from 'src/lib/is-invalid-oauth-token-error'; +import { store, RootState } from 'src/stores'; +import { getWpcomClient, setWpcomClient } from 'src/stores/wpcom-api'; +import type { StoredToken } from 'src/lib/oauth'; +import type { WPCOM } from 'wpcom/types'; + +interface WpcomParams extends Record< string, unknown > { + query?: string; + apiNamespace?: string; +} + +export type AuthUser = { id: number; email: string; displayName: string }; + +type AuthState = { + isAuthenticated: boolean; + user: AuthUser | null; +}; + +const initialState: AuthState = { + isAuthenticated: false, + user: null, +}; + +export function createWpcomClient( + token?: string, + locale?: string, + onInvalidToken?: () => void +): WPCOM { + let isAuthErrorDialogOpen = false; + const handleInvalidTokenError = async ( response: unknown ) => { + if ( isInvalidTokenError( response ) && onInvalidToken && ! isAuthErrorDialogOpen ) { + isAuthErrorDialogOpen = true; + onInvalidToken(); + await getIpcApi().showMessageBox( { + type: 'error', + message: 'Session Expired', + detail: 'Your session has expired. Please log in again.', + } ); + isAuthErrorDialogOpen = false; + } + }; + + const addLocaleToParams = ( params: WpcomParams ) => { + if ( locale && locale !== 'en' ) { + const queryParams = new URLSearchParams( + 'query' in params && typeof params.query === 'string' ? params.query : '' + ); + const localeParamName = + 'apiNamespace' in params && typeof params.apiNamespace === 'string' ? '_locale' : 'locale'; + queryParams.set( localeParamName, locale ); + + Object.assign( params, { + query: queryParams.toString(), + } ); + } + return params; + }; + + const wrappedRequestHandler = ( + params: object, + callback: ( err: unknown, response?: unknown, headers?: unknown ) => void + ) => { + const modifiedParams = addLocaleToParams( params as WpcomParams ); + const wrappedCallback = ( err: unknown, response: unknown, headers: unknown ) => { + if ( err ) { + void handleInvalidTokenError( err ); + } + if ( typeof callback === 'function' ) { + callback( err, response, headers ); + } + }; + + return wpcomXhrRequest( modifiedParams, wrappedCallback ); + }; + + return wpcomFactory( token, wrappedRequestHandler ); +} + +const createTypedAsyncThunk = createAsyncThunk.withTypes< { state: RootState } >(); + +export const handleInvalidToken = createTypedAsyncThunk( 'auth/handleInvalidToken', async () => { + try { + void getIpcApi().logRendererMessage( 'info', 'Detected invalid token. Logging out.' ); + await getIpcApi().clearAuthenticationToken(); + setWpcomClient( undefined ); + } catch ( err ) { + console.error( 'Failed to handle invalid token:', err ); + Sentry.captureException( err ); + } +} ); + +export const initializeAuth = createTypedAsyncThunk( + 'auth/initialize', + async ( { locale }: { locale?: string } ) => { + try { + const token = await getIpcApi().getAuthenticationToken(); + + if ( ! token ) { + return null; + } + + const client = createWpcomClient( token.accessToken, locale, () => + store.dispatch( handleInvalidToken() ) + ); + setWpcomClient( client ); + + return { + id: token.id, + email: token.email, + displayName: token.displayName || '', + }; + } catch ( err ) { + console.error( err ); + Sentry.captureException( err ); + return null; + } + } +); + +export const authTokenReceived = createTypedAsyncThunk( + 'auth/tokenReceived', + async ( { token, locale }: { token: StoredToken; locale?: string } ) => { + const client = createWpcomClient( token.accessToken, locale, () => + store.dispatch( handleInvalidToken() ) + ); + setWpcomClient( client ); + + return { + id: token.id, + email: token.email, + displayName: token.displayName || '', + }; + } +); + +export const authLogout = createTypedAsyncThunk( + 'auth/logout', + async ( { isOffline }: { isOffline: boolean } ) => { + const client = getWpcomClient(); + + if ( ! isOffline && client ) { + try { + await client.req.del( { + apiNamespace: 'wpcom/v2', + path: '/studio-app/token', + method: 'DELETE', + } ); + console.log( 'Token revoked' ); + } catch ( err ) { + console.error( 'Failed to revoke token:', err ); + Sentry.captureException( err ); + } + } else if ( isOffline ) { + console.log( 'Offline: Skipping token revocation request' ); + } + + try { + await getIpcApi().clearAuthenticationToken(); + setWpcomClient( undefined ); + } catch ( err ) { + console.error( err ); + Sentry.captureException( err ); + } + } +); + +const authSlice = createSlice( { + name: 'auth', + initialState, + reducers: {}, + extraReducers: ( builder ) => { + builder + .addCase( initializeAuth.fulfilled, ( state, action ) => { + state.isAuthenticated = !! action.payload; + state.user = action.payload ?? null; + } ) + .addCase( authTokenReceived.fulfilled, ( state, action ) => { + state.isAuthenticated = true; + state.user = action.payload; + } ) + .addMatcher( isAnyOf( handleInvalidToken.fulfilled, authLogout.fulfilled ), ( state ) => { + state.isAuthenticated = false; + state.user = null; + } ); + }, +} ); + +export const selectIsAuthenticated = ( state: RootState ) => state.auth.isAuthenticated; +export const selectUser = ( state: RootState ) => state.auth.user ?? undefined; + +export default authSlice.reducer; diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 1c23ef38ae..302d6daa5c 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -4,6 +4,7 @@ import { createListenerMiddleware, isAnyOf, } from '@reduxjs/toolkit'; +import authReducer from 'src/stores/auth-slice'; import { setupListeners } from '@reduxjs/toolkit/query'; import { useDispatch, useSelector } from 'react-redux'; import { LOCAL_STORAGE_CHAT_API_IDS_KEY, LOCAL_STORAGE_CHAT_MESSAGES_KEY } from 'src/constants'; @@ -40,6 +41,7 @@ import { wordpressVersionsApi } from './wordpress-versions-api'; import type { SupportedLocale } from '@studio/common/lib/locale'; export type RootState = { + auth: ReturnType< typeof authReducer >; appVersionApi: ReturnType< typeof appVersionApi.reducer >; betaFeatures: ReturnType< typeof betaFeaturesReducer >; chat: ReturnType< typeof chatReducer >; @@ -323,6 +325,7 @@ startAppListening( { } ); export const rootReducer = combineReducers( { + auth: authReducer, appVersionApi: appVersionApi.reducer, betaFeatures: betaFeaturesReducer, chat: chatReducer, From 598c6543630af40c7fe455c0a240fba8ace484f1 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 12 Mar 2026 14:24:38 +0100 Subject: [PATCH 02/26] Solve linter --- apps/studio/src/stores/index.ts | 2 +- apps/studio/src/stores/provider-constants-slice.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 302d6daa5c..a99e268105 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -4,7 +4,6 @@ import { createListenerMiddleware, isAnyOf, } from '@reduxjs/toolkit'; -import authReducer from 'src/stores/auth-slice'; import { setupListeners } from '@reduxjs/toolkit/query'; import { useDispatch, useSelector } from 'react-redux'; import { LOCAL_STORAGE_CHAT_API_IDS_KEY, LOCAL_STORAGE_CHAT_MESSAGES_KEY } from 'src/constants'; @@ -15,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 authReducer from 'src/stores/auth-slice'; 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'; diff --git a/apps/studio/src/stores/provider-constants-slice.ts b/apps/studio/src/stores/provider-constants-slice.ts index 01c473ec16..f8487dcee2 100644 --- a/apps/studio/src/stores/provider-constants-slice.ts +++ b/apps/studio/src/stores/provider-constants-slice.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; import { DEFAULT_PHP_VERSION, DEFAULT_WORDPRESS_VERSION, From f5d9d570fbc2c9b8def02ffc6091485e80706200 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 12 Mar 2026 15:05:02 +0100 Subject: [PATCH 03/26] Fix tests --- .../components/tests/content-tab-assistant.test.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/studio/src/components/tests/content-tab-assistant.test.tsx b/apps/studio/src/components/tests/content-tab-assistant.test.tsx index 6d5251cbfa..256f8082d2 100644 --- a/apps/studio/src/components/tests/content-tab-assistant.test.tsx +++ b/apps/studio/src/components/tests/content-tab-assistant.test.tsx @@ -7,12 +7,13 @@ import nock from 'nock'; import { Provider } from 'react-redux'; import { Dispatch } from 'redux'; import { vi } from 'vitest'; -import { AuthContext, AuthContextType } from 'src/components/auth-provider'; +import { AuthContextType } from 'src/components/auth-provider'; import { ContentTabAssistant, MIMIC_CONVERSATION_DELAY, } from 'src/components/content-tab-assistant'; import { LOCAL_STORAGE_CHAT_MESSAGES_KEY, CLEAR_HISTORY_REMINDER_TIME } from 'src/constants'; +import { useAuth } from 'src/hooks/use-auth'; import { useGetWpVersion } from 'src/hooks/use-get-wp-version'; import { useOffline } from 'src/hooks/use-offline'; import { ThemeDetailsProvider } from 'src/hooks/use-theme-details'; @@ -28,6 +29,7 @@ store.replaceReducer( testReducer ); vi.mock( 'src/hooks/use-offline' ); vi.mock( 'src/lib/get-ipc-api' ); vi.mock( 'src/hooks/use-get-wp-version' ); +vi.mock( 'src/hooks/use-auth' ); vi.mock( 'src/lib/app-globals', () => ( { getAppGlobals: () => ( { @@ -129,14 +131,13 @@ describe( 'ContentTabAssistant', () => { logout, ...auth, }; + vi.mocked( useAuth ).mockReturnValue( authContextValue ); return ( - - - - - + + + ); }; From 81cd242effe9da534b7753f7d77cac15fc9fb10d Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 12 Mar 2026 15:44:22 +0100 Subject: [PATCH 04/26] Clean up duplicate callbacks --- apps/studio/src/hooks/use-auth.ts | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/apps/studio/src/hooks/use-auth.ts b/apps/studio/src/hooks/use-auth.ts index 02009d4200..6ed814d1d5 100644 --- a/apps/studio/src/hooks/use-auth.ts +++ b/apps/studio/src/hooks/use-auth.ts @@ -1,23 +1,12 @@ -import { useCallback } from 'react'; -import { useOffline } from 'src/hooks/use-offline'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { useAppDispatch, useRootSelector } from 'src/stores'; -import { authLogout, selectIsAuthenticated, selectUser } from 'src/stores/auth-slice'; -import { getWpcomClient } from 'src/stores/wpcom-api'; -import type { AuthContextType } from 'src/components/auth-provider'; +import { useContext } from 'react'; +import { AuthContext, type AuthContextType } from 'src/components/auth-provider'; export const useAuth = (): AuthContextType => { - const dispatch = useAppDispatch(); - const isOffline = useOffline(); + const context = useContext( AuthContext ); - const isAuthenticated = useRootSelector( selectIsAuthenticated ); - const user = useRootSelector( selectUser ); - const client = getWpcomClient(); + if ( ! context ) { + throw new Error( 'useAuth must be used within an AuthProvider' ); + } - const authenticate = useCallback( () => getIpcApi().authenticate(), [] ); - const logout = useCallback( async () => { - await dispatch( authLogout( { isOffline } ) ); - }, [ dispatch, isOffline ] ); - - return { isAuthenticated, user, client, authenticate, logout }; + return context; }; From 149709eadd181120207e5828c390f5519a0d3898 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 12 Mar 2026 15:55:41 +0100 Subject: [PATCH 05/26] Fix unncessary export --- apps/studio/src/stores/auth-slice.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index 88abe590ce..65cfa490e9 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -26,7 +26,7 @@ const initialState: AuthState = { user: null, }; -export function createWpcomClient( +function createWpcomClient( token?: string, locale?: string, onInvalidToken?: () => void From d342280dba1a9518e9f9fbcc87b33b658fcb6de1 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 12 Mar 2026 15:56:30 +0100 Subject: [PATCH 06/26] Fix linter --- apps/studio/src/stores/auth-slice.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index 65cfa490e9..59f7fe020d 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -26,11 +26,7 @@ const initialState: AuthState = { user: null, }; -function createWpcomClient( - token?: string, - locale?: string, - onInvalidToken?: () => void -): WPCOM { +function createWpcomClient( token?: string, locale?: string, onInvalidToken?: () => void ): WPCOM { let isAuthErrorDialogOpen = false; const handleInvalidTokenError = async ( response: unknown ) => { if ( isInvalidTokenError( response ) && onInvalidToken && ! isAuthErrorDialogOpen ) { From 4e8d6d50d19e8a63bc41ddf4f7453b67cff1b897 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 19 Mar 2026 15:25:51 +0100 Subject: [PATCH 07/26] Adjust implementation --- apps/studio/src/components/app.tsx | 2 + apps/studio/src/components/auth-provider.tsx | 90 ------------------- apps/studio/src/components/root.tsx | 29 +++--- .../tests/content-tab-assistant.test.tsx | 3 +- apps/studio/src/hooks/use-auth-init.ts | 35 ++++++++ apps/studio/src/hooks/use-auth.ts | 39 ++++++-- .../components/create-preview-button.tsx | 2 +- apps/studio/src/stores/auth-slice.ts | 2 +- apps/studio/src/stores/index.ts | 3 +- apps/studio/src/stores/sync/wpcom-sites.ts | 2 +- apps/studio/src/stores/wpcom-api.ts | 17 +--- apps/studio/src/stores/wpcom-client.ts | 11 +++ 12 files changed, 103 insertions(+), 132 deletions(-) delete mode 100644 apps/studio/src/components/auth-provider.tsx create mode 100644 apps/studio/src/hooks/use-auth-init.ts create mode 100644 apps/studio/src/stores/wpcom-client.ts diff --git a/apps/studio/src/components/app.tsx b/apps/studio/src/components/app.tsx index 6683acda5c..adc80b8cf2 100644 --- a/apps/studio/src/components/app.tsx +++ b/apps/studio/src/components/app.tsx @@ -11,6 +11,7 @@ import TopBar from 'src/components/top-bar'; import WindowsTitlebar from 'src/components/windows-titlebar'; import { useListenDeepLinkConnection } from 'src/hooks/sync-sites/use-listen-deep-link-connection'; import { useAuth } from 'src/hooks/use-auth'; +import { useAuthInit } from 'src/hooks/use-auth-init'; import { useLocalizationSupport } from 'src/hooks/use-localization-support'; import { useSidebarVisibility } from 'src/hooks/use-sidebar-visibility'; import { useSiteDetails } from 'src/hooks/use-site-details'; @@ -27,6 +28,7 @@ import { syncOperationsThunks } from 'src/stores/sync'; import 'src/index.css'; export default function App() { + useAuthInit(); useLocalizationSupport(); const { needsOnboarding } = useOnboarding(); const isOnboardingLoading = useRootSelector( selectOnboardingLoading ); diff --git a/apps/studio/src/components/auth-provider.tsx b/apps/studio/src/components/auth-provider.tsx deleted file mode 100644 index c0924b5e3a..0000000000 --- a/apps/studio/src/components/auth-provider.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useI18n } from '@wordpress/react-i18n'; -import { createContext, useCallback, useEffect, useMemo, ReactNode } from 'react'; -import { useIpcListener } from 'src/hooks/use-ipc-listener'; -import { useOffline } from 'src/hooks/use-offline'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { useAppDispatch, useRootSelector, useI18nLocale } from 'src/stores'; -import { - authLogout, - authTokenReceived, - initializeAuth, - selectIsAuthenticated, - selectUser, -} from 'src/stores/auth-slice'; -import { getWpcomClient } from 'src/stores/wpcom-api'; -import type { WPCOM } from 'wpcom/types'; - -export interface AuthContextType { - client: WPCOM | undefined; - isAuthenticated: boolean; - authenticate: () => void; - logout: () => Promise< void >; - user?: { id: number; email: string; displayName: string }; -} - -interface AuthProviderProps { - children: ReactNode; -} - -export const AuthContext = createContext< AuthContextType >( { - client: undefined, - isAuthenticated: false, - authenticate: () => { - // Placeholder for authenticate logic. Just to avoid lint error - }, - logout: () => Promise.resolve(), -} ); - -const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { - const dispatch = useAppDispatch(); - const locale = useI18nLocale(); - const { __ } = useI18n(); - const isOffline = useOffline(); - - const isAuthenticated = useRootSelector( selectIsAuthenticated ); - const user = useRootSelector( selectUser ); - - const authenticate = useCallback( () => getIpcApi().authenticate(), [] ); - - useEffect( () => { - void dispatch( initializeAuth( { locale } ) ); - }, [ dispatch, locale ] ); - - useIpcListener( 'auth-updated', ( _event, payload ) => { - if ( 'error' in payload ) { - let title: string = __( 'Authentication error' ); - let message: string = __( 'Please try again.' ); - - if ( payload.error instanceof Error && payload.error.message.includes( 'access_denied' ) ) { - title = __( 'Authorization denied' ); - message = __( - 'It looks like you denied the authorization request. To proceed, please click "Approve"' - ); - } - - void getIpcApi().showErrorMessageBox( { title, message } ); - return; - } - - void dispatch( authTokenReceived( { token: payload.token, locale } ) ); - } ); - - const logout = useCallback( async () => { - await dispatch( authLogout( { isOffline } ) ); - }, [ dispatch, isOffline ] ); - - const contextValue: AuthContextType = useMemo( - () => ( { - client: getWpcomClient(), - isAuthenticated, - authenticate, - logout, - user, - } ), - [ isAuthenticated, authenticate, logout, user ] - ); - - return { children }; -}; - -export default AuthProvider; diff --git a/apps/studio/src/components/root.tsx b/apps/studio/src/components/root.tsx index d23e6b0959..e3e6bced51 100644 --- a/apps/studio/src/components/root.tsx +++ b/apps/studio/src/components/root.tsx @@ -5,7 +5,6 @@ import { I18nProvider } from '@wordpress/react-i18n'; import { useEffect } from 'react'; import { Provider as ReduxProvider } from 'react-redux'; import App from 'src/components/app'; -import AuthProvider from 'src/components/auth-provider'; import CrashTester from 'src/components/crash-tester'; import ErrorBoundary from 'src/components/error-boundary'; import { WordPressStyles } from 'src/components/wordpress-styles'; @@ -35,21 +34,19 @@ const Root = () => { - - - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/apps/studio/src/components/tests/content-tab-assistant.test.tsx b/apps/studio/src/components/tests/content-tab-assistant.test.tsx index 256f8082d2..4d6b3a2f5f 100644 --- a/apps/studio/src/components/tests/content-tab-assistant.test.tsx +++ b/apps/studio/src/components/tests/content-tab-assistant.test.tsx @@ -7,13 +7,12 @@ import nock from 'nock'; import { Provider } from 'react-redux'; import { Dispatch } from 'redux'; import { vi } from 'vitest'; -import { AuthContextType } from 'src/components/auth-provider'; import { ContentTabAssistant, MIMIC_CONVERSATION_DELAY, } from 'src/components/content-tab-assistant'; import { LOCAL_STORAGE_CHAT_MESSAGES_KEY, CLEAR_HISTORY_REMINDER_TIME } from 'src/constants'; -import { useAuth } from 'src/hooks/use-auth'; +import { AuthContextType, useAuth } from 'src/hooks/use-auth'; import { useGetWpVersion } from 'src/hooks/use-get-wp-version'; import { useOffline } from 'src/hooks/use-offline'; import { ThemeDetailsProvider } from 'src/hooks/use-theme-details'; diff --git a/apps/studio/src/hooks/use-auth-init.ts b/apps/studio/src/hooks/use-auth-init.ts new file mode 100644 index 0000000000..396ebf5c9f --- /dev/null +++ b/apps/studio/src/hooks/use-auth-init.ts @@ -0,0 +1,35 @@ +import { useI18n } from '@wordpress/react-i18n'; +import { useEffect } from 'react'; +import { useIpcListener } from 'src/hooks/use-ipc-listener'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useAppDispatch, useI18nLocale } from 'src/stores'; +import { authTokenReceived, initializeAuth } from 'src/stores/auth-slice'; + +export function useAuthInit() { + const dispatch = useAppDispatch(); + const locale = useI18nLocale(); + const { __ } = useI18n(); + + useEffect( () => { + void dispatch( initializeAuth( { locale } ) ); + }, [ dispatch, locale ] ); + + useIpcListener( 'auth-updated', ( _event, payload ) => { + if ( 'error' in payload ) { + let title: string = __( 'Authentication error' ); + let message: string = __( 'Please try again.' ); + + if ( payload.error instanceof Error && payload.error.message.includes( 'access_denied' ) ) { + title = __( 'Authorization denied' ); + message = __( + 'It looks like you denied the authorization request. To proceed, please click "Approve"' + ); + } + + void getIpcApi().showErrorMessageBox( { title, message } ); + return; + } + + void dispatch( authTokenReceived( { token: payload.token, locale } ) ); + } ); +} diff --git a/apps/studio/src/hooks/use-auth.ts b/apps/studio/src/hooks/use-auth.ts index 6ed814d1d5..686543bb7f 100644 --- a/apps/studio/src/hooks/use-auth.ts +++ b/apps/studio/src/hooks/use-auth.ts @@ -1,12 +1,37 @@ -import { useContext } from 'react'; -import { AuthContext, type AuthContextType } from 'src/components/auth-provider'; +import { useCallback } from 'react'; +import { useOffline } from 'src/hooks/use-offline'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useAppDispatch, useRootSelector } from 'src/stores'; +import { authLogout, selectIsAuthenticated, selectUser } from 'src/stores/auth-slice'; +import { getWpcomClient } from 'src/stores/wpcom-client'; +import type { AuthUser } from 'src/stores/auth-slice'; +import type { WPCOM } from 'wpcom/types'; + +export interface AuthContextType { + client: WPCOM | undefined; + isAuthenticated: boolean; + authenticate: () => void; + logout: () => Promise< void >; + user?: AuthUser; +} export const useAuth = (): AuthContextType => { - const context = useContext( AuthContext ); + const dispatch = useAppDispatch(); + const isOffline = useOffline(); + const isAuthenticated = useRootSelector( selectIsAuthenticated ); + const user = useRootSelector( selectUser ); + + const authenticate = useCallback( () => getIpcApi().authenticate(), [] ); - if ( ! context ) { - throw new Error( 'useAuth must be used within an AuthProvider' ); - } + const logout = useCallback( async () => { + await dispatch( authLogout( { isOffline } ) ); + }, [ dispatch, isOffline ] ); - return context; + return { + client: isAuthenticated ? getWpcomClient() : undefined, + isAuthenticated, + authenticate, + logout, + user, + }; }; diff --git a/apps/studio/src/modules/preview-site/components/create-preview-button.tsx b/apps/studio/src/modules/preview-site/components/create-preview-button.tsx index 9b8cf12ccd..3bdd3972f9 100644 --- a/apps/studio/src/modules/preview-site/components/create-preview-button.tsx +++ b/apps/studio/src/modules/preview-site/components/create-preview-button.tsx @@ -1,10 +1,10 @@ import { DEMO_SITE_SIZE_LIMIT_GB } from '@studio/common/constants'; import { __, sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; -import { AuthContextType } from 'src/components/auth-provider'; import Button from 'src/components/button'; import offlineIcon from 'src/components/offline-icon'; import { Tooltip } from 'src/components/tooltip'; +import { AuthContextType } from 'src/hooks/use-auth'; import { useGetWpVersion } from 'src/hooks/use-get-wp-version'; import { useOffline } from 'src/hooks/use-offline'; import { useSiteSize } from 'src/hooks/use-site-size'; diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index 59f7fe020d..cba3988f3e 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -5,7 +5,7 @@ import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { isInvalidTokenError } from 'src/lib/is-invalid-oauth-token-error'; import { store, RootState } from 'src/stores'; -import { getWpcomClient, setWpcomClient } from 'src/stores/wpcom-api'; +import { getWpcomClient, setWpcomClient } from 'src/stores/wpcom-client'; import type { StoredToken } from 'src/lib/oauth'; import type { WPCOM } from 'wpcom/types'; diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 33670eebc8..13a434c35a 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -35,7 +35,8 @@ import { } from 'src/stores/sync/sync-operations-slice'; 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 { wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import { wordpressVersionsApi } from './wordpress-versions-api'; import type { SupportedLocale } from '@studio/common/lib/locale'; diff --git a/apps/studio/src/stores/sync/wpcom-sites.ts b/apps/studio/src/stores/sync/wpcom-sites.ts index 9d1f634b56..c7fd2f0048 100644 --- a/apps/studio/src/stores/sync/wpcom-sites.ts +++ b/apps/studio/src/stores/sync/wpcom-sites.ts @@ -5,7 +5,7 @@ import { getIpcApi } from 'src/lib/get-ipc-api'; import { reconcileConnectedSites } from 'src/modules/sync/lib/reconcile-connected-sites'; import { getSyncSupport, isPressableSite } from 'src/modules/sync/lib/sync-support'; import { withOfflineCheck } from 'src/stores/utils/with-offline-check'; -import { getWpcomClient } from 'src/stores/wpcom-api'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import type { SyncSite, SyncSupport } from 'src/modules/sync/types'; // Schema for WordPress.com sites endpoint diff --git a/apps/studio/src/stores/wpcom-api.ts b/apps/studio/src/stores/wpcom-api.ts index 176f12f16f..cfb6f582ae 100644 --- a/apps/studio/src/stores/wpcom-api.ts +++ b/apps/studio/src/stores/wpcom-api.ts @@ -5,8 +5,8 @@ import wpcomFactory from '@studio/common/lib/wpcom-factory'; import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory'; import { z } from 'zod'; import { withOfflineCheck, withOfflineCheckMutation } from 'src/stores/utils/with-offline-check'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import type { BaseQueryFn, FetchBaseQueryError } from '@reduxjs/toolkit/query'; -import type { WPCOM } from 'wpcom/types'; const welcomeMessageSchema = z.object( { messages: z.array( z.string() ), @@ -70,24 +70,15 @@ const blueprintSchema = z.object( { export type Blueprint = z.infer< typeof blueprintSchema >; -let wpcomClient: WPCOM | undefined; const publicWpcomClient = wpcomFactory( wpcomXhrRequest ); -export const setWpcomClient = ( client: WPCOM | undefined ) => { - wpcomClient = client; -}; - -export const getWpcomClient = (): WPCOM | undefined => { - return wpcomClient; -}; - const wpcomBaseQuery: BaseQueryFn< { path: string; apiNamespace?: string }, unknown, FetchBaseQueryError > = async ( args ) => { try { - const response = await wpcomClient!.req.get( args ); + const response = await getWpcomClient()!.req.get( args ); return { data: response }; } catch ( error ) { return { @@ -247,7 +238,7 @@ function withWpcomClientCheck< TResult, TArg >( return ( arg, options = {} ) => { return useQueryHook( arg, { ...options, - skip: ! wpcomClient || options?.skip, + skip: ! getWpcomClient() || options?.skip, } ); }; } @@ -258,7 +249,7 @@ function withWpcomClientCheckMutation< TResult, TArg >( return ( options = {} ) => { const [ trigger, result ] = useMutationHook( options ); const wrappedTrigger = ( ( ...args: Parameters< typeof trigger > ) => { - if ( ! wpcomClient ) { + if ( ! getWpcomClient() ) { return Promise.reject( new Error( 'Not authenticated' ) ) as ReturnType< typeof trigger >; } return trigger( ...args ); diff --git a/apps/studio/src/stores/wpcom-client.ts b/apps/studio/src/stores/wpcom-client.ts new file mode 100644 index 0000000000..9f8fe34b43 --- /dev/null +++ b/apps/studio/src/stores/wpcom-client.ts @@ -0,0 +1,11 @@ +import type { WPCOM } from 'wpcom/types'; + +let wpcomClient: WPCOM | undefined; + +export const setWpcomClient = ( client: WPCOM | undefined ) => { + wpcomClient = client; +}; + +export const getWpcomClient = (): WPCOM | undefined => { + return wpcomClient; +}; From b6bc9fb9afe284025585c7a9403023dfa366bdd0 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 19 Mar 2026 15:28:39 +0100 Subject: [PATCH 08/26] Initialize locale --- apps/studio/src/hooks/use-auth-init.ts | 7 +------ apps/studio/src/stores/index.ts | 13 +++++++++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/studio/src/hooks/use-auth-init.ts b/apps/studio/src/hooks/use-auth-init.ts index 396ebf5c9f..5c263890d2 100644 --- a/apps/studio/src/hooks/use-auth-init.ts +++ b/apps/studio/src/hooks/use-auth-init.ts @@ -1,19 +1,14 @@ import { useI18n } from '@wordpress/react-i18n'; -import { useEffect } from 'react'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { useAppDispatch, useI18nLocale } from 'src/stores'; -import { authTokenReceived, initializeAuth } from 'src/stores/auth-slice'; +import { authTokenReceived } from 'src/stores/auth-slice'; export function useAuthInit() { const dispatch = useAppDispatch(); const locale = useI18nLocale(); const { __ } = useI18n(); - useEffect( () => { - void dispatch( initializeAuth( { locale } ) ); - }, [ dispatch, locale ] ); - useIpcListener( 'auth-updated', ( _event, payload ) => { if ( 'error' in payload ) { let title: string = __( 'Authentication error' ); diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 13a434c35a..f1d753f73b 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -14,11 +14,11 @@ 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 authReducer from 'src/stores/auth-slice'; +import authReducer, { initializeAuth } from 'src/stores/auth-slice'; 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'; -import i18nReducer from 'src/stores/i18n-slice'; +import i18nReducer, { initializeUserLocale } from 'src/stores/i18n-slice'; import { installedAppsApi } from 'src/stores/installed-apps-api'; import onboardingReducer from 'src/stores/onboarding-slice'; import { @@ -293,6 +293,15 @@ async function startPullPoller( selectedSiteId: string, remoteSiteId: number ) { } } +// Initialize auth once locale is loaded +startAppListening( { + actionCreator: initializeUserLocale.fulfilled, + effect( action, listenerApi ) { + const { locale } = listenerApi.getState().i18n; + void listenerApi.dispatch( initializeAuth( { locale } ) ); + }, +} ); + // Poll push progress when state enters a pollable status startAppListening( { actionCreator: syncOperationsActions.updatePushState, From fe87aadcd851e98288a5e3a5530c93c489da3850 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 19 Mar 2026 15:34:28 +0100 Subject: [PATCH 09/26] Futher adjustments --- apps/studio/src/components/app.tsx | 2 -- apps/studio/src/hooks/use-auth-init.ts | 30 -------------------------- apps/studio/src/stores/auth-slice.ts | 23 ++++++++++++++++++++ apps/studio/src/stores/index.ts | 3 ++- 4 files changed, 25 insertions(+), 33 deletions(-) delete mode 100644 apps/studio/src/hooks/use-auth-init.ts diff --git a/apps/studio/src/components/app.tsx b/apps/studio/src/components/app.tsx index adc80b8cf2..6683acda5c 100644 --- a/apps/studio/src/components/app.tsx +++ b/apps/studio/src/components/app.tsx @@ -11,7 +11,6 @@ import TopBar from 'src/components/top-bar'; import WindowsTitlebar from 'src/components/windows-titlebar'; import { useListenDeepLinkConnection } from 'src/hooks/sync-sites/use-listen-deep-link-connection'; import { useAuth } from 'src/hooks/use-auth'; -import { useAuthInit } from 'src/hooks/use-auth-init'; import { useLocalizationSupport } from 'src/hooks/use-localization-support'; import { useSidebarVisibility } from 'src/hooks/use-sidebar-visibility'; import { useSiteDetails } from 'src/hooks/use-site-details'; @@ -28,7 +27,6 @@ import { syncOperationsThunks } from 'src/stores/sync'; import 'src/index.css'; export default function App() { - useAuthInit(); useLocalizationSupport(); const { needsOnboarding } = useOnboarding(); const isOnboardingLoading = useRootSelector( selectOnboardingLoading ); diff --git a/apps/studio/src/hooks/use-auth-init.ts b/apps/studio/src/hooks/use-auth-init.ts deleted file mode 100644 index 5c263890d2..0000000000 --- a/apps/studio/src/hooks/use-auth-init.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useI18n } from '@wordpress/react-i18n'; -import { useIpcListener } from 'src/hooks/use-ipc-listener'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { useAppDispatch, useI18nLocale } from 'src/stores'; -import { authTokenReceived } from 'src/stores/auth-slice'; - -export function useAuthInit() { - const dispatch = useAppDispatch(); - const locale = useI18nLocale(); - const { __ } = useI18n(); - - useIpcListener( 'auth-updated', ( _event, payload ) => { - if ( 'error' in payload ) { - let title: string = __( 'Authentication error' ); - let message: string = __( 'Please try again.' ); - - if ( payload.error instanceof Error && payload.error.message.includes( 'access_denied' ) ) { - title = __( 'Authorization denied' ); - message = __( - 'It looks like you denied the authorization request. To proceed, please click "Approve"' - ); - } - - void getIpcApi().showErrorMessageBox( { title, message } ); - return; - } - - void dispatch( authTokenReceived( { token: payload.token, locale } ) ); - } ); -} diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index cba3988f3e..9234481d50 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -2,6 +2,7 @@ import { createAsyncThunk, createSlice, isAnyOf } from '@reduxjs/toolkit'; import * as Sentry from '@sentry/electron/renderer'; import wpcomFactory from '@studio/common/lib/wpcom-factory'; import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory'; +import { __ } from '@wordpress/i18n'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { isInvalidTokenError } from 'src/lib/is-invalid-oauth-token-error'; import { store, RootState } from 'src/stores'; @@ -189,4 +190,26 @@ const authSlice = createSlice( { export const selectIsAuthenticated = ( state: RootState ) => state.auth.isAuthenticated; export const selectUser = ( state: RootState ) => state.auth.user ?? undefined; +export function initializeAuthIpcListeners() { + window.ipcListener.subscribe( 'auth-updated', ( _event, payload ) => { + if ( 'error' in payload ) { + let title: string = __( 'Authentication error' ); + let message: string = __( 'Please try again.' ); + + if ( payload.error instanceof Error && payload.error.message.includes( 'access_denied' ) ) { + title = __( 'Authorization denied' ); + message = __( + 'It looks like you denied the authorization request. To proceed, please click "Approve"' + ); + } + + void getIpcApi().showErrorMessageBox( { title, message } ); + return; + } + + const locale = store.getState().i18n.locale; + void store.dispatch( authTokenReceived( { token: payload.token, locale } ) ); + } ); +} + export default authSlice.reducer; diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index f1d753f73b..e336c176be 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -14,7 +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 authReducer, { initializeAuth } from 'src/stores/auth-slice'; +import authReducer, { initializeAuth, initializeAuthIpcListeners } from 'src/stores/auth-slice'; 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'; @@ -373,6 +373,7 @@ setupListeners( store.dispatch ); // Initialize beta features on store initialization, but skip in test environment if ( process.env.NODE_ENV !== 'test' ) { + initializeAuthIpcListeners(); void store.dispatch( loadBetaFeatures() ); } From 319cc7dda3de732fcb97698aeb7038944f485020 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 19 Mar 2026 15:39:27 +0100 Subject: [PATCH 10/26] Adjust store --- apps/studio/src/stores/auth-slice.ts | 31 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index 9234481d50..c5dbf8985c 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -28,20 +28,6 @@ const initialState: AuthState = { }; function createWpcomClient( token?: string, locale?: string, onInvalidToken?: () => void ): WPCOM { - let isAuthErrorDialogOpen = false; - const handleInvalidTokenError = async ( response: unknown ) => { - if ( isInvalidTokenError( response ) && onInvalidToken && ! isAuthErrorDialogOpen ) { - isAuthErrorDialogOpen = true; - onInvalidToken(); - await getIpcApi().showMessageBox( { - type: 'error', - message: 'Session Expired', - detail: 'Your session has expired. Please log in again.', - } ); - isAuthErrorDialogOpen = false; - } - }; - const addLocaleToParams = ( params: WpcomParams ) => { if ( locale && locale !== 'en' ) { const queryParams = new URLSearchParams( @@ -64,8 +50,8 @@ function createWpcomClient( token?: string, locale?: string, onInvalidToken?: () ) => { const modifiedParams = addLocaleToParams( params as WpcomParams ); const wrappedCallback = ( err: unknown, response: unknown, headers: unknown ) => { - if ( err ) { - void handleInvalidTokenError( err ); + if ( err && isInvalidTokenError( err ) && onInvalidToken ) { + onInvalidToken(); } if ( typeof callback === 'function' ) { callback( err, response, headers ); @@ -80,14 +66,27 @@ function createWpcomClient( token?: string, locale?: string, onInvalidToken?: () const createTypedAsyncThunk = createAsyncThunk.withTypes< { state: RootState } >(); +let isAuthErrorDialogOpen = false; + export const handleInvalidToken = createTypedAsyncThunk( 'auth/handleInvalidToken', async () => { + if ( isAuthErrorDialogOpen ) { + return; + } + isAuthErrorDialogOpen = true; try { void getIpcApi().logRendererMessage( 'info', 'Detected invalid token. Logging out.' ); await getIpcApi().clearAuthenticationToken(); setWpcomClient( undefined ); + await getIpcApi().showMessageBox( { + type: 'error', + message: 'Session Expired', + detail: 'Your session has expired. Please log in again.', + } ); } catch ( err ) { console.error( 'Failed to handle invalid token:', err ); Sentry.captureException( err ); + } finally { + isAuthErrorDialogOpen = false; } } ); From 28cc95a60d1aea5ad7a5b6d7b2a2980973369802 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 19 Mar 2026 15:51:09 +0100 Subject: [PATCH 11/26] Fix regression --- apps/studio/src/stores/auth-slice.ts | 6 +++--- apps/studio/src/stores/index.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index c5dbf8985c..7af9e14180 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -50,7 +50,7 @@ function createWpcomClient( token?: string, locale?: string, onInvalidToken?: () ) => { const modifiedParams = addLocaleToParams( params as WpcomParams ); const wrappedCallback = ( err: unknown, response: unknown, headers: unknown ) => { - if ( err && isInvalidTokenError( err ) && onInvalidToken ) { + if ( err && isInvalidTokenError( err ) && onInvalidToken && ! isAuthErrorDialogOpen ) { onInvalidToken(); } if ( typeof callback === 'function' ) { @@ -79,8 +79,8 @@ export const handleInvalidToken = createTypedAsyncThunk( 'auth/handleInvalidToke setWpcomClient( undefined ); await getIpcApi().showMessageBox( { type: 'error', - message: 'Session Expired', - detail: 'Your session has expired. Please log in again.', + message: __( 'Session Expired' ), + detail: __( 'Your session has expired. Please log in again.' ), } ); } catch ( err ) { console.error( 'Failed to handle invalid token:', err ); diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index e336c176be..e91c913107 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -18,7 +18,7 @@ import authReducer, { initializeAuth, initializeAuthIpcListeners } from 'src/sto 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'; -import i18nReducer, { initializeUserLocale } from 'src/stores/i18n-slice'; +import i18nReducer, { initializeUserLocale, saveUserLocale } from 'src/stores/i18n-slice'; import { installedAppsApi } from 'src/stores/installed-apps-api'; import onboardingReducer from 'src/stores/onboarding-slice'; import { @@ -293,9 +293,9 @@ async function startPullPoller( selectedSiteId: string, remoteSiteId: number ) { } } -// Initialize auth once locale is loaded +// Initialize auth once locale is loaded, and re-initialize when locale changes startAppListening( { - actionCreator: initializeUserLocale.fulfilled, + matcher: isAnyOf( initializeUserLocale.fulfilled, saveUserLocale.fulfilled ), effect( action, listenerApi ) { const { locale } = listenerApi.getState().i18n; void listenerApi.dispatch( initializeAuth( { locale } ) ); From 649648fba6686b695f956f3e645fa55e47475d61 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Wed, 8 Apr 2026 11:15:00 +0200 Subject: [PATCH 12/26] Fix any of type error --- apps/studio/src/stores/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 4f8e387bc2..45561a7880 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -1,4 +1,9 @@ -import { combineReducers, configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; +import { + combineReducers, + configureStore, + createListenerMiddleware, + isAnyOf, +} from '@reduxjs/toolkit'; import { setupListeners } from '@reduxjs/toolkit/query'; import { useDispatch, useSelector } from 'react-redux'; import { LOCAL_STORAGE_CHAT_API_IDS_KEY, LOCAL_STORAGE_CHAT_MESSAGES_KEY } from 'src/constants'; From ae8e8d27a19451c506b3ba8f49d6d4ab12a8ea17 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Wed, 8 Apr 2026 11:27:57 +0200 Subject: [PATCH 13/26] Add tests --- apps/studio/src/stores/auth-slice.ts | 11 +- .../src/stores/tests/auth-slice.test.ts | 302 ++++++++++++++++++ 2 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 apps/studio/src/stores/tests/auth-slice.test.ts diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index a633f188dc..ef7da8f109 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -8,7 +8,7 @@ import { isInvalidTokenError } from 'src/lib/is-invalid-oauth-token-error'; import { setSentryWpcomUserIdRenderer } from 'src/lib/renderer-sentry-utils'; import { store, RootState } from 'src/stores'; import { getWpcomClient, setWpcomClient } from 'src/stores/wpcom-client'; -import type { StoredToken } from 'src/lib/oauth'; +import type { StoredAuthToken } from 'src/lib/oauth'; import type { WPCOM } from 'wpcom/types'; interface WpcomParams extends Record< string, unknown > { @@ -52,7 +52,7 @@ function createWpcomClient( token?: string, locale?: string, onInvalidToken?: () const modifiedParams = addLocaleToParams( params as WpcomParams ); const wrappedCallback = ( err: unknown, response: unknown, headers: unknown ) => { if ( err && isInvalidTokenError( err ) && onInvalidToken && ! isAuthErrorDialogOpen ) { - onInvalidToken(); + void onInvalidToken(); } if ( typeof callback === 'function' ) { callback( err, response, headers ); @@ -122,7 +122,7 @@ export const initializeAuth = createTypedAsyncThunk( export const authTokenReceived = createTypedAsyncThunk( 'auth/tokenReceived', - async ( { token, locale }: { token: StoredToken; locale?: string } ) => { + async ( { token, locale }: { token: StoredAuthToken; locale?: string } ) => { const client = createWpcomClient( token.accessToken, locale, () => store.dispatch( handleInvalidToken() ) ); @@ -208,6 +208,11 @@ export function initializeAuthIpcListeners() { return; } + if ( ! payload.token ) { + void store.dispatch( authLogout( { isOffline: true } ) ); + return; + } + const locale = store.getState().i18n.locale; void store.dispatch( authTokenReceived( { token: payload.token, locale } ) ); } ); diff --git a/apps/studio/src/stores/tests/auth-slice.test.ts b/apps/studio/src/stores/tests/auth-slice.test.ts new file mode 100644 index 0000000000..10caf60827 --- /dev/null +++ b/apps/studio/src/stores/tests/auth-slice.test.ts @@ -0,0 +1,302 @@ +import * as Sentry from '@sentry/electron/renderer'; +import { vi } from 'vitest'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import * as sentryUtils from 'src/lib/renderer-sentry-utils'; +import { store } from 'src/stores'; +import { + authLogout, + authTokenReceived, + handleInvalidToken, + initializeAuth, + initializeAuthIpcListeners, + selectIsAuthenticated, + selectUser, +} from 'src/stores/auth-slice'; +import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; +import { getWpcomClient, setWpcomClient } from 'src/stores/wpcom-client'; +import type { StoredAuthToken } from 'src/lib/oauth'; +import type { WPCOM } from 'wpcom/types'; + +vi.mock( 'src/lib/get-ipc-api' ); + +vi.mock( '@sentry/electron/renderer', () => ( { + captureException: vi.fn(), + setTag: vi.fn(), +} ) ); + +vi.mock( 'src/lib/renderer-sentry-utils', () => ( { + setSentryWpcomUserIdRenderer: vi.fn(), +} ) ); + +const mockClientDel = vi.hoisted( () => vi.fn().mockResolvedValue( undefined ) ); + +vi.mock( '@studio/common/lib/wpcom-factory', () => ( { + default: vi.fn( () => ( { req: { del: mockClientDel } } ) ), +} ) ); + +vi.mock( '@studio/common/lib/wpcom-xhr-request-factory', () => ( { + default: vi.fn(), +} ) ); + +store.replaceReducer( testReducer ); + +const mockToken: StoredAuthToken = { + accessToken: 'test-access-token', + expiresIn: 3600, + expirationTime: Date.now() + 3_600_000, + id: 123, + email: 'test@example.com', + displayName: 'Test User', +}; + +function setupIpcMocks( overrides: Partial< ReturnType< typeof getIpcApi > > = {} ) { + vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( { + getAuthenticationToken: vi.fn().mockResolvedValue( null ), + clearAuthenticationToken: vi.fn().mockResolvedValue( undefined ), + showMessageBox: vi.fn().mockResolvedValue( undefined ), + showErrorMessageBox: vi.fn().mockResolvedValue( undefined ), + logRendererMessage: vi.fn().mockResolvedValue( undefined ), + ...overrides, + } ); +} + +describe( 'auth-slice', () => { + beforeEach( () => { + vi.clearAllMocks(); + store.dispatch( testActions.resetState() ); + setWpcomClient( undefined ); + setupIpcMocks(); + } ); + + describe( 'initializeAuth', () => { + it( 'sets isAuthenticated to false when no token exists', async () => { + const result = await store.dispatch( initializeAuth( { locale: 'en' } ) ); + + expect( result.type ).toBe( 'auth/initialize/fulfilled' ); + expect( result.payload ).toBeNull(); + + const state = store.getState(); + expect( selectIsAuthenticated( state ) ).toBe( false ); + expect( selectUser( state ) ).toBeUndefined(); + } ); + + it( 'sets isAuthenticated and user when token exists', async () => { + setupIpcMocks( { + getAuthenticationToken: vi.fn().mockResolvedValue( mockToken ), + } ); + + await store.dispatch( initializeAuth( { locale: 'en' } ) ); + + const state = store.getState(); + expect( selectIsAuthenticated( state ) ).toBe( true ); + expect( selectUser( state ) ).toEqual( { + id: mockToken.id, + email: mockToken.email, + displayName: mockToken.displayName, + } ); + } ); + + it( 'creates wpcom client and tracks Sentry user id when token exists', async () => { + setupIpcMocks( { + getAuthenticationToken: vi.fn().mockResolvedValue( mockToken ), + } ); + + await store.dispatch( initializeAuth( { locale: 'en' } ) ); + + expect( getWpcomClient() ).toBeDefined(); + expect( sentryUtils.setSentryWpcomUserIdRenderer ).toHaveBeenCalledWith( mockToken.id ); + } ); + + it( 'returns null and captures Sentry exception when token fetch throws', async () => { + const error = new Error( 'IPC error' ); + setupIpcMocks( { + getAuthenticationToken: vi.fn().mockRejectedValue( error ), + } ); + + const result = await store.dispatch( initializeAuth( { locale: 'en' } ) ); + + expect( result.payload ).toBeNull(); + expect( Sentry.captureException ).toHaveBeenCalledWith( error ); + + const state = store.getState(); + expect( selectIsAuthenticated( state ) ).toBe( false ); + } ); + } ); + + describe( 'authTokenReceived', () => { + it( 'sets isAuthenticated and user when token received', async () => { + await store.dispatch( authTokenReceived( { token: mockToken } ) ); + + const state = store.getState(); + expect( selectIsAuthenticated( state ) ).toBe( true ); + expect( selectUser( state ) ).toEqual( { + id: mockToken.id, + email: mockToken.email, + displayName: mockToken.displayName, + } ); + } ); + + it( 'creates wpcom client and tracks Sentry user id', async () => { + await store.dispatch( authTokenReceived( { token: mockToken } ) ); + + expect( getWpcomClient() ).toBeDefined(); + expect( sentryUtils.setSentryWpcomUserIdRenderer ).toHaveBeenCalledWith( mockToken.id ); + } ); + + it( 'uses empty string for displayName when not provided', async () => { + const tokenWithoutName = { ...mockToken, displayName: '' }; + await store.dispatch( authTokenReceived( { token: tokenWithoutName } ) ); + + expect( selectUser( store.getState() )?.displayName ).toBe( '' ); + } ); + } ); + + describe( 'authLogout', () => { + beforeEach( async () => { + await store.dispatch( authTokenReceived( { token: mockToken } ) ); + vi.clearAllMocks(); + setupIpcMocks(); + } ); + + it( 'revokes token and clears auth state when online', async () => { + const result = await store.dispatch( authLogout( { isOffline: false } ) ); + + expect( result.type ).toBe( 'auth/logout/fulfilled' ); + expect( mockClientDel ).toHaveBeenCalledWith( { + apiNamespace: 'wpcom/v2', + path: '/studio-app/token', + method: 'DELETE', + } ); + expect( getIpcApi().clearAuthenticationToken ).toHaveBeenCalled(); + expect( getWpcomClient() ).toBeUndefined(); + expect( sentryUtils.setSentryWpcomUserIdRenderer ).toHaveBeenCalledWith( undefined ); + + const state = store.getState(); + expect( selectIsAuthenticated( state ) ).toBe( false ); + expect( selectUser( state ) ).toBeUndefined(); + } ); + + it( 'skips token revocation when offline', async () => { + await store.dispatch( authLogout( { isOffline: true } ) ); + + expect( mockClientDel ).not.toHaveBeenCalled(); + expect( getIpcApi().clearAuthenticationToken ).toHaveBeenCalled(); + } ); + + it( 'still clears local auth state when token revocation request fails', async () => { + mockClientDel.mockRejectedValueOnce( new Error( 'Network error' ) ); + + await store.dispatch( authLogout( { isOffline: false } ) ); + + expect( getIpcApi().clearAuthenticationToken ).toHaveBeenCalled(); + expect( getWpcomClient() ).toBeUndefined(); + expect( selectIsAuthenticated( store.getState() ) ).toBe( false ); + } ); + } ); + + describe( 'handleInvalidToken', () => { + beforeEach( async () => { + await store.dispatch( authTokenReceived( { token: mockToken } ) ); + vi.clearAllMocks(); + setupIpcMocks(); + } ); + + it( 'clears auth state, wpcom client and Sentry user', async () => { + await store.dispatch( handleInvalidToken() ); + + expect( getIpcApi().clearAuthenticationToken ).toHaveBeenCalled(); + expect( getWpcomClient() ).toBeUndefined(); + expect( sentryUtils.setSentryWpcomUserIdRenderer ).toHaveBeenCalledWith( undefined ); + + const state = store.getState(); + expect( selectIsAuthenticated( state ) ).toBe( false ); + expect( selectUser( state ) ).toBeUndefined(); + } ); + + it( 'shows session expired message box', async () => { + await store.dispatch( handleInvalidToken() ); + + expect( getIpcApi().showMessageBox ).toHaveBeenCalledWith( + expect.objectContaining( { type: 'error' } ) + ); + } ); + + it( 'prevents duplicate dialogs when called concurrently', async () => { + const firstCall = store.dispatch( handleInvalidToken() ); + const secondCall = store.dispatch( handleInvalidToken() ); + + await Promise.all( [ firstCall, secondCall ] ); + + expect( getIpcApi().showMessageBox ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'allows a new dialog after the first one is dismissed', async () => { + await store.dispatch( handleInvalidToken() ); + await store.dispatch( handleInvalidToken() ); + + expect( getIpcApi().showMessageBox ).toHaveBeenCalledTimes( 2 ); + } ); + } ); + + describe( 'initializeAuthIpcListeners', () => { + type IpcCallback = ( event: unknown, payload: unknown ) => void; + let capturedCallback: IpcCallback; + + beforeEach( () => { + vi.mocked( window.ipcListener.subscribe ).mockImplementationOnce( + ( _event: string, callback: IpcCallback ) => { + capturedCallback = callback; + } + ); + initializeAuthIpcListeners(); + } ); + + it( 'subscribes to auth-updated IPC events', () => { + expect( window.ipcListener.subscribe ).toHaveBeenCalledWith( + 'auth-updated', + expect.any( Function ) + ); + } ); + + it( 'dispatches authTokenReceived when a valid token is received', async () => { + capturedCallback( null, { token: mockToken } ); + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + const state = store.getState(); + expect( selectIsAuthenticated( state ) ).toBe( true ); + expect( selectUser( state ) ).toEqual( { + id: mockToken.id, + email: mockToken.email, + displayName: mockToken.displayName, + } ); + } ); + + it( 'shows authentication error message box on auth error', async () => { + capturedCallback( null, { error: new Error( 'Some error' ) } ); + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + expect( getIpcApi().showErrorMessageBox ).toHaveBeenCalledWith( + expect.objectContaining( { title: 'Authentication error' } ) + ); + } ); + + it( 'shows authorization denied message box when user denies access', async () => { + capturedCallback( null, { error: new Error( 'access_denied by user' ) } ); + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + expect( getIpcApi().showErrorMessageBox ).toHaveBeenCalledWith( + expect.objectContaining( { title: 'Authorization denied' } ) + ); + } ); + + it( 'clears auth state when a null token is received', async () => { + await store.dispatch( authTokenReceived( { token: mockToken } ) ); + + capturedCallback( null, { token: null } ); + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + expect( selectIsAuthenticated( store.getState() ) ).toBe( false ); + expect( selectUser( store.getState() ) ).toBeUndefined(); + } ); + } ); +} ); From 317fbcfec951c1478096f3ee0b510b9b7b4c53f1 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Wed, 8 Apr 2026 11:51:19 +0200 Subject: [PATCH 14/26] Remove test that is too complex and is not needed --- apps/studio/src/stores/auth-slice.ts | 19 ++--- apps/studio/src/stores/index.ts | 7 +- .../src/stores/tests/auth-slice.test.ts | 71 ++----------------- 3 files changed, 17 insertions(+), 80 deletions(-) diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index ef7da8f109..d2a8112979 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -6,9 +6,9 @@ import { __ } from '@wordpress/i18n'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { isInvalidTokenError } from 'src/lib/is-invalid-oauth-token-error'; import { setSentryWpcomUserIdRenderer } from 'src/lib/renderer-sentry-utils'; -import { store, RootState } from 'src/stores'; import { getWpcomClient, setWpcomClient } from 'src/stores/wpcom-client'; import type { StoredAuthToken } from 'src/lib/oauth'; +import type { AppDispatch, RootState } from 'src/stores'; import type { WPCOM } from 'wpcom/types'; interface WpcomParams extends Record< string, unknown > { @@ -93,7 +93,8 @@ export const handleInvalidToken = createTypedAsyncThunk( 'auth/handleInvalidToke export const initializeAuth = createTypedAsyncThunk( 'auth/initialize', - async ( { locale }: { locale?: string } ) => { + async ( _, { dispatch, getState } ) => { + const { locale } = getState().i18n; try { const token = await getIpcApi().getAuthenticationToken(); @@ -102,7 +103,7 @@ export const initializeAuth = createTypedAsyncThunk( } const client = createWpcomClient( token.accessToken, locale, () => - store.dispatch( handleInvalidToken() ) + dispatch( handleInvalidToken() ) ); setWpcomClient( client ); setSentryWpcomUserIdRenderer( token.id ); @@ -122,9 +123,9 @@ export const initializeAuth = createTypedAsyncThunk( export const authTokenReceived = createTypedAsyncThunk( 'auth/tokenReceived', - async ( { token, locale }: { token: StoredAuthToken; locale?: string } ) => { + async ( { token, locale }: { token: StoredAuthToken; locale?: string }, { dispatch } ) => { const client = createWpcomClient( token.accessToken, locale, () => - store.dispatch( handleInvalidToken() ) + dispatch( handleInvalidToken() ) ); setWpcomClient( client ); setSentryWpcomUserIdRenderer( token.id ); @@ -191,7 +192,7 @@ const authSlice = createSlice( { export const selectIsAuthenticated = ( state: RootState ) => state.auth.isAuthenticated; export const selectUser = ( state: RootState ) => state.auth.user ?? undefined; -export function initializeAuthIpcListeners() { +export function initializeAuthIpcListeners( dispatch: AppDispatch, getState: () => RootState ) { window.ipcListener.subscribe( 'auth-updated', ( _event, payload ) => { if ( 'error' in payload ) { let title: string = __( 'Authentication error' ); @@ -209,12 +210,12 @@ export function initializeAuthIpcListeners() { } if ( ! payload.token ) { - void store.dispatch( authLogout( { isOffline: true } ) ); + void dispatch( authLogout( { isOffline: true } ) ); return; } - const locale = store.getState().i18n.locale; - void store.dispatch( authTokenReceived( { token: payload.token, locale } ) ); + const locale = getState().i18n.locale; + void dispatch( authTokenReceived( { token: payload.token, locale } ) ); } ); } diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 45561a7880..3576fc888d 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -302,9 +302,8 @@ async function startPullPoller( selectedSiteId: string, remoteSiteId: number ) { // Initialize auth once locale is loaded, and re-initialize when locale changes startAppListening( { matcher: isAnyOf( initializeUserLocale.fulfilled, saveUserLocale.fulfilled ), - effect( action, listenerApi ) { - const { locale } = listenerApi.getState().i18n; - void listenerApi.dispatch( initializeAuth( { locale } ) ); + effect( _, listenerApi ) { + void listenerApi.dispatch( initializeAuth() ); }, } ); @@ -379,7 +378,7 @@ setupListeners( store.dispatch ); // Initialize beta features and fetch snapshots on store initialization, but skip in test environment if ( process.env.NODE_ENV !== 'test' ) { - initializeAuthIpcListeners(); + initializeAuthIpcListeners( store.dispatch, store.getState.bind( store ) ); void store.dispatch( loadBetaFeatures() ); void refreshSnapshots(); } diff --git a/apps/studio/src/stores/tests/auth-slice.test.ts b/apps/studio/src/stores/tests/auth-slice.test.ts index 10caf60827..edf29bc304 100644 --- a/apps/studio/src/stores/tests/auth-slice.test.ts +++ b/apps/studio/src/stores/tests/auth-slice.test.ts @@ -8,14 +8,12 @@ import { authTokenReceived, handleInvalidToken, initializeAuth, - initializeAuthIpcListeners, selectIsAuthenticated, selectUser, } from 'src/stores/auth-slice'; import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; import { getWpcomClient, setWpcomClient } from 'src/stores/wpcom-client'; import type { StoredAuthToken } from 'src/lib/oauth'; -import type { WPCOM } from 'wpcom/types'; vi.mock( 'src/lib/get-ipc-api' ); @@ -70,7 +68,7 @@ describe( 'auth-slice', () => { describe( 'initializeAuth', () => { it( 'sets isAuthenticated to false when no token exists', async () => { - const result = await store.dispatch( initializeAuth( { locale: 'en' } ) ); + const result = await store.dispatch( initializeAuth() ); expect( result.type ).toBe( 'auth/initialize/fulfilled' ); expect( result.payload ).toBeNull(); @@ -85,7 +83,7 @@ describe( 'auth-slice', () => { getAuthenticationToken: vi.fn().mockResolvedValue( mockToken ), } ); - await store.dispatch( initializeAuth( { locale: 'en' } ) ); + await store.dispatch( initializeAuth() ); const state = store.getState(); expect( selectIsAuthenticated( state ) ).toBe( true ); @@ -101,7 +99,7 @@ describe( 'auth-slice', () => { getAuthenticationToken: vi.fn().mockResolvedValue( mockToken ), } ); - await store.dispatch( initializeAuth( { locale: 'en' } ) ); + await store.dispatch( initializeAuth() ); expect( getWpcomClient() ).toBeDefined(); expect( sentryUtils.setSentryWpcomUserIdRenderer ).toHaveBeenCalledWith( mockToken.id ); @@ -113,7 +111,7 @@ describe( 'auth-slice', () => { getAuthenticationToken: vi.fn().mockRejectedValue( error ), } ); - const result = await store.dispatch( initializeAuth( { locale: 'en' } ) ); + const result = await store.dispatch( initializeAuth() ); expect( result.payload ).toBeNull(); expect( Sentry.captureException ).toHaveBeenCalledWith( error ); @@ -238,65 +236,4 @@ describe( 'auth-slice', () => { } ); } ); - describe( 'initializeAuthIpcListeners', () => { - type IpcCallback = ( event: unknown, payload: unknown ) => void; - let capturedCallback: IpcCallback; - - beforeEach( () => { - vi.mocked( window.ipcListener.subscribe ).mockImplementationOnce( - ( _event: string, callback: IpcCallback ) => { - capturedCallback = callback; - } - ); - initializeAuthIpcListeners(); - } ); - - it( 'subscribes to auth-updated IPC events', () => { - expect( window.ipcListener.subscribe ).toHaveBeenCalledWith( - 'auth-updated', - expect.any( Function ) - ); - } ); - - it( 'dispatches authTokenReceived when a valid token is received', async () => { - capturedCallback( null, { token: mockToken } ); - await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); - - const state = store.getState(); - expect( selectIsAuthenticated( state ) ).toBe( true ); - expect( selectUser( state ) ).toEqual( { - id: mockToken.id, - email: mockToken.email, - displayName: mockToken.displayName, - } ); - } ); - - it( 'shows authentication error message box on auth error', async () => { - capturedCallback( null, { error: new Error( 'Some error' ) } ); - await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); - - expect( getIpcApi().showErrorMessageBox ).toHaveBeenCalledWith( - expect.objectContaining( { title: 'Authentication error' } ) - ); - } ); - - it( 'shows authorization denied message box when user denies access', async () => { - capturedCallback( null, { error: new Error( 'access_denied by user' ) } ); - await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); - - expect( getIpcApi().showErrorMessageBox ).toHaveBeenCalledWith( - expect.objectContaining( { title: 'Authorization denied' } ) - ); - } ); - - it( 'clears auth state when a null token is received', async () => { - await store.dispatch( authTokenReceived( { token: mockToken } ) ); - - capturedCallback( null, { token: null } ); - await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); - - expect( selectIsAuthenticated( store.getState() ) ).toBe( false ); - expect( selectUser( store.getState() ) ).toBeUndefined(); - } ); - } ); } ); From 0fdfacde27b31c230b3fe0f54d0681b397abf8bf Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Wed, 8 Apr 2026 11:58:57 +0200 Subject: [PATCH 15/26] Final fix --- apps/studio/src/stores/auth-slice.ts | 2 +- apps/studio/src/stores/tests/auth-slice.test.ts | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index d2a8112979..e2c169e71c 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -123,7 +123,7 @@ export const initializeAuth = createTypedAsyncThunk( export const authTokenReceived = createTypedAsyncThunk( 'auth/tokenReceived', - async ( { token, locale }: { token: StoredAuthToken; locale?: string }, { dispatch } ) => { + async ( { token, locale }: { token: StoredAuthToken; locale: string }, { dispatch } ) => { const client = createWpcomClient( token.accessToken, locale, () => dispatch( handleInvalidToken() ) ); diff --git a/apps/studio/src/stores/tests/auth-slice.test.ts b/apps/studio/src/stores/tests/auth-slice.test.ts index edf29bc304..c0c5f04bf5 100644 --- a/apps/studio/src/stores/tests/auth-slice.test.ts +++ b/apps/studio/src/stores/tests/auth-slice.test.ts @@ -123,7 +123,7 @@ describe( 'auth-slice', () => { describe( 'authTokenReceived', () => { it( 'sets isAuthenticated and user when token received', async () => { - await store.dispatch( authTokenReceived( { token: mockToken } ) ); + await store.dispatch( authTokenReceived( { token: mockToken, locale: 'en' } ) ); const state = store.getState(); expect( selectIsAuthenticated( state ) ).toBe( true ); @@ -135,7 +135,7 @@ describe( 'auth-slice', () => { } ); it( 'creates wpcom client and tracks Sentry user id', async () => { - await store.dispatch( authTokenReceived( { token: mockToken } ) ); + await store.dispatch( authTokenReceived( { token: mockToken, locale: 'en' } ) ); expect( getWpcomClient() ).toBeDefined(); expect( sentryUtils.setSentryWpcomUserIdRenderer ).toHaveBeenCalledWith( mockToken.id ); @@ -143,7 +143,7 @@ describe( 'auth-slice', () => { it( 'uses empty string for displayName when not provided', async () => { const tokenWithoutName = { ...mockToken, displayName: '' }; - await store.dispatch( authTokenReceived( { token: tokenWithoutName } ) ); + await store.dispatch( authTokenReceived( { token: tokenWithoutName, locale: 'en' } ) ); expect( selectUser( store.getState() )?.displayName ).toBe( '' ); } ); @@ -151,7 +151,7 @@ describe( 'auth-slice', () => { describe( 'authLogout', () => { beforeEach( async () => { - await store.dispatch( authTokenReceived( { token: mockToken } ) ); + await store.dispatch( authTokenReceived( { token: mockToken, locale: 'en' } ) ); vi.clearAllMocks(); setupIpcMocks(); } ); @@ -194,7 +194,7 @@ describe( 'auth-slice', () => { describe( 'handleInvalidToken', () => { beforeEach( async () => { - await store.dispatch( authTokenReceived( { token: mockToken } ) ); + await store.dispatch( authTokenReceived( { token: mockToken, locale: 'en' } ) ); vi.clearAllMocks(); setupIpcMocks(); } ); @@ -235,5 +235,4 @@ describe( 'auth-slice', () => { expect( getIpcApi().showMessageBox ).toHaveBeenCalledTimes( 2 ); } ); } ); - } ); From 547baf85f60bd6b6129c4f090d7bafdd9ddbe9e0 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Fri, 24 Apr 2026 12:27:12 -0400 Subject: [PATCH 16/26] Fix error --- apps/studio/src/stores/sync/wpcom-sites.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/src/stores/sync/wpcom-sites.ts b/apps/studio/src/stores/sync/wpcom-sites.ts index e529ff3b47..ee2c74574c 100644 --- a/apps/studio/src/stores/sync/wpcom-sites.ts +++ b/apps/studio/src/stores/sync/wpcom-sites.ts @@ -10,7 +10,7 @@ import { z } from 'zod'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { reconcileConnectedSites } from 'src/modules/sync/lib/reconcile-connected-sites'; import { withOfflineCheck } from 'src/stores/utils/with-offline-check'; -import { getWpcomClient } from 'src/stores/wpcom-api'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import type { SyncSite } from '@studio/common/types/sync'; const SITE_FIELDS = [ From eca9dc4ede6624c7f590d236d39a28df1d51af75 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Fri, 24 Apr 2026 15:22:38 -0400 Subject: [PATCH 17/26] Remove test and use locator directly --- apps/studio/src/hooks/use-auth.ts | 6 +- apps/studio/src/lib/oauth.ts | 1 + apps/studio/src/stores/auth-slice.ts | 47 ++-- .../src/stores/tests/auth-slice.test.ts | 238 ------------------ 4 files changed, 25 insertions(+), 267 deletions(-) delete mode 100644 apps/studio/src/stores/tests/auth-slice.test.ts diff --git a/apps/studio/src/hooks/use-auth.ts b/apps/studio/src/hooks/use-auth.ts index 686543bb7f..4c26fc2922 100644 --- a/apps/studio/src/hooks/use-auth.ts +++ b/apps/studio/src/hooks/use-auth.ts @@ -1,5 +1,4 @@ import { useCallback } from 'react'; -import { useOffline } from 'src/hooks/use-offline'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { useAppDispatch, useRootSelector } from 'src/stores'; import { authLogout, selectIsAuthenticated, selectUser } from 'src/stores/auth-slice'; @@ -17,15 +16,14 @@ export interface AuthContextType { export const useAuth = (): AuthContextType => { const dispatch = useAppDispatch(); - const isOffline = useOffline(); const isAuthenticated = useRootSelector( selectIsAuthenticated ); const user = useRootSelector( selectUser ); const authenticate = useCallback( () => getIpcApi().authenticate(), [] ); const logout = useCallback( async () => { - await dispatch( authLogout( { isOffline } ) ); - }, [ dispatch, isOffline ] ); + await dispatch( authLogout() ); + }, [ dispatch ] ); return { client: isAuthenticated ? getWpcomClient() : undefined, diff --git a/apps/studio/src/lib/oauth.ts b/apps/studio/src/lib/oauth.ts index 0a081c30e2..f6a950c621 100644 --- a/apps/studio/src/lib/oauth.ts +++ b/apps/studio/src/lib/oauth.ts @@ -2,6 +2,7 @@ import { CLIENT_ID } from '@studio/common/constants'; import { SupportedLocale } from '@studio/common/lib/locale'; import { getAuthenticationUrl } from '@studio/common/lib/oauth'; import { readAuthToken, type StoredAuthToken } from '@studio/common/lib/shared-config'; +export type { StoredAuthToken }; export function getSignUpUrl( locale: SupportedLocale ) { const oauth2Redirect = encodeURIComponent( getAuthenticationUrl( locale ) ); diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index e2c169e71c..01e7f83aaa 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -138,35 +138,32 @@ export const authTokenReceived = createTypedAsyncThunk( } ); -export const authLogout = createTypedAsyncThunk( - 'auth/logout', - async ( { isOffline }: { isOffline: boolean } ) => { - const client = getWpcomClient(); - - if ( ! isOffline && client ) { - try { - await client.req.del( { - apiNamespace: 'wpcom/v2', - path: '/studio-app/token', - method: 'DELETE', - } ); - console.log( 'Token revoked' ); - } catch ( err ) { - console.error( 'Failed to revoke token:', err ); - } - } else if ( isOffline ) { - console.log( 'Offline: Skipping token revocation request' ); - } +export const authLogout = createTypedAsyncThunk( 'auth/logout', async () => { + const client = getWpcomClient(); + if ( navigator.onLine && client ) { try { - await getIpcApi().clearAuthenticationToken(); - setWpcomClient( undefined ); - setSentryWpcomUserIdRenderer( undefined ); + await client.req.del( { + apiNamespace: 'wpcom/v2', + path: '/studio-app/token', + method: 'DELETE', + } ); + console.log( 'Token revoked' ); } catch ( err ) { - console.error( err ); + console.error( 'Failed to revoke token:', err ); } + } else if ( ! navigator.onLine ) { + console.log( 'Offline: Skipping token revocation request' ); } -); + + try { + await getIpcApi().clearAuthenticationToken(); + setWpcomClient( undefined ); + setSentryWpcomUserIdRenderer( undefined ); + } catch ( err ) { + console.error( err ); + } +} ); const authSlice = createSlice( { name: 'auth', @@ -210,7 +207,7 @@ export function initializeAuthIpcListeners( dispatch: AppDispatch, getState: () } if ( ! payload.token ) { - void dispatch( authLogout( { isOffline: true } ) ); + void dispatch( authLogout() ); return; } diff --git a/apps/studio/src/stores/tests/auth-slice.test.ts b/apps/studio/src/stores/tests/auth-slice.test.ts deleted file mode 100644 index c0c5f04bf5..0000000000 --- a/apps/studio/src/stores/tests/auth-slice.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import * as Sentry from '@sentry/electron/renderer'; -import { vi } from 'vitest'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import * as sentryUtils from 'src/lib/renderer-sentry-utils'; -import { store } from 'src/stores'; -import { - authLogout, - authTokenReceived, - handleInvalidToken, - initializeAuth, - selectIsAuthenticated, - selectUser, -} from 'src/stores/auth-slice'; -import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; -import { getWpcomClient, setWpcomClient } from 'src/stores/wpcom-client'; -import type { StoredAuthToken } from 'src/lib/oauth'; - -vi.mock( 'src/lib/get-ipc-api' ); - -vi.mock( '@sentry/electron/renderer', () => ( { - captureException: vi.fn(), - setTag: vi.fn(), -} ) ); - -vi.mock( 'src/lib/renderer-sentry-utils', () => ( { - setSentryWpcomUserIdRenderer: vi.fn(), -} ) ); - -const mockClientDel = vi.hoisted( () => vi.fn().mockResolvedValue( undefined ) ); - -vi.mock( '@studio/common/lib/wpcom-factory', () => ( { - default: vi.fn( () => ( { req: { del: mockClientDel } } ) ), -} ) ); - -vi.mock( '@studio/common/lib/wpcom-xhr-request-factory', () => ( { - default: vi.fn(), -} ) ); - -store.replaceReducer( testReducer ); - -const mockToken: StoredAuthToken = { - accessToken: 'test-access-token', - expiresIn: 3600, - expirationTime: Date.now() + 3_600_000, - id: 123, - email: 'test@example.com', - displayName: 'Test User', -}; - -function setupIpcMocks( overrides: Partial< ReturnType< typeof getIpcApi > > = {} ) { - vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( { - getAuthenticationToken: vi.fn().mockResolvedValue( null ), - clearAuthenticationToken: vi.fn().mockResolvedValue( undefined ), - showMessageBox: vi.fn().mockResolvedValue( undefined ), - showErrorMessageBox: vi.fn().mockResolvedValue( undefined ), - logRendererMessage: vi.fn().mockResolvedValue( undefined ), - ...overrides, - } ); -} - -describe( 'auth-slice', () => { - beforeEach( () => { - vi.clearAllMocks(); - store.dispatch( testActions.resetState() ); - setWpcomClient( undefined ); - setupIpcMocks(); - } ); - - describe( 'initializeAuth', () => { - it( 'sets isAuthenticated to false when no token exists', async () => { - const result = await store.dispatch( initializeAuth() ); - - expect( result.type ).toBe( 'auth/initialize/fulfilled' ); - expect( result.payload ).toBeNull(); - - const state = store.getState(); - expect( selectIsAuthenticated( state ) ).toBe( false ); - expect( selectUser( state ) ).toBeUndefined(); - } ); - - it( 'sets isAuthenticated and user when token exists', async () => { - setupIpcMocks( { - getAuthenticationToken: vi.fn().mockResolvedValue( mockToken ), - } ); - - await store.dispatch( initializeAuth() ); - - const state = store.getState(); - expect( selectIsAuthenticated( state ) ).toBe( true ); - expect( selectUser( state ) ).toEqual( { - id: mockToken.id, - email: mockToken.email, - displayName: mockToken.displayName, - } ); - } ); - - it( 'creates wpcom client and tracks Sentry user id when token exists', async () => { - setupIpcMocks( { - getAuthenticationToken: vi.fn().mockResolvedValue( mockToken ), - } ); - - await store.dispatch( initializeAuth() ); - - expect( getWpcomClient() ).toBeDefined(); - expect( sentryUtils.setSentryWpcomUserIdRenderer ).toHaveBeenCalledWith( mockToken.id ); - } ); - - it( 'returns null and captures Sentry exception when token fetch throws', async () => { - const error = new Error( 'IPC error' ); - setupIpcMocks( { - getAuthenticationToken: vi.fn().mockRejectedValue( error ), - } ); - - const result = await store.dispatch( initializeAuth() ); - - expect( result.payload ).toBeNull(); - expect( Sentry.captureException ).toHaveBeenCalledWith( error ); - - const state = store.getState(); - expect( selectIsAuthenticated( state ) ).toBe( false ); - } ); - } ); - - describe( 'authTokenReceived', () => { - it( 'sets isAuthenticated and user when token received', async () => { - await store.dispatch( authTokenReceived( { token: mockToken, locale: 'en' } ) ); - - const state = store.getState(); - expect( selectIsAuthenticated( state ) ).toBe( true ); - expect( selectUser( state ) ).toEqual( { - id: mockToken.id, - email: mockToken.email, - displayName: mockToken.displayName, - } ); - } ); - - it( 'creates wpcom client and tracks Sentry user id', async () => { - await store.dispatch( authTokenReceived( { token: mockToken, locale: 'en' } ) ); - - expect( getWpcomClient() ).toBeDefined(); - expect( sentryUtils.setSentryWpcomUserIdRenderer ).toHaveBeenCalledWith( mockToken.id ); - } ); - - it( 'uses empty string for displayName when not provided', async () => { - const tokenWithoutName = { ...mockToken, displayName: '' }; - await store.dispatch( authTokenReceived( { token: tokenWithoutName, locale: 'en' } ) ); - - expect( selectUser( store.getState() )?.displayName ).toBe( '' ); - } ); - } ); - - describe( 'authLogout', () => { - beforeEach( async () => { - await store.dispatch( authTokenReceived( { token: mockToken, locale: 'en' } ) ); - vi.clearAllMocks(); - setupIpcMocks(); - } ); - - it( 'revokes token and clears auth state when online', async () => { - const result = await store.dispatch( authLogout( { isOffline: false } ) ); - - expect( result.type ).toBe( 'auth/logout/fulfilled' ); - expect( mockClientDel ).toHaveBeenCalledWith( { - apiNamespace: 'wpcom/v2', - path: '/studio-app/token', - method: 'DELETE', - } ); - expect( getIpcApi().clearAuthenticationToken ).toHaveBeenCalled(); - expect( getWpcomClient() ).toBeUndefined(); - expect( sentryUtils.setSentryWpcomUserIdRenderer ).toHaveBeenCalledWith( undefined ); - - const state = store.getState(); - expect( selectIsAuthenticated( state ) ).toBe( false ); - expect( selectUser( state ) ).toBeUndefined(); - } ); - - it( 'skips token revocation when offline', async () => { - await store.dispatch( authLogout( { isOffline: true } ) ); - - expect( mockClientDel ).not.toHaveBeenCalled(); - expect( getIpcApi().clearAuthenticationToken ).toHaveBeenCalled(); - } ); - - it( 'still clears local auth state when token revocation request fails', async () => { - mockClientDel.mockRejectedValueOnce( new Error( 'Network error' ) ); - - await store.dispatch( authLogout( { isOffline: false } ) ); - - expect( getIpcApi().clearAuthenticationToken ).toHaveBeenCalled(); - expect( getWpcomClient() ).toBeUndefined(); - expect( selectIsAuthenticated( store.getState() ) ).toBe( false ); - } ); - } ); - - describe( 'handleInvalidToken', () => { - beforeEach( async () => { - await store.dispatch( authTokenReceived( { token: mockToken, locale: 'en' } ) ); - vi.clearAllMocks(); - setupIpcMocks(); - } ); - - it( 'clears auth state, wpcom client and Sentry user', async () => { - await store.dispatch( handleInvalidToken() ); - - expect( getIpcApi().clearAuthenticationToken ).toHaveBeenCalled(); - expect( getWpcomClient() ).toBeUndefined(); - expect( sentryUtils.setSentryWpcomUserIdRenderer ).toHaveBeenCalledWith( undefined ); - - const state = store.getState(); - expect( selectIsAuthenticated( state ) ).toBe( false ); - expect( selectUser( state ) ).toBeUndefined(); - } ); - - it( 'shows session expired message box', async () => { - await store.dispatch( handleInvalidToken() ); - - expect( getIpcApi().showMessageBox ).toHaveBeenCalledWith( - expect.objectContaining( { type: 'error' } ) - ); - } ); - - it( 'prevents duplicate dialogs when called concurrently', async () => { - const firstCall = store.dispatch( handleInvalidToken() ); - const secondCall = store.dispatch( handleInvalidToken() ); - - await Promise.all( [ firstCall, secondCall ] ); - - expect( getIpcApi().showMessageBox ).toHaveBeenCalledTimes( 1 ); - } ); - - it( 'allows a new dialog after the first one is dismissed', async () => { - await store.dispatch( handleInvalidToken() ); - await store.dispatch( handleInvalidToken() ); - - expect( getIpcApi().showMessageBox ).toHaveBeenCalledTimes( 2 ); - } ); - } ); -} ); From bc04dec08b4b9cb147833b6af536158a5a6cbd98 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Fri, 24 Apr 2026 15:37:22 -0400 Subject: [PATCH 18/26] ADJUST EVENT handler --- apps/studio/src/stores/auth-slice.ts | 45 ++++++++++++++-------------- apps/studio/src/stores/index.ts | 3 +- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index 01e7f83aaa..e58f3b6735 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -6,9 +6,10 @@ import { __ } from '@wordpress/i18n'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { isInvalidTokenError } from 'src/lib/is-invalid-oauth-token-error'; import { setSentryWpcomUserIdRenderer } from 'src/lib/renderer-sentry-utils'; +import { store } from 'src/stores'; import { getWpcomClient, setWpcomClient } from 'src/stores/wpcom-client'; import type { StoredAuthToken } from 'src/lib/oauth'; -import type { AppDispatch, RootState } from 'src/stores'; +import type { RootState } from 'src/stores'; import type { WPCOM } from 'wpcom/types'; interface WpcomParams extends Record< string, unknown > { @@ -189,31 +190,29 @@ const authSlice = createSlice( { export const selectIsAuthenticated = ( state: RootState ) => state.auth.isAuthenticated; export const selectUser = ( state: RootState ) => state.auth.user ?? undefined; -export function initializeAuthIpcListeners( dispatch: AppDispatch, getState: () => RootState ) { - window.ipcListener.subscribe( 'auth-updated', ( _event, payload ) => { - if ( 'error' in payload ) { - let title: string = __( 'Authentication error' ); - let message: string = __( 'Please try again.' ); - - if ( payload.error instanceof Error && payload.error.message.includes( 'access_denied' ) ) { - title = __( 'Authorization denied' ); - message = __( - 'It looks like you denied the authorization request. To proceed, please click "Approve"' - ); - } +window.ipcListener.subscribe( 'auth-updated', ( _event, payload ) => { + if ( 'error' in payload ) { + let title: string = __( 'Authentication error' ); + let message: string = __( 'Please try again.' ); - void getIpcApi().showErrorMessageBox( { title, message } ); - return; + if ( payload.error instanceof Error && payload.error.message.includes( 'access_denied' ) ) { + title = __( 'Authorization denied' ); + message = __( + 'It looks like you denied the authorization request. To proceed, please click "Approve"' + ); } - if ( ! payload.token ) { - void dispatch( authLogout() ); - return; - } + void getIpcApi().showErrorMessageBox( { title, message } ); + return; + } - const locale = getState().i18n.locale; - void dispatch( authTokenReceived( { token: payload.token, locale } ) ); - } ); -} + if ( ! payload.token ) { + void store.dispatch( authLogout() ); + return; + } + + const locale = store.getState().i18n.locale; + void store.dispatch( authTokenReceived( { token: payload.token, locale } ) ); +} ); export default authSlice.reducer; diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 3576fc888d..53d4d87f02 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -14,7 +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 authReducer, { initializeAuth, initializeAuthIpcListeners } from 'src/stores/auth-slice'; +import authReducer, { initializeAuth } from 'src/stores/auth-slice'; 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'; @@ -378,7 +378,6 @@ setupListeners( store.dispatch ); // Initialize beta features and fetch snapshots on store initialization, but skip in test environment if ( process.env.NODE_ENV !== 'test' ) { - initializeAuthIpcListeners( store.dispatch, store.getState.bind( store ) ); void store.dispatch( loadBetaFeatures() ); void refreshSnapshots(); } From b511fb86f3f619468ee422ce6d29a4585fab98f9 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Fri, 24 Apr 2026 15:45:06 -0400 Subject: [PATCH 19/26] Adjust locale --- apps/studio/src/stores/auth-slice.ts | 21 +++++++++------------ apps/studio/src/stores/index.ts | 19 +++---------------- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index e58f3b6735..57da64321c 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -29,8 +29,9 @@ const initialState: AuthState = { user: null, }; -function createWpcomClient( token?: string, locale?: string, onInvalidToken?: () => void ): WPCOM { +function createWpcomClient( token?: string, onInvalidToken?: () => void ): WPCOM { const addLocaleToParams = ( params: WpcomParams ) => { + const locale = store.getState().i18n.locale; if ( locale && locale !== 'en' ) { const queryParams = new URLSearchParams( 'query' in params && typeof params.query === 'string' ? params.query : '' @@ -94,8 +95,7 @@ export const handleInvalidToken = createTypedAsyncThunk( 'auth/handleInvalidToke export const initializeAuth = createTypedAsyncThunk( 'auth/initialize', - async ( _, { dispatch, getState } ) => { - const { locale } = getState().i18n; + async ( _, { dispatch } ) => { try { const token = await getIpcApi().getAuthenticationToken(); @@ -103,9 +103,7 @@ export const initializeAuth = createTypedAsyncThunk( return null; } - const client = createWpcomClient( token.accessToken, locale, () => - dispatch( handleInvalidToken() ) - ); + const client = createWpcomClient( token.accessToken, () => dispatch( handleInvalidToken() ) ); setWpcomClient( client ); setSentryWpcomUserIdRenderer( token.id ); @@ -124,10 +122,8 @@ export const initializeAuth = createTypedAsyncThunk( export const authTokenReceived = createTypedAsyncThunk( 'auth/tokenReceived', - async ( { token, locale }: { token: StoredAuthToken; locale: string }, { dispatch } ) => { - const client = createWpcomClient( token.accessToken, locale, () => - dispatch( handleInvalidToken() ) - ); + async ( { token }: { token: StoredAuthToken }, { dispatch } ) => { + const client = createWpcomClient( token.accessToken, () => dispatch( handleInvalidToken() ) ); setWpcomClient( client ); setSentryWpcomUserIdRenderer( token.id ); @@ -190,6 +186,8 @@ const authSlice = createSlice( { export const selectIsAuthenticated = ( state: RootState ) => state.auth.isAuthenticated; export const selectUser = ( state: RootState ) => state.auth.user ?? undefined; +void store.dispatch( initializeAuth() ); + window.ipcListener.subscribe( 'auth-updated', ( _event, payload ) => { if ( 'error' in payload ) { let title: string = __( 'Authentication error' ); @@ -211,8 +209,7 @@ window.ipcListener.subscribe( 'auth-updated', ( _event, payload ) => { return; } - const locale = store.getState().i18n.locale; - void store.dispatch( authTokenReceived( { token: payload.token, locale } ) ); + void store.dispatch( authTokenReceived( { token: payload.token } ) ); } ); export default authSlice.reducer; diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 53d4d87f02..8fc9bee409 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -1,9 +1,4 @@ -import { - combineReducers, - configureStore, - createListenerMiddleware, - isAnyOf, -} from '@reduxjs/toolkit'; +import { combineReducers, configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import { setupListeners } from '@reduxjs/toolkit/query'; import { useDispatch, useSelector } from 'react-redux'; import { LOCAL_STORAGE_CHAT_API_IDS_KEY, LOCAL_STORAGE_CHAT_MESSAGES_KEY } from 'src/constants'; @@ -14,11 +9,11 @@ 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 authReducer, { initializeAuth } from 'src/stores/auth-slice'; +import authReducer from 'src/stores/auth-slice'; 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'; -import i18nReducer, { initializeUserLocale, saveUserLocale } from 'src/stores/i18n-slice'; +import i18nReducer from 'src/stores/i18n-slice'; import { installedAppsApi } from 'src/stores/installed-apps-api'; import onboardingReducer from 'src/stores/onboarding-slice'; import { @@ -299,14 +294,6 @@ async function startPullPoller( selectedSiteId: string, remoteSiteId: number ) { } } -// Initialize auth once locale is loaded, and re-initialize when locale changes -startAppListening( { - matcher: isAnyOf( initializeUserLocale.fulfilled, saveUserLocale.fulfilled ), - effect( _, listenerApi ) { - void listenerApi.dispatch( initializeAuth() ); - }, -} ); - // Poll push progress when state enters a pollable status startAppListening( { actionCreator: syncOperationsActions.updatePushState, From 24c66dbc2002aa0ee8edf48844ca9789e589994c Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 27 Apr 2026 09:54:31 -0400 Subject: [PATCH 20/26] Use better practice for auth trunk --- apps/studio/src/hooks/use-auth.ts | 8 ++++---- apps/studio/src/stores/auth-slice.ts | 13 +++++++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/studio/src/hooks/use-auth.ts b/apps/studio/src/hooks/use-auth.ts index 4c26fc2922..9c51681c0e 100644 --- a/apps/studio/src/hooks/use-auth.ts +++ b/apps/studio/src/hooks/use-auth.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { useAppDispatch, useRootSelector } from 'src/stores'; -import { authLogout, selectIsAuthenticated, selectUser } from 'src/stores/auth-slice'; +import { authSelectors, authThunks } from 'src/stores/auth-slice'; import { getWpcomClient } from 'src/stores/wpcom-client'; import type { AuthUser } from 'src/stores/auth-slice'; import type { WPCOM } from 'wpcom/types'; @@ -16,13 +16,13 @@ export interface AuthContextType { export const useAuth = (): AuthContextType => { const dispatch = useAppDispatch(); - const isAuthenticated = useRootSelector( selectIsAuthenticated ); - const user = useRootSelector( selectUser ); + const isAuthenticated = useRootSelector( authSelectors.selectIsAuthenticated ); + const user = useRootSelector( authSelectors.selectUser ); const authenticate = useCallback( () => getIpcApi().authenticate(), [] ); const logout = useCallback( async () => { - await dispatch( authLogout() ); + await dispatch( authThunks.authLogout() ); }, [ dispatch ] ); return { diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index 57da64321c..969c6d8af0 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -183,8 +183,17 @@ const authSlice = createSlice( { }, } ); -export const selectIsAuthenticated = ( state: RootState ) => state.auth.isAuthenticated; -export const selectUser = ( state: RootState ) => state.auth.user ?? undefined; +export const authThunks = { + handleInvalidToken, + initializeAuth, + authTokenReceived, + authLogout, +}; + +export const authSelectors = { + selectIsAuthenticated: ( state: RootState ) => state.auth.isAuthenticated, + selectUser: ( state: RootState ) => state.auth.user ?? undefined, +}; void store.dispatch( initializeAuth() ); From b170b06a12587b6a2aa8e2fe2fc5ef32c5f1b9fa Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 27 Apr 2026 10:42:46 -0400 Subject: [PATCH 21/26] Remove use-auth --- apps/studio/src/components/app.tsx | 8 +- apps/studio/src/components/chat-rating.tsx | 4 +- .../src/components/content-tab-assistant.tsx | 11 +- .../components/content-tab-import-export.tsx | 4 +- .../src/components/content-tab-previews.tsx | 10 +- apps/studio/src/components/gravatar.tsx | 5 +- .../src/components/publish-site-button.tsx | 4 +- .../src/components/studio-code-chat.tsx | 7 +- .../tests/content-tab-assistant.test.tsx | 44 +++++--- .../src/components/tests/gravatar.test.tsx | 37 ++++++- .../components/tests/main-sidebar.test.tsx | 15 ++- .../tests/site-content-tabs.test.tsx | 16 +-- .../tests/site-management-actions.test.tsx | 18 ++-- .../src/components/tests/topbar.test.tsx | 31 +++--- apps/studio/src/components/top-bar.tsx | 7 +- .../use-listen-deep-link-connection.ts | 6 +- .../src/hooks/tests/use-add-site.test.tsx | 11 +- apps/studio/src/hooks/use-add-site.ts | 8 +- apps/studio/src/hooks/use-auth.ts | 35 ------ apps/studio/src/hooks/use-feature-flags.tsx | 9 +- .../add-site/components/pull-remote-site.tsx | 9 +- .../components/connect-to-wpcom.tsx | 4 +- apps/studio/src/modules/onboarding/index.tsx | 6 +- .../components/create-preview-button.tsx | 4 +- .../sync/components/sync-connected-sites.tsx | 10 +- .../components/sync-sites-modal-selector.tsx | 4 +- apps/studio/src/modules/sync/index.tsx | 14 +-- .../src/modules/sync/tests/index.test.tsx | 80 ++++++++------ .../user-settings/components/account-tab.tsx | 9 +- .../non-authenticated-account-tab.tsx | 5 +- .../components/tests/user-settings.test.tsx | 101 +++++++++--------- .../components/user-settings.tsx | 4 +- apps/studio/src/stores/sync/sync-hooks.ts | 9 +- 33 files changed, 305 insertions(+), 244 deletions(-) delete mode 100644 apps/studio/src/hooks/use-auth.ts diff --git a/apps/studio/src/components/app.tsx b/apps/studio/src/components/app.tsx index 923ed44ef9..471ccce52a 100644 --- a/apps/studio/src/components/app.tsx +++ b/apps/studio/src/components/app.tsx @@ -10,7 +10,6 @@ import { SiteContentTabs } from 'src/components/site-content-tabs'; import TopBar from 'src/components/top-bar'; import WindowsTitlebar from 'src/components/windows-titlebar'; import { useListenDeepLinkConnection } from 'src/hooks/sync-sites/use-listen-deep-link-connection'; -import { useAuth } from 'src/hooks/use-auth'; import { useLocalizationSupport } from 'src/hooks/use-localization-support'; import { useSidebarResize } from 'src/hooks/use-sidebar-resize'; import { useSidebarVisibility } from 'src/hooks/use-sidebar-visibility'; @@ -23,8 +22,10 @@ import { useOnboarding } from 'src/modules/onboarding/hooks/use-onboarding'; import { UserSettings } from 'src/modules/user-settings'; import { WhatsNewModal, useWhatsNew } from 'src/modules/whats-new'; import { useAppDispatch, useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { selectOnboardingLoading } from 'src/stores/onboarding-slice'; import { syncOperationsThunks } from 'src/stores/sync'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import 'src/index.css'; export default function App() { @@ -40,15 +41,16 @@ export default function App() { const { sites: localSites, loadingSites } = useSiteDetails(); const isEmpty = ! loadingSites && ! localSites.length; const shouldShowWhatsNew = showWhatsNew && ! isEmpty; - const { client } = useAuth(); + const isAuthenticated = useRootSelector( authSelectors.selectIsAuthenticated ); const dispatch = useAppDispatch(); // Initialize sync states from in-progress server operations useEffect( () => { + const client = getWpcomClient(); if ( client ) { void dispatch( syncOperationsThunks.initializeSyncStates( { client } ) ); } - }, [ client, dispatch ] ); + }, [ isAuthenticated, dispatch ] ); useListenDeepLinkConnection(); diff --git a/apps/studio/src/components/chat-rating.tsx b/apps/studio/src/components/chat-rating.tsx index 5b176a716f..4aad534bdb 100644 --- a/apps/studio/src/components/chat-rating.tsx +++ b/apps/studio/src/components/chat-rating.tsx @@ -1,8 +1,8 @@ import { __ } from '@wordpress/i18n'; import { thumbsUp, thumbsDown, Icon } from '@wordpress/icons'; import Button from 'src/components/button'; -import { useAuth } from 'src/hooks/use-auth'; import { useAppDispatch } from 'src/stores'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import { chatThunks } from 'src/stores/chat-slice'; interface ChatRatingProps { @@ -21,9 +21,9 @@ export const FeedbackThanks = () => { }; export const ChatRating = ( { messageApiId, feedbackReceived, instanceId }: ChatRatingProps ) => { - const { client } = useAuth(); const dispatch = useAppDispatch(); const handleRatingClick = async ( feedback: number ) => { + const client = getWpcomClient(); if ( ! client ) { return; } diff --git a/apps/studio/src/components/content-tab-assistant.tsx b/apps/studio/src/components/content-tab-assistant.tsx index 72cc36d471..54e0549d5d 100644 --- a/apps/studio/src/components/content-tab-assistant.tsx +++ b/apps/studio/src/components/content-tab-assistant.tsx @@ -19,7 +19,6 @@ import offlineIcon from 'src/components/offline-icon'; import { StudioCodeChat } from 'src/components/studio-code-chat'; import WelcomeComponent from 'src/components/welcome-message-prompt'; import { LIMIT_OF_PROMPTS_PER_USER, TELEX_HOSTNAME, TELEX_UTM_PARAMS } from 'src/constants'; -import { useAuth } from 'src/hooks/use-auth'; import { useFeatureFlags } from 'src/hooks/use-feature-flags'; import { useOffline } from 'src/hooks/use-offline'; import { useThemeDetails } from 'src/hooks/use-theme-details'; @@ -27,6 +26,8 @@ import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { addUrlParams } from 'src/lib/url-utils'; import { useAppDispatch, useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import { chatThunks, generateMessage, @@ -375,7 +376,8 @@ function WpcomAssistant( { selectedSite }: ContentTabAssistantProps ) { const chatInput = useRootSelector( ( state ) => chatSelectors.selectChatInput( state, selectedSite.id ) ); - const { isAuthenticated, authenticate, user, client } = useAuth(); + const isAuthenticated = useRootSelector( authSelectors.selectIsAuthenticated ); + const user = useRootSelector( authSelectors.selectUser ); const instanceId = user?.id ? `${ user.id }_${ selectedSite.id }` : selectedSite.id; const chatApiId = useRootSelector( ( state ) => chatSelectors.selectChatApiId( state, instanceId ) @@ -409,6 +411,7 @@ function WpcomAssistant( { selectedSite }: ContentTabAssistantProps ) { const submitPrompt = useCallback( ( chatMessage: string, isRetry?: boolean ) => { + const client = getWpcomClient(); if ( ! chatMessage || ! client ) { return; } @@ -430,7 +433,7 @@ function WpcomAssistant( { selectedSite }: ContentTabAssistantProps ) { } ) ); }, - [ client, dispatch, instanceId, selectedSite.id, messages, chatApiId ] + [ dispatch, instanceId, selectedSite.id, messages, chatApiId ] ); const clearConversation = () => { @@ -532,7 +535,7 @@ function WpcomAssistant( { selectedSite }: ContentTabAssistantProps ) { /> ) : ( - ! isOffline && + ! isOffline && getIpcApi().authenticate() } /> ) } { renderNotice() } diff --git a/apps/studio/src/components/content-tab-import-export.tsx b/apps/studio/src/components/content-tab-import-export.tsx index d46bd912b0..c2fef6d9f3 100644 --- a/apps/studio/src/components/content-tab-import-export.tsx +++ b/apps/studio/src/components/content-tab-import-export.tsx @@ -12,7 +12,6 @@ import { ErrorIcon } from 'src/components/error-icon'; import { LearnMoreLink } from 'src/components/learn-more'; import ProgressBar from 'src/components/progress-bar'; import { Tooltip } from 'src/components/tooltip'; -import { useAuth } from 'src/hooks/use-auth'; import { useConfirmationDialog } from 'src/hooks/use-confirmation-dialog'; import { useDragAndDropFile } from 'src/hooks/use-drag-and-drop-file'; import { useImportExport } from 'src/hooks/use-import-export'; @@ -20,6 +19,7 @@ import { useSiteDetails } from 'src/hooks/use-site-details'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { syncOperationsSelectors } from 'src/stores/sync'; import { useGetConnectedSitesForLocalSiteQuery } from 'src/stores/sync/connected-sites'; @@ -339,7 +339,7 @@ const ImportSite = ( { export function ContentTabImportExport( { selectedSite }: ContentTabImportExportProps ) { const { __ } = useI18n(); const [ isSupported, setIsSupported ] = useState< boolean | null >( null ); - const { user } = useAuth(); + const user = useRootSelector( authSelectors.selectUser ); const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { localSiteId: selectedSite.id, userId: user?.id, diff --git a/apps/studio/src/components/content-tab-previews.tsx b/apps/studio/src/components/content-tab-previews.tsx index fc9b13befd..120ca77acf 100644 --- a/apps/studio/src/components/content-tab-previews.tsx +++ b/apps/studio/src/components/content-tab-previews.tsx @@ -10,7 +10,6 @@ import offlineIcon from 'src/components/offline-icon'; import { ScreenshotDemoSite } from 'src/components/screenshot-demo-site'; import { Tooltip } from 'src/components/tooltip'; import { LIMIT_OF_ZIP_SITES_PER_USER } from 'src/constants'; -import { useAuth } from 'src/hooks/use-auth'; import { useOffline } from 'src/hooks/use-offline'; import { useSiteSize } from 'src/hooks/use-site-size'; import { getIpcApi } from 'src/lib/get-ipc-api'; @@ -20,6 +19,7 @@ import { PreviewSitesTableHeader } from 'src/modules/preview-site/components/pre import { ProgressRow } from 'src/modules/preview-site/components/progress-row'; import { useUpdateButtonTooltip } from 'src/modules/preview-site/hooks/use-update-button-tooltip'; import { useAppDispatch, useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { snapshotSelectors, snapshotThunks } from 'src/stores/snapshot-slice'; import { useGetSnapshotUsage } from 'src/stores/wpcom-api'; @@ -80,7 +80,6 @@ function EmptyGeneric( { function NoAuth( { selectedSite }: React.ComponentProps< typeof EmptyGeneric > ) { const isOffline = useOffline(); const { __ } = useI18n(); - const { authenticate } = useAuth(); const offlineMessage = __( "You're currently offline." ); return ( @@ -95,7 +94,7 @@ function NoAuth( { selectedSite }: React.ComponentProps< typeof EmptyGeneric > ) if ( isOffline ) { return; } - authenticate(); + getIpcApi().authenticate(); } } > { __( 'Log in to WordPress.com' ) } @@ -135,7 +134,7 @@ function NoAuth( { selectedSite }: React.ComponentProps< typeof EmptyGeneric > ) function NoPreviews( { selectedSite }: React.ComponentProps< typeof EmptyGeneric > ) { const dispatch = useAppDispatch(); - const { user } = useAuth(); + const user = useRootSelector( authSelectors.selectUser ); return ( @@ -160,7 +159,8 @@ function NoPreviews( { selectedSite }: React.ComponentProps< typeof EmptyGeneric export function ContentTabPreviews( { selectedSite }: ContentTabPreviewsProps ) { const dispatch = useAppDispatch(); const { data: snapshotUsage } = useGetSnapshotUsage(); - const { isAuthenticated, user } = useAuth(); + const isAuthenticated = useRootSelector( authSelectors.selectIsAuthenticated ); + const user = useRootSelector( authSelectors.selectUser ); const { isOverLimit } = useSiteSize( selectedSite.id ); const activeOperation = useRootSelector( ( state ) => snapshotSelectors.selectActiveCreateOperationForSite( state, selectedSite.id ) diff --git a/apps/studio/src/components/gravatar.tsx b/apps/studio/src/components/gravatar.tsx index b9d0e07621..ad5ef0bb40 100644 --- a/apps/studio/src/components/gravatar.tsx +++ b/apps/studio/src/components/gravatar.tsx @@ -3,8 +3,9 @@ import { commentAuthorAvatar } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { useState } from 'react'; import profileIconDetailed from 'src/components/profile-icon-detailed'; -import { useAuth } from 'src/hooks/use-auth'; import { useGravatarUrl } from 'src/hooks/use-gravatar-url'; +import { useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { cx } from 'src/lib/cx'; export function Gravatar( { @@ -19,7 +20,7 @@ export function Gravatar( { detailedDefaultImage?: boolean; } ) { const { __ } = useI18n(); - const { user } = useAuth(); + const user = useRootSelector( authSelectors.selectUser ); const gravatarUrl = useGravatarUrl( user?.email, isBlack, detailedDefaultImage ); const [ imageError, setImageError ] = useState( false ); diff --git a/apps/studio/src/components/publish-site-button.tsx b/apps/studio/src/components/publish-site-button.tsx index 0f049f42df..bb01d55cef 100644 --- a/apps/studio/src/components/publish-site-button.tsx +++ b/apps/studio/src/components/publish-site-button.tsx @@ -1,8 +1,8 @@ import { cloudUpload } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback } from 'react'; -import { useAuth } from 'src/hooks/use-auth'; import { useSiteDetails } from 'src/hooks/use-site-details'; +import { authSelectors } from 'src/stores/auth-slice'; import { generateCheckoutUrl } from 'src/lib/generate-checkout-url'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { ConnectButton } from 'src/modules/sync/components/connect-button'; @@ -12,7 +12,7 @@ import { useGetConnectedSitesForLocalSiteQuery } from 'src/stores/sync/connected export const PublishSiteButton = () => { const { __ } = useI18n(); - const { user } = useAuth(); + const user = useRootSelector( authSelectors.selectUser ); const { selectedSite } = useSiteDetails(); const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { localSiteId: selectedSite?.id, diff --git a/apps/studio/src/components/studio-code-chat.tsx b/apps/studio/src/components/studio-code-chat.tsx index e1b36ae13b..3f2741e330 100644 --- a/apps/studio/src/components/studio-code-chat.tsx +++ b/apps/studio/src/components/studio-code-chat.tsx @@ -5,10 +5,11 @@ import { AIInput } from 'src/components/ai-input'; import { ArrowIcon } from 'src/components/arrow-icon'; import { MessageThinking } from 'src/components/assistant-thinking'; import Button from 'src/components/button'; -import { useAuth } from 'src/hooks/use-auth'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { useOffline } from 'src/hooks/use-offline'; import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { parseStudioCodeEvent, type ParsedAction } from './studio-code-event-parser'; import { StudioCodeMessage } from './studio-code-message'; import { StudioCodePermission } from './studio-code-permission'; @@ -271,7 +272,7 @@ export function StudioCodeChat( { selectedSite }: StudioCodeChatProps ) { const [ state, dispatch ] = useReducer( reducer, selectedSite.id, initState ); const [ inputValue, setInputValue ] = useState( '' ); const messagesEndRef = useRef< HTMLDivElement >( null ); - const { isAuthenticated, authenticate } = useAuth(); + const isAuthenticated = useRootSelector( authSelectors.selectIsAuthenticated ); const isOffline = useOffline(); const showUnauthenticated = ! isAuthenticated && ! isOffline; @@ -408,7 +409,7 @@ export function StudioCodeChat( { selectedSite }: StudioCodeChatProps ) { ) }
{ __( 'Usage limits may change in the future.' ) }
- diff --git a/apps/studio/src/components/tests/content-tab-assistant.test.tsx b/apps/studio/src/components/tests/content-tab-assistant.test.tsx index da13f57a41..610dbc8f3f 100644 --- a/apps/studio/src/components/tests/content-tab-assistant.test.tsx +++ b/apps/studio/src/components/tests/content-tab-assistant.test.tsx @@ -12,15 +12,17 @@ import { MIMIC_CONVERSATION_DELAY, } from 'src/components/content-tab-assistant'; import { LOCAL_STORAGE_CHAT_MESSAGES_KEY, CLEAR_HISTORY_REMINDER_TIME } from 'src/constants'; -import { AuthContextType, useAuth } from 'src/hooks/use-auth'; import { useGetWpVersion } from 'src/hooks/use-get-wp-version'; import { useOffline } from 'src/hooks/use-offline'; import { ThemeDetailsProvider } from 'src/hooks/use-theme-details'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { store } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { generateMessage, chatActions } from 'src/stores/chat-slice'; import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; import { useGetAssistantQuota, useGetWelcomeMessages } from 'src/stores/wpcom-api'; +import { getWpcomClient } from 'src/stores/wpcom-client'; +import type { AuthUser } from 'src/stores/auth-slice'; import type { WPCOM } from 'wpcom/types'; store.replaceReducer( testReducer ); @@ -28,7 +30,20 @@ store.replaceReducer( testReducer ); vi.mock( 'src/hooks/use-offline' ); vi.mock( 'src/lib/get-ipc-api' ); vi.mock( 'src/hooks/use-get-wp-version' ); -vi.mock( 'src/hooks/use-auth' ); +vi.mock( 'src/stores/auth-slice', async () => { + const actual = await vi.importActual( 'src/stores/auth-slice' ); + return { + ...actual, + authSelectors: { + selectIsAuthenticated: vi.fn( () => true ), + selectUser: vi.fn( () => undefined ), + }, + }; +} ); +vi.mock( 'src/stores/wpcom-client', () => ( { + getWpcomClient: vi.fn(), + setWpcomClient: vi.fn(), +} ) ); vi.mock( 'src/lib/app-globals', () => ( { getAppGlobals: () => ( { @@ -119,23 +134,21 @@ const initialMessages = [ ]; describe( 'ContentTabAssistant', () => { - const authenticate = vi.fn(); - const logout = vi.fn(); - type ContextState = { selectedSite?: SiteDetails; - auth?: Partial< AuthContextType >; + auth?: { + isAuthenticated?: boolean; + user?: AuthUser; + }; }; const buildContextTree = ( { selectedSite = runningSite, auth = {} }: ContextState = {} ) => { - const authContextValue: AuthContextType = { - client: createWpcomClient(), - isAuthenticated: true, - authenticate, - logout, - ...auth, - }; - vi.mocked( useAuth ).mockReturnValue( authContextValue ); + const { isAuthenticated = true, user } = auth; + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( isAuthenticated ); + vi.mocked( authSelectors.selectUser ).mockReturnValue( user ); + vi.mocked( getWpcomClient ).mockReturnValue( + isAuthenticated ? createWpcomClient() : undefined + ); return ( @@ -184,6 +197,7 @@ describe( 'ContentTabAssistant', () => { vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( { showMessageBox: vi.fn().mockResolvedValue( { response: 0, checkboxChecked: false } ), executeWPCLiInline: vi.fn().mockResolvedValue( { stdout: '', stderr: 'Error' } ), + authenticate: vi.fn(), } ); vi.mocked( useGetWpVersion ).mockReturnValue( [ '6.4.3', vi.fn() ] ); } ); @@ -268,7 +282,7 @@ describe( 'ContentTabAssistant', () => { const loginButton = screen.getByRole( 'button', { name: 'Log in to WordPress.com ↗' } ); fireEvent.click( loginButton ); - expect( authenticate ).toHaveBeenCalledTimes( 1 ); + expect( getIpcApi().authenticate ).toHaveBeenCalledTimes( 1 ); } ); it( 'it stores messages with user-unique keys', async () => { diff --git a/apps/studio/src/components/tests/gravatar.test.tsx b/apps/studio/src/components/tests/gravatar.test.tsx index fa84b193cb..e3a2899bb2 100644 --- a/apps/studio/src/components/tests/gravatar.test.tsx +++ b/apps/studio/src/components/tests/gravatar.test.tsx @@ -1,11 +1,25 @@ import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; import { vi } from 'vitest'; import { Gravatar } from 'src/components/gravatar'; import { useGravatarUrl } from 'src/hooks/use-gravatar-url'; +import { store } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; -vi.mock( 'src/hooks/use-auth', () => ( { - useAuth: () => ( { user: { email: 'antonio.sejas@automattic.com' } } ), -} ) ); +vi.mock( 'src/stores/auth-slice', async () => { + const actual = await vi.importActual( 'src/stores/auth-slice' ); + return { + ...actual, + authSelectors: { + selectIsAuthenticated: vi.fn( () => true ), + selectUser: vi.fn( () => ( { + id: 1, + email: 'antonio.sejas@automattic.com', + displayName: 'Antonio', + } ) ), + }, + }; +} ); vi.mock( 'src/hooks/use-gravatar-url', () => ( { useGravatarUrl: vi.fn(), @@ -14,6 +28,11 @@ vi.mock( 'src/hooks/use-gravatar-url', () => ( { describe( 'Gravatar', () => { beforeEach( () => { vi.clearAllMocks(); + vi.mocked( authSelectors.selectUser ).mockReturnValue( { + id: 1, + email: 'antonio.sejas@automattic.com', + displayName: 'Antonio', + } ); } ); test( 'Gravatar renders the image when gravatarUrl is available', () => { @@ -21,7 +40,11 @@ describe( 'Gravatar', () => { 'https://www.gravatar.com/avatar/efc7b0f52253614d24531995d89c6d3dcf36bedcf6357a28f034c2597d84266b?d=https://s0.wp.com/i/studio-app/profile-icon.png' ); - render( ); + render( + + + + ); const image = screen.getByAltText( 'User avatar' ); expect( image ).toBeVisible(); @@ -34,7 +57,11 @@ describe( 'Gravatar', () => { test( 'Gravatar does not render the image when there is no email', () => { vi.mocked( useGravatarUrl ).mockReturnValue( '' ); - render( ); + render( + + + + ); const image = screen.queryByAltText( 'User avatar' ); expect( image ).not.toBeInTheDocument(); diff --git a/apps/studio/src/components/tests/main-sidebar.test.tsx b/apps/studio/src/components/tests/main-sidebar.test.tsx index 4b525ef100..94c9188770 100644 --- a/apps/studio/src/components/tests/main-sidebar.test.tsx +++ b/apps/studio/src/components/tests/main-sidebar.test.tsx @@ -3,11 +3,20 @@ import { userEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; import MainSidebar from 'src/components/main-sidebar'; -import { useAuth } from 'src/hooks/use-auth'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { store } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; -vi.mock( 'src/hooks/use-auth' ); +vi.mock( 'src/stores/auth-slice', async () => { + const actual = await vi.importActual( 'src/stores/auth-slice' ); + return { + ...actual, + authSelectors: { + selectIsAuthenticated: vi.fn( () => false ), + selectUser: vi.fn( () => undefined ), + }, + }; +} ); vi.mock( 'src/stores/wordpress-versions-api', () => ( { wordpressVersionsApi: { @@ -111,7 +120,7 @@ describe( 'MainSidebar Footer', () => { vi.clearAllMocks(); } ); it( 'Has add site button', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( { isAuthenticated: false } ); + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( false ); await act( async () => renderWithProvider( ) ); expect( screen.getByRole( 'button', { name: 'Add site' } ) ).toBeVisible(); } ); diff --git a/apps/studio/src/components/tests/site-content-tabs.test.tsx b/apps/studio/src/components/tests/site-content-tabs.test.tsx index e2807e0f5d..00f026d353 100644 --- a/apps/studio/src/components/tests/site-content-tabs.test.tsx +++ b/apps/studio/src/components/tests/site-content-tabs.test.tsx @@ -17,12 +17,16 @@ const selectedSite: SiteDetails = { }; vi.mock( 'src/hooks/use-site-details' ); -vi.mock( 'src/hooks/use-auth', () => ( { - useAuth: () => ( { - isAuthenticated: true, - authenticate: vi.fn(), - } ), -} ) ); +vi.mock( 'src/stores/auth-slice', async () => { + const actual = await vi.importActual( 'src/stores/auth-slice' ); + return { + ...actual, + authSelectors: { + selectIsAuthenticated: vi.fn( () => true ), + selectUser: vi.fn( () => undefined ), + }, + }; +} ); vi.mock( 'src/lib/app-globals', async () => ( { ...( await vi.importActual( '../../lib/app-globals' ) ), getAppGlobals: vi.fn().mockReturnValue( { locale: ' en' } ), diff --git a/apps/studio/src/components/tests/site-management-actions.test.tsx b/apps/studio/src/components/tests/site-management-actions.test.tsx index fca0ec9eab..616e7fbb78 100644 --- a/apps/studio/src/components/tests/site-management-actions.test.tsx +++ b/apps/studio/src/components/tests/site-management-actions.test.tsx @@ -27,13 +27,17 @@ vi.mock( 'src/hooks/use-site-details', () => ( { } ), } ) ); -// Mock useAuth to return a dummy user -vi.mock( 'src/hooks/use-auth', () => ( { - useAuth: () => ( { - user: { id: 1 }, - isAuthenticated: true, - } ), -} ) ); +// Mock auth selectors to return a dummy user +vi.mock( 'src/stores/auth-slice', async () => { + const actual = await vi.importActual( 'src/stores/auth-slice' ); + return { + ...actual, + authSelectors: { + selectIsAuthenticated: vi.fn( () => true ), + selectUser: vi.fn( () => ( { id: 1, email: 'user@example.com', displayName: 'User' } ) ), + }, + }; +} ); const defaultProps = { onStart: vi.fn(), diff --git a/apps/studio/src/components/tests/topbar.test.tsx b/apps/studio/src/components/tests/topbar.test.tsx index 70242efcf0..0a825710f5 100644 --- a/apps/studio/src/components/tests/topbar.test.tsx +++ b/apps/studio/src/components/tests/topbar.test.tsx @@ -3,11 +3,20 @@ import { userEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; import TopBar from 'src/components/top-bar'; -import { useAuth } from 'src/hooks/use-auth'; import { useOffline } from 'src/hooks/use-offline'; import { store } from 'src/stores'; - -vi.mock( 'src/hooks/use-auth' ); +import { authSelectors } from 'src/stores/auth-slice'; + +vi.mock( 'src/stores/auth-slice', async () => { + const actual = await vi.importActual( 'src/stores/auth-slice' ); + return { + ...actual, + authSelectors: { + selectIsAuthenticated: vi.fn( () => false ), + selectUser: vi.fn( () => undefined ), + }, + }; +} ); vi.mock( 'src/hooks/use-offline' ); vi.mock( 'src/lib/app-globals', async ( importOriginal ) => ( { ...( await importOriginal< typeof import('src/lib/app-globals') >() ), @@ -38,11 +47,7 @@ describe( 'TopBar', () => { vi.clearAllMocks(); } ); it( 'Test unauthenticated TopBar has the Log in button', async () => { - const authenticate = vi.fn(); - vi.mocked( useAuth, { partial: true } ).mockReturnValue( { - isAuthenticated: false, - authenticate, - } ); + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( false ); await act( async () => renderWithProvider( ) ); expect( screen.queryByRole( 'button', { name: 'Open account settings' } ) @@ -53,7 +58,7 @@ describe( 'TopBar', () => { } ); it( 'Test authenticated TopBar does not have the log in button and it has the settings and account buttons', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( { isAuthenticated: true } ); + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( true ); await act( async () => renderWithProvider( ) ); expect( screen.queryByRole( 'button', { name: 'Log in' } ) ).not.toBeInTheDocument(); expect( screen.getByRole( 'button', { name: 'Open settings' } ) ).toBeVisible(); @@ -74,7 +79,7 @@ describe( 'TopBar', () => { it( 'opens the support URL', async () => { const user = userEvent.setup(); - vi.mocked( useAuth, { partial: true } ).mockReturnValue( { isAuthenticated: true } ); + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( true ); renderWithProvider( ); @@ -105,7 +110,7 @@ describe( 'TopBar', () => { describe( 'login button with offline state', () => { it( 'disables login button when offline and unauthenticated', async () => { const user = userEvent.setup(); - vi.mocked( useAuth, { partial: true } ).mockReturnValue( { isAuthenticated: false } ); + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( false ); vi.mocked( useOffline ).mockReturnValue( true ); renderWithProvider( ); @@ -122,7 +127,7 @@ describe( 'TopBar', () => { } ); it( 'enables login button when online and unauthenticated', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( { isAuthenticated: false } ); + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( false ); vi.mocked( useOffline ).mockReturnValue( false ); renderWithProvider( ); @@ -134,7 +139,7 @@ describe( 'TopBar', () => { } ); it( 'disables login button when offline and unauthenticated', () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( { isAuthenticated: false } ); + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( false ); vi.mocked( useOffline ).mockReturnValue( true ); renderWithProvider( ); const loginButton = screen.getByRole( 'button', { diff --git a/apps/studio/src/components/top-bar.tsx b/apps/studio/src/components/top-bar.tsx index 874c394f43..a991b1fd0d 100644 --- a/apps/studio/src/components/top-bar.tsx +++ b/apps/studio/src/components/top-bar.tsx @@ -5,11 +5,11 @@ import { Gravatar } from 'src/components/gravatar'; import offlineIcon from 'src/components/offline-icon'; import { Tooltip } from 'src/components/tooltip'; import { WordPressLogo } from 'src/components/wordpress-logo'; -import { useAuth } from 'src/hooks/use-auth'; import { useOffline } from 'src/hooks/use-offline'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getLocalizedLink } from 'src/lib/get-localized-link'; -import { useI18nLocale } from 'src/stores'; +import { useI18nLocale, useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; interface TopBarProps { onToggleSidebar: () => void; } @@ -71,7 +71,8 @@ function OfflineIndicator() { function Authentication() { const { __ } = useI18n(); - const { isAuthenticated, user } = useAuth(); + const isAuthenticated = useRootSelector( authSelectors.selectIsAuthenticated ); + const user = useRootSelector( authSelectors.selectUser ); const isOffline = useOffline(); if ( isAuthenticated ) { return ( diff --git a/apps/studio/src/hooks/sync-sites/use-listen-deep-link-connection.ts b/apps/studio/src/hooks/sync-sites/use-listen-deep-link-connection.ts index c9a5e45f6e..041f450de4 100644 --- a/apps/studio/src/hooks/sync-sites/use-listen-deep-link-connection.ts +++ b/apps/studio/src/hooks/sync-sites/use-listen-deep-link-connection.ts @@ -1,10 +1,10 @@ import { SyncSite } from '@studio/common/types/sync'; -import { useAuth } from 'src/hooks/use-auth'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { getIpcApi } from 'src/lib/get-ipc-api'; -import { useAppDispatch } from 'src/stores'; +import { useAppDispatch, useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { connectedSitesActions, connectedSitesApi, @@ -18,7 +18,7 @@ export function useListenDeepLinkConnection() { const [ connectSite ] = useConnectSiteMutation(); const { selectedSite, setSelectedSiteId } = useSiteDetails(); const { setSelectedTab, selectedTab } = useContentTabs(); - const { user } = useAuth(); + const user = useRootSelector( authSelectors.selectUser ); const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { localSiteId: selectedSite?.id, userId: user?.id, diff --git a/apps/studio/src/hooks/tests/use-add-site.test.tsx b/apps/studio/src/hooks/tests/use-add-site.test.tsx index 5af5a82b6b..1efda79a26 100644 --- a/apps/studio/src/hooks/tests/use-add-site.test.tsx +++ b/apps/studio/src/hooks/tests/use-add-site.test.tsx @@ -4,17 +4,20 @@ import nock from 'nock'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; import { useAddSite, CreateSiteFormValues } from 'src/hooks/use-add-site'; -import { useAuth } from 'src/hooks/use-auth'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { store } from 'src/stores'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import type { SyncSite } from '@studio/common/types/sync'; import type { WPCOM } from 'wpcom/types'; vi.mock( 'src/hooks/use-site-details' ); vi.mock( 'src/hooks/use-feature-flags' ); -vi.mock( 'src/hooks/use-auth' ); vi.mock( 'src/hooks/use-content-tabs' ); +vi.mock( 'src/stores/wpcom-client', () => ( { + getWpcomClient: vi.fn(), + setWpcomClient: vi.fn(), +} ) ); const mockPullSiteThunk = vi.hoisted( () => vi.fn() ); @@ -92,9 +95,7 @@ describe( 'useAddSite', () => { startServer: mockStartServer, } ); - vi.mocked( useAuth, { partial: true } ).mockReturnValue( { - client: mockClient, - } ); + vi.mocked( getWpcomClient ).mockReturnValue( mockClient ); mockSetSelectedTab.mockReset(); vi.mocked( useContentTabs, { partial: true } ).mockReturnValue( { diff --git a/apps/studio/src/hooks/use-add-site.ts b/apps/studio/src/hooks/use-add-site.ts index 5dc79b4f06..07868ad37f 100644 --- a/apps/studio/src/hooks/use-add-site.ts +++ b/apps/studio/src/hooks/use-add-site.ts @@ -5,7 +5,6 @@ import { generateCustomDomainFromSiteName } from '@studio/common/lib/domains'; import { SupportedPHPVersion } from '@studio/common/types/php-versions'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useMemo, useState } from 'react'; -import { useAuth } from 'src/hooks/use-auth'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useImportExport } from 'src/hooks/use-import-export'; import { useSiteDetails } from 'src/hooks/use-site-details'; @@ -13,6 +12,7 @@ import { getIpcApi } from 'src/lib/get-ipc-api'; import { useAppDispatch } from 'src/stores'; import { syncOperationsThunks } from 'src/stores/sync'; import { useConnectSiteMutation } from 'src/stores/sync/connected-sites'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import { Blueprint } from 'src/stores/wpcom-api'; import type { BlueprintPreferredVersions } from '@studio/common/lib/blueprint-validation'; import type { SyncSite } from '@studio/common/types/sync'; @@ -50,7 +50,6 @@ export function useAddSite() { const { createSite, sites } = useSiteDetails(); const { importFile, clearImportState, importState } = useImportExport(); const [ connectSite ] = useConnectSiteMutation(); - const { client } = useAuth(); const dispatch = useAppDispatch(); const { setSelectedTab } = useContentTabs(); const [ fileForImport, setFileForImport ] = useState< File | null >( null ); @@ -272,12 +271,12 @@ export function useAddSite() { title: newSite.name, body: __( 'Your new site was imported' ), } ); - } else if ( selectedRemoteSite && client ) { + } else if ( selectedRemoteSite && getWpcomClient() ) { await connectSite( { site: selectedRemoteSite, localSiteId: newSite.id } ); const pullOptions: SyncOption[] = [ 'all' ]; void dispatch( syncOperationsThunks.pullSite( { - client, + client: getWpcomClient()!, connectedSite: selectedRemoteSite, selectedSite: newSite, options: { optionsToSync: pullOptions }, @@ -303,7 +302,6 @@ export function useAddSite() { [ __, clearImportState, - client, createSite, dispatch, fileForImport, diff --git a/apps/studio/src/hooks/use-auth.ts b/apps/studio/src/hooks/use-auth.ts deleted file mode 100644 index 9c51681c0e..0000000000 --- a/apps/studio/src/hooks/use-auth.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useCallback } from 'react'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { useAppDispatch, useRootSelector } from 'src/stores'; -import { authSelectors, authThunks } from 'src/stores/auth-slice'; -import { getWpcomClient } from 'src/stores/wpcom-client'; -import type { AuthUser } from 'src/stores/auth-slice'; -import type { WPCOM } from 'wpcom/types'; - -export interface AuthContextType { - client: WPCOM | undefined; - isAuthenticated: boolean; - authenticate: () => void; - logout: () => Promise< void >; - user?: AuthUser; -} - -export const useAuth = (): AuthContextType => { - const dispatch = useAppDispatch(); - const isAuthenticated = useRootSelector( authSelectors.selectIsAuthenticated ); - const user = useRootSelector( authSelectors.selectUser ); - - const authenticate = useCallback( () => getIpcApi().authenticate(), [] ); - - const logout = useCallback( async () => { - await dispatch( authThunks.authLogout() ); - }, [ dispatch ] ); - - return { - client: isAuthenticated ? getWpcomClient() : undefined, - isAuthenticated, - authenticate, - logout, - user, - }; -}; diff --git a/apps/studio/src/hooks/use-feature-flags.tsx b/apps/studio/src/hooks/use-feature-flags.tsx index b1c220ee31..aa9a448101 100644 --- a/apps/studio/src/hooks/use-feature-flags.tsx +++ b/apps/studio/src/hooks/use-feature-flags.tsx @@ -1,8 +1,10 @@ import React, { createContext, useContext, ReactNode, useState, useEffect } from 'react'; -import { useAuth } from 'src/hooks/use-auth'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { FEATURE_FLAGS } from 'src/lib/feature-flags'; import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; +import { getWpcomClient } from 'src/stores/wpcom-client'; type FeatureFlagsContextType = FeatureFlags; @@ -31,7 +33,7 @@ export const FeatureFlagsProvider: React.FC< FeatureFlagsProviderProps > = ( { c ...window.appGlobals, }; } ); - const { isAuthenticated, client } = useAuth(); + const isAuthenticated = useRootSelector( authSelectors.selectIsAuthenticated ); const [ apiFlags, setApiFlags ] = useState< Partial< FeatureFlags > >( {} ); useIpcListener( 'refresh-app-globals', async () => { @@ -46,6 +48,7 @@ export const FeatureFlagsProvider: React.FC< FeatureFlagsProviderProps > = ( { c useEffect( () => { let cancel = false; async function loadFeatureFlags() { + const client = getWpcomClient(); if ( ! isAuthenticated || ! client ) { return; } @@ -72,7 +75,7 @@ export const FeatureFlagsProvider: React.FC< FeatureFlagsProviderProps > = ( { c return () => { cancel = true; }; - }, [ isAuthenticated, client ] ); + }, [ isAuthenticated ] ); return ( { children } diff --git a/apps/studio/src/modules/add-site/components/pull-remote-site.tsx b/apps/studio/src/modules/add-site/components/pull-remote-site.tsx index 2b75022f74..83004ccebd 100644 --- a/apps/studio/src/modules/add-site/components/pull-remote-site.tsx +++ b/apps/studio/src/modules/add-site/components/pull-remote-site.tsx @@ -9,9 +9,10 @@ import { ArrowIcon } from 'src/components/arrow-icon'; import Button from 'src/components/button'; import offlineIcon from 'src/components/offline-icon'; import { Tooltip } from 'src/components/tooltip'; -import { useAuth } from 'src/hooks/use-auth'; import { useOffline } from 'src/hooks/use-offline'; import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { NoWpcomSitesContent } from 'src/modules/sync/components/no-wpcom-sites-content'; import { SitesListContent } from 'src/modules/sync/components/sync-sites-modal-selector'; import { SyncTabImage } from 'src/modules/sync/components/sync-tab-image'; @@ -73,7 +74,6 @@ function NoWpcomSitesView() { function NoAuthPullRemoteSiteView() { const isOffline = useOffline(); const { __ } = useI18n(); - const { authenticate } = useAuth(); const offlineMessage = __( "You're currently offline." ); return ( @@ -88,7 +88,7 @@ function NoAuthPullRemoteSiteView() { if ( isOffline ) { return; } - authenticate(); + getIpcApi().authenticate(); } } > { __( 'Log in to WordPress.com' ) } @@ -134,7 +134,8 @@ export function PullRemoteSite( { setSelectedRemoteSite: ( site?: SyncSite ) => void; } ) { const { __ } = useI18n(); - const { isAuthenticated, user } = useAuth(); + const isAuthenticated = useRootSelector( authSelectors.selectIsAuthenticated ); + const user = useRootSelector( authSelectors.selectUser ); const { data: syncSites = [], diff --git a/apps/studio/src/modules/onboarding/components/connect-to-wpcom.tsx b/apps/studio/src/modules/onboarding/components/connect-to-wpcom.tsx index 0e4e12caf2..f602d9c493 100644 --- a/apps/studio/src/modules/onboarding/components/connect-to-wpcom.tsx +++ b/apps/studio/src/modules/onboarding/components/connect-to-wpcom.tsx @@ -6,7 +6,6 @@ import { ArrowIcon } from 'src/components/arrow-icon'; import Button from 'src/components/button'; import offlineIcon from 'src/components/offline-icon'; import { Tooltip } from 'src/components/tooltip'; -import { useAuth } from 'src/hooks/use-auth'; import { useOffline } from 'src/hooks/use-offline'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getLocalizedLink } from 'src/lib/get-localized-link'; @@ -16,7 +15,6 @@ export function OnboardingConnectToWpcom( { onSkip }: { onSkip: () => void } ) { const { __ } = useI18n(); const isOffline = useOffline(); const offlineMessage = __( "You're currently offline." ); - const { authenticate } = useAuth(); const locale = useI18nLocale(); return ( @@ -55,7 +53,7 @@ export function OnboardingConnectToWpcom( { onSkip }: { onSkip: () => void } ) { if ( isOffline ) { return; } - authenticate(); + getIpcApi().authenticate(); } } > { __( 'Log in to WordPress.com' ) } diff --git a/apps/studio/src/modules/onboarding/index.tsx b/apps/studio/src/modules/onboarding/index.tsx index 538d4dec65..67c1c42b57 100644 --- a/apps/studio/src/modules/onboarding/index.tsx +++ b/apps/studio/src/modules/onboarding/index.tsx @@ -1,9 +1,9 @@ import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useEffect } from 'react'; import { StudioLogo } from 'src/components/studio-logo'; -import { useAuth } from 'src/hooks/use-auth'; import { OnboardingConnectToWpcom } from 'src/modules/onboarding/components/connect-to-wpcom'; -import { useAppDispatch } from 'src/stores'; +import { useAppDispatch, useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { useSaveLastSeenVersionMutation } from 'src/stores/app-version-api'; import { saveOnboardingStatus } from 'src/stores/onboarding-slice'; @@ -31,7 +31,7 @@ const GradientBox = () => { export function Onboarding() { const { __ } = useI18n(); const dispatch = useAppDispatch(); - const { isAuthenticated } = useAuth(); + const isAuthenticated = useRootSelector( authSelectors.selectIsAuthenticated ); const [ saveLastSeenVersion ] = useSaveLastSeenVersionMutation(); const handleSkip = useCallback( async () => { diff --git a/apps/studio/src/modules/preview-site/components/create-preview-button.tsx b/apps/studio/src/modules/preview-site/components/create-preview-button.tsx index 992ce27955..2a0bf0ec57 100644 --- a/apps/studio/src/modules/preview-site/components/create-preview-button.tsx +++ b/apps/studio/src/modules/preview-site/components/create-preview-button.tsx @@ -6,7 +6,6 @@ import { useI18n } from '@wordpress/react-i18n'; import Button from 'src/components/button'; import offlineIcon from 'src/components/offline-icon'; import { Tooltip } from 'src/components/tooltip'; -import { AuthContextType } from 'src/hooks/use-auth'; import { useGetWpVersion } from 'src/hooks/use-get-wp-version'; import { useIsMultisite } from 'src/hooks/use-is-multisite'; import { useOffline } from 'src/hooks/use-offline'; @@ -14,6 +13,7 @@ import { useSiteSize } from 'src/hooks/use-site-size'; import { getLatestStableWpVersion } from 'src/lib/version-utils'; import { hasUnsupportedWpOrPhpVersion } from 'src/modules/preview-site/lib/version-comparison'; import { useRootSelector } from 'src/stores'; +import type { AuthUser } from 'src/stores/auth-slice'; import { snapshotSelectors } from 'src/stores/snapshot-slice'; import { useGetWordPressVersions } from 'src/stores/wordpress-versions-api'; import { useGetSnapshotUsage } from 'src/stores/wpcom-api'; @@ -21,7 +21,7 @@ import { useGetSnapshotUsage } from 'src/stores/wpcom-api'; interface CreatePreviewButtonProps { onClick: () => void; selectedSite: SiteDetails; - user: AuthContextType[ 'user' ]; + user?: AuthUser; } export function CreatePreviewButton( { onClick, selectedSite, user }: CreatePreviewButtonProps ) { 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 8767034f80..2bb83f8a56 100644 --- a/apps/studio/src/modules/sync/components/sync-connected-sites.tsx +++ b/apps/studio/src/modules/sync/components/sync-connected-sites.tsx @@ -16,7 +16,6 @@ import ProgressBar from 'src/components/progress-bar'; import { Tooltip, DynamicTooltip } from 'src/components/tooltip'; import { WordPressLogoCircle } from 'src/components/wordpress-logo-circle'; import { useLastSyncTimeText } from 'src/hooks/sync-sites/use-last-sync-time-text'; -import { useAuth } from 'src/hooks/use-auth'; import { useImportExport } from 'src/hooks/use-import-export'; import { useOffline } from 'src/hooks/use-offline'; import { useSyncStatesProgressInfo } from 'src/hooks/use-sync-states-progress-info'; @@ -36,6 +35,7 @@ import { } from 'src/modules/sync/lib/convert-tree-to-sync-options'; import { getSiteEnvironment } from 'src/modules/sync/lib/environment-utils'; import { useAppDispatch, useI18nLocale, useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { syncOperationsSelectors, syncOperationsThunks, @@ -46,6 +46,7 @@ import { connectedSitesSelectors, useGetConnectedSitesForLocalSiteQuery, } from 'src/stores/sync/connected-sites'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import type { SyncSite } from '@studio/common/types/sync'; const SyncConnectedSiteControls = ( { @@ -62,7 +63,7 @@ const SyncConnectedSiteControls = ( { const isAnySitePulling = useRootSelector( syncOperationsSelectors.selectIsAnySitePulling ); const isAnySitePushing = useRootSelector( syncOperationsSelectors.selectIsAnySitePushing ); const getLastSyncTimeText = useLastSyncTimeText(); - const { user, client } = useAuth(); + const user = useRootSelector( authSelectors.selectUser ); const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { localSiteId: selectedSite.id, userId: user?.id, @@ -182,13 +183,14 @@ const SyncConnectedSiteControls = ( { ); } } onPull={ ( tree ) => { - if ( ! client ) { + const wpcomClient = getWpcomClient(); + if ( ! wpcomClient ) { return; } const pullOptions = convertTreeToPullOptions( tree ); void dispatch( syncOperationsThunks.pullSite( { - client, + client: wpcomClient, connectedSite, selectedSite, options: pullOptions, diff --git a/apps/studio/src/modules/sync/components/sync-sites-modal-selector.tsx b/apps/studio/src/modules/sync/components/sync-sites-modal-selector.tsx index df46e25473..1d4cbf8019 100644 --- a/apps/studio/src/modules/sync/components/sync-sites-modal-selector.tsx +++ b/apps/studio/src/modules/sync/components/sync-sites-modal-selector.tsx @@ -9,7 +9,6 @@ import offlineIcon from 'src/components/offline-icon'; import { PressableLogo } from 'src/components/pressable-logo'; import { Tooltip } from 'src/components/tooltip'; import { WordPressLogoCircle } from 'src/components/wordpress-logo-circle'; -import { useAuth } from 'src/hooks/use-auth'; import { useOffline } from 'src/hooks/use-offline'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; @@ -19,6 +18,7 @@ import { EnvironmentBadge } from 'src/modules/sync/components/environment-badge' import { NoWpcomSitesModal } from 'src/modules/sync/components/no-wpcom-sites-modal'; import { getSiteEnvironment } from 'src/modules/sync/lib/environment-utils'; import { useI18nLocale, useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { connectedSitesSelectors, useGetConnectedSitesForLocalSiteQuery, @@ -46,7 +46,7 @@ export function SyncSitesModalSelector( { mode?: SyncModalMode; } ) { const { __ } = useI18n(); - const { user } = useAuth(); + const user = useRootSelector( authSelectors.selectUser ); const [ selectedSiteId, setSelectedSiteId ] = useState< number | null >( null ); const isOffline = useOffline(); diff --git a/apps/studio/src/modules/sync/index.tsx b/apps/studio/src/modules/sync/index.tsx index 951d841823..bf6be6d6d7 100644 --- a/apps/studio/src/modules/sync/index.tsx +++ b/apps/studio/src/modules/sync/index.tsx @@ -6,7 +6,6 @@ import Button from 'src/components/button'; import { IllustrationGrid } from 'src/components/illustration-grid'; import offlineIcon from 'src/components/offline-icon'; import { Tooltip } from 'src/components/tooltip'; -import { useAuth } from 'src/hooks/use-auth'; import { useOffline } from 'src/hooks/use-offline'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { ConnectButton } from 'src/modules/sync/components/connect-button'; @@ -19,6 +18,7 @@ import { convertTreeToPushOptions, } from 'src/modules/sync/lib/convert-tree-to-sync-options'; import { useAppDispatch, useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { syncOperationsThunks } from 'src/stores/sync'; import { connectedSitesActions, @@ -28,6 +28,7 @@ import { useGetConnectedSitesForLocalSiteQuery, } from 'src/stores/sync/connected-sites'; import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import type { SyncSite } from '@studio/common/types/sync'; function SiteSyncDescription( { children }: PropsWithChildren ) { @@ -69,7 +70,6 @@ function SiteSyncDescription( { children }: PropsWithChildren ) { function NoAuthSyncTab() { const isOffline = useOffline(); const { __ } = useI18n(); - const { authenticate } = useAuth(); const offlineMessage = __( "You're currently offline." ); return ( @@ -84,7 +84,7 @@ function NoAuthSyncTab() { if ( isOffline ) { return; } - authenticate(); + getIpcApi().authenticate(); } } > { __( 'Log in to WordPress.com' ) } @@ -131,7 +131,8 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } connectedSitesSelectors.selectSelectedRemoteSiteId ); const selectedLocalSiteId = useRootSelector( connectedSitesSelectors.selectSelectedLocalSiteId ); - const { isAuthenticated, user, client } = useAuth(); + const isAuthenticated = useRootSelector( authSelectors.selectIsAuthenticated ); + const user = useRootSelector( authSelectors.selectUser ); const { data: connectedSites = [], isLoading: isLoadingConnectedSites } = useGetConnectedSitesForLocalSiteQuery( { localSiteId: selectedSite.id, @@ -258,14 +259,15 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } ); } } onPull={ async ( tree ) => { - if ( ! client ) { + const wpcomClient = getWpcomClient(); + if ( ! wpcomClient ) { return; } await handleConnect( effectiveRemoteSite ); const pullOptions = convertTreeToPullOptions( tree ); void dispatch( syncOperationsThunks.pullSite( { - client, + client: wpcomClient, connectedSite: effectiveRemoteSite, selectedSite, options: pullOptions, diff --git a/apps/studio/src/modules/sync/tests/index.test.tsx b/apps/studio/src/modules/sync/tests/index.test.tsx index 5deaea6b63..724062783a 100644 --- a/apps/studio/src/modules/sync/tests/index.test.tsx +++ b/apps/studio/src/modules/sync/tests/index.test.tsx @@ -4,15 +4,16 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; import { SYNC_OPTIONS } from 'src/constants'; -import { useAuth } from 'src/hooks/use-auth'; import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; import { useFeatureFlags } from 'src/hooks/use-feature-flags'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { ContentTabSync } from 'src/modules/sync'; import { useSelectedItemsPushSize } from 'src/modules/sync/hooks/use-selected-items-push-size'; import { store } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { syncOperationsActions, useLatestRewindId, useRemoteFileTree } from 'src/stores/sync'; import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; store.replaceReducer( testReducer ); @@ -22,7 +23,20 @@ const mockPushSiteThunk = vi.hoisted( () => vi.fn() ); vi.mock( 'src/components/dot-grid', () => ( { DotGrid: () => null } ) ); vi.mock( 'src/lib/get-ipc-api' ); -vi.mock( 'src/hooks/use-auth' ); +vi.mock( 'src/stores/auth-slice', async () => { + const actual = await vi.importActual( 'src/stores/auth-slice' ); + return { + ...actual, + authSelectors: { + selectIsAuthenticated: vi.fn( () => false ), + selectUser: vi.fn( () => undefined ), + }, + }; +} ); +vi.mock( 'src/stores/wpcom-client', () => ( { + getWpcomClient: vi.fn(), + setWpcomClient: vi.fn(), +} ) ); vi.mock( 'src/stores/sync/wpcom-sites', async () => { const actual = await vi.importActual< typeof import('src/stores/sync/wpcom-sites') >( 'src/stores/sync/wpcom-sites' @@ -86,12 +100,15 @@ vi.mock( 'src/stores/wordpress-versions-api', async () => { }; } ); -const createAuthMock = ( isAuthenticated: boolean = false ) => ( { - isAuthenticated, - authenticate: vi.fn(), - user: isAuthenticated ? { id: 123, email: 'user@example.com', displayName: 'user' } : undefined, - client: isAuthenticated ? ( {} as never ) : undefined, -} ); +const setupAuthMock = ( isAuthenticated: boolean = false ) => { + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( isAuthenticated ); + vi.mocked( authSelectors.selectUser ).mockReturnValue( + isAuthenticated ? { id: 123, email: 'user@example.com', displayName: 'user' } : undefined + ); + vi.mocked( getWpcomClient ).mockReturnValue( + isAuthenticated ? ( {} as never ) : undefined + ); +}; const selectedSite: SiteDetails = { name: 'Test Site', @@ -143,7 +160,7 @@ describe( 'ContentTabSync', () => { } ) ); store.dispatch( testActions.resetState() ); store.dispatch( { type: 'connectedSitesApi/resetApiState' } ); - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( false ) ); + setupAuthMock( false ); vi.mocked( useFeatureFlags, { partial: true } ).mockReturnValue( { enableBlueprints: true, } ); @@ -256,8 +273,7 @@ describe( 'ContentTabSync', () => { }; it( 'renders the sync title and login buttons', () => { - const authMock = createAuthMock( false ); - vi.mocked( useAuth, { partial: true } ).mockReturnValue( authMock ); + setupAuthMock( false ); renderWithProvider( ); expect( screen.getByText( 'Sync with WordPress.com or Pressable' ) ).toBeInTheDocument(); @@ -265,7 +281,7 @@ describe( 'ContentTabSync', () => { expect( loginButton ).toBeInTheDocument(); fireEvent.click( loginButton ); - expect( authMock.authenticate ).toHaveBeenCalled(); + expect( getIpcApi().authenticate ).toHaveBeenCalled(); const freeAccountButton = screen.getByRole( 'button', { name: /Create a free account/i } ); expect( freeAccountButton ).toBeInTheDocument(); @@ -275,7 +291,7 @@ describe( 'ContentTabSync', () => { } ); it( 'displays the list of connected sites', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); @@ -298,7 +314,7 @@ describe( 'ContentTabSync', () => { lastPullTimestamp: null, lastPushTimestamp: null, }; - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); @@ -313,7 +329,7 @@ describe( 'ContentTabSync', () => { } ); it( 'opens the modal and displays the create new site button', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [], [ fakeSyncSite ] ); renderWithProvider( ); @@ -365,7 +381,7 @@ describe( 'ContentTabSync', () => { lastPullTimestamp: null, lastPushTimestamp: null, }; - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); const allSites = [ fakePressableProductionSite, @@ -384,7 +400,7 @@ describe( 'ContentTabSync', () => { expect( screen.getByText( 'Development' ) ).toBeInTheDocument(); } ); it( 'displays the progress bar when the site is being pushed', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); store.dispatch( syncOperationsActions.updatePushState( { @@ -407,7 +423,7 @@ describe( 'ContentTabSync', () => { } ); it( 'opens sync pullSite dialog with development environment label', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); const fakeDevelopmentSyncSite: SyncSite = { ...fakeSyncSite, isPressable: true, @@ -428,7 +444,7 @@ describe( 'ContentTabSync', () => { } ); it( 'opens sync pullSite dialog and displays production when the environment is not supported', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); const fakeDevelopmentSyncSite: SyncSite = { ...fakeSyncSite, isPressable: true, @@ -449,7 +465,7 @@ describe( 'ContentTabSync', () => { } ); it( 'calls pullSite with correct optionsToSync when all options are selected', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); @@ -478,7 +494,7 @@ describe( 'ContentTabSync', () => { } ); it( 'calls pullSite with correct optionsToSync when only database is selected', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); @@ -505,7 +521,7 @@ describe( 'ContentTabSync', () => { } ); it( 'calls pullSite with correct optionsToSync when options partially are selected', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); vi.mocked( useRemoteFileTree, { partial: true } ).mockReturnValue( { fetchChildren: vi.fn().mockResolvedValue( [ { @@ -609,7 +625,7 @@ describe( 'ContentTabSync', () => { } ); it( 'disables the pull button when all checkboxes are unchecked, which is the initial state', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); @@ -623,7 +639,7 @@ describe( 'ContentTabSync', () => { } ); it( 'disables the push button when all checkboxes are unchecked, which is the initial state', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); @@ -637,7 +653,7 @@ describe( 'ContentTabSync', () => { } ); it( 'enables the pull button when at least one checkbox is checked', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); @@ -656,7 +672,7 @@ describe( 'ContentTabSync', () => { } ); it( 'enables the pull button when at least one checkbox children is checked', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); @@ -682,7 +698,7 @@ describe( 'ContentTabSync', () => { } ); it( 'disables the push button when all checkboxes are unchecked', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); @@ -696,7 +712,7 @@ describe( 'ContentTabSync', () => { } ); it( 'enables the push button when at least one checkbox is checked', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); @@ -713,7 +729,7 @@ describe( 'ContentTabSync', () => { } ); it( 'enables the push button when at least one checkbox children is checked', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); @@ -741,7 +757,7 @@ describe( 'ContentTabSync', () => { describe( 'Sync Dialog Push Selection Over Limit Notice', () => { it( 'shows warning notice when push selection exceeds limit', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); vi.mocked( useSelectedItemsPushSize, { partial: true } ).mockReturnValue( { isPushSelectionOverLimit: true, @@ -763,7 +779,7 @@ describe( 'ContentTabSync', () => { } ); it( 'does not show warning notice when push selection is within limit', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); vi.mocked( useSelectedItemsPushSize, { partial: true } ).mockReturnValue( { isPushSelectionOverLimit: false, @@ -782,7 +798,7 @@ describe( 'ContentTabSync', () => { } ); it( 'does not show warning notice for pull operations even when limit exceeded', async () => { - vi.mocked( useAuth, { partial: true } ).mockReturnValue( createAuthMock( true ) ); + setupAuthMock( true ); setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); vi.mocked( useSelectedItemsPushSize, { partial: true } ).mockReturnValue( { isPushSelectionOverLimit: true, diff --git a/apps/studio/src/modules/user-settings/components/account-tab.tsx b/apps/studio/src/modules/user-settings/components/account-tab.tsx index 8ab8ae659f..261ffb57c7 100644 --- a/apps/studio/src/modules/user-settings/components/account-tab.tsx +++ b/apps/studio/src/modules/user-settings/components/account-tab.tsx @@ -1,4 +1,6 @@ -import { useAuth } from 'src/hooks/use-auth'; +import { useCallback } from 'react'; +import { useAppDispatch, useRootSelector } from 'src/stores'; +import { authSelectors, authThunks } from 'src/stores/auth-slice'; import { NonAuthenticatedAccountTab } from './non-authenticated-account-tab'; import { PromptInfo } from './prompt-info'; import { SnapshotInfo } from './snapshot-info'; @@ -19,7 +21,10 @@ export const AccountTab = ( { snapshotQuota: number; onRemoveSnapshots: () => void; } ) => { - const { isAuthenticated, user, logout } = useAuth(); + const dispatch = useAppDispatch(); + const isAuthenticated = useRootSelector( authSelectors.selectIsAuthenticated ); + const user = useRootSelector( authSelectors.selectUser ); + const logout = useCallback( () => dispatch( authThunks.authLogout() ), [ dispatch ] ); return ( <> diff --git a/apps/studio/src/modules/user-settings/components/non-authenticated-account-tab.tsx b/apps/studio/src/modules/user-settings/components/non-authenticated-account-tab.tsx index f086f39416..d84acf50fa 100644 --- a/apps/studio/src/modules/user-settings/components/non-authenticated-account-tab.tsx +++ b/apps/studio/src/modules/user-settings/components/non-authenticated-account-tab.tsx @@ -3,12 +3,11 @@ import Button from 'src/components/button'; import offlineIcon from 'src/components/offline-icon'; import { Tooltip } from 'src/components/tooltip'; import { WordPressLogo } from 'src/components/wordpress-logo'; -import { useAuth } from 'src/hooks/use-auth'; import { useOffline } from 'src/hooks/use-offline'; +import { getIpcApi } from 'src/lib/get-ipc-api'; export const NonAuthenticatedAccountTab = () => { const { __ } = useI18n(); - const { authenticate } = useAuth(); const isOffline = useOffline(); const offlineMessage = __( "You're currently offline." ); @@ -25,7 +24,7 @@ export const NonAuthenticatedAccountTab = () => { if ( isOffline ) { return; } - authenticate(); + getIpcApi().authenticate(); } } > { __( 'Log in' ) } diff --git a/apps/studio/src/modules/user-settings/components/tests/user-settings.test.tsx b/apps/studio/src/modules/user-settings/components/tests/user-settings.test.tsx index 40fd2b9f93..09d707e00b 100644 --- a/apps/studio/src/modules/user-settings/components/tests/user-settings.test.tsx +++ b/apps/studio/src/modules/user-settings/components/tests/user-settings.test.tsx @@ -3,11 +3,11 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; -import { useAuth } from 'src/hooks/use-auth'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { useOffline } from 'src/hooks/use-offline'; import { UserSettings } from 'src/modules/user-settings'; import { store } from 'src/stores'; +import { authSelectors, authThunks } from 'src/stores/auth-slice'; vi.mock( 'src/lib/app-globals', () => ( { getAppGlobals: vi.fn( () => ( { @@ -17,10 +17,23 @@ vi.mock( 'src/lib/app-globals', () => ( { isWindows: vi.fn( () => false ), isWindowsStore: vi.fn( () => false ), } ) ); -vi.mock( 'src/hooks/use-auth' ); +vi.mock( 'src/stores/auth-slice', async () => { + const actual = await vi.importActual( 'src/stores/auth-slice' ); + return { + ...actual, + authSelectors: { + selectIsAuthenticated: vi.fn( () => false ), + selectUser: vi.fn( () => undefined ), + }, + authThunks: { + authLogout: vi.fn( () => ( { type: 'auth/logout/fulfilled' } ) ), + }, + }; +} ); vi.mock( 'src/hooks/use-ipc-listener' ); vi.mock( 'src/hooks/use-offline' ); +const mockAuthenticate = vi.hoisted( () => vi.fn() ); vi.mock( 'src/lib/get-ipc-api', () => ( { getIpcApi: () => ( { getUserTerminal: vi.fn().mockResolvedValue( 'terminal' ), @@ -32,6 +45,7 @@ vi.mock( 'src/lib/get-ipc-api', () => ( { isStudioCliInstalled: vi.fn().mockResolvedValue( true ), copyText: vi.fn().mockResolvedValue( undefined ), getDefaultSiteDirectory: vi.fn().mockResolvedValue( '/mock/default/site/path' ), + authenticate: mockAuthenticate, } ), } ) ); @@ -57,28 +71,22 @@ describe( 'UserSettings', () => { } ); it( 'logs in when not authenticated', async () => { - const authenticate = vi.fn(); - vi.mocked( useAuth ).mockReturnValue( { - isAuthenticated: false, - authenticate, - logout: vi.fn(), - client: undefined, - } ); + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( false ); + vi.mocked( authSelectors.selectUser ).mockReturnValue( undefined ); renderWithProvider( ); await userEvent.click( screen.getByText( 'Account' ) ); const loginButton = screen.getByRole( 'button', { name: 'Log in' } ); expect( loginButton ).toBeVisible(); await userEvent.click( loginButton ); - expect( authenticate ).toHaveBeenCalledTimes( 1 ); + expect( mockAuthenticate ).toHaveBeenCalledTimes( 1 ); } ); it( 'logs out if authenticated', async () => { - const logout = vi.fn(); - vi.mocked( useAuth ).mockReturnValue( { - isAuthenticated: true, - logout, - authenticate: vi.fn(), - client: undefined, + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( true ); + vi.mocked( authSelectors.selectUser ).mockReturnValue( { + id: 1, + email: 'user@example.com', + displayName: 'User', } ); renderWithProvider( ); // Navigate to Account tab to find the logout button @@ -86,25 +94,20 @@ describe( 'UserSettings', () => { const logoutButton = screen.getByRole( 'button', { name: 'Log out' } ); expect( logoutButton ).toBeVisible(); await userEvent.click( logoutButton ); - expect( logout ).toHaveBeenCalledTimes( 1 ); + expect( authThunks.authLogout ).toHaveBeenCalledTimes( 1 ); } ); it( 'disables log in button when offline', async () => { - const authenticate = vi.fn(); vi.mocked( useOffline ).mockReturnValue( true ); - vi.mocked( useAuth ).mockReturnValue( { - isAuthenticated: false, - authenticate, - logout: vi.fn(), - client: undefined, - } ); + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( false ); + vi.mocked( authSelectors.selectUser ).mockReturnValue( undefined ); renderWithProvider( ); // Navigate to Account tab await userEvent.click( screen.getByText( 'Account' ) ); const loginButton = screen.getByRole( 'button', { name: 'Log in' } ); expect( loginButton ).toHaveAttribute( 'aria-disabled', 'true' ); await userEvent.click( loginButton ); - expect( authenticate ).not.toHaveBeenCalled(); + expect( mockAuthenticate ).not.toHaveBeenCalled(); await userEvent.hover( loginButton ); expect( screen.getByRole( 'tooltip', { @@ -116,11 +119,11 @@ describe( 'UserSettings', () => { describe( 'Tab Navigation', () => { it( 'switches between tabs correctly', async () => { const user = userEvent.setup(); - vi.mocked( useAuth ).mockReturnValue( { - isAuthenticated: true, - authenticate: vi.fn(), - logout: vi.fn(), - client: undefined, + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( true ); + vi.mocked( authSelectors.selectUser ).mockReturnValue( { + id: 1, + email: 'user@example.com', + displayName: 'User', } ); renderWithProvider( ); @@ -151,11 +154,11 @@ describe( 'UserSettings', () => { setTimeout( () => callback( mockIpcEvent, { tabName: 'general' } ), 0 ); } } ); - vi.mocked( useAuth ).mockReturnValue( { - isAuthenticated: true, - authenticate: vi.fn(), - logout: vi.fn(), - client: undefined, + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( true ); + vi.mocked( authSelectors.selectUser ).mockReturnValue( { + id: 1, + email: 'user@example.com', + displayName: 'User', } ); renderWithProvider( ); @@ -172,11 +175,11 @@ describe( 'UserSettings', () => { setTimeout( () => callback( mockIpcEvent, { tabName: 'account' } ), 0 ); } } ); - vi.mocked( useAuth ).mockReturnValue( { - isAuthenticated: true, - authenticate: vi.fn(), - logout: vi.fn(), - client: undefined, + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( true ); + vi.mocked( authSelectors.selectUser ).mockReturnValue( { + id: 1, + email: 'user@example.com', + displayName: 'User', } ); renderWithProvider( ); @@ -193,11 +196,11 @@ describe( 'UserSettings', () => { setTimeout( () => callback( mockIpcEvent, {} ), 0 ); } } ); - vi.mocked( useAuth ).mockReturnValue( { - isAuthenticated: true, - authenticate: vi.fn(), - logout: vi.fn(), - client: undefined, + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( true ); + vi.mocked( authSelectors.selectUser ).mockReturnValue( { + id: 1, + email: 'user@example.com', + displayName: 'User', } ); renderWithProvider( ); @@ -213,12 +216,8 @@ describe( 'UserSettings', () => { setTimeout( () => callback( mockIpcEvent, { tabName: 'account' } ), 0 ); } } ); - vi.mocked( useAuth ).mockReturnValue( { - isAuthenticated: false, - authenticate: vi.fn(), - logout: vi.fn(), - client: undefined, - } ); + vi.mocked( authSelectors.selectIsAuthenticated ).mockReturnValue( false ); + vi.mocked( authSelectors.selectUser ).mockReturnValue( undefined ); renderWithProvider( ); diff --git a/apps/studio/src/modules/user-settings/components/user-settings.tsx b/apps/studio/src/modules/user-settings/components/user-settings.tsx index 2637a1bcfc..73ec0a3e9a 100644 --- a/apps/studio/src/modules/user-settings/components/user-settings.tsx +++ b/apps/studio/src/modules/user-settings/components/user-settings.tsx @@ -2,7 +2,6 @@ import { TabPanel } from '@wordpress/components'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useMemo, useState } from 'react'; import Modal from 'src/components/modal'; -import { useAuth } from 'src/hooks/use-auth'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { useOffline } from 'src/hooks/use-offline'; import { cx } from 'src/lib/cx'; @@ -13,12 +12,13 @@ import { PreferencesTab } from 'src/modules/user-settings/components/preferences import { SkillsTab } from 'src/modules/user-settings/components/skills-tab'; import { UserSettingsTab } from 'src/modules/user-settings/user-settings-types'; import { useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { snapshotSelectors } from 'src/stores/snapshot-slice'; import { useDeleteAllSnapshots, useGetSnapshotUsage } from 'src/stores/wpcom-api'; export default function UserSettings() { const { __ } = useI18n(); - const { user } = useAuth(); + const user = useRootSelector( authSelectors.selectUser ); const snapshotsByUser = useRootSelector( ( state ) => snapshotSelectors.selectSnapshotsByUser( state, user?.id ?? 0 ) ); diff --git a/apps/studio/src/stores/sync/sync-hooks.ts b/apps/studio/src/stores/sync/sync-hooks.ts index 4be6259d4a..36a2b0d289 100644 --- a/apps/studio/src/stores/sync/sync-hooks.ts +++ b/apps/studio/src/stores/sync/sync-hooks.ts @@ -1,9 +1,10 @@ import { useCallback, useState } from 'react'; import { TreeNode } from 'src/components/tree-view'; -import { useAuth } from 'src/hooks/use-auth'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { convertRawToTreeNodes } from 'src/modules/sync/lib/tree-utils'; import { useAppDispatch, useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import { fetchRemoteFileTree, useGetLatestRewindIdQuery } from './sync-api'; import { syncSelectors } from './sync-slice'; import { useGetPhpVersionQuery } from './wpcom-sites'; @@ -38,7 +39,7 @@ export function useHostingPhpVersion( } ) { const { skip = false } = options || {}; - const { user } = useAuth(); + const user = useRootSelector( authSelectors.selectUser ); const { data: phpVersion, @@ -60,7 +61,6 @@ export function useHostingPhpVersion( export function useRemoteFileTree() { const dispatch = useAppDispatch(); - const { client } = useAuth(); const isLoading = useRootSelector( syncSelectors.selectIsLoadingFileTree ); const error = useRootSelector( syncSelectors.selectFileTreeError ); @@ -71,6 +71,7 @@ export function useRemoteFileTree() { path: string, parentChecked: boolean = false ): Promise< TreeNode[] > => { + const client = getWpcomClient(); if ( ! client ) { return []; } @@ -93,7 +94,7 @@ export function useRemoteFileTree() { throw new Error( errorMessage ); } }, - [ client, dispatch ] + [ dispatch ] ); return { From 10f81f2bb38b28b0dbca438ed8cec8ed35c44335 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 27 Apr 2026 10:53:03 -0400 Subject: [PATCH 22/26] Add guards for unit tests --- apps/studio/src/stores/auth-slice.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index 969c6d8af0..413c157cfe 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -195,7 +195,9 @@ export const authSelectors = { selectUser: ( state: RootState ) => state.auth.user ?? undefined, }; -void store.dispatch( initializeAuth() ); +if ( process.env.NODE_ENV !== 'test' ) { + void store.dispatch( initializeAuth() ); +} window.ipcListener.subscribe( 'auth-updated', ( _event, payload ) => { if ( 'error' in payload ) { From 928ffbe31dce10cf4c103968e2ff2d0ec2fec2e7 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 27 Apr 2026 10:57:05 -0400 Subject: [PATCH 23/26] Solve for linter --- apps/studio/src/components/chat-rating.tsx | 2 +- apps/studio/src/components/content-tab-assistant.tsx | 6 ++++-- apps/studio/src/components/gravatar.tsx | 2 +- apps/studio/src/components/publish-site-button.tsx | 2 +- apps/studio/src/hooks/use-add-site.ts | 2 +- .../src/modules/add-site/components/pull-remote-site.tsx | 4 ++-- apps/studio/src/modules/onboarding/index.tsx | 2 +- .../preview-site/components/create-preview-button.tsx | 2 +- apps/studio/src/modules/sync/tests/index.test.tsx | 6 ++---- 9 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/studio/src/components/chat-rating.tsx b/apps/studio/src/components/chat-rating.tsx index 4aad534bdb..abbe67b095 100644 --- a/apps/studio/src/components/chat-rating.tsx +++ b/apps/studio/src/components/chat-rating.tsx @@ -2,8 +2,8 @@ import { __ } from '@wordpress/i18n'; import { thumbsUp, thumbsDown, Icon } from '@wordpress/icons'; import Button from 'src/components/button'; import { useAppDispatch } from 'src/stores'; -import { getWpcomClient } from 'src/stores/wpcom-client'; import { chatThunks } from 'src/stores/chat-slice'; +import { getWpcomClient } from 'src/stores/wpcom-client'; interface ChatRatingProps { instanceId: string; diff --git a/apps/studio/src/components/content-tab-assistant.tsx b/apps/studio/src/components/content-tab-assistant.tsx index 54e0549d5d..031caf9458 100644 --- a/apps/studio/src/components/content-tab-assistant.tsx +++ b/apps/studio/src/components/content-tab-assistant.tsx @@ -27,7 +27,6 @@ import { getIpcApi } from 'src/lib/get-ipc-api'; import { addUrlParams } from 'src/lib/url-utils'; import { useAppDispatch, useRootSelector } from 'src/stores'; import { authSelectors } from 'src/stores/auth-slice'; -import { getWpcomClient } from 'src/stores/wpcom-client'; import { chatThunks, generateMessage, @@ -36,6 +35,7 @@ import { chatSelectors, } from 'src/stores/chat-slice'; import { useGetAssistantQuota, useGetWelcomeMessages } from 'src/stores/wpcom-api'; +import { getWpcomClient } from 'src/stores/wpcom-client'; export const MIMIC_CONVERSATION_DELAY = 500; @@ -535,7 +535,9 @@ function WpcomAssistant( { selectedSite }: ContentTabAssistantProps ) { /> ) : ( - ! isOffline && getIpcApi().authenticate() } /> + ! isOffline && ( + getIpcApi().authenticate() } /> + ) ) } { renderNotice() } diff --git a/apps/studio/src/components/gravatar.tsx b/apps/studio/src/components/gravatar.tsx index ad5ef0bb40..a5bcb02c73 100644 --- a/apps/studio/src/components/gravatar.tsx +++ b/apps/studio/src/components/gravatar.tsx @@ -4,9 +4,9 @@ import { useI18n } from '@wordpress/react-i18n'; import { useState } from 'react'; import profileIconDetailed from 'src/components/profile-icon-detailed'; import { useGravatarUrl } from 'src/hooks/use-gravatar-url'; +import { cx } from 'src/lib/cx'; import { useRootSelector } from 'src/stores'; import { authSelectors } from 'src/stores/auth-slice'; -import { cx } from 'src/lib/cx'; export function Gravatar( { className, diff --git a/apps/studio/src/components/publish-site-button.tsx b/apps/studio/src/components/publish-site-button.tsx index bb01d55cef..22e3322add 100644 --- a/apps/studio/src/components/publish-site-button.tsx +++ b/apps/studio/src/components/publish-site-button.tsx @@ -2,11 +2,11 @@ import { cloudUpload } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback } from 'react'; import { useSiteDetails } from 'src/hooks/use-site-details'; -import { authSelectors } from 'src/stores/auth-slice'; import { generateCheckoutUrl } from 'src/lib/generate-checkout-url'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { ConnectButton } from 'src/modules/sync/components/connect-button'; import { useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { syncOperationsSelectors } from 'src/stores/sync'; import { useGetConnectedSitesForLocalSiteQuery } from 'src/stores/sync/connected-sites'; diff --git a/apps/studio/src/hooks/use-add-site.ts b/apps/studio/src/hooks/use-add-site.ts index 07868ad37f..a4539f3b43 100644 --- a/apps/studio/src/hooks/use-add-site.ts +++ b/apps/studio/src/hooks/use-add-site.ts @@ -12,8 +12,8 @@ import { getIpcApi } from 'src/lib/get-ipc-api'; import { useAppDispatch } from 'src/stores'; import { syncOperationsThunks } from 'src/stores/sync'; import { useConnectSiteMutation } from 'src/stores/sync/connected-sites'; -import { getWpcomClient } from 'src/stores/wpcom-client'; import { Blueprint } from 'src/stores/wpcom-api'; +import { getWpcomClient } from 'src/stores/wpcom-client'; import type { BlueprintPreferredVersions } from '@studio/common/lib/blueprint-validation'; import type { SyncSite } from '@studio/common/types/sync'; import type { SyncOption } from 'src/types'; diff --git a/apps/studio/src/modules/add-site/components/pull-remote-site.tsx b/apps/studio/src/modules/add-site/components/pull-remote-site.tsx index 83004ccebd..7d007f35f1 100644 --- a/apps/studio/src/modules/add-site/components/pull-remote-site.tsx +++ b/apps/studio/src/modules/add-site/components/pull-remote-site.tsx @@ -11,11 +11,11 @@ import offlineIcon from 'src/components/offline-icon'; import { Tooltip } from 'src/components/tooltip'; import { useOffline } from 'src/hooks/use-offline'; import { getIpcApi } from 'src/lib/get-ipc-api'; -import { useRootSelector } from 'src/stores'; -import { authSelectors } from 'src/stores/auth-slice'; import { NoWpcomSitesContent } from 'src/modules/sync/components/no-wpcom-sites-content'; import { SitesListContent } from 'src/modules/sync/components/sync-sites-modal-selector'; import { SyncTabImage } from 'src/modules/sync/components/sync-tab-image'; +import { useRootSelector } from 'src/stores'; +import { authSelectors } from 'src/stores/auth-slice'; import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites'; import type { SyncSite } from '@studio/common/types/sync'; diff --git a/apps/studio/src/modules/onboarding/index.tsx b/apps/studio/src/modules/onboarding/index.tsx index 67c1c42b57..bd88aace2b 100644 --- a/apps/studio/src/modules/onboarding/index.tsx +++ b/apps/studio/src/modules/onboarding/index.tsx @@ -3,8 +3,8 @@ import { useCallback, useEffect } from 'react'; import { StudioLogo } from 'src/components/studio-logo'; import { OnboardingConnectToWpcom } from 'src/modules/onboarding/components/connect-to-wpcom'; import { useAppDispatch, useRootSelector } from 'src/stores'; -import { authSelectors } from 'src/stores/auth-slice'; import { useSaveLastSeenVersionMutation } from 'src/stores/app-version-api'; +import { authSelectors } from 'src/stores/auth-slice'; import { saveOnboardingStatus } from 'src/stores/onboarding-slice'; const GradientBox = () => { diff --git a/apps/studio/src/modules/preview-site/components/create-preview-button.tsx b/apps/studio/src/modules/preview-site/components/create-preview-button.tsx index 2a0bf0ec57..b19768a744 100644 --- a/apps/studio/src/modules/preview-site/components/create-preview-button.tsx +++ b/apps/studio/src/modules/preview-site/components/create-preview-button.tsx @@ -13,10 +13,10 @@ import { useSiteSize } from 'src/hooks/use-site-size'; import { getLatestStableWpVersion } from 'src/lib/version-utils'; import { hasUnsupportedWpOrPhpVersion } from 'src/modules/preview-site/lib/version-comparison'; import { useRootSelector } from 'src/stores'; -import type { AuthUser } from 'src/stores/auth-slice'; import { snapshotSelectors } from 'src/stores/snapshot-slice'; import { useGetWordPressVersions } from 'src/stores/wordpress-versions-api'; import { useGetSnapshotUsage } from 'src/stores/wpcom-api'; +import type { AuthUser } from 'src/stores/auth-slice'; interface CreatePreviewButtonProps { onClick: () => void; diff --git a/apps/studio/src/modules/sync/tests/index.test.tsx b/apps/studio/src/modules/sync/tests/index.test.tsx index 724062783a..919eae44ab 100644 --- a/apps/studio/src/modules/sync/tests/index.test.tsx +++ b/apps/studio/src/modules/sync/tests/index.test.tsx @@ -13,8 +13,8 @@ import { store } from 'src/stores'; import { authSelectors } from 'src/stores/auth-slice'; import { syncOperationsActions, useLatestRewindId, useRemoteFileTree } from 'src/stores/sync'; import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites'; -import { getWpcomClient } from 'src/stores/wpcom-client'; import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; +import { getWpcomClient } from 'src/stores/wpcom-client'; store.replaceReducer( testReducer ); @@ -105,9 +105,7 @@ const setupAuthMock = ( isAuthenticated: boolean = false ) => { vi.mocked( authSelectors.selectUser ).mockReturnValue( isAuthenticated ? { id: 123, email: 'user@example.com', displayName: 'user' } : undefined ); - vi.mocked( getWpcomClient ).mockReturnValue( - isAuthenticated ? ( {} as never ) : undefined - ); + vi.mocked( getWpcomClient ).mockReturnValue( isAuthenticated ? ( {} as never ) : undefined ); }; const selectedSite: SiteDetails = { From 95401c0b79978b8dcd5a837dac9adcccd00fc698 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 27 Apr 2026 11:08:04 -0400 Subject: [PATCH 24/26] Another test fix --- .../user-settings/components/tests/user-settings.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/studio/src/modules/user-settings/components/tests/user-settings.test.tsx b/apps/studio/src/modules/user-settings/components/tests/user-settings.test.tsx index 09d707e00b..71d9567e82 100644 --- a/apps/studio/src/modules/user-settings/components/tests/user-settings.test.tsx +++ b/apps/studio/src/modules/user-settings/components/tests/user-settings.test.tsx @@ -30,6 +30,9 @@ vi.mock( 'src/stores/auth-slice', async () => { }, }; } ); +vi.mock( 'src/hooks/use-gravatar-url', () => ( { + useGravatarUrl: vi.fn( () => null ), +} ) ); vi.mock( 'src/hooks/use-ipc-listener' ); vi.mock( 'src/hooks/use-offline' ); From 379f5d8b10655795ad96c341c0dd4183a08a0512 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 27 Apr 2026 11:35:09 -0400 Subject: [PATCH 25/26] Test fix --- .../src/components/tests/content-tab-assistant.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/studio/src/components/tests/content-tab-assistant.test.tsx b/apps/studio/src/components/tests/content-tab-assistant.test.tsx index 610dbc8f3f..d044d62588 100644 --- a/apps/studio/src/components/tests/content-tab-assistant.test.tsx +++ b/apps/studio/src/components/tests/content-tab-assistant.test.tsx @@ -301,6 +301,10 @@ describe( 'ContentTabAssistant', () => { // Simulate user authentication change rerender( buildContextTree( { auth: { user: user2 } } ) ); + // Force store subscribers (useRootSelector) to re-evaluate with updated mock + act( () => { + store.dispatch( { type: 'test/forceUpdate' } ); + } ); await waitFor( () => { From f2c6f449a0f7221c9ceab85c0ca6e3c2eecd57a8 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 27 Apr 2026 12:22:24 -0400 Subject: [PATCH 26/26] tESTS FIX --- apps/studio/src/stores/auth-slice.ts | 42 ++++++++++++++-------------- apps/studio/src/stores/index.ts | 5 ++-- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/apps/studio/src/stores/auth-slice.ts b/apps/studio/src/stores/auth-slice.ts index 413c157cfe..7ff167f0b4 100644 --- a/apps/studio/src/stores/auth-slice.ts +++ b/apps/studio/src/stores/auth-slice.ts @@ -195,32 +195,32 @@ export const authSelectors = { selectUser: ( state: RootState ) => state.auth.user ?? undefined, }; -if ( process.env.NODE_ENV !== 'test' ) { +export function initializeAuthListeners() { void store.dispatch( initializeAuth() ); -} -window.ipcListener.subscribe( 'auth-updated', ( _event, payload ) => { - if ( 'error' in payload ) { - let title: string = __( 'Authentication error' ); - let message: string = __( 'Please try again.' ); + window.ipcListener.subscribe( 'auth-updated', ( _event, payload ) => { + if ( 'error' in payload ) { + let title: string = __( 'Authentication error' ); + let message: string = __( 'Please try again.' ); - if ( payload.error instanceof Error && payload.error.message.includes( 'access_denied' ) ) { - title = __( 'Authorization denied' ); - message = __( - 'It looks like you denied the authorization request. To proceed, please click "Approve"' - ); - } + if ( payload.error instanceof Error && payload.error.message.includes( 'access_denied' ) ) { + title = __( 'Authorization denied' ); + message = __( + 'It looks like you denied the authorization request. To proceed, please click "Approve"' + ); + } - void getIpcApi().showErrorMessageBox( { title, message } ); - return; - } + void getIpcApi().showErrorMessageBox( { title, message } ); + return; + } - if ( ! payload.token ) { - void store.dispatch( authLogout() ); - return; - } + if ( ! payload.token ) { + void store.dispatch( authLogout() ); + return; + } - void store.dispatch( authTokenReceived( { token: payload.token } ) ); -} ); + void store.dispatch( authTokenReceived( { token: payload.token } ) ); + } ); +} export default authSlice.reducer; diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index 826c16b79f..a5d6cd1d5a 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -9,7 +9,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 authReducer from 'src/stores/auth-slice'; +import authReducer, { initializeAuthListeners } from 'src/stores/auth-slice'; 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'; @@ -367,8 +367,9 @@ export const store = configureStore( { // Enable the refetchOnFocus behavior setupListeners( store.dispatch ); -// Initialize beta features and fetch snapshots on store initialization, but skip in test environment +// Initialize beta features, auth, and fetch snapshots on store initialization, but skip in test environment if ( process.env.NODE_ENV !== 'test' ) { + initializeAuthListeners(); void store.dispatch( loadBetaFeatures() ); void refreshSnapshots(); }