From 086e4b3b03bc4dd64f87a696cda129e43c7cdba8 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 12 Sep 2026 20:28:42 -0400 Subject: [PATCH] fix(auth): auth pages reload when a session appears in another tab Fixes #69 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/hooks/useSessionAppeared.ts | 33 +++++++++++++++++++++++++++++ src/app/pages/App.tsx | 7 ++++++ 2 files changed, 40 insertions(+) create mode 100644 src/app/hooks/useSessionAppeared.ts diff --git a/src/app/hooks/useSessionAppeared.ts b/src/app/hooks/useSessionAppeared.ts new file mode 100644 index 000000000..85d905340 --- /dev/null +++ b/src/app/hooks/useSessionAppeared.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { getFallbackSession, subscribeSessionChanges } from '../state/sessions'; + +/** + * #69 — `useSessionSync` (the full cross-tab session sync, with in-place token + * rotation handling) is mounted only inside the authenticated `ClientRoot` + * shell, so a tab parked on the login/register/OIDC-callback pages never + * learns that another tab just signed in. Left alone, that tab keeps showing + * the login form and, if submitted, starts a second device/session for the + * same account. + * + * This is a deliberately lighter watcher for the unauthenticated routes: it + * only cares about a session APPEARING where there was none before (a fresh + * login completed in another tab) and reloads so this tab picks up the + * authenticated shell instead. It does not need `useSessionSync`'s + * rotation/relogin handling since there is no running client here to keep in + * sync — do not import/duplicate that logic, just reload. + */ +export function useSessionAppeared(): void { + useEffect(() => { + // If we already have a session, there's nothing to watch for here — the + // authenticated shell's own useSessionSync takes over once mounted. + if (getFallbackSession()) return undefined; + + const unsubscribe = subscribeSessionChanges((session) => { + if (session) { + window.location.reload(); + } + }); + + return unsubscribe; + }, []); +} diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 36abe4ae4..db1fbca96 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -26,6 +26,7 @@ import { settingsAtom } from '../state/settings'; import { isWithinTimeWindow } from '../utils/timeWindow'; import { LotusToastContainer } from '../features/toast/LotusToastContainer'; import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge'; +import { useSessionAppeared } from '../hooks/useSessionAppeared'; import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome'; import { isTauri } from '../hooks/useTauri'; import { TitleBar } from '../features/desktop/TitleBar'; @@ -165,6 +166,12 @@ const queryClient = new QueryClient(); function App() { const screenSize = useScreenSize(); useCompositionEndTracking(); + // #69 — cross-tab session sync (useSessionSync) only mounts inside the + // authenticated ClientRoot shell, so a tab still on the unauthenticated + // routes (login/register/OIDC callback) never learns that another tab just + // signed in. This lighter watcher covers that gap by reloading once a + // session appears where there was none. + useSessionAppeared(); const portalContainer = document.getElementById('portalContainer') ?? undefined;