diff --git a/client/src/App.tsx b/client/src/App.tsx index 23a3949..667b077 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -18,7 +18,8 @@ import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/Unsa import { logoutUser, checkServerSession, - hasUnlockedLocalCrypto + hasUnlockedLocalSession, + persistSessionUserId } from './services/auth.js' import AppErrorBoundary from './components/AppErrorBoundary.tsx' import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js' @@ -225,7 +226,8 @@ function App() { /** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */ const enforceUnlockedSession = useCallback(() => { if (isViewerMode || isDemoMode || isAcceptingInvite) return - if (isAuthenticated && !hasUnlockedLocalCrypto()) { + // Require full local session (incl. userId) so API calls are not left headless. + if (isAuthenticated && !hasUnlockedLocalSession()) { clearAuthenticatedAppState() } }, [ @@ -267,11 +269,12 @@ function App() { const session = await checkServerSession() if (cancelled) return - if (session.authenticated && session.userId) { - localStorage.setItem('active_userid', session.userId) + if (session.authenticated) { + persistSessionUserId(session.userId) } - if (session.authenticated && hasUnlockedLocalCrypto()) { + // Cookie alone is insufficient — need in-memory master key, username, and userId for API. + if (session.authenticated && hasUnlockedLocalSession()) { setIsAuthenticated(true) const savedLogbookId = localStorage.getItem('active_logbook_id') const savedLogbookTitle = localStorage.getItem('active_logbook_title') @@ -280,7 +283,7 @@ function App() { setActiveLogbookTitle(savedLogbookTitle) } } - // authenticated without local crypto: stay on login (cookie alone is not enough) + // authenticated + crypto but no userId: stay on login (enforceUnlockedSession guards active UI) } catch (err) { if (!cancelled) { console.warn('Session restore failed:', err) diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index 5c4a4a0..9fd9647 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -56,6 +56,13 @@ export function hasUnlockedLocalSession(): boolean { return hasUnlockedLocalCrypto() && !!localStorage.getItem('active_userid') } +/** Persist server session user id when the /session response includes it. */ +export function persistSessionUserId(userId: string | undefined): void { + if (userId) { + localStorage.setItem('active_userid', userId) + } +} + export async function reauthWithPasskey(): Promise { const options = await apiJson(`${API_BASE}/reauth-options`, { method: 'POST' diff --git a/client/src/services/authSession.test.ts b/client/src/services/authSession.test.ts index 9b8d900..9f1fc07 100644 --- a/client/src/services/authSession.test.ts +++ b/client/src/services/authSession.test.ts @@ -32,3 +32,22 @@ describe('local session unlock checks', () => { expect(hasUnlockedLocalCrypto()).toBe(false) }) }) + +describe('persistSessionUserId', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('stores userId when provided', async () => { + const { persistSessionUserId } = await import('./auth.js') + persistSessionUserId('user-42') + expect(localStorage.getItem('active_userid')).toBe('user-42') + }) + + it('does not clear existing userId when omitted', async () => { + const { persistSessionUserId } = await import('./auth.js') + localStorage.setItem('active_userid', 'user-1') + persistSessionUserId(undefined) + expect(localStorage.getItem('active_userid')).toBe('user-1') + }) +})