diff --git a/client/src/App.tsx b/client/src/App.tsx index 0a097a9..23a3949 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -15,7 +15,11 @@ import InvitationAcceptance from './components/InvitationAcceptance.tsx' import AppTourOverlay from './components/AppTourOverlay.tsx' import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx' import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx' -import { logoutUser, checkServerSession, hasUnlockedLocalSession } from './services/auth.js' +import { + logoutUser, + checkServerSession, + hasUnlockedLocalCrypto +} from './services/auth.js' import AppErrorBoundary from './components/AppErrorBoundary.tsx' import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js' import { @@ -221,7 +225,7 @@ 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 && !hasUnlockedLocalSession()) { + if (isAuthenticated && !hasUnlockedLocalCrypto()) { clearAuthenticatedAppState() } }, [ @@ -267,7 +271,7 @@ function App() { localStorage.setItem('active_userid', session.userId) } - if (session.authenticated && hasUnlockedLocalSession()) { + if (session.authenticated && hasUnlockedLocalCrypto()) { setIsAuthenticated(true) const savedLogbookId = localStorage.getItem('active_logbook_id') const savedLogbookTitle = localStorage.getItem('active_logbook_title') @@ -275,9 +279,8 @@ function App() { setActiveLogbookId(savedLogbookId) setActiveLogbookTitle(savedLogbookTitle) } - } else if (session.authenticated) { - clearAuthenticatedAppState() } + // authenticated without local crypto: stay on login (cookie alone is not enough) } 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 778c064..5c4a4a0 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -46,13 +46,14 @@ export async function checkServerSession(): Promise<{ authenticated: boolean; us } } -/** Master key is memory-only; after process kill the HTTP session may outlive local crypto state. */ +/** Master key + username in memory/storage — enough to stay in the unlocked UI. */ +export function hasUnlockedLocalCrypto(): boolean { + return !!(getActiveMasterKey() && localStorage.getItem('active_username')) +} + +/** Crypto unlock plus user id for authenticated API calls (userId may already be in localStorage). */ export function hasUnlockedLocalSession(): boolean { - return !!( - getActiveMasterKey() && - localStorage.getItem('active_username') && - localStorage.getItem('active_userid') - ) + return hasUnlockedLocalCrypto() && !!localStorage.getItem('active_userid') } export async function reauthWithPasskey(): Promise { diff --git a/client/src/services/authSession.test.ts b/client/src/services/authSession.test.ts new file mode 100644 index 0000000..9b8d900 --- /dev/null +++ b/client/src/services/authSession.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + hasUnlockedLocalCrypto, + hasUnlockedLocalSession, + setActiveMasterKey +} from './auth.js' + +describe('local session unlock checks', () => { + beforeEach(() => { + localStorage.clear() + setActiveMasterKey(null) + }) + + it('hasUnlockedLocalCrypto with master key and username only', () => { + setActiveMasterKey(new ArrayBuffer(32)) + localStorage.setItem('active_username', 'skipper') + expect(hasUnlockedLocalCrypto()).toBe(true) + expect(hasUnlockedLocalSession()).toBe(false) + }) + + it('hasUnlockedLocalSession when userId is present', () => { + setActiveMasterKey(new ArrayBuffer(32)) + localStorage.setItem('active_username', 'skipper') + localStorage.setItem('active_userid', 'user-1') + expect(hasUnlockedLocalCrypto()).toBe(true) + expect(hasUnlockedLocalSession()).toBe(true) + }) + + it('hasUnlockedLocalCrypto false without master key', () => { + localStorage.setItem('active_username', 'skipper') + localStorage.setItem('active_userid', 'user-1') + expect(hasUnlockedLocalCrypto()).toBe(false) + }) +})