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
@@ -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.
|
||||
|
||||
+5
-15
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<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;
|
||||
return <CurrentUserContext.Provider value={value}>{children}</CurrentUserContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `{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 <CurrentUserProvider>');
|
||||
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 {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 = [];
|
||||
|
||||
@@ -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 = () => {};
|
||||
@@ -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}) => (
|
||||
<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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 GameLibrary from './GameLibrary.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.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 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-count': json(200, {count: games.length, hasSteamId}),
|
||||
});
|
||||
@@ -34,7 +36,7 @@ function renderLibrary(initialEntry = '/library') {
|
||||
}
|
||||
const router = createMemoryRouter([{
|
||||
path: '/library',
|
||||
element: <PreferencesProvider><GameLibrary/><Spy/></PreferencesProvider>,
|
||||
element: <PreferencesProvider><CurrentUserProvider><GameLibrary/><Spy/></CurrentUserProvider></PreferencesProvider>,
|
||||
}], {initialEntries: [initialEntry]});
|
||||
render(<RouterProvider router={router}/>);
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import Logo from '../components/Logo.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';
|
||||
|
||||
@@ -39,6 +39,7 @@ function RankedGames({title, games, formatValue}) {
|
||||
export default function LandingPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {data, isLoading} = usePublicStats();
|
||||
const {active} = useSession();
|
||||
|
||||
const auth = data.auth;
|
||||
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>
|
||||
<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">
|
||||
{hasSession() ? (
|
||||
{active ? (
|
||||
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<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 {hasSession, markSession, safeReturnPath} from '../utils/auth.js';
|
||||
import {clientFetch} from '../utils/api.js';
|
||||
import {useCurrentUserActions} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
const LOGIN_ERROR_KEY = {
|
||||
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
|
||||
@@ -21,7 +20,6 @@ const LOGIN_ERROR_KEY = {
|
||||
export default function LoginPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {reload: reloadUser} = useCurrentUserActions();
|
||||
const {state} = useLocation();
|
||||
const target = safeReturnPath(state?.from) ?? '/dashboard';
|
||||
|
||||
@@ -56,7 +54,6 @@ export default function LoginPage() {
|
||||
|
||||
if (response.ok) {
|
||||
markSession();
|
||||
reloadUser();
|
||||
navigate(target, {replace: true});
|
||||
} else {
|
||||
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 {GAME_STATUSES} from '../utils/limits.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 {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
@@ -37,7 +37,7 @@ export default function PublicProfilePage() {
|
||||
const [error, setError] = useState(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [reloads, setReloads] = useState(0);
|
||||
const viewer = useCurrentUser(hasSession());
|
||||
const viewer = useCurrentUser(useSession().active);
|
||||
const runSave = useSaveResponse();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -154,7 +154,7 @@ export default function PublicProfilePage() {
|
||||
</Button>
|
||||
)}
|
||||
</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)}/>
|
||||
</PageContainer>
|
||||
</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).
|
||||
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;
|
||||
}
|
||||
|
||||
+38
-16
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+43
-1
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user