refactor(session): one session store for login, logout and 401

This commit is contained in:
lobo committed 2026-09-30 07:36:08 +02:00
1 parent 2a22789435
commit cbafa3d540
23 files changed
+246 -280

No files matched your search

+2 -7
View File
@@ -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;
}
+4 -8
View File
@@ -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) {
+7 -9
View File
@@ -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(
<MemoryRouter initialEntries={['/settings']}>
<Routes>
<Route element={<RequireSession/>}><Route path="/settings" element={<DeleteAccountCard email={EMAIL}/>}/></Route>
<Route path="/" element={<p>landing</p>}/>
<Route path="/login" element={<p>login</p>}/>
</Routes>
</MemoryRouter>);
// A data router like main.jsx, without the providers: their /me load would change the request order.
const renderCard = () => render(<RouterProvider router={createMemoryRouter([
{element: <RequireSession/>, children: [{path: '/settings', element: <DeleteAccountCard email={EMAIL}/>}]},
{path: '/', element: <p>landing</p>},
{path: '/login', element: <p>login</p>},
], {initialEntries: ['/settings']})}/>);
const openAndFill = (email, password) => {
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.DELETE_ACCOUNT.BUTTON'}));
+2
View File
@@ -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();
+8 -26
View File
@@ -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
? <Outlet/>
: <Navigate to="/login" replace state={{from: pathname + search}}/>;
if (active) return <Outlet/>;
const from = pathname + search;
if (endReason === 'logout') return <Navigate to="/login" replace/>;
return <Navigate to="/login" replace state={endReason === 'expired' ? {sessionExpired: true, from} : {from}}/>;
}
+44 -51
View File
@@ -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 <div data-testid="state">{JSON.stringify(location.state)}</div>;
}
import {clearSession, expireSession, markSession} from '../utils/auth.js';
function renderAt(entry) {
render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route element={<Outlet/>}>
<Route element={<RequireSession/>}>
<Route path="/library" element={<p>library</p>}/>
</Route>
<Route path="/u/:name" element={<p>profile</p>}/>
<Route path="/login" element={<LoginState/>}/>
</Route>
</Routes>
</MemoryRouter>,
);
const router = createMemoryRouter([
{element: <RequireSession/>, children: [{path: '/library', element: <p>library</p>}]},
{path: '/u/:name', element: <p>profile</p>},
{path: '/login', element: <p>login</p>},
], {initialEntries: [entry]});
render(<RouterProvider router={router}/>);
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();
});
});