From d4a35d34e101aa498eeb6e7c3bb41af7a76098b0 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 12 Mar 2026 14:18:48 +0100 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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 } ) );