From 9a4c6504a486ee71e2424f13b910419f45dfe8b7 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 9 Oct 2026 12:47:35 -0700 Subject: [PATCH 1/6] fix(auth): clear the previous account's persisted state when a different account signs in --- frontend/src/models/auth.test.ts | 55 +++++++++++++++++++++++++- frontend/src/models/auth.ts | 38 +++++++++++------- frontend/src/models/persistedModels.ts | 18 +++++++++ frontend/src/store.ts | 20 +--------- 4 files changed, 97 insertions(+), 34 deletions(-) create mode 100644 frontend/src/models/persistedModels.ts diff --git a/frontend/src/models/auth.test.ts b/frontend/src/models/auth.test.ts index 8905a58f9..4c35b1e68 100644 --- a/frontend/src/models/auth.test.ts +++ b/frontend/src/models/auth.test.ts @@ -73,7 +73,7 @@ vi.mock('../helpers/stageHelper', () => ({ chooseStage, reloadIfStageChanged })) vi.mock('../services/permitteerAccount', () => ({ signOutEverywhere })) vi.mock('../services/accountSecurity', () => ({ changePassword })) vi.mock('../services/Controller', () => ({ - default: { close: controllerClose, emitWithAck, reconnectNow }, + default: { close: controllerClose, emitWithAck, reconnectNow, setupConnection: vi.fn() }, emit: vi.fn(() => false), })) vi.mock('../services/CloudSync', () => ({ default: { reset: vi.fn() } })) @@ -117,6 +117,8 @@ const aFailureShowing = (signInError: string) => expect.objectContaining({ signI // eslint-disable-next-line @typescript-eslint/no-var-requires import authModel, { liveAuthCallback } from './auth' import { agentOwnedMessage } from '@common/agentOwner' +import { graphQLLogin } from '../services/graphQLRequest' +import { PERSISTED_MODELS } from './persistedModels' const effectsFor = (dispatch: any) => (authModel as any).effects(dispatch) @@ -639,3 +641,54 @@ describe('auth model — the desktop hands the chooser callback to a signed-in w expect(completeCallback).toHaveBeenCalledTimes(2) }) }) + +describe('auth model — signing in as another account clears the last account’s persisted state', () => { + const spyDispatch = () => { + const models: Record>> = {} + const dispatch = new Proxy(models, { + get: (all, model: string) => + (all[model] ??= new Proxy({} as Record>, { + get: (calls, fn: string) => (calls[fn] ??= vi.fn()), + })), + }) + return { models, dispatch: dispatch as any } + } + const signInAs = async (id: string, persistedOwner: string) => { + ;(storeState as any).user = { id: persistedOwner } + vi.mocked(graphQLLogin).mockResolvedValue({ + data: { data: { login: { id, authhash: 'hash', yoicsId: 'yoics' } } }, + } as any) + const { dispatch } = spyDispatch() + await effectsFor(dispatch).fetchUser() + return dispatch + } + + afterEach(() => { + delete (storeState as any).user + }) + + it('resets before the new user is set when the persisted state is another account’s', async () => { + const dispatch = await signInAs('USER-B', 'USER-A') + expect(dispatch.auth.resetAccountData).toHaveBeenCalledTimes(1) + expect(dispatch.auth.resetAccountData.mock.invocationCallOrder[0]).toBeLessThan( + dispatch.auth.set.mock.invocationCallOrder[0] + ) + }) + + it('keeps the persisted state for the same account', async () => { + const dispatch = await signInAs('USER-A', 'USER-A') + expect(dispatch.auth.resetAccountData).not.toHaveBeenCalled() + }) + + it('keeps the persisted state when no account owns it yet', async () => { + const dispatch = await signInAs('USER-A', '') + expect(dispatch.auth.resetAccountData).not.toHaveBeenCalled() + }) + + it('resets every persisted model except chat, which clears itself by ownerId', () => { + const { models, dispatch } = spyDispatch() + effectsFor(dispatch).resetAccountData() + const reset = Object.keys(models).filter(model => models[model].reset?.mock.calls.length) + expect(reset.sort()).toEqual(PERSISTED_MODELS.filter(model => model !== 'chat').sort()) + }) +}) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 9b15ab963..41387615a 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -289,6 +289,10 @@ export default createModel()({ const user = response?.data?.data?.login + // Switching saved accounts reloads without signing out, so the persisted state can still be the last account's. + const owner = store.getState().user.id + if (owner && user?.id && owner !== user.id) auth.resetAccountData() + auth.set({ user, ...signInCleared }) if (user.authhash && user.yoicsId) { Controller.setupConnection({ username: user.yoicsId, authHash: user.authhash, guid: user.id }) @@ -500,27 +504,13 @@ export default createModel()({ await dispatch.auth.set({ user: undefined, ...signInCleared }) dispatch.chat.reset() dispatch.agents.reset() - dispatch.user.reset() - dispatch.organization.reset() - dispatch.networks.reset() - dispatch.accounts.reset() - dispatch.connections.reset() - dispatch.devices.reset() - dispatch.sessions.reset() + dispatch.auth.resetAccountData() dispatch.logs.reset() dispatch.search.reset() - dispatch.announcements.reset() - dispatch.applicationTypes.reset() - dispatch.plans.reset() - dispatch.contacts.reset() dispatch.billing.reset() dispatch.backend.reset() - dispatch.files.reset() dispatch.keys.reset() - dispatch.jobs.reset() - dispatch.tags.reset() dispatch.ui.reset() - dispatch.products.reset() dispatch.partnerStats.reset() dispatch.adminUsers.reset() dispatch.adminPartners.reset() @@ -542,6 +532,24 @@ export default createModel()({ Controller.close() reloadIfStageChanged() }, + /** Every persisted model but chat, which clears itself when its ownerId stops matching the user. */ + resetAccountData() { + dispatch.user.reset() + dispatch.organization.reset() + dispatch.networks.reset() + dispatch.accounts.reset() + dispatch.connections.reset() + dispatch.devices.reset() + dispatch.sessions.reset() + dispatch.announcements.reset() + dispatch.applicationTypes.reset() + dispatch.plans.reset() + dispatch.contacts.reset() + dispatch.files.reset() + dispatch.jobs.reset() + dispatch.tags.reset() + dispatch.products.reset() + }, /** Test Settings' stage switch. This signs out of the old login server; the reload at the end of * signedOut boots every endpoint on the new stage, where init drops the old stage's accounts. */ async switchStage(stage: StageName, state) { diff --git a/frontend/src/models/persistedModels.ts b/frontend/src/models/persistedModels.ts new file mode 100644 index 000000000..54e416b0f --- /dev/null +++ b/frontend/src/models/persistedModels.ts @@ -0,0 +1,18 @@ +export const PERSISTED_MODELS = [ + 'accounts', + 'announcements', + 'applicationTypes', + 'chat', + 'connections', + 'contacts', + 'devices', + 'files', + 'jobs', + 'networks', + 'organization', + 'plans', + 'products', + 'sessions', + 'tags', + 'user', +] as const diff --git a/frontend/src/store.ts b/frontend/src/store.ts index b89c1bf64..ba7413a70 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -7,6 +7,7 @@ import { init, RematchDispatch, RematchRootState } from '@rematch/core' import { createTransform, PersistConfig } from 'redux-persist' import persistPlugin, { getPersistor } from '@rematch/persist' import DateTransform from './helpers/DateTransform' +import { PERSISTED_MODELS } from './models/persistedModels' import immerPlugin from '@rematch/immer' import localForage from 'localforage' @@ -48,24 +49,7 @@ const persistConfig: PersistConfig = { version: numericVersion(), // The popout persists nothing (noopStorage) so it cannot clobber the main window's 'app' key. storage: isChatPopout ? noopStorage : localForage, - whitelist: [ - 'accounts', - 'announcements', - 'applicationTypes', - 'chat', - 'connections', - 'contacts', - 'devices', - 'files', - 'jobs', - 'networks', - 'organization', - 'plans', - 'products', - 'sessions', - 'tags', - 'user', - ], + whitelist: [...PERSISTED_MODELS], throttle: 1000, transforms: [DateTransform, chatTransform], } From 04537585314308ee4cdac64e995fbfcfa9c26fc7 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 9 Oct 2026 12:58:48 -0700 Subject: [PATCH 2/6] fix(auth): stamp the state owner at sign-in and close the previous account's chat popouts on a switch --- frontend/src/models/auth.test.ts | 57 +++++++++++++++++++------------- frontend/src/models/auth.ts | 10 ++++-- 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/frontend/src/models/auth.test.ts b/frontend/src/models/auth.test.ts index 4c35b1e68..7e0b054f2 100644 --- a/frontend/src/models/auth.test.ts +++ b/frontend/src/models/auth.test.ts @@ -49,7 +49,7 @@ const { oidcActor: vi.fn(), oidcMcpDetailReady: vi.fn(), browser: { isElectron: false, hasBackend: false }, - storeState: { auth: {} as Record }, + storeState: { auth: {} as Record, user: { id: '' } }, })) // signInFailure() tests `error instanceof OidcError`, so the mock must export a real class @@ -71,6 +71,8 @@ vi.mock('../services/oidc', () => ({ })) vi.mock('../helpers/stageHelper', () => ({ chooseStage, reloadIfStageChanged })) vi.mock('../services/permitteerAccount', () => ({ signOutEverywhere })) +const { broadcastChatSignout } = vi.hoisted(() => ({ broadcastChatSignout: vi.fn() })) +vi.mock('../services/chatPopout', () => ({ broadcastChatSignout })) vi.mock('../services/accountSecurity', () => ({ changePassword })) vi.mock('../services/Controller', () => ({ default: { close: controllerClose, emitWithAck, reconnectNow, setupConnection: vi.fn() }, @@ -110,6 +112,18 @@ function makeDispatch() { } } +// Every model and method on demand, as a spy: for effects that fan out across many models. +function spyDispatch() { + const models: Record>> = {} + const dispatch = new Proxy(models, { + get: (all, model: string) => + (all[model] ??= new Proxy({} as Record>, { + get: (calls, fn: string) => (calls[fn] ??= vi.fn()), + })), + }) + return { models, dispatch: dispatch as any } +} + // The only shape SignInApp renders: it shows a message ONLY while signInFailed is true, and // signInFailed is also the brake on auto sign-in — so every failure writer must produce it. const aFailureShowing = (signInError: string) => expect.objectContaining({ signInFailed: true, signInError }) @@ -396,13 +410,7 @@ describe('auth model — a stage switch signs out and reloads onto the new stage }) it('the sign-out teardown reloads onto a changed stage as its last step', async () => { - const fns: Record>> = {} - const dispatch = new Proxy(fns, { - get: (models, model: string) => - (models[model] ??= new Proxy({} as Record>, { - get: (calls, fn: string) => (calls[fn] ??= vi.fn()), - })), - }) + const { dispatch } = spyDispatch() await effectsFor(dispatch).signedOut() expect(reloadIfStageChanged).toHaveBeenCalledTimes(1) expect(controllerClose.mock.invocationCallOrder[0]).toBeLessThan(reloadIfStageChanged.mock.invocationCallOrder[0]) @@ -643,18 +651,8 @@ describe('auth model — the desktop hands the chooser callback to a signed-in w }) describe('auth model — signing in as another account clears the last account’s persisted state', () => { - const spyDispatch = () => { - const models: Record>> = {} - const dispatch = new Proxy(models, { - get: (all, model: string) => - (all[model] ??= new Proxy({} as Record>, { - get: (calls, fn: string) => (calls[fn] ??= vi.fn()), - })), - }) - return { models, dispatch: dispatch as any } - } const signInAs = async (id: string, persistedOwner: string) => { - ;(storeState as any).user = { id: persistedOwner } + storeState.user = { id: persistedOwner } vi.mocked(graphQLLogin).mockResolvedValue({ data: { data: { login: { id, authhash: 'hash', yoicsId: 'yoics' } } }, } as any) @@ -663,26 +661,39 @@ describe('auth model — signing in as another account clears the last account return dispatch } + beforeEach(() => broadcastChatSignout.mockReset()) + afterEach(() => { - delete (storeState as any).user + storeState.user = { id: '' } }) - it('resets before the new user is set when the persisted state is another account’s', async () => { + it('resets and closes chat popouts before the new user is set when the persisted state is another account’s', async () => { const dispatch = await signInAs('USER-B', 'USER-A') expect(dispatch.auth.resetAccountData).toHaveBeenCalledTimes(1) + expect(broadcastChatSignout).toHaveBeenCalledTimes(1) expect(dispatch.auth.resetAccountData.mock.invocationCallOrder[0]).toBeLessThan( dispatch.auth.set.mock.invocationCallOrder[0] ) }) - it('keeps the persisted state for the same account', async () => { + it('stamps the new owner right after the reset, before any account fetch can persist', async () => { + const dispatch = await signInAs('USER-B', 'USER-A') + expect(dispatch.user.set).toHaveBeenCalledWith({ id: 'USER-B' }) + expect(dispatch.auth.resetAccountData.mock.invocationCallOrder[0]).toBeLessThan( + dispatch.user.set.mock.invocationCallOrder[0] + ) + }) + + it('keeps the persisted state and chat popouts for the same account', async () => { const dispatch = await signInAs('USER-A', 'USER-A') expect(dispatch.auth.resetAccountData).not.toHaveBeenCalled() + expect(broadcastChatSignout).not.toHaveBeenCalled() }) - it('keeps the persisted state when no account owns it yet', async () => { + it('keeps the persisted state but still stamps an owner when none is recorded', async () => { const dispatch = await signInAs('USER-A', '') expect(dispatch.auth.resetAccountData).not.toHaveBeenCalled() + expect(dispatch.user.set).toHaveBeenCalledWith({ id: 'USER-A' }) }) it('resets every persisted model except chat, which clears itself by ownerId', () => { diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 41387615a..1a8573188 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -18,6 +18,7 @@ import { graphQLLogin } from '../services/graphQLRequest' import { getToken } from '../services/remoteit' import { changePassword as changeAccountPassword } from '../services/accountSecurity' import { signOutEverywhere } from '../services/permitteerAccount' +import { broadcastChatSignout } from '../services/chatPopout' import { oidcConfigured, oidcSignedIn, @@ -289,9 +290,14 @@ export default createModel()({ const user = response?.data?.data?.login - // Switching saved accounts reloads without signing out, so the persisted state can still be the last account's. + // Switching accounts reloads without signing out, so persisted state and open chat popouts can be the last account's. const owner = store.getState().user.id - if (owner && user?.id && owner !== user.id) auth.resetAccountData() + if (owner && user?.id && owner !== user.id) { + auth.resetAccountData() + broadcastChatSignout() + } + // Stamped here rather than when user.fetch lands, so a second switch before then still sees an owner. + if (user?.id) dispatch.user.set({ id: user.id }) auth.set({ user, ...signInCleared }) if (user.authhash && user.yoicsId) { From 19674d88c278b81c6d8ea0e4a1bb2d9af577346a Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 9 Oct 2026 12:58:48 -0700 Subject: [PATCH 3/6] fix(store): keep a support tab's state out of the operator's persisted storage --- frontend/src/store.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/store.ts b/frontend/src/store.ts index ba7413a70..87ab0d502 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -2,6 +2,7 @@ import { numericVersion } from './helpers/versionHelper' import { models, RootModel } from './models' import { defaultChatState, IChatState } from './models/chat' import { isChatPopout, popoutScopeId } from './services/chatPopout' +import { oidcIsSupportTab } from './services/oidc' import { createLogger, ReduxLoggerOptions } from 'redux-logger' import { init, RematchDispatch, RematchRootState } from '@rematch/core' import { createTransform, PersistConfig } from 'redux-persist' @@ -47,8 +48,8 @@ const noopStorage = { const persistConfig: PersistConfig = { key: 'app', version: numericVersion(), - // The popout persists nothing (noopStorage) so it cannot clobber the main window's 'app' key. - storage: isChatPopout ? noopStorage : localForage, + // The popout and support tabs persist nothing (noopStorage), so neither can clobber the main window's 'app' key. + storage: isChatPopout || oidcIsSupportTab() ? noopStorage : localForage, whitelist: [...PERSISTED_MODELS], throttle: 1000, transforms: [DateTransform, chatTransform], From c643950befefbc70e9c912e59266203bf0339634 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 9 Oct 2026 13:00:54 -0700 Subject: [PATCH 4/6] fix(auth): wait for rehydration before the owner check and reset chat with the other persisted models --- frontend/src/hooks/useChatSync.ts | 7 ++----- frontend/src/models/auth.test.ts | 35 +++++++++++++++++++++++++++---- frontend/src/models/auth.ts | 18 +++++++++++++--- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/frontend/src/hooks/useChatSync.ts b/frontend/src/hooks/useChatSync.ts index 629be5812..fb9514885 100644 --- a/frontend/src/hooks/useChatSync.ts +++ b/frontend/src/hooks/useChatSync.ts @@ -27,11 +27,8 @@ const useAgentHealthOnReconnect = (check: () => void): void => { const currentHandoff = () => toChatHandoff(store.getState().chat) /* The identity the chat is scoped by. auth.user, NOT the persisted `user` model: auth.user is - fetched for the CURRENT tokens at sign-in (it is what lets App mount), while the user model - is restored from storage and only catches up when the cloud sync lands. Activating a saved - account swaps tokens and reloads without purging persisted models, so for that interval - (indefinitely, if the sync stalls) the user model still names the PREVIOUS account — and an - ownership check against it would keep that account's transcript on the new account's screen. */ + fetched for the CURRENT tokens at sign-in (it is what lets App mount), while the rest of the + user model is restored from storage and only catches up when the cloud sync lands. */ const useChatIdentity = (): string => useSelector((state: State) => state.auth.user?.id ?? '') // '' = not signed in: syncIdentity no-ops /* What both chat surfaces do on boot: follow the signed-in identity, clear what must not diff --git a/frontend/src/models/auth.test.ts b/frontend/src/models/auth.test.ts index 7e0b054f2..5f5ef3146 100644 --- a/frontend/src/models/auth.test.ts +++ b/frontend/src/models/auth.test.ts @@ -87,8 +87,16 @@ vi.mock('../services/zendesk', () => ({ default: { endChat: vi.fn() } })) vi.mock('../services/graphQLRequest', () => ({ graphQLLogin: vi.fn() })) vi.mock('../services/remoteit', () => ({ getToken: vi.fn(), apiAuthHeaders: vi.fn() })) vi.mock('../selectors/devices', () => ({ selectDeviceModelAttributes: vi.fn() })) +const persist = vi.hoisted(() => ({ bootstrapped: true, listener: undefined as undefined | (() => void) })) vi.mock('../store', () => ({ - persistor: { purge: vi.fn() }, + persistor: { + purge: vi.fn(), + getState: () => ({ bootstrapped: persist.bootstrapped }), + subscribe: (listener: () => void) => { + persist.listener = listener + return () => (persist.listener = undefined) + }, + }, store: { getState: () => storeState, dispatch: { auth: { completeCallback } } }, })) vi.mock('../i18n', () => ({ default: { t: (k: string) => k } })) @@ -665,6 +673,7 @@ describe('auth model — signing in as another account clears the last account afterEach(() => { storeState.user = { id: '' } + persist.bootstrapped = true }) it('resets and closes chat popouts before the new user is set when the persisted state is another account’s', async () => { @@ -690,16 +699,34 @@ describe('auth model — signing in as another account clears the last account expect(broadcastChatSignout).not.toHaveBeenCalled() }) - it('keeps the persisted state but still stamps an owner when none is recorded', async () => { + it('keeps the persisted state and other tabs’ popouts, but stamps an owner, when none is recorded', async () => { const dispatch = await signInAs('USER-A', '') expect(dispatch.auth.resetAccountData).not.toHaveBeenCalled() + expect(broadcastChatSignout).not.toHaveBeenCalled() expect(dispatch.user.set).toHaveBeenCalledWith({ id: 'USER-A' }) }) - it('resets every persisted model except chat, which clears itself by ownerId', () => { + it('waits for rehydration before reading the owner', async () => { + persist.bootstrapped = false + vi.mocked(graphQLLogin).mockResolvedValue({ + data: { data: { login: { id: 'USER-B', authhash: 'h', yoicsId: 'y' } } }, + } as any) + const { dispatch } = spyDispatch() + const signingIn = effectsFor(dispatch).fetchUser() + await vi.waitFor(() => expect(persist.listener).toBeDefined()) + expect(dispatch.auth.set).not.toHaveBeenCalled() + + storeState.user = { id: 'USER-A' } + persist.bootstrapped = true + persist.listener?.() + await signingIn + expect(dispatch.auth.resetAccountData).toHaveBeenCalledTimes(1) + }) + + it('resets every persisted model', () => { const { models, dispatch } = spyDispatch() effectsFor(dispatch).resetAccountData() const reset = Object.keys(models).filter(model => models[model].reset?.mock.calls.length) - expect(reset.sort()).toEqual(PERSISTED_MODELS.filter(model => model !== 'chat').sort()) + expect(reset.sort()).toEqual([...PERSISTED_MODELS].sort()) }) }) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 1a8573188..db8aea2b4 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -116,6 +116,17 @@ const signInFailure = (error: any): Partial => ({ signInRetryAfter: error instanceof OidcError ? error.retryAfter : undefined, }) +// auth.init is not gated on rehydration, and an owner read before it sees the empty default. +const rehydrated = () => + new Promise(resolve => { + if (persistor.getState().bootstrapped) return resolve() + const unsubscribe = persistor.subscribe(() => { + if (!persistor.getState().bootstrapped) return + unsubscribe() + resolve() + }) + }) + const signInCleared = { signInFailed: false, signInError: undefined, @@ -291,6 +302,7 @@ export default createModel()({ const user = response?.data?.data?.login // Switching accounts reloads without signing out, so persisted state and open chat popouts can be the last account's. + await rehydrated() const owner = store.getState().user.id if (owner && user?.id && owner !== user.id) { auth.resetAccountData() @@ -508,9 +520,8 @@ export default createModel()({ a stale error and no redirect, for something that happened in someone else's session. */ await dispatch.auth.set({ user: undefined, ...signInCleared }) - dispatch.chat.reset() - dispatch.agents.reset() dispatch.auth.resetAccountData() + dispatch.agents.reset() dispatch.logs.reset() dispatch.search.reset() dispatch.billing.reset() @@ -538,8 +549,9 @@ export default createModel()({ Controller.close() reloadIfStageChanged() }, - /** Every persisted model but chat, which clears itself when its ownerId stops matching the user. */ + /** Every persisted model: what sign-out clears, and what a sign-in finding another account's state clears. */ resetAccountData() { + dispatch.chat.reset() dispatch.user.reset() dispatch.organization.reset() dispatch.networks.reset() From d21a913c2e8509066b53caffac983b0ff30f5fb5 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 9 Oct 2026 13:05:22 -0700 Subject: [PATCH 5/6] fix(auth): treat persisted state with no recorded owner as another account's --- frontend/src/models/auth.test.ts | 4 ++-- frontend/src/models/auth.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/models/auth.test.ts b/frontend/src/models/auth.test.ts index 558c11deb..c3b713dd4 100644 --- a/frontend/src/models/auth.test.ts +++ b/frontend/src/models/auth.test.ts @@ -718,9 +718,9 @@ describe('auth model — signing in as another account clears the last account expect(broadcastChatSignout).not.toHaveBeenCalled() }) - it('keeps the persisted state and other tabs’ popouts, but stamps an owner, when none is recorded', async () => { + it('resets state with no recorded owner, but leaves other windows’ popouts open', async () => { const dispatch = await signInAs('USER-A', '') - expect(dispatch.auth.resetAccountData).not.toHaveBeenCalled() + expect(dispatch.auth.resetAccountData).toHaveBeenCalledTimes(1) expect(broadcastChatSignout).not.toHaveBeenCalled() expect(dispatch.user.set).toHaveBeenCalledWith({ id: 'USER-A' }) }) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 916d66c2f..4daf0887b 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -314,12 +314,13 @@ export default createModel()({ const user = response?.data?.data?.login - // Switching accounts reloads without signing out, so persisted state and open chat popouts can be the last account's. + // Switching accounts reloads without signing out, so persisted state can be the last account's; with no owner + // recorded it can't be trusted either. Only a known other owner means open chat popouts are that account's. await rehydrated() const owner = store.getState().user.id - if (owner && user?.id && owner !== user.id) { + if (user?.id && owner !== user.id) { auth.resetAccountData() - broadcastChatSignout() + if (owner) broadcastChatSignout() } // Stamped here rather than when user.fetch lands, so a second switch before then still sees an owner. if (user?.id) dispatch.user.set({ id: user.id }) From 2fc85fdec039652c72987e592077383ca12fcd05 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 9 Oct 2026 13:09:52 -0700 Subject: [PATCH 6/6] fix(auth): run the owner check only in windows that persist state, and always close stale popouts there --- frontend/src/models/auth.test.ts | 20 ++++++++++++++++++-- frontend/src/models/auth.ts | 10 +++++----- frontend/src/store.ts | 4 +++- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/frontend/src/models/auth.test.ts b/frontend/src/models/auth.test.ts index c3b713dd4..b9e70c612 100644 --- a/frontend/src/models/auth.test.ts +++ b/frontend/src/models/auth.test.ts @@ -91,8 +91,15 @@ vi.mock('../services/pushNotifications', () => ({ default: { register: vi.fn(), vi.mock('../services/graphQLRequest', () => ({ graphQLLogin: vi.fn() })) vi.mock('../services/remoteit', () => ({ getToken: vi.fn(), apiAuthHeaders: vi.fn() })) vi.mock('../selectors/devices', () => ({ selectDeviceModelAttributes: vi.fn() })) -const persist = vi.hoisted(() => ({ bootstrapped: true, listener: undefined as undefined | (() => void) })) +const persist = vi.hoisted(() => ({ + bootstrapped: true, + persistsState: true, + listener: undefined as undefined | (() => void), +})) vi.mock('../store', () => ({ + get persistsState() { + return persist.persistsState + }, persistor: { purge: vi.fn(), getState: () => ({ bootstrapped: persist.bootstrapped }), @@ -693,6 +700,7 @@ describe('auth model — signing in as another account clears the last account afterEach(() => { storeState.user = { id: '' } persist.bootstrapped = true + persist.persistsState = true }) it('resets and closes chat popouts before the new user is set when the persisted state is another account’s', async () => { @@ -718,9 +726,17 @@ describe('auth model — signing in as another account clears the last account expect(broadcastChatSignout).not.toHaveBeenCalled() }) - it('resets state with no recorded owner, but leaves other windows’ popouts open', async () => { + it('treats state with no recorded owner as another account’s, popouts included', async () => { const dispatch = await signInAs('USER-A', '') expect(dispatch.auth.resetAccountData).toHaveBeenCalledTimes(1) + expect(broadcastChatSignout).toHaveBeenCalledTimes(1) + expect(dispatch.user.set).toHaveBeenCalledWith({ id: 'USER-A' }) + }) + + it('skips the check in a popout or support tab, which persists nothing and must not close other windows’ popouts', async () => { + persist.persistsState = false + const dispatch = await signInAs('USER-A', '') + expect(dispatch.auth.resetAccountData).not.toHaveBeenCalled() expect(broadcastChatSignout).not.toHaveBeenCalled() expect(dispatch.user.set).toHaveBeenCalledWith({ id: 'USER-A' }) }) diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts index 4daf0887b..7e121d6e2 100644 --- a/frontend/src/models/auth.ts +++ b/frontend/src/models/auth.ts @@ -14,7 +14,7 @@ import { StageName, } from '../constants' import { chooseStage, reloadIfStageChanged } from '../helpers/stageHelper' -import { persistor, store } from '../store' +import { persistor, persistsState, store } from '../store' import { graphQLLogin } from '../services/graphQLRequest' import { getToken } from '../services/remoteit' import { changePassword as changeAccountPassword } from '../services/accountSecurity' @@ -314,13 +314,13 @@ export default createModel()({ const user = response?.data?.data?.login - // Switching accounts reloads without signing out, so persisted state can be the last account's; with no owner - // recorded it can't be trusted either. Only a known other owner means open chat popouts are that account's. + // Switching accounts reloads without signing out, so a window's persisted state, and any chat popout still paired + // with it, can be the last account's. With no owner recorded neither can be trusted. await rehydrated() const owner = store.getState().user.id - if (user?.id && owner !== user.id) { + if (persistsState && user?.id && owner !== user.id) { auth.resetAccountData() - if (owner) broadcastChatSignout() + broadcastChatSignout() } // Stamped here rather than when user.fetch lands, so a second switch before then still sees an owner. if (user?.id) dispatch.user.set({ id: user.id }) diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 87ab0d502..5e1c628c3 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -45,11 +45,13 @@ const noopStorage = { removeItem: () => Promise.resolve(), } +export const persistsState = !isChatPopout && !oidcIsSupportTab() + const persistConfig: PersistConfig = { key: 'app', version: numericVersion(), // The popout and support tabs persist nothing (noopStorage), so neither can clobber the main window's 'app' key. - storage: isChatPopout || oidcIsSupportTab() ? noopStorage : localForage, + storage: persistsState ? localForage : noopStorage, whitelist: [...PERSISTED_MODELS], throttle: 1000, transforms: [DateTransform, chatTransform],