diff --git a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx index d4c0b797a..5e2e8a428 100644 --- a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx +++ b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx @@ -35,7 +35,11 @@ describe('OAuthProviderButton', () => { vi.mocked(getBackendUrl).mockResolvedValue('https://backend.test'); vi.mocked(openUrl).mockResolvedValue(undefined); vi.mocked(isTauri).mockReturnValue(true); - vi.mocked(getDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(getDeepLinkAuthState).mockReturnValue({ + isProcessing: false, + errorMessage: null, + requiresAppDataReset: false, + }); }); afterEach(() => { @@ -82,7 +86,11 @@ describe('OAuthProviderButton', () => { }); it('does NOT reset isLoading on focus when a deep-link auth round-trip is processing', async () => { - vi.mocked(getDeepLinkAuthState).mockReturnValue({ isProcessing: true, errorMessage: null }); + vi.mocked(getDeepLinkAuthState).mockReturnValue({ + isProcessing: true, + errorMessage: null, + requiresAppDataReset: false, + }); render(); diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index 12b9c9bd9..b54ada712 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -2,14 +2,9 @@ import { ReactNode, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useCoreState } from '../../providers/CoreStateProvider'; -import { persistor } from '../../store'; +import { clearAllAppData } from '../../utils/clearAllAppData'; import { BILLING_DASHBOARD_URL } from '../../utils/links'; import { openUrl } from '../../utils/openUrl'; -import { - resetOpenHumanDataAndRestartCore, - restartApp, - scheduleCefProfilePurge, -} from '../../utils/tauriCommands'; import { resetWalkthrough } from '../walkthrough/AppWalkthrough'; import SettingsHeader from './components/SettingsHeader'; import SettingsMenuItem from './components/SettingsMenuItem'; @@ -55,60 +50,12 @@ const SettingsHome = () => { } }; - const clearAllAppData = async () => { - const currentUserId = snapshot.auth.userId ?? snapshot.currentUser?._id ?? null; - - // Queue the current user-scoped CEF profile for deletion on next launch. - // The active CEF browser process may still hold SQLite/cache file handles, - // so we delete after the shell restarts rather than relying on in-process - // removal to succeed everywhere. - try { - await scheduleCefProfilePurge(currentUserId); - } catch (err) { - console.warn('[Settings] Failed to queue CEF profile purge:', err); - } - - // 1. Logout — clear session in core (auth_clear_session). Best-effort: - // if the core process is wedged we still want to wipe local data. - try { - await clearSession(); - } catch (err) { - console.warn('[Settings] Rust logout failed during clearAllAppData:', err); - } - - // 2. Delete workspace folder + restart core. The core RPC removes both - // the active openhuman_dir and the default ~/.openhuman, then we - // restart the sidecar so it boots from a clean slate. - try { - await resetOpenHumanDataAndRestartCore(); - } catch (err) { - console.warn('[Settings] Failed to reset OpenHuman data dir and restart core:', err); - throw err; - } - - // 3. Purge redux-persist storage + browser storage. `persistor.purge()` - // wipes the persisted backend; localStorage/sessionStorage clears - // everything else (auth flags, theme, etc.). - try { - await persistor.purge(); - } catch (err) { - console.warn('[Settings] persistor.purge failed:', err); - setError('Failed to clear persisted app state. Please try again.'); - return; - } - window.localStorage.clear(); - window.sessionStorage.clear(); - - // 4. Full app restart so the CEF runtime reboots into the fresh - // pre-login profile instead of keeping the old browser process alive. - await restartApp(); - }; - const handleLogoutAndClearData = async () => { try { setIsLoading(true); setError(null); - await clearAllAppData(); // This will redirect to login + const currentUserId = snapshot.auth.userId ?? snapshot.currentUser?._id ?? null; + await clearAllAppData({ clearSession, userId: currentUserId }); // restarts the app } catch (_error) { setError('Failed to clear data and logout. Please try again.'); } finally { diff --git a/app/src/components/settings/__tests__/SettingsHome.test.tsx b/app/src/components/settings/__tests__/SettingsHome.test.tsx index 2add00ed5..8e97a6511 100644 --- a/app/src/components/settings/__tests__/SettingsHome.test.tsx +++ b/app/src/components/settings/__tests__/SettingsHome.test.tsx @@ -40,6 +40,13 @@ vi.mock('../../../utils/tauriCommands', () => ({ scheduleCefProfilePurge: vi.fn().mockResolvedValue(undefined), })); +const { mockClearAllAppData } = vi.hoisted(() => ({ + mockClearAllAppData: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../../../utils/clearAllAppData', () => ({ + clearAllAppData: (...args: unknown[]) => mockClearAllAppData(...args), +})); + vi.mock('../../walkthrough/AppWalkthrough', () => ({ resetWalkthrough: vi.fn() })); // --- helpers --- @@ -245,4 +252,26 @@ describe('SettingsHome', () => { expect(mockNavigateToSettings).toHaveBeenCalledWith('developer-options'); }); }); + + describe('Clear App Data flow', () => { + beforeEach(() => { + mockClearAllAppData.mockReset().mockResolvedValue(undefined); + }); + + it('passes the current snapshot user id + clearSession to clearAllAppData', async () => { + const user = userEvent.setup(); + renderSettingsHome(); + + await user.click(screen.getByText('Clear App Data').closest('button')!); + // Confirm in the modal + const confirmButtons = screen.getAllByRole('button', { name: /Clear App Data/i }); + // The last one is the modal confirm button (first is the menu item we just clicked). + await user.click(confirmButtons[confirmButtons.length - 1]); + + expect(mockClearAllAppData).toHaveBeenCalledTimes(1); + const args = mockClearAllAppData.mock.calls[0][0]; + expect(args).toMatchObject({ userId: null }); + expect(typeof args.clearSession).toBe('function'); + }); + }); }); diff --git a/app/src/pages/Welcome.tsx b/app/src/pages/Welcome.tsx index 31f6f3895..600512a7f 100644 --- a/app/src/pages/Welcome.tsx +++ b/app/src/pages/Welcome.tsx @@ -1,3 +1,4 @@ +import createDebug from 'debug'; import { useState } from 'react'; import OAuthProviderButton from '../components/oauth/OAuthProviderButton'; @@ -6,6 +7,7 @@ import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas'; import { clearBackendUrlCache } from '../services/backendUrl'; import { clearCoreRpcUrlCache, testCoreRpcConnection } from '../services/coreRpcClient'; import { useDeepLinkAuthState } from '../store/deepLinkAuthState'; +import { clearAllAppData } from '../utils/clearAllAppData'; import { clearStoredRpcUrl, getDefaultRpcUrl, @@ -15,14 +17,33 @@ import { storeRpcUrl, } from '../utils/configPersistence'; +const log = createDebug('app:welcome'); + const Welcome = () => { - const { isProcessing, errorMessage } = useDeepLinkAuthState(); + const { isProcessing, errorMessage, requiresAppDataReset } = useDeepLinkAuthState(); const [showAdvanced, setShowAdvanced] = useState(false); const [rpcUrl, setRpcUrl] = useState(getStoredRpcUrl()); const [rpcUrlError, setRpcUrlError] = useState(null); const [isTestingConnection, setIsTestingConnection] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false); + const [isClearingAppData, setIsClearingAppData] = useState(false); + const [resetError, setResetError] = useState(null); + + const handleClearAppData = async () => { + setIsClearingAppData(true); + setResetError(null); + try { + // No live session at the Welcome screen — skip the core-side + // `clearSession` step, just wipe local data and restart. + await clearAllAppData(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log('clearAllAppData failed: %s', message); + setResetError('Could not clear app data. Please quit and reopen OpenHuman, then try again.'); + setIsClearingAppData(false); + } + }; const handleRpcUrlChange = (value: string) => { setRpcUrl(value); @@ -175,7 +196,32 @@ const Welcome = () => {
- {errorMessage} +

{errorMessage}

+ {requiresAppDataReset ? ( +
+ +

+ This wipes locally stored secrets and accounts on this device. Your cloud + account is unaffected — you can sign in again right after. +

+ {resetError ? ( +

{resetError}

+ ) : null} +
+ ) : null}
) : null} diff --git a/app/src/pages/__tests__/Welcome.test.tsx b/app/src/pages/__tests__/Welcome.test.tsx index f5f926c16..93bacac78 100644 --- a/app/src/pages/__tests__/Welcome.test.tsx +++ b/app/src/pages/__tests__/Welcome.test.tsx @@ -52,6 +52,13 @@ vi.mock('../../components/oauth/providerConfigs', () => ({ vi.mock('../../store/deepLinkAuthState', () => ({ useDeepLinkAuthState: vi.fn() })); +const { mockClearAllAppData } = vi.hoisted(() => ({ + mockClearAllAppData: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../../utils/clearAllAppData', () => ({ + clearAllAppData: (...args: unknown[]) => mockClearAllAppData(...args), +})); + vi.mock('../../services/coreRpcClient', () => ({ clearCoreRpcUrlCache: vi.fn(), testCoreRpcConnection: vi.fn(), @@ -90,7 +97,11 @@ describe('Welcome auth entrypoint', () => { beforeEach(() => { oauthButtonSpy.mockReset(); oauthOverrideSpy.mockReset(); - vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(useDeepLinkAuthState).mockReturnValue({ + isProcessing: false, + errorMessage: null, + requiresAppDataReset: false, + }); vi.mocked(clearCoreRpcUrlCache).mockReset(); vi.mocked(clearBackendUrlCache).mockReset(); vi.mocked(storeRpcUrl).mockReset(); @@ -125,7 +136,11 @@ describe('Welcome auth entrypoint', () => { }); it('shows the deep-link processing state when auth is already in progress', () => { - vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: true, errorMessage: null }); + vi.mocked(useDeepLinkAuthState).mockReturnValue({ + isProcessing: true, + errorMessage: null, + requiresAppDataReset: false, + }); render(); @@ -136,17 +151,66 @@ describe('Welcome auth entrypoint', () => { vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: 'OAuth failed', + requiresAppDataReset: false, }); render(); expect(screen.getByRole('alert')).toHaveTextContent('OAuth failed'); + expect( + screen.queryByRole('button', { name: /Clear app data & restart/ }) + ).not.toBeInTheDocument(); + }); +}); + +describe('Welcome — decryption-failure recovery action', () => { + beforeEach(() => { + mockClearAllAppData.mockReset().mockResolvedValue(undefined); + vi.mocked(useDeepLinkAuthState).mockReturnValue({ + isProcessing: false, + errorMessage: "Sign-in failed because OpenHuman couldn't decrypt locally stored data.", + requiresAppDataReset: true, + }); + }); + + it('renders the "Clear app data & restart" button when reset is required', () => { + render(); + + expect(screen.getByRole('button', { name: /Clear app data & restart/ })).toBeInTheDocument(); + expect(screen.getByText(/cloud account is unaffected/i)).toBeInTheDocument(); + }); + + it('invokes clearAllAppData on click', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /Clear app data & restart/ })); + + await waitFor(() => expect(mockClearAllAppData).toHaveBeenCalledTimes(1)); + // Pre-login path: no clearSession callback is passed. + expect(mockClearAllAppData).toHaveBeenCalledWith(); + }); + + it('surfaces an inline error if clearAllAppData rejects', async () => { + mockClearAllAppData.mockRejectedValueOnce(new Error('reset blew up')); + + render(); + fireEvent.click(screen.getByRole('button', { name: /Clear app data & restart/ })); + + await waitFor(() => { + expect(screen.getByText(/Could not clear app data/)).toBeInTheDocument(); + }); + // Button re-enabled so the user can retry. + expect(screen.getByRole('button', { name: /Clear app data & restart/ })).not.toBeDisabled(); }); }); describe('Welcome — RPC URL advanced panel', () => { beforeEach(() => { - vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(useDeepLinkAuthState).mockReturnValue({ + isProcessing: false, + errorMessage: null, + requiresAppDataReset: false, + }); vi.mocked(clearCoreRpcUrlCache).mockReset(); vi.mocked(clearBackendUrlCache).mockReset(); vi.mocked(storeRpcUrl).mockReset(); @@ -216,7 +280,11 @@ describe('Welcome — RPC URL advanced panel', () => { describe('Welcome — Save button', () => { beforeEach(() => { - vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(useDeepLinkAuthState).mockReturnValue({ + isProcessing: false, + errorMessage: null, + requiresAppDataReset: false, + }); vi.mocked(clearCoreRpcUrlCache).mockReset(); vi.mocked(clearBackendUrlCache).mockReset(); vi.mocked(storeRpcUrl).mockReset(); @@ -294,7 +362,11 @@ describe('Welcome — Save button', () => { describe('Welcome — Test Connection button', () => { beforeEach(() => { - vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(useDeepLinkAuthState).mockReturnValue({ + isProcessing: false, + errorMessage: null, + requiresAppDataReset: false, + }); vi.mocked(clearCoreRpcUrlCache).mockReset(); vi.mocked(clearBackendUrlCache).mockReset(); vi.mocked(storeRpcUrl).mockReset(); @@ -419,7 +491,11 @@ describe('Welcome — Test Connection button', () => { describe('Welcome — Reset to Default button', () => { beforeEach(() => { - vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(useDeepLinkAuthState).mockReturnValue({ + isProcessing: false, + errorMessage: null, + requiresAppDataReset: false, + }); vi.mocked(clearCoreRpcUrlCache).mockReset(); vi.mocked(clearBackendUrlCache).mockReset(); vi.mocked(storeRpcUrl).mockReset(); @@ -475,7 +551,11 @@ describe('Welcome — Reset to Default button', () => { describe('Welcome — URL input behaviour', () => { beforeEach(() => { - vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(useDeepLinkAuthState).mockReturnValue({ + isProcessing: false, + errorMessage: null, + requiresAppDataReset: false, + }); vi.mocked(clearCoreRpcUrlCache).mockReset(); vi.mocked(clearBackendUrlCache).mockReset(); vi.mocked(storeRpcUrl).mockReset(); @@ -526,7 +606,11 @@ describe('Welcome — URL input behaviour', () => { describe('Welcome — OAuth buttons presence', () => { beforeEach(() => { - vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: false, errorMessage: null }); + vi.mocked(useDeepLinkAuthState).mockReturnValue({ + isProcessing: false, + errorMessage: null, + requiresAppDataReset: false, + }); }); it('renders all providers with showOnWelcome=true', () => { @@ -544,7 +628,11 @@ describe('Welcome — OAuth buttons presence', () => { }); it('hides OAuth buttons while auth is processing', () => { - vi.mocked(useDeepLinkAuthState).mockReturnValue({ isProcessing: true, errorMessage: null }); + vi.mocked(useDeepLinkAuthState).mockReturnValue({ + isProcessing: true, + errorMessage: null, + requiresAppDataReset: false, + }); render(); expect(screen.queryByRole('button', { name: 'google' })).not.toBeInTheDocument(); diff --git a/app/src/store/__tests__/deepLinkAuthState.test.ts b/app/src/store/__tests__/deepLinkAuthState.test.ts index f81331aba..c5c6764e2 100644 --- a/app/src/store/__tests__/deepLinkAuthState.test.ts +++ b/app/src/store/__tests__/deepLinkAuthState.test.ts @@ -21,7 +21,11 @@ afterEach(() => { describe('deepLinkAuthState transitions', () => { it('starts idle with no error message', () => { completeDeepLinkAuthProcessing(); - expect(getDeepLinkAuthState()).toEqual({ isProcessing: false, errorMessage: null }); + expect(getDeepLinkAuthState()).toEqual({ + isProcessing: false, + errorMessage: null, + requiresAppDataReset: false, + }); }); it('beginDeepLinkAuthProcessing flips isProcessing true and clears prior error', () => { @@ -29,19 +33,40 @@ describe('deepLinkAuthState transitions', () => { expect(getDeepLinkAuthState().errorMessage).toBe('prior failure'); beginDeepLinkAuthProcessing(); - expect(getDeepLinkAuthState()).toEqual({ isProcessing: true, errorMessage: null }); + expect(getDeepLinkAuthState()).toEqual({ + isProcessing: true, + errorMessage: null, + requiresAppDataReset: false, + }); }); it('completeDeepLinkAuthProcessing returns to idle', () => { beginDeepLinkAuthProcessing(); completeDeepLinkAuthProcessing(); - expect(getDeepLinkAuthState()).toEqual({ isProcessing: false, errorMessage: null }); + expect(getDeepLinkAuthState()).toEqual({ + isProcessing: false, + errorMessage: null, + requiresAppDataReset: false, + }); }); it('failDeepLinkAuthProcessing surfaces message and resets processing flag', () => { beginDeepLinkAuthProcessing(); failDeepLinkAuthProcessing('token expired'); - expect(getDeepLinkAuthState()).toEqual({ isProcessing: false, errorMessage: 'token expired' }); + expect(getDeepLinkAuthState()).toEqual({ + isProcessing: false, + errorMessage: 'token expired', + requiresAppDataReset: false, + }); + }); + + it('failDeepLinkAuthProcessing carries through the requiresAppDataReset hint', () => { + failDeepLinkAuthProcessing('cannot decrypt', { requiresAppDataReset: true }); + expect(getDeepLinkAuthState()).toEqual({ + isProcessing: false, + errorMessage: 'cannot decrypt', + requiresAppDataReset: true, + }); }); }); @@ -92,16 +117,28 @@ describe('useDeepLinkAuthState hook', () => { it('re-renders when state changes', () => { completeDeepLinkAuthProcessing(); const { result } = renderHook(() => useDeepLinkAuthState()); - expect(result.current).toEqual({ isProcessing: false, errorMessage: null }); + expect(result.current).toEqual({ + isProcessing: false, + errorMessage: null, + requiresAppDataReset: false, + }); act(() => { beginDeepLinkAuthProcessing(); }); - expect(result.current).toEqual({ isProcessing: true, errorMessage: null }); + expect(result.current).toEqual({ + isProcessing: true, + errorMessage: null, + requiresAppDataReset: false, + }); act(() => { failDeepLinkAuthProcessing('denied'); }); - expect(result.current).toEqual({ isProcessing: false, errorMessage: 'denied' }); + expect(result.current).toEqual({ + isProcessing: false, + errorMessage: 'denied', + requiresAppDataReset: false, + }); }); }); diff --git a/app/src/store/deepLinkAuthState.ts b/app/src/store/deepLinkAuthState.ts index cab3e8cb1..32f3c5695 100644 --- a/app/src/store/deepLinkAuthState.ts +++ b/app/src/store/deepLinkAuthState.ts @@ -3,9 +3,19 @@ import { useSyncExternalStore } from 'react'; export interface DeepLinkAuthState { isProcessing: boolean; errorMessage: string | null; + // Set when sign-in fails because the local core could not decrypt persisted + // secrets — typically the encryption key on disk no longer matches the + // ciphertext (key rotated, profile copied between machines, tampered/corrupt + // storage). The only safe recovery is wiping local app data so the next + // login starts from a clean slate. + requiresAppDataReset: boolean; } -const initialState: DeepLinkAuthState = { isProcessing: false, errorMessage: null }; +const initialState: DeepLinkAuthState = { + isProcessing: false, + errorMessage: null, + requiresAppDataReset: false, +}; let deepLinkAuthState: DeepLinkAuthState = initialState; const listeners = new Set<() => void>(); @@ -31,15 +41,22 @@ export const subscribeDeepLinkAuthState = (listener: () => void): (() => void) = }; export const beginDeepLinkAuthProcessing = (): void => { - setDeepLinkAuthState({ isProcessing: true, errorMessage: null }); + setDeepLinkAuthState({ isProcessing: true, errorMessage: null, requiresAppDataReset: false }); }; export const completeDeepLinkAuthProcessing = (): void => { - setDeepLinkAuthState({ isProcessing: false, errorMessage: null }); + setDeepLinkAuthState({ isProcessing: false, errorMessage: null, requiresAppDataReset: false }); }; -export const failDeepLinkAuthProcessing = (message: string): void => { - setDeepLinkAuthState({ isProcessing: false, errorMessage: message }); +export const failDeepLinkAuthProcessing = ( + message: string, + options: { requiresAppDataReset?: boolean } = {} +): void => { + setDeepLinkAuthState({ + isProcessing: false, + errorMessage: message, + requiresAppDataReset: Boolean(options.requiresAppDataReset), + }); }; export const useDeepLinkAuthState = (): DeepLinkAuthState => diff --git a/app/src/utils/__tests__/clearAllAppData.test.ts b/app/src/utils/__tests__/clearAllAppData.test.ts new file mode 100644 index 000000000..c7fb93594 --- /dev/null +++ b/app/src/utils/__tests__/clearAllAppData.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clearAllAppData } from '../clearAllAppData'; + +const { mockPurge, mockReset, mockRestart, mockPurgeCef } = vi.hoisted(() => ({ + mockPurge: vi.fn().mockResolvedValue(undefined), + mockReset: vi.fn().mockResolvedValue(undefined), + mockRestart: vi.fn().mockResolvedValue(undefined), + mockPurgeCef: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../store', () => ({ persistor: { purge: mockPurge } })); + +vi.mock('../tauriCommands', () => ({ + resetOpenHumanDataAndRestartCore: mockReset, + restartApp: mockRestart, + scheduleCefProfilePurge: mockPurgeCef, +})); + +describe('clearAllAppData', () => { + beforeEach(() => { + mockPurge.mockReset().mockResolvedValue(undefined); + mockReset.mockReset().mockResolvedValue(undefined); + mockRestart.mockReset().mockResolvedValue(undefined); + mockPurgeCef.mockReset().mockResolvedValue(undefined); + window.localStorage.setItem('persisted', '1'); + window.sessionStorage.setItem('session-persisted', '1'); + }); + + it('runs the full wipe sequence and restarts the app', async () => { + const clearSession = vi.fn().mockResolvedValue(undefined); + + await clearAllAppData({ clearSession, userId: 'user-1' }); + + expect(mockPurgeCef).toHaveBeenCalledWith('user-1'); + expect(clearSession).toHaveBeenCalledTimes(1); + expect(mockReset).toHaveBeenCalledTimes(1); + expect(mockPurge).toHaveBeenCalledTimes(1); + expect(window.localStorage.getItem('persisted')).toBeNull(); + expect(window.sessionStorage.getItem('session-persisted')).toBeNull(); + expect(mockRestart).toHaveBeenCalledTimes(1); + }); + + it('defaults to a null user scope when no userId is provided', async () => { + await clearAllAppData(); + + expect(mockPurgeCef).toHaveBeenCalledWith(null); + // No clearSession was provided — call sequence still completes. + expect(mockReset).toHaveBeenCalledTimes(1); + expect(mockRestart).toHaveBeenCalledTimes(1); + }); + + it('continues if scheduleCefProfilePurge fails (best-effort)', async () => { + mockPurgeCef.mockRejectedValueOnce(new Error('cef-purge boom')); + + await expect(clearAllAppData()).resolves.toBeUndefined(); + + expect(mockReset).toHaveBeenCalledTimes(1); + expect(mockRestart).toHaveBeenCalledTimes(1); + }); + + it('continues if clearSession fails (best-effort)', async () => { + const clearSession = vi.fn().mockRejectedValue(new Error('logout boom')); + + await expect(clearAllAppData({ clearSession })).resolves.toBeUndefined(); + + expect(clearSession).toHaveBeenCalledTimes(1); + expect(mockReset).toHaveBeenCalledTimes(1); + expect(mockRestart).toHaveBeenCalledTimes(1); + }); + + it('throws when resetOpenHumanDataAndRestartCore fails (unrecoverable)', async () => { + mockReset.mockRejectedValueOnce(new Error('core reset boom')); + + await expect(clearAllAppData()).rejects.toThrow('core reset boom'); + + expect(mockPurge).not.toHaveBeenCalled(); + expect(mockRestart).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/utils/__tests__/desktopDeepLinkListener.test.ts b/app/src/utils/__tests__/desktopDeepLinkListener.test.ts index e641e81af..150db6174 100644 --- a/app/src/utils/__tests__/desktopDeepLinkListener.test.ts +++ b/app/src/utils/__tests__/desktopDeepLinkListener.test.ts @@ -5,8 +5,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { completeDeepLinkAuthProcessing, getDeepLinkAuthState, + subscribeDeepLinkAuthState, } from '../../store/deepLinkAuthState'; import { setupDesktopDeepLinkListener } from '../desktopDeepLinkListener'; +import { storeSession } from '../tauriCommands'; + +const waitForAuthSettled = (): Promise => + new Promise(resolve => { + if (!getDeepLinkAuthState().isProcessing) { + resolve(); + return; + } + const unsubscribe = subscribeDeepLinkAuthState(() => { + if (!getDeepLinkAuthState().isProcessing) { + unsubscribe(); + resolve(); + } + }); + }); + +vi.mock('../../lib/coreState/store', () => ({ + getCoreStateSnapshot: () => ({ isBootstrapping: false, snapshot: { sessionToken: null } }), + patchCoreStateSnapshot: vi.fn(), +})); const windowControls = vi.hoisted(() => ({ show: vi.fn().mockResolvedValue(undefined), @@ -46,6 +67,7 @@ describe('desktopDeepLinkListener', () => { isProcessing: false, errorMessage: 'Twitter/X sign-in failed before OpenHuman received authorization. Check the Twitter Developer Portal app settings: OAuth 2.0 must be enabled, callback URL must match the backend redirect URL exactly, and the client ID, client secret, and requested scopes must match the OpenHuman backend configuration.', + requiresAppDataReset: false, }); expect(oauthErrorEvents).toHaveLength(1); expect(oauthErrorEvents[0].detail).toEqual({ @@ -65,6 +87,36 @@ describe('desktopDeepLinkListener', () => { expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain('token%3Dsecret'); }); + it('flags requiresAppDataReset when auth fails with a decryption error', async () => { + vi.mocked(storeSession).mockRejectedValueOnce( + new Error('Decryption failed — wrong key or tampered data') + ); + + vi.mocked(getCurrent).mockResolvedValue(['openhuman://auth?token=abc&key=auth']); + + await setupDesktopDeepLinkListener(); + + await waitForAuthSettled(); + + const state = getDeepLinkAuthState(); + expect(state.requiresAppDataReset).toBe(true); + expect(state.errorMessage).toMatch(/Clear app data to start fresh/); + expect(state.isProcessing).toBe(false); + }); + + it('keeps requiresAppDataReset false for non-decryption auth failures', async () => { + vi.mocked(storeSession).mockRejectedValueOnce(new Error('network down')); + + vi.mocked(getCurrent).mockResolvedValue(['openhuman://auth?token=abc&key=auth']); + + await setupDesktopDeepLinkListener(); + await waitForAuthSettled(); + + const state = getDeepLinkAuthState(); + expect(state.requiresAppDataReset).toBe(false); + expect(state.errorMessage).toBe('Sign-in failed. Please try again.'); + }); + it('sanitizes provider and error code values from OAuth error deep links', async () => { const oauthErrorEvents: CustomEvent[] = []; window.addEventListener('oauth:error', event => { diff --git a/app/src/utils/clearAllAppData.ts b/app/src/utils/clearAllAppData.ts new file mode 100644 index 000000000..98a87e2bb --- /dev/null +++ b/app/src/utils/clearAllAppData.ts @@ -0,0 +1,70 @@ +import { persistor } from '../store'; +import { + resetOpenHumanDataAndRestartCore, + restartApp, + scheduleCefProfilePurge, +} from './tauriCommands'; + +export interface ClearAllAppDataOptions { + // Optional core-side session clear (e.g. `auth_clear_session`). Best-effort — + // skipped silently if the caller cannot/does not provide it (e.g. pre-login + // recovery from a corrupt key file, where there is no live session). + clearSession?: () => Promise; + // User scope passed to the CEF profile purge so per-user browser data is + // queued for deletion on the next launch. `null` purges the unauthenticated + // default profile. + userId?: string | null; +} + +/** + * Sign out + wipe every local data store and restart the app: + * + * 1. Queue the CEF profile directory for deletion on next launch. + * 2. Best-effort `clearSession` to drop the core's auth state. + * 3. Reset the openhuman workspace dir + restart the core sidecar. + * 4. Purge redux-persist + window storage. + * 5. Restart the desktop shell so CEF reboots into the fresh profile. + * + * Used by Settings (Danger Zone) and the Welcome screen's decryption-recovery + * action. Throws on the first step that can't be recovered from — callers are + * expected to surface that to the user. + */ +export const clearAllAppData = async ({ + clearSession, + userId = null, +}: ClearAllAppDataOptions = {}): Promise => { + // 1. Queue the active user-scoped CEF profile for deletion on next launch. + // The CEF process may still hold SQLite/cache handles, so we delete + // after the shell restarts. + try { + await scheduleCefProfilePurge(userId); + } catch (err) { + console.warn('[clearAllAppData] Failed to queue CEF profile purge:', err); + } + + // 2. Best-effort core-side session clear. If the core is wedged or there is + // no session yet (pre-login recovery), keep going — we still want to wipe + // local data. + if (clearSession) { + try { + await clearSession(); + } catch (err) { + console.warn('[clearAllAppData] core session clear failed:', err); + } + } + + // 3. Delete workspace folder + restart core. The core RPC removes both the + // active openhuman_dir and the default `~/.openhuman`, then we restart + // the sidecar so it boots from a clean slate. + await resetOpenHumanDataAndRestartCore(); + + // 4. Purge redux-persist + browser storage. `persistor.purge()` wipes the + // persisted backend; localStorage/sessionStorage clear everything else + // (auth flags, theme, etc.). + await persistor.purge(); + window.localStorage.clear(); + window.sessionStorage.clear(); + + // 5. Full app restart so CEF reboots into the fresh pre-login profile. + await restartApp(); +}; diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts index 443732f7b..50c75d1a6 100644 --- a/app/src/utils/desktopDeepLinkListener.ts +++ b/app/src/utils/desktopDeepLinkListener.ts @@ -118,10 +118,29 @@ const handleAuthDeepLink = async (parsed: URL) => { completeDeepLinkAuthProcessing(); } catch (error) { console.error('[DeepLink][auth] failed to complete login:', error); - failDeepLinkAuthProcessing('Sign-in failed. Please try again.'); + const rawMessage = error instanceof Error ? error.message : String(error); + if (isDecryptionFailure(rawMessage)) { + failDeepLinkAuthProcessing( + "Sign-in failed because OpenHuman couldn't decrypt locally stored data. " + + 'This usually means the encryption key on this device no longer matches ' + + 'your stored secrets. Clear app data to start fresh.', + { requiresAppDataReset: true } + ); + } else { + failDeepLinkAuthProcessing('Sign-in failed. Please try again.'); + } } }; +const isDecryptionFailure = (message: string): boolean => { + const lowered = message.toLowerCase(); + return ( + lowered.includes('decryption failed') || + lowered.includes('wrong key or tampered data') || + lowered.includes('corrupt data') + ); +}; + /** * Handle `openhuman://payment/success?session_id=...` deep links. * Fired when a Stripe checkout session completes and the browser redirects