From 2408964ade832221f184c70da1e624ca36c0296e Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 12 Mar 2026 14:15:32 -0400 Subject: [PATCH 1/7] yarn lock update --- yarn.lock | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/yarn.lock b/yarn.lock index 033ed9e..4db5eaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2923,11 +2923,6 @@ lodash.get "^4.4.2" render-and-add-props "^0.5.0" -"@react-leaflet/core@^2.1.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@react-leaflet/core/-/core-2.1.0.tgz#383acd31259d7c9ae8fb1b02d5e18fe613c2a13d" - integrity sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg== - "@redocly/ajv@^8.11.2": version "8.17.2" resolved "https://registry.yarnpkg.com/@redocly/ajv/-/ajv-8.17.2.tgz#17abe1f8b6d17c1d4185be5359bac394b13bec4d" @@ -3888,13 +3883,6 @@ "@types/ms" "*" "@types/node" "*" -"@types/leaflet@^1.9.12": - version "1.9.21" - resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.9.21.tgz#542e8f91250bc444f8a1416d472f5b518d83e979" - integrity sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w== - dependencies: - "@types/geojson" "*" - "@types/lodash@^4.17.20": version "4.17.23" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.23.tgz#c1bb06db218acc8fc232da0447473fc2fb9d9841" @@ -9606,11 +9594,6 @@ lazystream@^1.0.0: dependencies: readable-stream "^2.0.5" -leaflet@^1.9.4: - version "1.9.4" - resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d" - integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA== - leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -11444,13 +11427,6 @@ react-is@^19.2.0, react-is@^19.2.3: resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.3.tgz#eec2feb69c7fb31f77d0b5c08c10ae1c88886b29" integrity sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA== -react-leaflet@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/react-leaflet/-/react-leaflet-4.2.1.tgz#c300e9eccaf15cb40757552e181200aa10b94780" - integrity sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q== - dependencies: - "@react-leaflet/core" "^2.1.0" - react-map-gl@^8.0.4: version "8.1.0" resolved "https://registry.yarnpkg.com/react-map-gl/-/react-map-gl-8.1.0.tgz#97ce754e3bbdd49421fc10283119d189db945e00" From 6debf812860da353bec24b2d4c75c5285224467f Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 12 Mar 2026 14:15:53 -0400 Subject: [PATCH 2/7] release cannot be manually done --- .github/workflows/vercel-prod-on-release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/vercel-prod-on-release.yml b/.github/workflows/vercel-prod-on-release.yml index 4b037f1..abd2440 100644 --- a/.github/workflows/vercel-prod-on-release.yml +++ b/.github/workflows/vercel-prod-on-release.yml @@ -1,7 +1,6 @@ name: Deploy to Vercel Production on Release on: - workflow_dispatch: release: types: [published] From 85990df11e20c0cb756eda0fa54bfac920e28ec6 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 12 Mar 2026 14:16:06 -0400 Subject: [PATCH 3/7] homepage text adjustment --- src/app/[locale]/components/HomePage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/[locale]/components/HomePage.tsx b/src/app/[locale]/components/HomePage.tsx index 6059663..9263ccd 100644 --- a/src/app/[locale]/components/HomePage.tsx +++ b/src/app/[locale]/components/HomePage.tsx @@ -91,7 +91,7 @@ export default async function HomePage(): Promise { mt: 4, }} > - {t('servingOver')} + {t('servingOver') + ' '} { > 6000 - {t('feeds')} + {' ' + t('feeds') + ' '} { > 99 - {t('countries')} + {' ' + t('countries')} Date: Mon, 16 Mar 2026 08:06:50 -0400 Subject: [PATCH 4/7] centralized the auth state change --- src/app/App.tsx | 24 ++----------- src/app/[locale]/feeds/lib/useFeedsSearch.ts | 38 ++------------------ src/app/providers.tsx | 3 +- 3 files changed, 7 insertions(+), 58 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index bc79339..4603a47 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -3,10 +3,8 @@ import './App.css'; import AppRouter from './router/Router'; import { MemoryRouter } from 'react-router-dom'; -import { useDispatch } from 'react-redux'; -import { anonymousLogin } from './store/profile-reducer'; -import { app } from '../firebase'; -import { Suspense, useEffect, useState } from 'react'; +import { Suspense } from 'react'; +import { useAuthReady } from './components/AuthSessionProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import AppContainer from './AppContainer'; @@ -32,29 +30,13 @@ function buildPathFromNextRouter( } function App({ locale }: AppProps): React.ReactElement { - const dispatch = useDispatch(); - const [isAppReady, setIsAppReady] = useState(false); + const isAppReady = useAuthReady(); const pathname = usePathname(); const searchParams = useSearchParams(); const initialPath = buildPathFromNextRouter(pathname, searchParams, locale); - useEffect(() => { - const unsubscribe = app.auth().onAuthStateChanged((user) => { - if (user != null) { - setIsAppReady(true); - } else { - setIsAppReady(false); - dispatch(anonymousLogin()); - } - }); - dispatch(anonymousLogin()); - return () => { - unsubscribe(); - }; - }, [dispatch]); - return ( diff --git a/src/app/[locale]/feeds/lib/useFeedsSearch.ts b/src/app/[locale]/feeds/lib/useFeedsSearch.ts index 541924c..97ecd69 100644 --- a/src/app/[locale]/feeds/lib/useFeedsSearch.ts +++ b/src/app/[locale]/feeds/lib/useFeedsSearch.ts @@ -1,5 +1,4 @@ 'use client'; -import { useEffect, useState } from 'react'; import useSWR, { useSWRConfig } from 'swr'; import { searchFeeds } from '../../../services/feeds'; import { @@ -7,7 +6,7 @@ import { type AllFeedsParams, } from '../../../services/feeds/utils'; import { getUserAccessToken } from '../../../services/profile-service'; -import { app } from '../../../../firebase'; +import { useAuthReady } from '../../../components/AuthSessionProvider'; import { getDataTypeParamFromSelectedFeedTypes, getInitialSelectedFeedTypes, @@ -18,39 +17,6 @@ const SEARCH_LIMIT = 20; // This is for client-side caching const CACHE_TTL_MS = 60 * 30 * 1000; // 30 minutes - controls how long search results are cached in SWR -/** - * Ensures a Firebase user exists (anonymous or authenticated) before - * SWR attempts to fetch. If no user is signed in, triggers anonymous - * sign-in — the same thing App.tsx does for legacy React Router pages. - * This is needed for the access token - * - * TODO: Revisit this logic to be used at a more global level without slowing down the initial load of all pages that don't require auth (e.g. about, contact). For example, we could move this logic to a context provider that's used only on the feeds page and its children. - */ -function useFirebaseAuthReady(): boolean { - const [isReady, setIsReady] = useState(() => app.auth().currentUser !== null); - - useEffect(() => { - const unsubscribe = app.auth().onAuthStateChanged((user) => { - if (user !== null) { - setIsReady(true); - } else { - // No user — trigger anonymous sign-in (mirrors App.tsx behavior) - setIsReady(false); - app - .auth() - .signInAnonymously() - .catch(() => { - // Auth listener will handle the state update on success; - // if sign-in fails, isReady stays false and SWR won't fetch. - }); - } - }); - return unsubscribe; - }, []); - - return isReady; -} - /** * Derives all API query params from the URL search params. * This is the single source of truth — no duplicated React state. @@ -192,7 +158,7 @@ export function useFeedsSearch(searchParams: URLSearchParams): { isError: boolean; searchLimit: number; } { - const authReady = useFirebaseAuthReady(); + const authReady = useAuthReady(); const { cache } = useSWRConfig(); const derivedSearchParams = deriveSearchParams(searchParams); const key = authReady ? buildSwrKey(derivedSearchParams) : null; diff --git a/src/app/providers.tsx b/src/app/providers.tsx index b3cf880..f9209ee 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -8,6 +8,7 @@ import { type RemoteConfigValues } from './interface/RemoteConfig'; // Look into this provider and see if it's client blocking. Niche provider might be able to isolate for single use import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { AuthSessionProvider } from './components/AuthSessionProvider'; interface ProvidersProps { children: React.ReactNode; @@ -35,7 +36,7 @@ export function Providers({ - {children} + {children} From a2aed6bafbba55b1c0877d445954ee41b05d9d58 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Mon, 16 Mar 2026 08:08:37 -0400 Subject: [PATCH 5/7] server side auth cookie renewal --- .../components/AuthSessionProvider.spec.tsx | 270 ++++++++++++++++++ src/app/components/AuthSessionProvider.tsx | 88 ++++++ src/app/services/session-service.ts | 70 ++++- src/app/store/saga/auth-saga.ts | 12 +- 4 files changed, 418 insertions(+), 22 deletions(-) create mode 100644 src/app/components/AuthSessionProvider.spec.tsx create mode 100644 src/app/components/AuthSessionProvider.tsx diff --git a/src/app/components/AuthSessionProvider.spec.tsx b/src/app/components/AuthSessionProvider.spec.tsx new file mode 100644 index 0000000..6326680 --- /dev/null +++ b/src/app/components/AuthSessionProvider.spec.tsx @@ -0,0 +1,270 @@ +import React, { type JSX } from 'react'; +import { + render, + act, + renderHook, + type RenderResult, +} from '@testing-library/react'; +import { configureStore } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; +import { AuthSessionProvider, useAuthReady } from './AuthSessionProvider'; +import { setUserCookieSession } from '../services/session-service'; +import { anonymousLogin } from '../store/profile-reducer'; + +const RENEWAL_INTERVAL_MS = 5 * 60 * 1000; + +// ---------- Mock: firebase ---------- + +let capturedAuthCallback: (user: unknown) => void = () => {}; +const mockUnsubscribe = jest.fn(); + +jest.mock('../../firebase', () => ({ + app: { + auth: jest.fn(() => ({ + onIdTokenChanged: jest.fn((cb: (user: unknown) => void) => { + capturedAuthCallback = cb; + return mockUnsubscribe; + }), + currentUser: null, + })), + }, +})); + +// ---------- Mock: session-service ---------- + +jest.mock('../services/session-service', () => ({ + setUserCookieSession: jest.fn().mockResolvedValue(undefined), +})); + +// ---------- Mock: profile-reducer ---------- + +jest.mock('../store/profile-reducer', () => ({ + anonymousLogin: jest.fn(() => ({ type: 'profile/anonymousLogin' })), +})); + +// ---------- Mock: react-redux (preserve Provider, stub useDispatch) ---------- + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: () => mockDispatch, +})); + +// ---------- Helpers ---------- + +function makeStore(): ReturnType { + return configureStore({ reducer: { _: () => null } }); +} + +function wrapper({ children }: { children: React.ReactNode }): JSX.Element { + return {children}; +} + +function wrapperWithAuth({ + children, +}: { + children: React.ReactNode; +}): JSX.Element { + return ( + + {children} + + ); +} + +function renderProvider(): RenderResult { + return render( + + + + + , + ); +} + +const mockUser = { uid: 'user-1' }; + +// ---------- Tests ---------- + +beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + capturedAuthCallback = () => {}; +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('AuthSessionProvider', () => { + describe('useAuthReady', () => { + it('returns false before any auth state change', () => { + const { result } = renderHook(() => useAuthReady(), { wrapper }); + expect(result.current).toBe(false); + }); + + it('returns true after onIdTokenChanged fires with a user', async () => { + const { result } = renderHook(() => useAuthReady(), { + wrapper: wrapperWithAuth, + }); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + expect(result.current).toBe(true); + }); + + it('returns false after onIdTokenChanged fires with null', async () => { + const { result } = renderHook(() => useAuthReady(), { + wrapper: wrapperWithAuth, + }); + + await act(async () => { + capturedAuthCallback(null); + }); + + expect(result.current).toBe(false); + }); + }); + + describe('when a user signs in', () => { + it('calls setUserCookieSession immediately', async () => { + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + expect(setUserCookieSession).toHaveBeenCalledTimes(1); + }); + + it('calls setUserCookieSession again after 5 minutes', async () => { + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + await act(async () => { + jest.advanceTimersByTime(RENEWAL_INTERVAL_MS); + }); + + expect(setUserCookieSession).toHaveBeenCalledTimes(2); + }); + + it('keeps calling setUserCookieSession every 5 minutes', async () => { + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + await act(async () => { + jest.advanceTimersByTime(RENEWAL_INTERVAL_MS * 3); + }); + + expect(setUserCookieSession).toHaveBeenCalledTimes(4); // 1 immediate + 3 ticks + }); + + it('clears the interval when auth state changes', async () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + await act(async () => { + capturedAuthCallback(null); + }); + + expect(clearIntervalSpy).toHaveBeenCalled(); + clearIntervalSpy.mockRestore(); + }); + + it('does not call setUserCookieSession after auth state changes to null', async () => { + renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + await act(async () => { + capturedAuthCallback(null); + }); + + (setUserCookieSession as jest.Mock).mockClear(); + + await act(async () => { + jest.advanceTimersByTime(RENEWAL_INTERVAL_MS); + }); + + expect(setUserCookieSession).not.toHaveBeenCalled(); + }); + }); + + describe('when no user is present', () => { + it('dispatches anonymousLogin', async () => { + renderProvider(); + + await act(async () => { + capturedAuthCallback(null); + }); + + expect(mockDispatch).toHaveBeenCalledWith( + (anonymousLogin as unknown as jest.Mock).mock.results[0].value, + ); + }); + + it('does not call setUserCookieSession', async () => { + renderProvider(); + + await act(async () => { + capturedAuthCallback(null); + }); + + expect(setUserCookieSession).not.toHaveBeenCalled(); + }); + }); + + describe('cleanup on unmount', () => { + it('unsubscribes from onIdTokenChanged', () => { + const { unmount } = renderProvider(); + unmount(); + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('cancels the renewal interval on unmount', async () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + const { unmount } = renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + unmount(); + + expect(clearIntervalSpy).toHaveBeenCalled(); + clearIntervalSpy.mockRestore(); + }); + + it('does not call setUserCookieSession after unmount', async () => { + const { unmount } = renderProvider(); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + unmount(); + (setUserCookieSession as jest.Mock).mockClear(); + + await act(async () => { + jest.advanceTimersByTime(RENEWAL_INTERVAL_MS); + }); + + expect(setUserCookieSession).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/components/AuthSessionProvider.tsx b/src/app/components/AuthSessionProvider.tsx new file mode 100644 index 0000000..8ab845b --- /dev/null +++ b/src/app/components/AuthSessionProvider.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { + createContext, + useContext, + useEffect, + useRef, + useState, + type ReactNode, + type ReactElement, +} from 'react'; +import { useDispatch } from 'react-redux'; +import { app } from '../../firebase'; +import { anonymousLogin } from '../store/profile-reducer'; +import { setUserCookieSession } from '../services/session-service'; + +const AuthReadyContext = createContext(false); + +/** + * Returns true once a Firebase user (anonymous or authenticated) is + * available. Use this instead of registering your own + * `onAuthStateChanged` listener. + */ +export function useAuthReady(): boolean { + return useContext(AuthReadyContext); +} + +/** + * Global auth session provider. Renders inside the Redux provider tree + * and manages a single `onAuthStateChanged` listener that: + * + * 1. Triggers anonymous sign-in when no user exists. + * 2. Re-establishes the `md_session` cookie on return visits (Firebase + * restores auth from IndexedDB but the 1-hour cookie has expired). + * 3. Schedules the next renewal at exactly `expiresAt - 5 min` using + * a setTimeout derived from the value stored in localStorage. + * 4. Deduplicates POSTs across tabs — localStorage is shared across all + * tabs, so a renewal written by any tab is immediately visible to all + * others via the `isCookieFresh` check in setUserCookieSession. + * 5. Exposes `isAuthReady` via context. + */ +export function AuthSessionProvider({ + children, +}: { + children: ReactNode; +}): ReactElement { + const dispatch = useDispatch(); + const [isAuthReady, setIsAuthReady] = useState(false); + const intervalRef = useRef | null>(null); + + useEffect(() => { + const unsubscribe = app.auth().onIdTokenChanged((user) => { + if (intervalRef.current != null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + if (user != null) { + setIsAuthReady(true); + setUserCookieSession().catch(() => {}); + + // Check every 5 minutes; the cookie lasts 60 minutes, so this ensures renewal well before expiry + // If the cookie is not expired, it will return early and skip the POST + // The token will refresh 5 minutes before expiry which is why the 5 minute interval is used here. + intervalRef.current = setInterval( + () => { + setUserCookieSession().catch(() => {}); + }, + 5 * 60 * 1000, + ); // 5 minutes + } else { + setIsAuthReady(false); + dispatch(anonymousLogin()); + } + }); + + return () => { + unsubscribe(); + if (intervalRef.current != null) clearInterval(intervalRef.current); + }; + }, [dispatch]); + + return ( + + {children} + + ); +} diff --git a/src/app/services/session-service.ts b/src/app/services/session-service.ts index 5f8298a..b95877d 100644 --- a/src/app/services/session-service.ts +++ b/src/app/services/session-service.ts @@ -1,36 +1,84 @@ import { app } from '../../firebase'; +const STORED_SESSION_KEY = 'md_session_meta'; +const SESSION_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour +// Renew 5 minutes before expiry to avoid edge-case lapses. +const RENEWAL_BUFFER_MS = 5 * 60 * 1000; + +interface SessionMeta { + uid: string; + expiresAt: number; +} + +function isCookieFresh(uid: string): boolean { + try { + const raw = localStorage.getItem(STORED_SESSION_KEY); + const meta = raw != null ? (JSON.parse(raw) as SessionMeta) : null; + return ( + meta !== null && + meta.uid === uid && + Date.now() < meta.expiresAt - RENEWAL_BUFFER_MS + ); + } catch { + return false; + } +} + /** - * After Firebase login on the client, call this to establish - * a server-side session via the /api/session endpoint. + * Establishes or renews the server-side `md_session` cookie. + * + * Skips the POST if localStorage shows the same user's cookie is still + * fresh. Since localStorage is shared across all tabs of the same origin, + * a renewal in any tab is immediately visible to all others. + * + * Identity changes (e.g. anonymous → authenticated) are handled + * automatically: a different uid always triggers a fresh POST. */ export const setUserCookieSession = async (): Promise => { - // Ensure this only runs in the browser - if (typeof window === 'undefined') { - return; - } + if (typeof window === 'undefined') return; const user = app.auth().currentUser; - if (user == null) { - return; - } + if (user == null) return; + + if (isCookieFresh(user.uid)) return; const idToken = await user.getIdToken(); - await fetch('/api/session', { + const resp = await fetch('/api/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ idToken }), }); + + if (resp.ok) { + try { + localStorage.setItem( + STORED_SESSION_KEY, + JSON.stringify({ + uid: user.uid, + expiresAt: Date.now() + SESSION_MAX_AGE_MS, + }), + ); + } catch { + // Private browsing or storage quota exceeded — best-effort. + } + } }; /** - * Clear the server-side session cookie on logout. + * Clears the server-side session cookie on logout. + * Also clears localStorage so the next anonymous sign-in always + * issues a fresh cookie regardless of any prior expiry stored there. */ export const clearUserCookieSession = async (): Promise => { if (typeof window === 'undefined') { return; } + try { + localStorage.removeItem(STORED_SESSION_KEY); + } catch { + // Ignore + } await fetch('/api/session', { method: 'DELETE', }); diff --git a/src/app/store/saga/auth-saga.ts b/src/app/store/saga/auth-saga.ts index db21908..295659e 100644 --- a/src/app/store/saga/auth-saga.ts +++ b/src/app/store/saga/auth-saga.ts @@ -39,10 +39,7 @@ import { retrieveUserInformation, sendEmailVerification, } from '../../services'; -import { - setUserCookieSession, - clearUserCookieSession, -} from '../../services/session-service'; +import { clearUserCookieSession } from '../../services/session-service'; import { type AdditionalUserInfo, type UserCredential, @@ -274,11 +271,6 @@ function* anonymousLoginSaga(): Generator { } } -function* sessionCookieAfterLoginSaga(): Generator { - // Establish server-side HTTP-only session cookie after any loginSuccess. - yield call(setUserCookieSession); -} - export function* watchAuth(): Generator { yield takeLatest(USER_PROFILE_LOGIN, emailLoginSaga); yield takeLatest(USER_PROFILE_LOGOUT, logoutSaga); @@ -291,6 +283,4 @@ export function* watchAuth(): Generator { yield takeLatest(USER_PROFILE_CHANGE_PASSWORD, changePasswordSaga); yield takeLatest(USER_PROFILE_RESET_PASSWORD, resetPasswordSaga); yield takeLatest(USER_PROFILE_ANONYMOUS_LOGIN, anonymousLoginSaga); - // When loginSuccess is dispatched (any login flow), set the session cookie. - yield takeLatest(loginSuccess.type, sessionCookieAfterLoginSaga); } From 5d7d410766975ba900b5377643085f3214a1628b Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 17 Mar 2026 14:46:17 -0400 Subject: [PATCH 6/7] header remove firebase hook --- src/app/App.tsx | 4 +-- src/app/[locale]/feeds/lib/useFeedsSearch.ts | 4 +-- .../components/AuthSessionProvider.spec.tsx | 16 ++++----- src/app/components/AuthSessionProvider.tsx | 35 ++++++++++++++----- src/app/components/Header.tsx | 26 ++------------ 5 files changed, 40 insertions(+), 45 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 4603a47..721ebb0 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4,7 +4,7 @@ import './App.css'; import AppRouter from './router/Router'; import { MemoryRouter } from 'react-router-dom'; import { Suspense } from 'react'; -import { useAuthReady } from './components/AuthSessionProvider'; +import { useAuthSession } from './components/AuthSessionProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import AppContainer from './AppContainer'; @@ -30,7 +30,7 @@ function buildPathFromNextRouter( } function App({ locale }: AppProps): React.ReactElement { - const isAppReady = useAuthReady(); + const { isAuthReady: isAppReady } = useAuthSession(); const pathname = usePathname(); const searchParams = useSearchParams(); diff --git a/src/app/[locale]/feeds/lib/useFeedsSearch.ts b/src/app/[locale]/feeds/lib/useFeedsSearch.ts index 97ecd69..227e88a 100644 --- a/src/app/[locale]/feeds/lib/useFeedsSearch.ts +++ b/src/app/[locale]/feeds/lib/useFeedsSearch.ts @@ -6,7 +6,7 @@ import { type AllFeedsParams, } from '../../../services/feeds/utils'; import { getUserAccessToken } from '../../../services/profile-service'; -import { useAuthReady } from '../../../components/AuthSessionProvider'; +import { useAuthSession } from '../../../components/AuthSessionProvider'; import { getDataTypeParamFromSelectedFeedTypes, getInitialSelectedFeedTypes, @@ -158,7 +158,7 @@ export function useFeedsSearch(searchParams: URLSearchParams): { isError: boolean; searchLimit: number; } { - const authReady = useAuthReady(); + const { isAuthReady: authReady } = useAuthSession(); const { cache } = useSWRConfig(); const derivedSearchParams = deriveSearchParams(searchParams); const key = authReady ? buildSwrKey(derivedSearchParams) : null; diff --git a/src/app/components/AuthSessionProvider.spec.tsx b/src/app/components/AuthSessionProvider.spec.tsx index 6326680..546f634 100644 --- a/src/app/components/AuthSessionProvider.spec.tsx +++ b/src/app/components/AuthSessionProvider.spec.tsx @@ -7,7 +7,7 @@ import { } from '@testing-library/react'; import { configureStore } from '@reduxjs/toolkit'; import { Provider } from 'react-redux'; -import { AuthSessionProvider, useAuthReady } from './AuthSessionProvider'; +import { AuthSessionProvider, useAuthSession } from './AuthSessionProvider'; import { setUserCookieSession } from '../services/session-service'; import { anonymousLogin } from '../store/profile-reducer'; @@ -97,14 +97,14 @@ afterEach(() => { }); describe('AuthSessionProvider', () => { - describe('useAuthReady', () => { + describe('useAuthSession', () => { it('returns false before any auth state change', () => { - const { result } = renderHook(() => useAuthReady(), { wrapper }); - expect(result.current).toBe(false); + const { result } = renderHook(() => useAuthSession(), { wrapper }); + expect(result.current.isAuthReady).toBe(false); }); it('returns true after onIdTokenChanged fires with a user', async () => { - const { result } = renderHook(() => useAuthReady(), { + const { result } = renderHook(() => useAuthSession(), { wrapper: wrapperWithAuth, }); @@ -112,11 +112,11 @@ describe('AuthSessionProvider', () => { capturedAuthCallback(mockUser); }); - expect(result.current).toBe(true); + expect(result.current.isAuthReady).toBe(true); }); it('returns false after onIdTokenChanged fires with null', async () => { - const { result } = renderHook(() => useAuthReady(), { + const { result } = renderHook(() => useAuthSession(), { wrapper: wrapperWithAuth, }); @@ -124,7 +124,7 @@ describe('AuthSessionProvider', () => { capturedAuthCallback(null); }); - expect(result.current).toBe(false); + expect(result.current.isAuthReady).toBe(false); }); }); diff --git a/src/app/components/AuthSessionProvider.tsx b/src/app/components/AuthSessionProvider.tsx index 8ab845b..91e23e1 100644 --- a/src/app/components/AuthSessionProvider.tsx +++ b/src/app/components/AuthSessionProvider.tsx @@ -14,14 +14,23 @@ import { app } from '../../firebase'; import { anonymousLogin } from '../store/profile-reducer'; import { setUserCookieSession } from '../services/session-service'; -const AuthReadyContext = createContext(false); +interface AuthSession { + isAuthReady: boolean; + email: string | null; + isAuthenticated: boolean; +} + +const AuthReadyContext = createContext({ + isAuthReady: false, + email: null, + isAuthenticated: false, +}); /** - * Returns true once a Firebase user (anonymous or authenticated) is - * available. Use this instead of registering your own - * `onAuthStateChanged` listener. + * Returns the current auth session state once Firebase has resolved. + * Use this instead of registering your own `onAuthStateChanged` listener. */ -export function useAuthReady(): boolean { +export function useAuthSession(): AuthSession { return useContext(AuthReadyContext); } @@ -45,7 +54,11 @@ export function AuthSessionProvider({ children: ReactNode; }): ReactElement { const dispatch = useDispatch(); - const [isAuthReady, setIsAuthReady] = useState(false); + const [session, setSession] = useState({ + isAuthReady: false, + email: null, + isAuthenticated: false, + }); const intervalRef = useRef | null>(null); useEffect(() => { @@ -56,7 +69,11 @@ export function AuthSessionProvider({ } if (user != null) { - setIsAuthReady(true); + setSession({ + isAuthReady: true, + email: user.email ?? null, + isAuthenticated: !user.isAnonymous, + }); setUserCookieSession().catch(() => {}); // Check every 5 minutes; the cookie lasts 60 minutes, so this ensures renewal well before expiry @@ -69,7 +86,7 @@ export function AuthSessionProvider({ 5 * 60 * 1000, ); // 5 minutes } else { - setIsAuthReady(false); + setSession({ isAuthReady: false, email: null, isAuthenticated: false }); dispatch(anonymousLogin()); } }); @@ -81,7 +98,7 @@ export function AuthSessionProvider({ }, [dispatch]); return ( - + {children} ); diff --git a/src/app/components/Header.tsx b/src/app/components/Header.tsx index 13244d2..3c088ae 100644 --- a/src/app/components/Header.tsx +++ b/src/app/components/Header.tsx @@ -43,7 +43,7 @@ import { animatedButtonStyling } from './Header.style'; import ThemeToggle from './ThemeToggle'; import { useTranslations, useLocale } from 'next-intl'; import Link from 'next/link'; -import { app } from '../../firebase'; +import { useAuthSession } from './AuthSessionProvider'; // Lazy load components not needed for initial render const LogoutConfirmModal = dynamic( @@ -74,9 +74,7 @@ function useClientSearchParams(): URLSearchParams | null { } export default function DrawerAppBar(): React.ReactElement { - const [currentUser, setCurrentUser] = React.useState< - { email: string; isAuthenticated: boolean } | undefined - >(undefined); + const { email: userEmail, isAuthenticated } = useAuthSession(); const clientSearchParams = useClientSearchParams(); const hasTransitFeedsRedirectParam = clientSearchParams?.get('utm_source') === 'transitfeeds'; @@ -95,23 +93,6 @@ export default function DrawerAppBar(): React.ReactElement { const { config } = useRemoteConfig(); const t = useTranslations('common'); - React.useEffect(() => { - const auth = app.auth(); - const unsubscribe = auth.onAuthStateChanged(async (user) => { - if (user != null) { - setCurrentUser({ - email: user.email ?? '', - isAuthenticated: !user.isAnonymous, - }); - } else { - setCurrentUser(undefined); - } - }); - return () => { - unsubscribe(); - }; - }, []); - React.useEffect(() => { if (hasTransitFeedsRedirectParam) { setHasTransitFeedsRedirect(true); @@ -128,9 +109,6 @@ export default function DrawerAppBar(): React.ReactElement { const router = useRouter(); - const isAuthenticated = currentUser != null && currentUser.isAuthenticated; - const userEmail = currentUser?.email; - const handleDrawerToggle = (): void => { setMobileOpen((prevState) => !prevState); }; From e4234299f1f333c137eb57e15ea18dcad478375d Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Wed, 18 Mar 2026 08:13:31 -0400 Subject: [PATCH 7/7] package bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 33af784..4bd37d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mobility-database", - "version": "1.0.0", + "version": "1.0.1", "private": true, "dependencies": { "@emotion/cache": "11.14.0",