fix(auth): offer "clear app data" recovery when login decryption fails (#1652)

This commit is contained in:
Steven Enamakel
2026-05-13 09:29:54 -07:00
committed by GitHub
parent 256f004c36
commit 74a08c8ad6
11 changed files with 475 additions and 82 deletions
@@ -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(<OAuthProviderButton provider={stubProvider} />);
+3 -56
View File
@@ -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 {
@@ -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');
});
});
});
+48 -2
View File
@@ -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<string | null>(null);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [isClearingAppData, setIsClearingAppData] = useState(false);
const [resetError, setResetError] = useState<string | null>(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 = () => {
<div
role="alert"
className="mb-5 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{errorMessage}
<p>{errorMessage}</p>
{requiresAppDataReset ? (
<div className="mt-3 space-y-2">
<button
type="button"
onClick={handleClearAppData}
disabled={isClearingAppData}
className="w-full rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60">
{isClearingAppData ? (
<span className="flex items-center justify-center gap-2">
<span className="h-3 w-3 animate-spin rounded-full border border-white border-t-transparent" />
Clearing app data...
</span>
) : (
'Clear app data & restart'
)}
</button>
<p className="text-[11px] leading-4 text-red-600/80">
This wipes locally stored secrets and accounts on this device. Your cloud
account is unaffected you can sign in again right after.
</p>
{resetError ? (
<p className="text-[11px] leading-4 font-medium text-red-700">{resetError}</p>
) : null}
</div>
) : null}
</div>
) : null}
+97 -9
View File
@@ -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(<Welcome />);
@@ -136,17 +151,66 @@ describe('Welcome auth entrypoint', () => {
vi.mocked(useDeepLinkAuthState).mockReturnValue({
isProcessing: false,
errorMessage: 'OAuth failed',
requiresAppDataReset: false,
});
render(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
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(<Welcome />);
expect(screen.queryByRole('button', { name: 'google' })).not.toBeInTheDocument();
@@ -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,
});
});
});
+22 -5
View File
@@ -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 =>
@@ -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();
});
});
@@ -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<void> =>
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 => {
+70
View File
@@ -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<unknown>;
// 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<void> => {
// 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();
};
+20 -1
View File
@@ -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