mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
fix(auth): offer "clear app data" recovery when login decryption fails (#1652)
This commit is contained in:
@@ -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} />);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user