Public Access
refactor(session): one session store for login, logout and 401
This commit is contained in:
1 parent
2a22789435
commit
cbafa3d540
23 files changed
+246
-280
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}}/>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user