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
+249
-283
No files matched your search
@@ -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
|
- 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.
|
`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
|
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}`
|
`401`, `apiFetch` ends the session (`expireSession`); the pages only stop. `request()` wraps `apiFetch` as
|
||||||
and never throws. Public pages (landing page, public profile) and the public `GET /api/version` in the settings
|
`{ok, status, data}` and never throws. Public pages (landing page, public profile) and the public
|
||||||
use plain `fetch`.
|
`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 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
|
the login page: after a `401` with the "session expired" message and back to the same page, after a logout
|
||||||
`401` on a public page ends nothing.
|
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`).
|
- `CurrentUserProvider` holds `GET /api/users/me` for the whole app (`useCurrentUserContext`, `useCurrentUser`).
|
||||||
Dashboard and settings load it again when they open.
|
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
|
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
|
||||||
detail page) to Core.
|
detail page) to Core.
|
||||||
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
||||||
|
|||||||
+5
-15
@@ -1,6 +1,6 @@
|
|||||||
import {Link, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
|
import {Link, Route, Routes, useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect} from 'react';
|
||||||
|
|
||||||
import LandingPage from './pages/LandingPage';
|
import LandingPage from './pages/LandingPage';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
@@ -23,26 +23,19 @@ import {Toaster} from '@/components/ui/sonner';
|
|||||||
import {TooltipProvider} from '@/components/ui/tooltip';
|
import {TooltipProvider} from '@/components/ui/tooltip';
|
||||||
import PageBackground from './components/PageBackground.jsx';
|
import PageBackground from './components/PageBackground.jsx';
|
||||||
import {usePreferences} from './context/PreferencesContext.jsx';
|
import {usePreferences} from './context/PreferencesContext.jsx';
|
||||||
import {useCurrentUserActions} from './context/CurrentUserContext.jsx';
|
import {clearSession} from './utils/auth.js';
|
||||||
import {clearSession, hasSession} from './utils/auth.js';
|
import useSession from './hooks/useSession.js';
|
||||||
import {logout} from './utils/api.js';
|
import {logout} from './utils/api.js';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
const {active: isAuthenticated} = useSession();
|
||||||
const {appearance, syncPreferences, resetAppearance} = usePreferences();
|
const {appearance, syncPreferences} = usePreferences();
|
||||||
const {setUser} = useCurrentUserActions();
|
|
||||||
|
|
||||||
// Hooks for routing
|
// Hooks for routing
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
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.
|
// On login and on page load; the cached look shows until Auth answers.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) syncPreferences();
|
if (isAuthenticated) syncPreferences();
|
||||||
@@ -52,9 +45,6 @@ function App() {
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
clearSession();
|
clearSession();
|
||||||
resetAppearance();
|
|
||||||
setUser(null);
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,16 +16,14 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import {usePreferences} from '@/context/PreferencesContext.jsx';
|
import {usePreferences} from '@/context/PreferencesContext.jsx';
|
||||||
import {useCurrentUserActions} from '@/context/CurrentUserContext.jsx';
|
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import Logo from './Logo.jsx';
|
import Logo from './Logo.jsx';
|
||||||
import PageContainer from './PageContainer.jsx';
|
import PageContainer from './PageContainer.jsx';
|
||||||
import SteamAvatar from './SteamAvatar.jsx';
|
import SteamAvatar from './SteamAvatar.jsx';
|
||||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
|
||||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||||
import {ROLES, viewAs} from '../utils/admin.js';
|
import {ROLES, viewAs} from '../utils/admin.js';
|
||||||
import {markSession} from '../utils/auth.js';
|
import {expireSession, markSession} from '../utils/auth.js';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
||||||
@@ -48,9 +46,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
const {pathname} = useLocation();
|
const {pathname} = useLocation();
|
||||||
const steamProfile = useSteamProfile(isAuthenticated);
|
const steamProfile = useSteamProfile(isAuthenticated);
|
||||||
const currentUser = useCurrentUser(isAuthenticated);
|
const currentUser = useCurrentUser(isAuthenticated);
|
||||||
const {reload: reloadUser} = useCurrentUserActions();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
|
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
|
||||||
const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null;
|
const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null;
|
||||||
|
|
||||||
@@ -58,9 +54,8 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
try {
|
try {
|
||||||
const response = await viewAs(role === currentUser.role ? null : role);
|
const response = await viewAs(role === currentUser.role ? null : role);
|
||||||
if (response.ok) {
|
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();
|
markSession();
|
||||||
reloadUser();
|
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import {Input} from '@/components/ui/input';
|
import {Input} from '@/components/ui/input';
|
||||||
import {Label} from '@/components/ui/label';
|
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 {deleteAccount} from '../utils/account.js';
|
||||||
import {clearSession} from '../utils/auth.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}) {
|
export default function DeleteAccountCard({email}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const resetAppearance = useResetAppearance();
|
|
||||||
const {setUser} = useCurrentUserActions();
|
|
||||||
const [typedEmail, setTypedEmail] = useState('');
|
const [typedEmail, setTypedEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -43,11 +39,11 @@ export default function DeleteAccountCard({email}) {
|
|||||||
try {
|
try {
|
||||||
const response = await deleteAccount(password);
|
const response = await deleteAccount(password);
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
clearSession();
|
|
||||||
resetAppearance();
|
|
||||||
setUser(null);
|
|
||||||
toast.success(t('SETTINGS.DELETE_ACCOUNT.DONE'));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
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 RequireSession from './RequireSession.jsx';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import DeleteAccountCard from './DeleteAccountCard.jsx';
|
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 deleteAnswers = (answer, routes = {}) => fetchRoutes({'DELETE /api/users/me': answer, ...routes});
|
||||||
|
|
||||||
const renderCard = () => render(
|
// A data router like main.jsx, without the providers: their /me load would change the request order.
|
||||||
<MemoryRouter initialEntries={['/settings']}>
|
const renderCard = () => render(<RouterProvider router={createMemoryRouter([
|
||||||
<Routes>
|
{element: <RequireSession/>, children: [{path: '/settings', element: <DeleteAccountCard email={EMAIL}/>}]},
|
||||||
<Route element={<RequireSession/>}><Route path="/settings" element={<DeleteAccountCard email={EMAIL}/>}/></Route>
|
{path: '/', element: <p>landing</p>},
|
||||||
<Route path="/" element={<p>landing</p>}/>
|
{path: '/login', element: <p>login</p>},
|
||||||
<Route path="/login" element={<p>login</p>}/>
|
], {initialEntries: ['/settings']})}/>);
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>);
|
|
||||||
|
|
||||||
const openAndFill = (email, password) => {
|
const openAndFill = (email, password) => {
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.DELETE_ACCOUNT.BUTTON'}));
|
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.DELETE_ACCOUNT.BUTTON'}));
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ describe('PageContainer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is wider with the wide layout', () => {
|
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}));
|
localStorage.setItem('tbp_appearance', JSON.stringify({wideLayout: true}));
|
||||||
|
|
||||||
const container = renderContainer();
|
const container = renderContainer();
|
||||||
|
|||||||
@@ -1,34 +1,16 @@
|
|||||||
import {useEffect, useRef} from 'react';
|
|
||||||
import {Navigate, Outlet, useLocation} from 'react-router-dom';
|
import {Navigate, Outlet, useLocation} from 'react-router-dom';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
import useSession from '../hooks/useSession.js';
|
||||||
import {sessionEvents} from '../utils/api.js';
|
|
||||||
import {hasSession} from '../utils/auth.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layout route of the pages that need a login. Without a session it opens the login page, which comes back here;
|
* 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).
|
* 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() {
|
export default function RequireSession() {
|
||||||
const {pathname, search} = useLocation();
|
const {pathname, search} = useLocation();
|
||||||
const expireSession = useSessionExpired();
|
const {active, endReason} = useSession();
|
||||||
// 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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (active) return <Outlet/>;
|
||||||
// Parallel requests each fire; only the first ends the session. Another tab may have removed the flag already.
|
const from = pathname + search;
|
||||||
const onExpired = () => {
|
if (endReason === 'logout') return <Navigate to="/login" replace/>;
|
||||||
if (expiring.current) return;
|
return <Navigate to="/login" replace state={endReason === 'expired' ? {sessionExpired: true, from} : {from}}/>;
|
||||||
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}}/>;
|
|
||||||
}
|
}
|
||||||
@@ -1,88 +1,81 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, describe, expect, it} from 'vitest';
|
import {afterEach, describe, expect, it} from 'vitest';
|
||||||
import {act, cleanup, render, screen} from '@testing-library/react';
|
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 RequireSession from './RequireSession.jsx';
|
||||||
import {sessionEvents} from '../utils/api.js';
|
import {clearSession, expireSession, markSession} from '../utils/auth.js';
|
||||||
|
|
||||||
const visitedLogin = [];
|
|
||||||
|
|
||||||
function LoginState() {
|
|
||||||
const location = useLocation();
|
|
||||||
visitedLogin.push(location.key);
|
|
||||||
return <div data-testid="state">{JSON.stringify(location.state)}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAt(entry) {
|
function renderAt(entry) {
|
||||||
render(
|
const router = createMemoryRouter([
|
||||||
<MemoryRouter initialEntries={[entry]}>
|
{element: <RequireSession/>, children: [{path: '/library', element: <p>library</p>}]},
|
||||||
<Routes>
|
{path: '/u/:name', element: <p>profile</p>},
|
||||||
<Route element={<Outlet/>}>
|
{path: '/login', element: <p>login</p>},
|
||||||
<Route element={<RequireSession/>}>
|
], {initialEntries: [entry]});
|
||||||
<Route path="/library" element={<p>library</p>}/>
|
render(<RouterProvider router={router}/>);
|
||||||
</Route>
|
return router;
|
||||||
<Route path="/u/:name" element={<p>profile</p>}/>
|
|
||||||
<Route path="/login" element={<LoginState/>}/>
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginState = () => JSON.parse(screen.getByTestId('state').textContent);
|
const at = (router) => ({path: router.state.location.pathname, state: router.state.location.state});
|
||||||
const expire = () => act(() => {
|
|
||||||
sessionEvents.dispatchEvent(new Event('expired'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
visitedLogin.length = 0;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('RequireSession', () => {
|
describe('RequireSession', () => {
|
||||||
it('sends a deep link without a session to the login page, which comes back to it', () => {
|
it('sends a deep link without a session to the login page, which comes back to it', async () => {
|
||||||
renderAt('/library?status=PLAYING');
|
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', () => {
|
it('shows the page with a session', () => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
markSession();
|
||||||
renderAt('/library');
|
renderAt('/library');
|
||||||
|
|
||||||
expect(screen.getByText('library')).toBeTruthy();
|
expect(screen.getByText('library')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ends the session once when requests fail with a 401', () => {
|
it('says the session expired and comes back after a 401', async () => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
markSession();
|
||||||
renderAt('/library?status=PLAYING');
|
const router = renderAt('/library?status=PLAYING');
|
||||||
|
|
||||||
expire();
|
act(() => {
|
||||||
expire();
|
expireSession();
|
||||||
|
expireSession();
|
||||||
expect(loginState()).toEqual({sessionExpired: true, from: '/library?status=PLAYING'});
|
|
||||||
expect(new Set(visitedLogin).size).toBe(1);
|
|
||||||
expect(localStorage.getItem('tbp_session')).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves a public page alone on a 401', () => {
|
expect(await screen.findByText('login')).toBeTruthy();
|
||||||
localStorage.setItem('tbp_session', '1');
|
expect(at(router)).toEqual({path: '/login', state: {sessionExpired: true, from: '/library?status=PLAYING'}});
|
||||||
renderAt('/u/someone');
|
|
||||||
|
|
||||||
expire();
|
|
||||||
|
|
||||||
expect(screen.getByText('profile')).toBeTruthy();
|
|
||||||
expect(localStorage.getItem('tbp_session')).toBe('1');
|
|
||||||
});
|
});
|
||||||
it('ends the session when another tab already removed the session flag', () => {
|
|
||||||
localStorage.setItem('tbp_session', '1');
|
it('comes back to no page after a logout, so the next user starts on their own', async () => {
|
||||||
|
markSession();
|
||||||
|
const router = renderAt('/library');
|
||||||
|
|
||||||
|
act(() => clearSession());
|
||||||
|
|
||||||
|
expect(await screen.findByText('login')).toBeTruthy();
|
||||||
|
expect(router.state.location.state).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the login page when another tab logs out', async () => {
|
||||||
|
markSession();
|
||||||
renderAt('/library');
|
renderAt('/library');
|
||||||
|
|
||||||
localStorage.removeItem('tbp_session');
|
localStorage.removeItem('tbp_session');
|
||||||
expire();
|
act(() => window.dispatchEvent(new StorageEvent('storage', {key: 'tbp_session'})));
|
||||||
|
|
||||||
expect(screen.queryByText('library')).toBeNull();
|
expect(await screen.findByText('login')).toBeTruthy();
|
||||||
expect(loginState()).toEqual({sessionExpired: true, from: '/library'});
|
});
|
||||||
|
|
||||||
|
it('leaves a public page open when the session ends there', () => {
|
||||||
|
markSession();
|
||||||
|
renderAt('/u/someone');
|
||||||
|
|
||||||
|
act(() => expireSession());
|
||||||
|
|
||||||
|
expect(screen.getByText('profile')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
|
import {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
import {request} from '../utils/api.js';
|
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';
|
import {ME_URL, REQUEST_TIMEOUT_MS} from '../utils/settings.js';
|
||||||
|
|
||||||
const UserContext = createContext(null);
|
const CurrentUserContext = 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);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/users/me for the whole app. `user` is undefined until the first answer and null without a session.
|
* 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).
|
* `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)`
|
* `updateUser(fn)` changes it locally after a save, and skips it when the user is gone by then. It follows the
|
||||||
* drops it. A save that replaces settings still loads /me itself (see saveSettings), as it must be fresh.
|
* session. A save that replaces settings still loads /me itself (see saveSettings), as it must be fresh.
|
||||||
*/
|
*/
|
||||||
export function CurrentUserProvider({children}) {
|
export function CurrentUserProvider({children}) {
|
||||||
const [user, setUser] = useState(() => (hasSession() ? undefined : null));
|
const [user, setUser] = useState(() => (hasSession() ? undefined : null));
|
||||||
@@ -32,43 +31,29 @@ export function CurrentUserProvider({children}) {
|
|||||||
return result;
|
return result;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// A new stamp is a login, a "view as" or a settings save, also in another tab.
|
||||||
|
const {stamp} = useSession();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasSession()) reload();
|
if (stamp) {
|
||||||
// A login, logout or "view as" in another tab.
|
reload();
|
||||||
const onStorage = (event) => {
|
} else {
|
||||||
if (event.key !== null && event.key !== SESSION_KEY) return;
|
// A /me answer still on the way must not bring the user back.
|
||||||
if (hasSession()) reload();
|
latest.current += 1;
|
||||||
else setUser(null);
|
setUser(null);
|
||||||
};
|
}
|
||||||
window.addEventListener('storage', onStorage);
|
}, [stamp, reload]);
|
||||||
return () => window.removeEventListener('storage', onStorage);
|
|
||||||
}, [reload]);
|
|
||||||
|
|
||||||
const updateUser = useCallback((change) => setUser((current) => current && change(current)), []);
|
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 (
|
return <CurrentUserContext.Provider value={value}>{children}</CurrentUserContext.Provider>;
|
||||||
<ActionsContext.Provider value={actions}>
|
|
||||||
<UserContext.Provider value={user}>{children}</UserContext.Provider>
|
|
||||||
</ActionsContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `{user, reload, setUser, updateUser}`; outside a provider there is no user.
|
* `{user, reload, updateUser}`.
|
||||||
*/
|
*/
|
||||||
export function useCurrentUserContext() {
|
export function useCurrentUserContext() {
|
||||||
const user = useContext(UserContext);
|
const value = useContext(CurrentUserContext);
|
||||||
const actions = useContext(ActionsContext);
|
if (!value) throw new Error('useCurrentUserContext must be used inside <CurrentUserProvider>');
|
||||||
return {user: actions ? user : null, ...(actions ?? NO_ACTIONS)};
|
return value;
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import {afterEach, describe, expect, it, vi} from 'vitest';
|
|||||||
import {act, cleanup, render, screen, waitFor} from '@testing-library/react';
|
import {act, cleanup, render, screen, waitFor} from '@testing-library/react';
|
||||||
import {CurrentUserProvider, useCurrentUserContext} from './CurrentUserContext.jsx';
|
import {CurrentUserProvider, useCurrentUserContext} from './CurrentUserContext.jsx';
|
||||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||||
|
import {clearSession, markSession} from '../utils/auth.js';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
const context = {current: null};
|
const context = {current: null};
|
||||||
@@ -70,10 +71,7 @@ describe('CurrentUserProvider', () => {
|
|||||||
renderTwo();
|
renderTwo();
|
||||||
expect(context.current.user).toBeNull();
|
expect(context.current.user).toBeNull();
|
||||||
|
|
||||||
localStorage.setItem('tbp_session', '1');
|
act(() => markSession());
|
||||||
act(() => {
|
|
||||||
context.current.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(context.current.user).toBeUndefined();
|
expect(context.current.user).toBeUndefined();
|
||||||
await act(async () => answers[0](json(200, {role: 'USER'})));
|
await act(async () => answers[0](json(200, {role: 'USER'})));
|
||||||
@@ -122,6 +120,19 @@ describe('CurrentUserProvider', () => {
|
|||||||
expect(context.current.user).toBeNull();
|
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 () => {
|
it('takes only the answer of the last reload', async () => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
const answers = [];
|
const answers = [];
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
saveAppearance,
|
saveAppearance,
|
||||||
} from '../utils/appearance.js';
|
} from '../utils/appearance.js';
|
||||||
import {hasSession} from '../utils/auth.js';
|
import {hasSession} from '../utils/auth.js';
|
||||||
|
import useSession from '../hooks/useSession.js';
|
||||||
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
||||||
|
|
||||||
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
|
||||||
@@ -124,6 +125,12 @@ export function PreferencesProvider({children}) {
|
|||||||
setAppearanceState(DEFAULT_APPEARANCE);
|
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.
|
* Applies the language at once and saves it to Auth while logged in.
|
||||||
*
|
*
|
||||||
@@ -161,12 +168,3 @@ export function usePreferences() {
|
|||||||
}
|
}
|
||||||
return ctx;
|
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 = () => {};
|
|
||||||
@@ -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.
|
// RequireSession ends the session on a 401, as on the pages that use the hook.
|
||||||
const wrapper = ({children}) => (
|
const wrapper = ({children}) => (
|
||||||
<MemoryRouter><PreferencesProvider>
|
<MemoryRouter><PreferencesProvider>
|
||||||
<Routes><Route element={<RequireSession/>}><Route path="*" element={children}/></Route></Routes>
|
<Routes>
|
||||||
|
<Route element={<RequireSession/>}><Route path="/" element={children}/></Route>
|
||||||
|
<Route path="/login" element={children}/>
|
||||||
|
</Routes>
|
||||||
</PreferencesProvider></MemoryRouter>
|
</PreferencesProvider></MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
import {useCallback} from 'react';
|
import {useCallback} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
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
|
* Returns `run(send)` for a settings save: it awaits `send()` (a fetch) and shows the error message. Resolves to
|
||||||
* session on a 401. Resolves to true when saved.
|
* true when saved.
|
||||||
*/
|
*/
|
||||||
export default function useSaveResponse() {
|
export default function useSaveResponse() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const expireSession = useSessionExpired();
|
|
||||||
|
|
||||||
return useCallback(async (send) => {
|
return useCallback(async (send) => {
|
||||||
try {
|
try {
|
||||||
const response = await send();
|
const response = await send();
|
||||||
if (response.status === 401) {
|
if (response.status === 401) return false;
|
||||||
// Also on the public profile, which has no RequireSession.
|
|
||||||
expireSession();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(t('SETTINGS.ERROR_SAVE'));
|
toast.error(t('SETTINGS.ERROR_SAVE'));
|
||||||
return false;
|
return false;
|
||||||
@@ -29,5 +23,5 @@ export default function useSaveResponse() {
|
|||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [expireSession, t]);
|
}, [t]);
|
||||||
}
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -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 <button onClick={expireSession}>expire</button>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoginState() {
|
|
||||||
return <div data-testid="state">{JSON.stringify(useLocation().state)}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('useSessionExpired', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes the token and opens the login page with the current page as return path', () => {
|
|
||||||
localStorage.setItem('tbp_jwt', 'expired');
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/library?status=PLAYING']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/library" element={<ExpireButton/>}/>
|
|
||||||
<Route path="/login" element={<LoginState/>}/>
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', {name: 'expire'}));
|
|
||||||
|
|
||||||
expect(JSON.parse(screen.getByTestId('state').textContent))
|
|
||||||
.toEqual({sessionExpired: true, from: '/library?status=PLAYING'});
|
|
||||||
expect(localStorage.getItem('tbp_jwt')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library
|
|||||||
import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
|
import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
|
||||||
import GameLibrary from './GameLibrary.jsx';
|
import GameLibrary from './GameLibrary.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
|
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
@@ -14,6 +15,7 @@ const DEBOUNCE_WAIT_MS = 700;
|
|||||||
const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1};
|
const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1};
|
||||||
|
|
||||||
const answer = (games = page.content, hasSteamId = true) => fetchRoutes({
|
const answer = (games = page.content, hasSteamId = true) => fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
'GET /api/library/games': json(200, {...page, content: games, totalElements: games.length, totalPages: games.length ? 1 : 0}),
|
'GET /api/library/games': json(200, {...page, content: games, totalElements: games.length, totalPages: games.length ? 1 : 0}),
|
||||||
'GET /api/library/games-count': json(200, {count: games.length, hasSteamId}),
|
'GET /api/library/games-count': json(200, {count: games.length, hasSteamId}),
|
||||||
});
|
});
|
||||||
@@ -34,7 +36,7 @@ function renderLibrary(initialEntry = '/library') {
|
|||||||
}
|
}
|
||||||
const router = createMemoryRouter([{
|
const router = createMemoryRouter([{
|
||||||
path: '/library',
|
path: '/library',
|
||||||
element: <PreferencesProvider><GameLibrary/><Spy/></PreferencesProvider>,
|
element: <PreferencesProvider><CurrentUserProvider><GameLibrary/><Spy/></CurrentUserProvider></PreferencesProvider>,
|
||||||
}], {initialEntries: [initialEntry]});
|
}], {initialEntries: [initialEntry]});
|
||||||
render(<RouterProvider router={router}/>);
|
render(<RouterProvider router={router}/>);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
|||||||
import GameCover from '../components/GameCover.jsx';
|
import GameCover from '../components/GameCover.jsx';
|
||||||
import Logo from '../components/Logo.jsx';
|
import Logo from '../components/Logo.jsx';
|
||||||
import StatCard from '../components/StatCard.jsx';
|
import StatCard from '../components/StatCard.jsx';
|
||||||
import {hasSession} from '../utils/auth.js';
|
import useSession from '../hooks/useSession.js';
|
||||||
|
|
||||||
import usePublicStats from '../hooks/usePublicStats.js';
|
import usePublicStats from '../hooks/usePublicStats.js';
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ function RankedGames({title, games, formatValue}) {
|
|||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const {data, isLoading} = usePublicStats();
|
const {data, isLoading} = usePublicStats();
|
||||||
|
const {active} = useSession();
|
||||||
|
|
||||||
const auth = data.auth;
|
const auth = data.auth;
|
||||||
const core = data.core;
|
const core = data.core;
|
||||||
@@ -65,7 +66,7 @@ export default function LandingPage() {
|
|||||||
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1>
|
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1>
|
||||||
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p>
|
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p>
|
||||||
<Button asChild size="lg" className="mt-10">
|
<Button asChild size="lg" className="mt-10">
|
||||||
{hasSession() ? (
|
{active ? (
|
||||||
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
|
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
|
||||||
) : (
|
) : (
|
||||||
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
|
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import AuthLayout from '../components/AuthLayout.jsx';
|
|||||||
import FeedbackAlert from '../components/FeedbackAlert.jsx';
|
import FeedbackAlert from '../components/FeedbackAlert.jsx';
|
||||||
import {hasSession, markSession, safeReturnPath} from '../utils/auth.js';
|
import {hasSession, markSession, safeReturnPath} from '../utils/auth.js';
|
||||||
import {clientFetch} from '../utils/api.js';
|
import {clientFetch} from '../utils/api.js';
|
||||||
import {useCurrentUserActions} from '../context/CurrentUserContext.jsx';
|
|
||||||
|
|
||||||
const LOGIN_ERROR_KEY = {
|
const LOGIN_ERROR_KEY = {
|
||||||
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
|
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
|
||||||
@@ -21,7 +20,6 @@ const LOGIN_ERROR_KEY = {
|
|||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {reload: reloadUser} = useCurrentUserActions();
|
|
||||||
const {state} = useLocation();
|
const {state} = useLocation();
|
||||||
const target = safeReturnPath(state?.from) ?? '/dashboard';
|
const target = safeReturnPath(state?.from) ?? '/dashboard';
|
||||||
|
|
||||||
@@ -56,7 +54,6 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
markSession();
|
markSession();
|
||||||
reloadUser();
|
|
||||||
navigate(target, {replace: true});
|
navigate(target, {replace: true});
|
||||||
} else {
|
} else {
|
||||||
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import GameCover from '../components/GameCover.jsx';
|
|||||||
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
||||||
import {GAME_STATUSES} from '../utils/limits.js';
|
import {GAME_STATUSES} from '../utils/limits.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import {hasSession} from '../utils/auth.js';
|
import useSession from '../hooks/useSession.js';
|
||||||
import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
||||||
import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js';
|
import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js';
|
||||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||||
@@ -37,7 +37,7 @@ export default function PublicProfilePage() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [reloads, setReloads] = useState(0);
|
const [reloads, setReloads] = useState(0);
|
||||||
const viewer = useCurrentUser(hasSession());
|
const viewer = useCurrentUser(useSession().active);
|
||||||
const runSave = useSaveResponse();
|
const runSave = useSaveResponse();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -154,7 +154,7 @@ export default function PublicProfilePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile} editing={owner && editing}
|
||||||
defaultLayout={DEFAULT_PROFILE} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
defaultLayout={DEFAULT_PROFILE} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+6
-8
@@ -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).
|
// Auth and Core refuse a request that changes data without this header (CSRF guard, see ClientHeaderFilter).
|
||||||
export const CLIENT_HEADER = 'X-TBP-Client';
|
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();
|
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
|
* 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 = {}) {
|
export async function apiFetch(url, init = {}) {
|
||||||
|
const stamp = sessionStamp();
|
||||||
let response = await clientFetch(url, init);
|
let response = await clientFetch(url, init);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
if (await refreshSession()) response = await clientFetch(url, init);
|
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;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-16
@@ -1,6 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
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 response = (status) => ({ok: status >= 200 && status < 300, status, json: async () => ({})});
|
||||||
const isRefresh = (url) => url.endsWith('/api/auth/refresh');
|
const isRefresh = (url) => url.endsWith('/api/auth/refresh');
|
||||||
@@ -59,7 +60,7 @@ describe('apiFetch', () => {
|
|||||||
expect(refreshCalls()).toHaveLength(1);
|
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));
|
globalThis.fetch = vi.fn(async () => response(401));
|
||||||
|
|
||||||
const result = await apiFetch('/api/x');
|
const result = await apiFetch('/api/x');
|
||||||
@@ -67,7 +68,7 @@ describe('apiFetch', () => {
|
|||||||
expect(result.status).toBe(401);
|
expect(result.status).toBe(401);
|
||||||
expect(refreshCalls()).toHaveLength(1);
|
expect(refreshCalls()).toHaveLength(1);
|
||||||
expect(dataCalls()).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 () => {
|
it('keeps the session and rejects when the refresh has no network', async () => {
|
||||||
@@ -126,36 +127,56 @@ describe('apiFetch', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('the expired event', () => {
|
describe('the session end on a 401', () => {
|
||||||
let expired;
|
let changes;
|
||||||
const count = () => expired.mock.calls.length;
|
let unsubscribe;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
expired = vi.fn();
|
markSession();
|
||||||
sessionEvents.addEventListener('expired', expired);
|
changes = vi.fn();
|
||||||
|
unsubscribe = subscribeSession(changes);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
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));
|
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));
|
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 204 : 401));
|
||||||
|
|
||||||
await apiFetch('/api/x');
|
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) => {
|
globalThis.fetch = vi.fn(async (url) => {
|
||||||
if (isRefresh(url)) throw new TypeError('Failed to fetch');
|
if (isRefresh(url)) throw new TypeError('Failed to fetch');
|
||||||
return response(401);
|
return response(401);
|
||||||
@@ -164,7 +185,8 @@ describe('the expired event', () => {
|
|||||||
await expect(apiFetch('/api/x')).rejects.toThrow();
|
await expect(apiFetch('/api/x')).rejects.toThrow();
|
||||||
await clientFetch('/api/x');
|
await clientFetch('/api/x');
|
||||||
|
|
||||||
expect(count()).toBe(0);
|
expect(localStorage.getItem('tbp_session')).not.toBeNull();
|
||||||
|
expect(changes).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+43
-1
@@ -11,16 +11,58 @@ export const hasSession = () => localStorage.getItem(SESSION_KEY) !== null;
|
|||||||
*/
|
*/
|
||||||
export const sessionStamp = () => localStorage.getItem(SESSION_KEY);
|
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 = () => {
|
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);
|
localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||||
|
change(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearSession = () => {
|
export const clearSession = () => {
|
||||||
localStorage.removeItem(SESSION_KEY);
|
localStorage.removeItem(SESSION_KEY);
|
||||||
localStorage.removeItem(LEGACY_TOKEN_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
|
* 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")
|
* session expired; "//host", "/\host" and tabs or line breaks (browsers drop them, so "/\t/host" is "//host")
|
||||||
|
|||||||
Reference in new issue
Block a user