From b4c19b105b320d8a950b183efdc8e631b57d36cb Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Sat, 16 May 2026 03:27:30 +0530 Subject: [PATCH] fix(auth): scope clearAllAppData to active user; fix re-onboarding race; drop dead API call (#1816) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Steven Enamakel --- app/src/components/DefaultRedirect.tsx | 18 ++++ .../__tests__/DefaultRedirect.test.tsx | 98 +++++++++++++++++++ app/src/pages/onboarding/OnboardingLayout.tsx | 7 -- app/src/services/api/userApi.ts | 9 -- .../utils/__tests__/clearAllAppData.test.ts | 27 ++++- app/src/utils/clearAllAppData.ts | 54 +++++++++- 6 files changed, 189 insertions(+), 24 deletions(-) create mode 100644 app/src/components/__tests__/DefaultRedirect.test.tsx diff --git a/app/src/components/DefaultRedirect.tsx b/app/src/components/DefaultRedirect.tsx index b9727231e..b42b378cb 100644 --- a/app/src/components/DefaultRedirect.tsx +++ b/app/src/components/DefaultRedirect.tsx @@ -23,6 +23,24 @@ const DefaultRedirect = () => { return ; } + // Guard against the post-login race where the session token has arrived + // (via `core-state:session-token-updated` or `storeSessionToken`) but the + // snapshot hasn't been refreshed from the core yet. `toSignedOutSnapshot` + // clears `currentUser` to null on logout, and it stays null until the + // first post-login `refresh()` resolves with the real snapshot — including + // the correct `onboardingCompleted` value. Routing to /onboarding here + // would be wrong for any returning user whose flag is already true. + if (!snapshot.currentUser) { + // Diagnostic: this branch should resolve within one `refresh()` cycle. + // If a user reports a stuck loading screen post-login, this log lets us + // confirm the stuck state and trace it back to a failed/never-resolved + // refresh in `CoreStateProvider`. + console.debug( + '[default-redirect] waiting for currentUser — sessionToken set but snapshot not yet refreshed' + ); + return ; + } + if (DEV_FORCE_ONBOARDING || !snapshot.onboardingCompleted) { return ; } diff --git a/app/src/components/__tests__/DefaultRedirect.test.tsx b/app/src/components/__tests__/DefaultRedirect.test.tsx new file mode 100644 index 000000000..4bb7fb9da --- /dev/null +++ b/app/src/components/__tests__/DefaultRedirect.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; + +import DefaultRedirect from '../DefaultRedirect'; + +vi.mock('../../utils/config', () => ({ DEV_FORCE_ONBOARDING: false })); + +const mockUseCoreState = vi.fn(); +vi.mock('../../providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() })); + +function renderRedirect(initialEntry = '*') { + return render( + + + Welcome} /> + Onboarding} /> + Home} /> + } /> + + + ); +} + +describe('DefaultRedirect', () => { + it('shows loading while bootstrapping', () => { + mockUseCoreState.mockReturnValue({ + isBootstrapping: true, + snapshot: { sessionToken: null, currentUser: null, onboardingCompleted: false }, + }); + + renderRedirect(); + + expect(screen.queryByText('Welcome')).not.toBeInTheDocument(); + expect(screen.queryByText('Onboarding')).not.toBeInTheDocument(); + expect(screen.queryByText('Home')).not.toBeInTheDocument(); + }); + + it('redirects to / when no session token', () => { + mockUseCoreState.mockReturnValue({ + isBootstrapping: false, + snapshot: { sessionToken: null, currentUser: null, onboardingCompleted: false }, + }); + + renderRedirect(); + + expect(screen.getByText('Welcome')).toBeInTheDocument(); + }); + + it('shows loading when session token arrived but currentUser is not yet set (post-login race)', () => { + // This is the race: token set by core-state:session-token-updated but + // refresh() hasn't resolved yet — currentUser is still null from + // toSignedOutSnapshot(), onboardingCompleted is still false. + mockUseCoreState.mockReturnValue({ + isBootstrapping: false, + snapshot: { sessionToken: 'token-abc', currentUser: null, onboardingCompleted: false }, + }); + + renderRedirect(); + + // Must NOT navigate to /onboarding — that would be the stale-snapshot bug + expect(screen.queryByText('Onboarding')).not.toBeInTheDocument(); + expect(screen.queryByText('Home')).not.toBeInTheDocument(); + expect(screen.queryByText('Welcome')).not.toBeInTheDocument(); + // Positively assert the loading screen rendered (not just "nothing visible") + expect(screen.getByText('Initializing OpenHuman...')).toBeInTheDocument(); + }); + + it('redirects to /onboarding for a genuinely new user (currentUser set, onboarding false)', () => { + mockUseCoreState.mockReturnValue({ + isBootstrapping: false, + snapshot: { + sessionToken: 'token-abc', + currentUser: { _id: 'user-1', email: 'new@test.com' }, + onboardingCompleted: false, + }, + }); + + renderRedirect(); + + expect(screen.getByText('Onboarding')).toBeInTheDocument(); + }); + + it('redirects to /home for a returning user who already completed onboarding', () => { + mockUseCoreState.mockReturnValue({ + isBootstrapping: false, + snapshot: { + sessionToken: 'token-abc', + currentUser: { _id: 'user-1', email: 'returning@test.com' }, + onboardingCompleted: true, + }, + }); + + renderRedirect(); + + expect(screen.getByText('Home')).toBeInTheDocument(); + }); +}); diff --git a/app/src/pages/onboarding/OnboardingLayout.tsx b/app/src/pages/onboarding/OnboardingLayout.tsx index 0a75b0846..0fd137c6e 100644 --- a/app/src/pages/onboarding/OnboardingLayout.tsx +++ b/app/src/pages/onboarding/OnboardingLayout.tsx @@ -12,7 +12,6 @@ import { setWalkthroughPending } from '../../components/walkthrough/AppWalkthrou // import { ONBOARDING_WELCOME_THREAD_LABEL } from '../../constants/onboardingChat'; import { useCoreState } from '../../providers/CoreStateProvider'; import { trackEvent } from '../../services/analytics'; -import { userApi } from '../../services/api/userApi'; import { getDefaultEnabledTools } from '../../utils/toolDefinitions'; import BetaBanner from './components/BetaBanner'; import { OnboardingContext, type OnboardingDraft } from './OnboardingContext'; @@ -79,12 +78,6 @@ const OnboardingLayout = () => { console.warn('[onboarding] Failed to persist onboarding tasks; continuing completion', e); } - try { - await userApi.onboardingComplete(); - } catch { - console.warn('[onboarding] Failed to notify backend of onboarding completion'); - } - try { await setOnboardingCompletedFlag(true); } catch (e) { diff --git a/app/src/services/api/userApi.ts b/app/src/services/api/userApi.ts index 5ee33b550..a589c2153 100644 --- a/app/src/services/api/userApi.ts +++ b/app/src/services/api/userApi.ts @@ -1,5 +1,4 @@ import type { User } from '../../types/api'; -import { apiClient } from '../apiClient'; import { callCoreCommand } from '../coreCommandClient'; /** @@ -13,12 +12,4 @@ export const userApi = { getMe: async (): Promise => { return await callCoreCommand('openhuman.auth_get_me'); }, - - /** - * Mark onboarding complete for the current user. - * POST /settings/onboarding-complete - */ - onboardingComplete: async (): Promise => { - await apiClient.post<{ success: boolean; data: unknown }>('/settings/onboarding-complete', {}); - }, }; diff --git a/app/src/utils/__tests__/clearAllAppData.test.ts b/app/src/utils/__tests__/clearAllAppData.test.ts index c7fb93594..e82ffc306 100644 --- a/app/src/utils/__tests__/clearAllAppData.test.ts +++ b/app/src/utils/__tests__/clearAllAppData.test.ts @@ -23,31 +23,50 @@ describe('clearAllAppData', () => { mockReset.mockReset().mockResolvedValue(undefined); mockRestart.mockReset().mockResolvedValue(undefined); mockPurgeCef.mockReset().mockResolvedValue(undefined); - window.localStorage.setItem('persisted', '1'); - window.sessionStorage.setItem('session-persisted', '1'); + window.localStorage.clear(); + window.sessionStorage.clear(); }); it('runs the full wipe sequence and restarts the app', async () => { const clearSession = vi.fn().mockResolvedValue(undefined); + // Seed active-user key + user-1-scoped data + another user's data + window.localStorage.setItem('OPENHUMAN_ACTIVE_USER_ID', 'user-1'); + window.localStorage.setItem('user-1:persist:accounts', 'a'); + window.localStorage.setItem('user-1:persist:coreMode', 'b'); + window.localStorage.setItem('user-2:persist:accounts', 'other'); + window.sessionStorage.setItem('session-persisted', '1'); + 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(); + // user-1's own keys are gone + expect(window.localStorage.getItem('OPENHUMAN_ACTIVE_USER_ID')).toBeNull(); + expect(window.localStorage.getItem('user-1:persist:accounts')).toBeNull(); + expect(window.localStorage.getItem('user-1:persist:coreMode')).toBeNull(); + // user-2's keys are untouched (#983: other accounts must not lose data) + expect(window.localStorage.getItem('user-2:persist:accounts')).toBe('other'); expect(window.sessionStorage.getItem('session-persisted')).toBeNull(); expect(mockRestart).toHaveBeenCalledTimes(1); }); - it('defaults to a null user scope when no userId is provided', async () => { + it('falls back to localStorage.clear() when no userId is provided', async () => { + window.localStorage.setItem('user-1:persist:accounts', 'a'); + window.localStorage.setItem('user-2:persist:accounts', 'b'); + window.sessionStorage.setItem('session-persisted', '1'); + await clearAllAppData(); expect(mockPurgeCef).toHaveBeenCalledWith(null); // No clearSession was provided — call sequence still completes. expect(mockReset).toHaveBeenCalledTimes(1); expect(mockRestart).toHaveBeenCalledTimes(1); + // Without a userId we have no way to scope, so everything is cleared + expect(window.localStorage.getItem('user-1:persist:accounts')).toBeNull(); + expect(window.localStorage.getItem('user-2:persist:accounts')).toBeNull(); }); it('continues if scheduleCefProfilePurge fails (best-effort)', async () => { diff --git a/app/src/utils/clearAllAppData.ts b/app/src/utils/clearAllAppData.ts index 98a87e2bb..5027a060d 100644 --- a/app/src/utils/clearAllAppData.ts +++ b/app/src/utils/clearAllAppData.ts @@ -5,6 +5,53 @@ import { scheduleCefProfilePurge, } from './tauriCommands'; +const ACTIVE_USER_KEY = 'OPENHUMAN_ACTIVE_USER_ID'; + +/** + * Selectively purge localStorage keys belonging to a single user. + * + * Removes: + * - `${userId}:persist:*` — per-user Redux-persist blobs + * - `${userId}:*` — any other user-scoped keys + * - `OPENHUMAN_ACTIVE_USER_ID` — the boot-time user seed (only when a userId + * is supplied so we don't wipe it on pre-login + * recovery where userId is null) + * + * Intentionally leaves other users' scoped keys untouched so that + * "clear my data" on account B does not silently destroy account A's + * persisted state (#983). + */ +function clearUserScopedStorage(userId: string | null): void { + try { + if (userId) { + const prefix = `${userId}:`; + const toRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(prefix)) { + toRemove.push(key); + } + } + for (const key of toRemove) { + localStorage.removeItem(key); + } + localStorage.removeItem(ACTIVE_USER_KEY); + } else { + // No known user (pre-login recovery) — fall back to clearing everything + // so we don't leave orphaned blobs with no way to scope the deletion. + localStorage.clear(); + } + } catch (err) { + console.warn('[clearAllAppData] storage clear failed:', err); + } finally { + try { + sessionStorage.clear(); + } catch { + // best-effort + } + } +} + 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 @@ -59,11 +106,10 @@ export const clearAllAppData = async ({ await resetOpenHumanDataAndRestartCore(); // 4. Purge redux-persist + browser storage. `persistor.purge()` wipes the - // persisted backend; localStorage/sessionStorage clear everything else - // (auth flags, theme, etc.). + // persisted backend; `clearUserScopedStorage` removes only the active + // user's localStorage keys so other accounts' data is not destroyed. await persistor.purge(); - window.localStorage.clear(); - window.sessionStorage.clear(); + clearUserScopedStorage(userId); // 5. Full app restart so CEF reboots into the fresh pre-login profile. await restartApp();