diff --git a/README.md b/README.md
index c5ad221..65cfac1 100644
--- a/README.md
+++ b/README.md
@@ -128,14 +128,19 @@ flowchart LR
- Calls for the logged-in user go through `src/utils/api.js`. `clientFetch` sends the HttpOnly cookies and the
`X-TBP-Client` header. `apiFetch` answers a `401` with one `POST /api/auth/refresh` and sends the request again.
Parallel requests and other tabs share this refresh (`navigator.locks`). When the second answer is still a
- `401`, `apiFetch` fires `expired`; the pages only stop. `request()` wraps `apiFetch` as `{ok, status, data}`
- and never throws. Public pages (landing page, public profile) and the public `GET /api/version` in the settings
- use plain `fetch`.
+ `401`, `apiFetch` ends the session (`expireSession`); the pages only stop. `request()` wraps `apiFetch` as
+ `{ok, status, data}` and never throws. Public pages (landing page, public profile) and the public
+ `GET /api/version` in the settings use plain `fetch`.
+- `src/utils/auth.js` is the session store: `markSession`, `clearSession` (logout) and `expireSession` (a `401`)
+ tell every `useSession()`, also on a login or logout in another tab. The user (`CurrentUserProvider`) and the
+ look (`PreferencesProvider`) follow it.
- The pages that need a login sit under the `RequireSession` layout route in `App.jsx`. Without a session it opens
- the login page, which comes back to the same page; on `expired` it ends the session (`useSessionExpired`). A
- `401` on a public page ends nothing.
+ the login page: after a `401` with the "session expired" message and back to the same page, after a logout
+ without a way back. A public page stays open when the session ends there.
- `CurrentUserProvider` holds `GET /api/users/me` for the whole app (`useCurrentUserContext`, `useCurrentUser`).
Dashboard and settings load it again when they open.
+- Tests render through `src/test/renderWithProviders.jsx` (a data router like `main.jsx`) and answer requests
+ with `src/test/fetchRoutes.js`, which fails a test on any request without a route.
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
detail page) to Core.
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
diff --git a/src/App.jsx b/src/App.jsx
index 03f25ad..bb13544 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,6 +1,6 @@
import {Link, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
-import {useEffect, useState} from 'react';
+import {useEffect} from 'react';
import LandingPage from './pages/LandingPage';
import LoginPage from './pages/LoginPage';
@@ -23,26 +23,19 @@ import {Toaster} from '@/components/ui/sonner';
import {TooltipProvider} from '@/components/ui/tooltip';
import PageBackground from './components/PageBackground.jsx';
import {usePreferences} from './context/PreferencesContext.jsx';
-import {useCurrentUserActions} from './context/CurrentUserContext.jsx';
-import {clearSession, hasSession} from './utils/auth.js';
+import {clearSession} from './utils/auth.js';
+import useSession from './hooks/useSession.js';
import {logout} from './utils/api.js';
function App() {
const {t} = useTranslation();
- const [isAuthenticated, setIsAuthenticated] = useState(false);
- const {appearance, syncPreferences, resetAppearance} = usePreferences();
- const {setUser} = useCurrentUserActions();
+ const {active: isAuthenticated} = useSession();
+ const {appearance, syncPreferences} = usePreferences();
// Hooks for routing
const navigate = useNavigate();
const location = useLocation();
- useEffect(() => {
- // No local expiry check here: a 401 from the backend ends the session (useSessionExpired), and a browser
- // clock that runs ahead would otherwise end it silently.
- setIsAuthenticated(hasSession());
- }, [location]);
-
// On login and on page load; the cached look shows until Auth answers.
useEffect(() => {
if (isAuthenticated) syncPreferences();
@@ -52,9 +45,6 @@ function App() {
const handleLogout = async () => {
await logout();
clearSession();
- resetAppearance();
- setUser(null);
- setIsAuthenticated(false);
navigate('/login');
};
diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx
index 2622e54..83120c7 100644
--- a/src/components/AppHeader.jsx
+++ b/src/components/AppHeader.jsx
@@ -16,16 +16,14 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {usePreferences} from '@/context/PreferencesContext.jsx';
-import {useCurrentUserActions} from '@/context/CurrentUserContext.jsx';
import {cn} from '@/lib/utils';
import Logo from './Logo.jsx';
import PageContainer from './PageContainer.jsx';
import SteamAvatar from './SteamAvatar.jsx';
import useCurrentUser from '../hooks/useCurrentUser.js';
-import useSessionExpired from '../hooks/useSessionExpired.js';
import useSteamProfile from '../hooks/useSteamProfile.js';
import {ROLES, viewAs} from '../utils/admin.js';
-import {markSession} from '../utils/auth.js';
+import {expireSession, markSession} from '../utils/auth.js';
const NAV_ITEMS = [
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
@@ -48,9 +46,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
const {pathname} = useLocation();
const steamProfile = useSteamProfile(isAuthenticated);
const currentUser = useCurrentUser(isAuthenticated);
- const {reload: reloadUser} = useCurrentUserActions();
const navigate = useNavigate();
- const expireSession = useSessionExpired();
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null;
@@ -58,9 +54,8 @@ export default function AppHeader({isAuthenticated, onLogout}) {
try {
const response = await viewAs(role === currentUser.role ? null : role);
if (response.ok) {
- // New cookies with the other role; the new stamp makes the Steam data load again.
+ // New cookies with the other role; the new stamp loads the user and the Steam data again.
markSession();
- reloadUser();
navigate('/dashboard');
return;
}
diff --git a/src/components/DeleteAccountCard.jsx b/src/components/DeleteAccountCard.jsx
index 70a4dc6..c51ac65 100644
--- a/src/components/DeleteAccountCard.jsx
+++ b/src/components/DeleteAccountCard.jsx
@@ -18,8 +18,6 @@ import {
} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
-import {useResetAppearance} from '../context/PreferencesContext.jsx';
-import {useCurrentUserActions} from '../context/CurrentUserContext.jsx';
import {deleteAccount} from '../utils/account.js';
import {clearSession} from '../utils/auth.js';
@@ -32,8 +30,6 @@ const sameEmail = (typed, email) => !!email && typed.trim().toLowerCase() === em
export default function DeleteAccountCard({email}) {
const {t} = useTranslation();
const navigate = useNavigate();
- const resetAppearance = useResetAppearance();
- const {setUser} = useCurrentUserActions();
const [typedEmail, setTypedEmail] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
@@ -43,11 +39,11 @@ export default function DeleteAccountCard({email}) {
try {
const response = await deleteAccount(password);
if (response.status === 204) {
- clearSession();
- resetAppearance();
- setUser(null);
toast.success(t('SETTINGS.DELETE_ACCOUNT.DONE'));
- navigate('/', {replace: true});
+ // First away from the settings, which would open the login page without a session. flushSync: the
+ // router shows the new page before the session change renders, not later in a transition.
+ await navigate('/', {replace: true, flushSync: true});
+ clearSession();
return;
}
if (response.status === 403) {
diff --git a/src/components/DeleteAccountCard.test.jsx b/src/components/DeleteAccountCard.test.jsx
index 09672e1..154c521 100644
--- a/src/components/DeleteAccountCard.test.jsx
+++ b/src/components/DeleteAccountCard.test.jsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
-import {MemoryRouter, Route, Routes} from 'react-router-dom';
+import {createMemoryRouter, RouterProvider} from 'react-router-dom';
import RequireSession from './RequireSession.jsx';
import {toast} from 'sonner';
import DeleteAccountCard from './DeleteAccountCard.jsx';
@@ -14,14 +14,12 @@ const EMAIL = 'player@example.com';
const deleteAnswers = (answer, routes = {}) => fetchRoutes({'DELETE /api/users/me': answer, ...routes});
-const renderCard = () => render(
-
-
- }>}/>
- landing
}/>
- login}/>
-
- );
+// A data router like main.jsx, without the providers: their /me load would change the request order.
+const renderCard = () => render(, children: [{path: '/settings', element: }]},
+ {path: '/', element:
landing
},
+ {path: '/login', element:
login
},
+], {initialEntries: ['/settings']})}/>);
const openAndFill = (email, password) => {
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.DELETE_ACCOUNT.BUTTON'}));
diff --git a/src/components/PageContainer.test.jsx b/src/components/PageContainer.test.jsx
index bd6e3e4..d965cd9 100644
--- a/src/components/PageContainer.test.jsx
+++ b/src/components/PageContainer.test.jsx
@@ -21,6 +21,8 @@ describe('PageContainer', () => {
});
it('is wider with the wide layout', () => {
+ // The cached look of a logged-in user; without a session it is reset.
+ localStorage.setItem('tbp_session', '1');
localStorage.setItem('tbp_appearance', JSON.stringify({wideLayout: true}));
const container = renderContainer();
diff --git a/src/components/RequireSession.jsx b/src/components/RequireSession.jsx
index 5d39b2f..9ed57fb 100644
--- a/src/components/RequireSession.jsx
+++ b/src/components/RequireSession.jsx
@@ -1,34 +1,16 @@
-import {useEffect, useRef} from 'react';
import {Navigate, Outlet, useLocation} from 'react-router-dom';
-import useSessionExpired from '../hooks/useSessionExpired.js';
-import {sessionEvents} from '../utils/api.js';
-import {hasSession} from '../utils/auth.js';
+import useSession from '../hooks/useSession.js';
/**
- * Layout route of the pages that need a login. Without a session it opens the login page, which comes back here;
- * a 401 on one of these pages ends the session (see apiFetch).
+ * Layout route of the pages that need a login. Without a session it opens the login page, which comes back here.
+ * After a logout it only opens the login page, so the next login does not land on the page of the last user.
*/
export default function RequireSession() {
const {pathname, search} = useLocation();
- const expireSession = useSessionExpired();
- // In a ref, so the listener is added once and not again on every navigation.
- const expireRef = useRef(expireSession);
- expireRef.current = expireSession;
- // A render before the login page shows must not redirect again without the "session expired" state.
- const expiring = useRef(false);
+ const {active, endReason} = useSession();
- useEffect(() => {
- // Parallel requests each fire; only the first ends the session. Another tab may have removed the flag already.
- const onExpired = () => {
- if (expiring.current) return;
- expiring.current = true;
- expireRef.current();
- };
- sessionEvents.addEventListener('expired', onExpired);
- return () => sessionEvents.removeEventListener('expired', onExpired);
- }, []);
-
- return hasSession() || expiring.current
- ?
- : ;
+ if (active) return ;
+ const from = pathname + search;
+ if (endReason === 'logout') return ;
+ return ;
}
diff --git a/src/components/RequireSession.test.jsx b/src/components/RequireSession.test.jsx
index d559727..c442df4 100644
--- a/src/components/RequireSession.test.jsx
+++ b/src/components/RequireSession.test.jsx
@@ -1,88 +1,81 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {act, cleanup, render, screen} from '@testing-library/react';
-import {MemoryRouter, Outlet, Route, Routes, useLocation} from 'react-router-dom';
+import {createMemoryRouter, RouterProvider} from 'react-router-dom';
import RequireSession from './RequireSession.jsx';
-import {sessionEvents} from '../utils/api.js';
-
-const visitedLogin = [];
-
-function LoginState() {
- const location = useLocation();
- visitedLogin.push(location.key);
- return
},
+ ], {initialEntries: [entry]});
+ render();
+ return router;
}
-const loginState = () => JSON.parse(screen.getByTestId('state').textContent);
-const expire = () => act(() => {
- sessionEvents.dispatchEvent(new Event('expired'));
-});
+const at = (router) => ({path: router.state.location.pathname, state: router.state.location.state});
afterEach(() => {
cleanup();
localStorage.clear();
- visitedLogin.length = 0;
});
describe('RequireSession', () => {
- it('sends a deep link without a session to the login page, which comes back to it', () => {
- renderAt('/library?status=PLAYING');
+ it('sends a deep link without a session to the login page, which comes back to it', async () => {
+ const router = renderAt('/library?status=PLAYING');
- expect(loginState()).toEqual({from: '/library?status=PLAYING'});
+ expect(await screen.findByText('login')).toBeTruthy();
+ expect(at(router)).toEqual({path: '/login', state: {from: '/library?status=PLAYING'}});
});
it('shows the page with a session', () => {
- localStorage.setItem('tbp_session', '1');
+ markSession();
renderAt('/library');
expect(screen.getByText('library')).toBeTruthy();
});
- it('ends the session once when requests fail with a 401', () => {
- localStorage.setItem('tbp_session', '1');
- renderAt('/library?status=PLAYING');
+ it('says the session expired and comes back after a 401', async () => {
+ markSession();
+ const router = renderAt('/library?status=PLAYING');
- expire();
- expire();
+ act(() => {
+ expireSession();
+ expireSession();
+ });
- expect(loginState()).toEqual({sessionExpired: true, from: '/library?status=PLAYING'});
- expect(new Set(visitedLogin).size).toBe(1);
- expect(localStorage.getItem('tbp_session')).toBeNull();
+ expect(await screen.findByText('login')).toBeTruthy();
+ expect(at(router)).toEqual({path: '/login', state: {sessionExpired: true, from: '/library?status=PLAYING'}});
});
- it('leaves a public page alone on a 401', () => {
- localStorage.setItem('tbp_session', '1');
- renderAt('/u/someone');
+ it('comes back to no page after a logout, so the next user starts on their own', async () => {
+ markSession();
+ const router = renderAt('/library');
- expire();
+ act(() => clearSession());
- expect(screen.getByText('profile')).toBeTruthy();
- expect(localStorage.getItem('tbp_session')).toBe('1');
+ expect(await screen.findByText('login')).toBeTruthy();
+ expect(router.state.location.state).toBeNull();
});
- it('ends the session when another tab already removed the session flag', () => {
- localStorage.setItem('tbp_session', '1');
+
+ it('opens the login page when another tab logs out', async () => {
+ markSession();
renderAt('/library');
localStorage.removeItem('tbp_session');
- expire();
+ act(() => window.dispatchEvent(new StorageEvent('storage', {key: 'tbp_session'})));
- expect(screen.queryByText('library')).toBeNull();
- expect(loginState()).toEqual({sessionExpired: true, from: '/library'});
+ expect(await screen.findByText('login')).toBeTruthy();
+ });
+
+ it('leaves a public page open when the session ends there', () => {
+ markSession();
+ renderAt('/u/someone');
+
+ act(() => expireSession());
+
+ expect(screen.getByText('profile')).toBeTruthy();
});
});
diff --git a/src/context/CurrentUserContext.jsx b/src/context/CurrentUserContext.jsx
index b4502cf..4e6e405 100644
--- a/src/context/CurrentUserContext.jsx
+++ b/src/context/CurrentUserContext.jsx
@@ -1,17 +1,16 @@
import {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
import {request} from '../utils/api.js';
-import {SESSION_KEY, hasSession} from '../utils/auth.js';
+import {hasSession} from '../utils/auth.js';
+import useSession from '../hooks/useSession.js';
import {ME_URL, REQUEST_TIMEOUT_MS} from '../utils/settings.js';
-const UserContext = createContext(null);
-// Apart from the user, so a component that only reloads or clears it does not render again on each change.
-const ActionsContext = createContext(null);
+const CurrentUserContext = createContext(null);
/**
* GET /api/users/me for the whole app. `user` is undefined until the first answer and null without a session.
* `reload()` loads it again and resolves to the `request()` result (not ok for a 200 without a body).
- * `updateUser(fn)` changes it locally after a save, and skips it when the user is gone by then; `setUser(null)`
- * drops it. A save that replaces settings still loads /me itself (see saveSettings), as it must be fresh.
+ * `updateUser(fn)` changes it locally after a save, and skips it when the user is gone by then. It follows the
+ * session. A save that replaces settings still loads /me itself (see saveSettings), as it must be fresh.
*/
export function CurrentUserProvider({children}) {
const [user, setUser] = useState(() => (hasSession() ? undefined : null));
@@ -32,43 +31,29 @@ export function CurrentUserProvider({children}) {
return result;
}, []);
+ // A new stamp is a login, a "view as" or a settings save, also in another tab.
+ const {stamp} = useSession();
useEffect(() => {
- if (hasSession()) reload();
- // A login, logout or "view as" in another tab.
- const onStorage = (event) => {
- if (event.key !== null && event.key !== SESSION_KEY) return;
- if (hasSession()) reload();
- else setUser(null);
- };
- window.addEventListener('storage', onStorage);
- return () => window.removeEventListener('storage', onStorage);
- }, [reload]);
+ if (stamp) {
+ reload();
+ } else {
+ // A /me answer still on the way must not bring the user back.
+ latest.current += 1;
+ setUser(null);
+ }
+ }, [stamp, reload]);
const updateUser = useCallback((change) => setUser((current) => current && change(current)), []);
- const actions = useMemo(() => ({reload, setUser, updateUser}), [reload, updateUser]);
+ const value = useMemo(() => ({user, reload, updateUser}), [user, reload, updateUser]);
- return (
-
- {children}
-
- );
-}
-
-const NO_ACTIONS = {reload: async () => ({ok: false, status: 0, data: null}), setUser: () => {}, updateUser: () => {}};
-
-/**
- * `{reload, setUser, updateUser}`, without the user. Outside a provider (e.g. a test of one component) they do
- * nothing.
- */
-export function useCurrentUserActions() {
- return useContext(ActionsContext) ?? NO_ACTIONS;
+ return {children};
}
/**
- * `{user, reload, setUser, updateUser}`; outside a provider there is no user.
+ * `{user, reload, updateUser}`.
*/
export function useCurrentUserContext() {
- const user = useContext(UserContext);
- const actions = useContext(ActionsContext);
- return {user: actions ? user : null, ...(actions ?? NO_ACTIONS)};
+ const value = useContext(CurrentUserContext);
+ if (!value) throw new Error('useCurrentUserContext must be used inside ');
+ return value;
}
diff --git a/src/context/CurrentUserContext.test.jsx b/src/context/CurrentUserContext.test.jsx
index 7393159..6d3e3de 100644
--- a/src/context/CurrentUserContext.test.jsx
+++ b/src/context/CurrentUserContext.test.jsx
@@ -3,6 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, render, screen, waitFor} from '@testing-library/react';
import {CurrentUserProvider, useCurrentUserContext} from './CurrentUserContext.jsx';
import useCurrentUser from '../hooks/useCurrentUser.js';
+import {clearSession, markSession} from '../utils/auth.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
const context = {current: null};
@@ -70,10 +71,7 @@ describe('CurrentUserProvider', () => {
renderTwo();
expect(context.current.user).toBeNull();
- localStorage.setItem('tbp_session', '1');
- act(() => {
- context.current.reload();
- });
+ act(() => markSession());
expect(context.current.user).toBeUndefined();
await act(async () => answers[0](json(200, {role: 'USER'})));
@@ -122,6 +120,19 @@ describe('CurrentUserProvider', () => {
expect(context.current.user).toBeNull();
});
+ it('does not bring the user back when /me answers after a logout', async () => {
+ markSession();
+ const answers = [];
+ fetchRoutes({'GET /api/users/me': () => new Promise((resolve) => answers.push(resolve))});
+ renderTwo();
+ await waitFor(() => expect(answers).toHaveLength(1));
+
+ act(() => clearSession());
+ await act(async () => answers[0](json(200, {role: 'PLATFORM_ADMIN'})));
+
+ expect(screen.getByText('header: none')).toBeTruthy();
+ });
+
it('takes only the answer of the last reload', async () => {
localStorage.setItem('tbp_session', '1');
const answers = [];
diff --git a/src/context/PreferencesContext.jsx b/src/context/PreferencesContext.jsx
index c52131d..39f7147 100644
--- a/src/context/PreferencesContext.jsx
+++ b/src/context/PreferencesContext.jsx
@@ -10,6 +10,7 @@ import {
saveAppearance,
} from '../utils/appearance.js';
import {hasSession} from '../utils/auth.js';
+import useSession from '../hooks/useSession.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
@@ -124,6 +125,12 @@ export function PreferencesProvider({children}) {
setAppearanceState(DEFAULT_APPEARANCE);
}, []);
+ // Without a session the next user must not get this look.
+ const {active} = useSession();
+ useEffect(() => {
+ if (!active) resetAppearance();
+ }, [active, resetAppearance]);
+
/**
* Applies the language at once and saves it to Auth while logged in.
*
@@ -161,12 +168,3 @@ export function usePreferences() {
}
return ctx;
}
-
-/**
- * resetAppearance, or a no-op outside a PreferencesProvider, so hooks used in bare component tests keep working.
- */
-export function useResetAppearance() {
- return useContext(PreferencesContext)?.resetAppearance ?? noop;
-}
-
-const noop = () => {};
diff --git a/src/hooks/useSaveAppearance.test.jsx b/src/hooks/useSaveAppearance.test.jsx
index 744145b..3bda156 100644
--- a/src/hooks/useSaveAppearance.test.jsx
+++ b/src/hooks/useSaveAppearance.test.jsx
@@ -15,7 +15,10 @@ vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
// RequireSession ends the session on a 401, as on the pages that use the hook.
const wrapper = ({children}) => (
- }>
+
+ }>
+
+
);
diff --git a/src/hooks/useSaveResponse.js b/src/hooks/useSaveResponse.js
index 1c98590..9688f61 100644
--- a/src/hooks/useSaveResponse.js
+++ b/src/hooks/useSaveResponse.js
@@ -1,24 +1,18 @@
import {useCallback} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
-import useSessionExpired from './useSessionExpired.js';
/**
- * Returns `run(send)` for a settings save: it awaits `send()` (a fetch) and shows the error message, or ends the
- * session on a 401. Resolves to true when saved.
+ * Returns `run(send)` for a settings save: it awaits `send()` (a fetch) and shows the error message. Resolves to
+ * true when saved.
*/
export default function useSaveResponse() {
const {t} = useTranslation();
- const expireSession = useSessionExpired();
return useCallback(async (send) => {
try {
const response = await send();
- if (response.status === 401) {
- // Also on the public profile, which has no RequireSession.
- expireSession();
- return false;
- }
+ if (response.status === 401) return false;
if (!response.ok) {
toast.error(t('SETTINGS.ERROR_SAVE'));
return false;
@@ -29,5 +23,5 @@ export default function useSaveResponse() {
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
return false;
}
- }, [expireSession, t]);
+ }, [t]);
}
diff --git a/src/hooks/useSession.js b/src/hooks/useSession.js
new file mode 100644
index 0000000..da035dd
--- /dev/null
+++ b/src/hooks/useSession.js
@@ -0,0 +1,17 @@
+import {useMemo, useSyncExternalStore} from 'react';
+import {sessionEndReason, sessionStamp, subscribeSession} from '../utils/auth.js';
+
+// A string, so the same session gives the same snapshot.
+const snapshot = () => JSON.stringify([sessionStamp(), sessionEndReason()]);
+
+/**
+ * The session of this tab: `active`, its `stamp` (new on every login and settings save) and `endReason`
+ * (null, 'logout' or 'expired'). Renders again when it changes, also through another tab.
+ */
+export default function useSession() {
+ const current = useSyncExternalStore(subscribeSession, snapshot);
+ return useMemo(() => {
+ const [stamp, endReason] = JSON.parse(current);
+ return {active: stamp !== null, stamp, endReason};
+ }, [current]);
+}
diff --git a/src/hooks/useSessionExpired.js b/src/hooks/useSessionExpired.js
deleted file mode 100644
index 9c72f11..0000000
--- a/src/hooks/useSessionExpired.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import {useCallback, useRef} from 'react';
-import {useLocation, useNavigate} from 'react-router-dom';
-import {useResetAppearance} from '../context/PreferencesContext.jsx';
-import {useCurrentUserActions} from '../context/CurrentUserContext.jsx';
-import {clearSession} from '../utils/auth.js';
-
-/**
- * Returns a function for a 401 answer: it removes the token and the user's look and opens the login page, which
- * then says that the session expired and comes back to the current page after login.
- */
-export default function useSessionExpired() {
- const navigate = useNavigate();
- const resetAppearance = useResetAppearance();
- const {setUser} = useCurrentUserActions();
- const location = useLocation();
- const locationRef = useRef(location);
- locationRef.current = location;
-
- return useCallback(() => {
- const {pathname, search} = locationRef.current;
- clearSession();
- resetAppearance();
- setUser(null);
- navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}});
- }, [navigate, resetAppearance, setUser]);
-}
diff --git a/src/hooks/useSessionExpired.test.jsx b/src/hooks/useSessionExpired.test.jsx
deleted file mode 100644
index 3d6c683..0000000
--- a/src/hooks/useSessionExpired.test.jsx
+++ /dev/null
@@ -1,39 +0,0 @@
-// @vitest-environment jsdom
-import {afterEach, describe, expect, it} from 'vitest';
-import {cleanup, fireEvent, render, screen} from '@testing-library/react';
-import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
-import useSessionExpired from './useSessionExpired.js';
-
-function ExpireButton() {
- const expireSession = useSessionExpired();
- return ;
-}
-
-function LoginState() {
- return
)}
- setEditing(false)}/>
diff --git a/src/utils/api.js b/src/utils/api.js
index 955f930..c64182f 100644
--- a/src/utils/api.js
+++ b/src/utils/api.js
@@ -1,3 +1,5 @@
+import {expireSession, sessionStamp} from './auth.js';
+
// Auth and Core refuse a request that changes data without this header (CSRF guard, see ClientHeaderFilter).
export const CLIENT_HEADER = 'X-TBP-Client';
@@ -54,21 +56,17 @@ export async function rotatingFetch(url, init = {}) {
return globalThis.navigator?.locks ? navigator.locks.request('tbp-refresh', send) : send();
}
-/**
- * Fires `expired` when the session is over. RequireSession ends it, so the user sees why and comes back to the same
- * page; a public page does not listen.
- */
-export const sessionEvents = new EventTarget();
-
/**
* fetch for the logged-in user. A 401 renews the access cookie once and sends the request again. A 401 after that
- * goes back to the caller and fires `expired`; the caller only stops.
+ * ends the session (RequireSession then opens the login page, a public page stays) and goes back to the caller,
+ * which only stops.
*/
export async function apiFetch(url, init = {}) {
+ const stamp = sessionStamp();
let response = await clientFetch(url, init);
if (response.status === 401) {
if (await refreshSession()) response = await clientFetch(url, init);
- if (response.status === 401) sessionEvents.dispatchEvent(new Event('expired'));
+ if (response.status === 401) expireSession(stamp);
}
return response;
}
diff --git a/src/utils/api.test.js b/src/utils/api.test.js
index 6bac5e3..4351d29 100644
--- a/src/utils/api.test.js
+++ b/src/utils/api.test.js
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
-import {apiFetch, clientFetch, logout, request, sessionEvents} from './api.js';
+import {apiFetch, clientFetch, logout, request} from './api.js';
+import {clearSession, markSession, sessionEndReason, subscribeSession} from './auth.js';
const response = (status) => ({ok: status >= 200 && status < 300, status, json: async () => ({})});
const isRefresh = (url) => url.endsWith('/api/auth/refresh');
@@ -59,7 +60,7 @@ describe('apiFetch', () => {
expect(refreshCalls()).toHaveLength(1);
});
- it('returns the 401 when the refresh fails, without a loop', async () => {
+ it('returns the 401 when the refresh fails, without a loop, and ends the session', async () => {
globalThis.fetch = vi.fn(async () => response(401));
const result = await apiFetch('/api/x');
@@ -67,7 +68,7 @@ describe('apiFetch', () => {
expect(result.status).toBe(401);
expect(refreshCalls()).toHaveLength(1);
expect(dataCalls()).toHaveLength(1);
- expect(localStorage.getItem('tbp_session')).toBe('1');
+ expect(localStorage.getItem('tbp_session')).toBeNull();
});
it('keeps the session and rejects when the refresh has no network', async () => {
@@ -126,36 +127,56 @@ describe('apiFetch', () => {
});
});
-describe('the expired event', () => {
- let expired;
- const count = () => expired.mock.calls.length;
+describe('the session end on a 401', () => {
+ let changes;
+ let unsubscribe;
beforeEach(() => {
- expired = vi.fn();
- sessionEvents.addEventListener('expired', expired);
+ markSession();
+ changes = vi.fn();
+ unsubscribe = subscribeSession(changes);
});
afterEach(() => {
- sessionEvents.removeEventListener('expired', expired);
+ unsubscribe();
});
- it('fires once when the refresh says the session is over', async () => {
+ it('ends the session once when the refresh says it is over', async () => {
globalThis.fetch = vi.fn(async () => response(401));
- await apiFetch('/api/x');
+ await Promise.all([apiFetch('/api/x'), apiFetch('/api/y')]);
- expect(count()).toBe(1);
+ expect(localStorage.getItem('tbp_session')).toBeNull();
+ expect(sessionEndReason()).toBe('expired');
+ expect(changes).toHaveBeenCalledTimes(1);
});
- it('fires when the request after a good refresh still gets a 401', async () => {
+ it('keeps a new login when the refresh of the session before it fails late', async () => {
+ let failRefresh;
+ globalThis.fetch = vi.fn((url) => isRefresh(url)
+ ? new Promise((resolve) => { failRefresh = () => resolve(response(401)); })
+ : Promise.resolve(response(401)));
+
+ const pending = apiFetch('/api/x');
+ await vi.waitFor(() => expect(failRefresh).toBeDefined());
+ clearSession();
+ markSession();
+ failRefresh();
+ await pending;
+
+ expect(localStorage.getItem('tbp_session')).not.toBeNull();
+ expect(sessionEndReason()).toBeNull();
+ });
+
+ it('ends it when the request after a good refresh still gets a 401', async () => {
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 204 : 401));
await apiFetch('/api/x');
- expect(count()).toBe(1);
+ expect(sessionEndReason()).toBe('expired');
});
- it('does not fire when the refresh has no network, or for clientFetch', async () => {
+ it('keeps it when the refresh has no network, and for clientFetch', async () => {
globalThis.fetch = vi.fn(async (url) => {
if (isRefresh(url)) throw new TypeError('Failed to fetch');
return response(401);
@@ -164,7 +185,8 @@ describe('the expired event', () => {
await expect(apiFetch('/api/x')).rejects.toThrow();
await clientFetch('/api/x');
- expect(count()).toBe(0);
+ expect(localStorage.getItem('tbp_session')).not.toBeNull();
+ expect(changes).not.toHaveBeenCalled();
});
});
diff --git a/src/utils/auth.js b/src/utils/auth.js
index 3ea5f58..19174b5 100644
--- a/src/utils/auth.js
+++ b/src/utils/auth.js
@@ -11,16 +11,58 @@ export const hasSession = () => localStorage.getItem(SESSION_KEY) !== null;
*/
export const sessionStamp = () => localStorage.getItem(SESSION_KEY);
+const listeners = new Set();
+// Why the session of this tab ended: null (never had one, or another tab ended it), 'logout' or 'expired'.
+let endReason = null;
+
+const change = (reason) => {
+ endReason = reason;
+ listeners.forEach((listener) => listener());
+};
+
+export const sessionEndReason = () => endReason;
+
+const onStorage = (event) => {
+ if (event.key === SESSION_KEY || event.key === null) change(null);
+};
+
export const markSession = () => {
- localStorage.setItem(SESSION_KEY, String(Date.now()));
+ // New for every login, also two in the same millisecond: apiFetch tells sessions apart by it.
+ localStorage.setItem(SESSION_KEY, crypto.randomUUID());
localStorage.removeItem(LEGACY_TOKEN_KEY);
+ change(null);
};
export const clearSession = () => {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem(LEGACY_TOKEN_KEY);
+ change('logout');
};
+/**
+ * Ends the session after a 401 that the refresh could not fix; the login page then says why. Parallel requests may
+ * each call it. Only the session with `stamp` can expire: a late answer for the session before a new login must not
+ * end the new one.
+ */
+export const expireSession = (stamp = sessionStamp()) => {
+ if (stamp === null || sessionStamp() !== stamp) return;
+ localStorage.removeItem(SESSION_KEY);
+ localStorage.removeItem(LEGACY_TOKEN_KEY);
+ change('expired');
+};
+
+/**
+ * Calls `listener` on every change of the session, also on a login or logout in another tab.
+ */
+export function subscribeSession(listener) {
+ if (listeners.size === 0) window.addEventListener('storage', onStorage);
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0) window.removeEventListener('storage', onStorage);
+ };
+}
+
/**
* Returns the path when it is a path inside this app, else null. The login page uses it to go back after a
* session expired; "//host", "/\host" and tabs or line breaks (browsers drop them, so "/\t/host" is "//host")