diff --git a/.github/workflows/vercel-prod-on-release.yml b/.github/workflows/vercel-prod-on-release.yml index 4b037f14..abd2440d 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] diff --git a/package.json b/package.json index 33af784d..4bd37d1f 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", diff --git a/src/app/App.tsx b/src/app/App.tsx index bc79339f..721ebb0c 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 { useAuthSession } 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 { isAuthReady: isAppReady } = useAuthSession(); 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]/components/HomePage.tsx b/src/app/[locale]/components/HomePage.tsx index 60596632..9263ccde 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')} 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 { 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 new file mode 100644 index 00000000..546f6345 --- /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, useAuthSession } 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('useAuthSession', () => { + it('returns false before any auth state change', () => { + const { result } = renderHook(() => useAuthSession(), { wrapper }); + expect(result.current.isAuthReady).toBe(false); + }); + + it('returns true after onIdTokenChanged fires with a user', async () => { + const { result } = renderHook(() => useAuthSession(), { + wrapper: wrapperWithAuth, + }); + + await act(async () => { + capturedAuthCallback(mockUser); + }); + + expect(result.current.isAuthReady).toBe(true); + }); + + it('returns false after onIdTokenChanged fires with null', async () => { + const { result } = renderHook(() => useAuthSession(), { + wrapper: wrapperWithAuth, + }); + + await act(async () => { + capturedAuthCallback(null); + }); + + expect(result.current.isAuthReady).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 00000000..91e23e1c --- /dev/null +++ b/src/app/components/AuthSessionProvider.tsx @@ -0,0 +1,105 @@ +'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'; + +interface AuthSession { + isAuthReady: boolean; + email: string | null; + isAuthenticated: boolean; +} + +const AuthReadyContext = createContext({ + isAuthReady: false, + email: null, + isAuthenticated: false, +}); + +/** + * Returns the current auth session state once Firebase has resolved. + * Use this instead of registering your own `onAuthStateChanged` listener. + */ +export function useAuthSession(): AuthSession { + 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 [session, setSession] = useState({ + isAuthReady: false, + email: null, + isAuthenticated: 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) { + 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 + // 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 { + setSession({ isAuthReady: false, email: null, isAuthenticated: false }); + dispatch(anonymousLogin()); + } + }); + + return () => { + unsubscribe(); + if (intervalRef.current != null) clearInterval(intervalRef.current); + }; + }, [dispatch]); + + return ( + + {children} + + ); +} diff --git a/src/app/components/Header.tsx b/src/app/components/Header.tsx index 13244d26..3c088aea 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); }; diff --git a/src/app/providers.tsx b/src/app/providers.tsx index b3cf8808..f9209ee8 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} diff --git a/src/app/services/session-service.ts b/src/app/services/session-service.ts index 5f8298a9..b95877dc 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 db219081..295659e5 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); } diff --git a/yarn.lock b/yarn.lock index 033ed9ea..4db5eaf7 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"