Public Access
refactor(frontend): lint, one session guard, one request helper, one user context
This commit is contained in:
1 parent
111ab1e200
commit
d0ea9e900d
78 files changed
+2138
-624
No files matched your search
@@ -99,7 +99,8 @@ To Be Played is split into three repositories:
|
||||
- Vitest + React Testing Library + jsdom
|
||||
- Production image: nginx (Alpine), SPA fallback, long-term caching for hashed assets, Content Security Policy
|
||||
and security headers
|
||||
- CI: Forgejo Actions (`npm ci`, `npm test`, `npm run build`, Docker image)
|
||||
- ESLint with the React hooks rules (`npm run lint`)
|
||||
- CI: Forgejo Actions (`npm ci`, `npm run lint`, `npm test`, `npm run build`, Docker image)
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -127,8 +128,14 @@ 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`, the page calls `useSessionExpired`, which opens the login page. Public pages (landing page, public
|
||||
profile) and the public `GET /api/version` in the settings use plain `fetch`.
|
||||
`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`.
|
||||
- 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.
|
||||
- `CurrentUserProvider` holds `GET /api/users/me` for the whole app (`useCurrentUserContext`, `useCurrentUser`).
|
||||
Dashboard and settings load it again when they open.
|
||||
- `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.
|
||||
@@ -191,6 +198,7 @@ browser bundle, so they must never hold a secret.
|
||||
## Tests and build
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
|
||||
export default [
|
||||
{ignores: ['dist']},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
parserOptions: {ecmaFeatures: {jsx: true}},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
// set by `define` in vite.config.js
|
||||
__TRANSLATIONS_VERSION__: 'readonly',
|
||||
__BUILD_COMMIT__: 'readonly',
|
||||
__BUILD_TIME__: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {'react-hooks': reactHooks},
|
||||
// Only the two classic rules: the React Compiler rules of the plugin's recommended set are a later step.
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
},
|
||||
},
|
||||
{files: ['*.config.js'], languageOptions: {globals: globals.node}},
|
||||
];
|
||||
Generated
+1312
File diff suppressed because it is too large.
Load diff
+6
-1
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
@@ -29,10 +30,14 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"autoprefixer": "~10.6.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.12.0",
|
||||
"jsdom": "^30.1.1",
|
||||
"postcss": "~8.5.28",
|
||||
"tailwindcss": "~4.3.3",
|
||||
|
||||
+16
-10
@@ -18,10 +18,12 @@ import StatsPage from './pages/StatsPage.jsx';
|
||||
import ImportPage from './pages/ImportPage.jsx';
|
||||
import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
||||
import AppHeader from './components/AppHeader.jsx';
|
||||
import RequireSession from './components/RequireSession.jsx';
|
||||
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 {logout} from './utils/api.js';
|
||||
|
||||
@@ -29,6 +31,7 @@ function App() {
|
||||
const {t} = useTranslation();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const {appearance, syncPreferences, resetAppearance} = usePreferences();
|
||||
const {setUser} = useCurrentUserActions();
|
||||
|
||||
// Hooks for routing
|
||||
const navigate = useNavigate();
|
||||
@@ -50,6 +53,7 @@ function App() {
|
||||
await logout();
|
||||
clearSession();
|
||||
resetAppearance();
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
navigate('/login');
|
||||
};
|
||||
@@ -67,17 +71,19 @@ function App() {
|
||||
<Route path="/" element={<LandingPage/>}/>
|
||||
<Route path="/login" element={<LoginPage/>}/>
|
||||
<Route path="/register" element={<RegistrationPage/>}/>
|
||||
<Route path="/dashboard" element={<Dashboard/>}/>
|
||||
<Route path="/library" element={<GameLibrary/>}/>
|
||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||
<Route path="/play-next" element={<PlayNextPage/>}/>
|
||||
<Route path="/stats" element={<StatsPage/>}/>
|
||||
<Route path="/import" element={<ImportPage/>}/>
|
||||
<Route element={<RequireSession/>}>
|
||||
<Route path="/dashboard" element={<Dashboard/>}/>
|
||||
<Route path="/library" element={<GameLibrary/>}/>
|
||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||
<Route path="/play-next" element={<PlayNextPage/>}/>
|
||||
<Route path="/stats" element={<StatsPage/>}/>
|
||||
<Route path="/import" element={<ImportPage/>}/>
|
||||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
<Route path="/admin/users" element={<AdminUsersPage/>}/>
|
||||
<Route path="/admin/hints" element={<AdminHintsPage/>}/>
|
||||
</Route>
|
||||
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
||||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
<Route path="/admin/users" element={<AdminUsersPage/>}/>
|
||||
<Route path="/admin/hints" element={<AdminHintsPage/>}/>
|
||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -11,11 +11,11 @@ import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import GameSearch from './GameSearch.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useStartImport from '../hooks/useStartImport.js';
|
||||
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
import {addGame, hoursToMinutes} from '../utils/manualGames.js';
|
||||
import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js';
|
||||
import {preselectPlatform} from '../utils/platforms.js';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
/**
|
||||
* Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch; or check several and review
|
||||
@@ -24,16 +24,12 @@ import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js';
|
||||
export default function AddGamePanel({onClose}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [query, setQuery] = useState('');
|
||||
const [picked, setPicked] = useState(null);
|
||||
// igdbId -> hit, over several searches.
|
||||
const [selected, setSelected] = useState(() => new Map());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [favorite, setFavorite] = useState(null);
|
||||
useEffect(() => {
|
||||
loadFavoritePlatform().then(setFavorite);
|
||||
}, []);
|
||||
const favorite = useCurrentUserContext().user?.settings?.favoritePlatform ?? null;
|
||||
const startImport = useStartImport();
|
||||
|
||||
const toggle = (hit) => {
|
||||
@@ -64,8 +60,7 @@ export default function AddGamePanel({onClose}) {
|
||||
</div>
|
||||
|
||||
{picked ? (
|
||||
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}
|
||||
expireSession={expireSession}/>
|
||||
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}/>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p>
|
||||
@@ -85,7 +80,7 @@ export default function AddGamePanel({onClose}) {
|
||||
);
|
||||
}
|
||||
|
||||
function AddForm({hit, favorite, onBack, navigate, expireSession}) {
|
||||
function AddForm({hit, favorite, onBack, navigate}) {
|
||||
const {t} = useTranslation();
|
||||
const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite));
|
||||
// The favorite can arrive after the pick; it fills only a platform the user has not picked yet.
|
||||
@@ -103,10 +98,7 @@ function AddForm({hit, favorite, onBack, navigate, expireSession}) {
|
||||
const response = await addGame({
|
||||
igdbId: hit.igdbId, platform: platform.trim(), status, playtimeMinutes: hoursToMinutes(hours),
|
||||
});
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 409) {
|
||||
toast.error(t('MANUAL_GAME.ALREADY_IN_LIBRARY'));
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,8 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import AddGamePanel from './AddGamePanel.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
import {json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
@@ -14,7 +16,6 @@ const zelda = {
|
||||
};
|
||||
const portal = {...zelda, appid: 400, igdbId: 71, name: 'Portal', inLibrary: true};
|
||||
|
||||
const json = (status, body) => ({ok: status < 400, status, json: async () => body});
|
||||
|
||||
function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch') {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
@@ -22,13 +23,13 @@ function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Ninte
|
||||
return init?.method === 'POST' ? post : json(200, {available: true, results: [zelda, portal]});
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/library']}>
|
||||
<MemoryRouter initialEntries={['/library']}><CurrentUserProvider>
|
||||
<Routes>
|
||||
<Route path="/library" element={<AddGamePanel onClose={() => {}}/>}/>
|
||||
<Route path="/library/:appid" element={<p>detail page</p>}/>
|
||||
<Route path="/import" element={<p>import page</p>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
</CurrentUserProvider></MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
@@ -47,6 +48,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';
|
||||
@@ -56,8 +58,9 @@ 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 header and pages load again.
|
||||
// New cookies with the other role; the new stamp makes the Steam data load again.
|
||||
markSession();
|
||||
reloadUser();
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {toast} from 'sonner';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import AppHeader from './AppHeader.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
@@ -23,7 +24,7 @@ const loggedInAs = (me) => {
|
||||
}));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PreferencesProvider><AppHeader isAuthenticated onLogout={() => {}}/></PreferencesProvider>
|
||||
<PreferencesProvider><CurrentUserProvider><AppHeader isAuthenticated onLogout={() => {}}/></CurrentUserProvider></PreferencesProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
@@ -48,7 +49,7 @@ describe('AppHeader', () => {
|
||||
it('keeps a name on the home link when a narrow phone hides the app name', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PreferencesProvider><AppHeader isAuthenticated={false} onLogout={() => {}}/></PreferencesProvider>
|
||||
<PreferencesProvider><CurrentUserProvider><AppHeader isAuthenticated={false} onLogout={() => {}}/></CurrentUserProvider></PreferencesProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
screen.getByText('ToBePlayed').style.display = 'none';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {ListTodo, Loader2, Star} from 'lucide-react';
|
||||
@@ -13,8 +12,6 @@ import {cn} from '@/lib/utils';
|
||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||
import {NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
|
||||
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const toForm = (entry) => ({
|
||||
status: entry.status ?? 'BACKLOG',
|
||||
@@ -25,8 +22,6 @@ const toForm = (entry) => ({
|
||||
|
||||
export default function BacklogCard({game, onSaved}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [saved, setSaved] = useState(() => toForm(game));
|
||||
const [form, setForm] = useState(saved);
|
||||
|
||||
@@ -41,17 +36,10 @@ export default function BacklogCard({game, onSaved}) {
|
||||
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
|
||||
|
||||
const save = async () => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await saveBacklogEntry(game.appid, toBacklogRequest(form));
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,6 @@ import {Loader2, Trophy} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {saveGameStatus} from '../utils/backlog.js';
|
||||
import {allAchievementsUnlocked} from '../utils/confetti.js';
|
||||
|
||||
@@ -16,7 +15,6 @@ import {allAchievementsUnlocked} from '../utils/confetti.js';
|
||||
*/
|
||||
export default function CompletedHint({game, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null;
|
||||
@@ -25,10 +23,7 @@ export default function CompletedHint({game, onChanged}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await saveGameStatus(game.appid, 'COMPLETED');
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -49,6 +49,9 @@ function pieces(size, width, height) {
|
||||
export default function Confetti({size, onDone}) {
|
||||
const canvasRef = useRef(null);
|
||||
const [reduce] = useState(reducedMotion);
|
||||
// In a ref: a new function from a re-render must not restart the animation, and the latest one runs at the end.
|
||||
const onDoneRef = useRef(onDone);
|
||||
onDoneRef.current = onDone;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -67,7 +70,7 @@ export default function Confetti({size, onDone}) {
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (elapsed >= DURATION_MS) {
|
||||
onDone?.();
|
||||
onDoneRef.current?.();
|
||||
return;
|
||||
}
|
||||
context.globalAlpha = 1 - elapsed / DURATION_MS;
|
||||
@@ -88,7 +91,6 @@ export default function Confetti({size, onDone}) {
|
||||
};
|
||||
frame = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
// Not on onDone: a new function from a re-render must not restart the animation.
|
||||
}, [size]);
|
||||
|
||||
if (reduce) return null;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {useResetAppearance} from '../context/PreferencesContext.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {useCurrentUserActions} from '../context/CurrentUserContext.jsx';
|
||||
import {deleteAccount} from '../utils/account.js';
|
||||
import {clearSession} from '../utils/auth.js';
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function DeleteAccountCard({email}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const resetAppearance = useResetAppearance();
|
||||
const expireSession = useSessionExpired();
|
||||
const {setUser} = useCurrentUserActions();
|
||||
const [typedEmail, setTypedEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -45,18 +45,17 @@ export default function DeleteAccountCard({email}) {
|
||||
if (response.status === 204) {
|
||||
clearSession();
|
||||
resetAppearance();
|
||||
setUser(null);
|
||||
toast.success(t('SETTINGS.DELETE_ACCOUNT.DONE'));
|
||||
navigate('/', {replace: true});
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
} else if (response.status === 403) {
|
||||
if (response.status === 403) {
|
||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD'));
|
||||
} else if (response.status === 429) {
|
||||
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes}));
|
||||
} else {
|
||||
} else if (response.status !== 401) {
|
||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.ERROR'));
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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 RequireSession from './RequireSession.jsx';
|
||||
import {toast} from 'sonner';
|
||||
import DeleteAccountCard from './DeleteAccountCard.jsx';
|
||||
|
||||
@@ -15,7 +16,7 @@ const answer = (status, headers = {}) => ({ok: status < 300, status, headers: ne
|
||||
const renderCard = () => render(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<Routes>
|
||||
<Route path="/settings" element={<DeleteAccountCard email={EMAIL}/>}/>
|
||||
<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>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {Flag, Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {dismissFinishedHint, saveGameStatus} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
|
||||
@@ -17,7 +16,6 @@ import {formatPlaytime} from '../utils/games.js';
|
||||
*/
|
||||
export default function FinishedHint({game, onChanged, onDismissed}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const hint = game.finishedHint;
|
||||
@@ -28,10 +26,7 @@ export default function FinishedHint({game, onChanged, onDismissed}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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 RequireSession from './RequireSession.jsx';
|
||||
import FinishedHint from './FinishedHint.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
@@ -12,7 +13,9 @@ const ending = {appid: 322110, status: 'PLAYING', playtimeMinutes: 600, timeToBe
|
||||
const renderHint = (props) => render(
|
||||
<MemoryRouter initialEntries={['/library/322110']}>
|
||||
<Routes>
|
||||
<Route path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
|
||||
<Route element={<RequireSession/>}>
|
||||
<Route path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
|
||||
</Route>
|
||||
<Route path="/login" element={<p>login page</p>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {Button} from '@/components/ui/button';
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
|
||||
import {genreKey} from '../utils/stats.js';
|
||||
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js';
|
||||
@@ -35,7 +34,6 @@ const toggled = (list, item) => (list.includes(item) ? list.filter((each) => eac
|
||||
*/
|
||||
export default function GameCorrectionDialog({game, open, onOpenChange, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [values, setValues] = useState(() => initialValues(game));
|
||||
const [busy, setBusy] = useState(null);
|
||||
// The field whose save or reset the next game from GameDetail answers; the other fields keep their edits.
|
||||
@@ -44,6 +42,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
||||
useEffect(() => {
|
||||
if (open) setValues(initialValues(game));
|
||||
// Only when the dialog opens; a reload after a save is handled below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,10 +60,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
||||
setBusy(field);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t('CORRECTION.SAVED'));
|
||||
sentField.current = field;
|
||||
|
||||
@@ -6,7 +6,6 @@ import {toast} from 'sonner';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
||||
import GameSearch from './GameSearch.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {mergeGame} from '../utils/corrections.js';
|
||||
import {isSteamKey} from '../utils/manualGames.js';
|
||||
|
||||
@@ -18,7 +17,6 @@ const KNOWN_ERRORS = new Set(['SAME_GAME', 'STEAM_GAMES', 'GAME_NOT_FOUND']);
|
||||
*/
|
||||
export default function GameMergeDialog({game, open, onOpenChange, onMerged}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [query, setQuery] = useState('');
|
||||
const [picked, setPicked] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -34,10 +32,7 @@ export default function GameMergeDialog({game, open, onOpenChange, onMerged}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await mergeGame(game.appid, picked.appid);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.ok) {
|
||||
toast.success(t('MERGE.DONE'));
|
||||
|
||||
@@ -5,7 +5,6 @@ import {Loader2, Search} from 'lucide-react';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {searchGames} from '../utils/manualGames.js';
|
||||
import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js';
|
||||
|
||||
@@ -81,7 +80,6 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
|
||||
|
||||
// Asks Core 400 ms after the last key press; searchState is idle, loading, done, unavailable or failed.
|
||||
function useGameSearch(query) {
|
||||
const expireSession = useSessionExpired();
|
||||
const [searchState, setSearchState] = useState('idle');
|
||||
const [results, setResults] = useState([]);
|
||||
|
||||
@@ -97,10 +95,7 @@ function useGameSearch(query) {
|
||||
setSearchState('loading');
|
||||
try {
|
||||
const response = await searchGames(trimmed, controller.signal);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
setSearchState('failed');
|
||||
return;
|
||||
@@ -116,7 +111,7 @@ function useGameSearch(query) {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [query, expireSession]);
|
||||
}, [query]);
|
||||
|
||||
return {searchState, results};
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js';
|
||||
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
|
||||
@@ -20,7 +19,6 @@ import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
export default function ManualGameCard({game, onSaved}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [platform, setPlatform] = useState(game.platform ?? '');
|
||||
const [hours, setHours] = useState(toHours(game.playtimeMinutes));
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -33,10 +31,7 @@ export default function ManualGameCard({game, onSaved}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,6 @@ import {Gamepad2, Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {saveBacklogEntry} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
|
||||
@@ -16,7 +15,6 @@ import {formatPlaytime} from '../utils/games.js';
|
||||
*/
|
||||
export default function PlayingHint({game, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (game.status !== 'BACKLOG' || !(game.playtime2Weeks > 0)) return null;
|
||||
@@ -27,10 +25,7 @@ export default function PlayingHint({game, onChanged}) {
|
||||
const response = await saveBacklogEntry(game.appid, {
|
||||
status: 'PLAYING', rating: game.rating ?? null, notes: game.notes ?? null, priority: game.priority ?? null,
|
||||
});
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
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);
|
||||
|
||||
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}}/>;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// @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 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>;
|
||||
}
|
||||
|
||||
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 loginState = () => JSON.parse(screen.getByTestId('state').textContent);
|
||||
const expire = () => act(() => {
|
||||
sessionEvents.dispatchEvent(new Event('expired'));
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
expect(loginState()).toEqual({from: '/library?status=PLAYING'});
|
||||
});
|
||||
|
||||
it('shows the page with a session', () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
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');
|
||||
|
||||
expire();
|
||||
expire();
|
||||
|
||||
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', () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
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');
|
||||
renderAt('/library');
|
||||
|
||||
localStorage.removeItem('tbp_session');
|
||||
expire();
|
||||
|
||||
expect(screen.queryByText('library')).toBeNull();
|
||||
expect(loginState()).toEqual({sessionExpired: true, from: '/library'});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {toast} from 'sonner';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {markSession} from '../utils/auth.js';
|
||||
import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
|
||||
|
||||
@@ -16,7 +15,6 @@ import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
|
||||
*/
|
||||
export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const link = async () => {
|
||||
@@ -24,11 +22,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
||||
try {
|
||||
const response = await startSteamLink();
|
||||
if (response.ok) return; // on the way to Steam: the button stays busy
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
} else {
|
||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||
}
|
||||
if (response.status !== 401) toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||
} catch (err) {
|
||||
console.error('Steam link error:', err);
|
||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||
@@ -40,10 +34,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await unlinkSteam();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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 {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);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function CurrentUserProvider({children}) {
|
||||
const [user, setUser] = useState(() => (hasSession() ? undefined : null));
|
||||
const latest = useRef(0);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const call = ++latest.current;
|
||||
// After a logout, "no user" becomes "loading" (e.g. the import waits for the favorite platform).
|
||||
setUser((current) => current ?? undefined);
|
||||
const response = await request(ME_URL(), {signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)});
|
||||
const result = {...response, ok: response.ok && response.data != null};
|
||||
if (call === latest.current) {
|
||||
// Only a 401 means "no user"; another failure keeps the user shown so far, or ends "loading".
|
||||
if (result.ok) setUser(result.data);
|
||||
else if (result.status === 401) setUser(null);
|
||||
else setUser((current) => current ?? null);
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
const updateUser = useCallback((change) => setUser((current) => current && change(current)), []);
|
||||
const actions = useMemo(() => ({reload, setUser, updateUser}), [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;
|
||||
}
|
||||
|
||||
/**
|
||||
* `{user, reload, setUser, updateUser}`; outside a provider there is no user.
|
||||
*/
|
||||
export function useCurrentUserContext() {
|
||||
const user = useContext(UserContext);
|
||||
const actions = useContext(ActionsContext);
|
||||
return {user: actions ? user : null, ...(actions ?? NO_ACTIONS)};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// @vitest-environment jsdom
|
||||
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 {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
const context = {current: null};
|
||||
|
||||
function Role({name}) {
|
||||
const user = useCurrentUser(true);
|
||||
return <p>{name}: {user?.activeRole ?? 'none'}</p>;
|
||||
}
|
||||
|
||||
function Spy() {
|
||||
context.current = useCurrentUserContext();
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderTwo = () => render(
|
||||
<CurrentUserProvider><Role name="header"/><Role name="page"/><Spy/></CurrentUserProvider>,
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('CurrentUserProvider', () => {
|
||||
it('loads /me once for every place that shows the user', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
const fetch = fetchRoutes({'GET /api/users/me': json(200, {role: 'PLATFORM_ADMIN'})});
|
||||
renderTwo();
|
||||
|
||||
expect(await screen.findByText('header: PLATFORM_ADMIN')).toBeTruthy();
|
||||
expect(screen.getByText('page: PLATFORM_ADMIN')).toBeTruthy();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('asks nothing without a session', () => {
|
||||
const fetch = fetchRoutes({});
|
||||
renderTwo();
|
||||
|
||||
expect(screen.getByText('header: none')).toBeTruthy();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the new role after a reload, and keeps it on a network error', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
let answer = json(200, {role: 'PLATFORM_ADMIN'});
|
||||
fetchRoutes({'GET /api/users/me': () => answer});
|
||||
renderTwo();
|
||||
await screen.findByText('header: PLATFORM_ADMIN');
|
||||
|
||||
answer = json(200, {role: 'PLATFORM_ADMIN', activeRole: 'USER'});
|
||||
await act(() => context.current.reload());
|
||||
expect(screen.getByText('header: USER')).toBeTruthy();
|
||||
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
answer = Promise.reject(new TypeError('Failed to fetch'));
|
||||
answer.catch(() => undefined);
|
||||
await act(() => context.current.reload());
|
||||
expect(screen.getByText('header: USER')).toBeTruthy();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('is loading again while the reload after a new login runs', async () => {
|
||||
const answers = [];
|
||||
fetchRoutes({'GET /api/users/me': () => new Promise((resolve) => answers.push(resolve))});
|
||||
renderTwo();
|
||||
expect(context.current.user).toBeNull();
|
||||
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
act(() => {
|
||||
context.current.reload();
|
||||
});
|
||||
|
||||
expect(context.current.user).toBeUndefined();
|
||||
await act(async () => answers[0](json(200, {role: 'USER'})));
|
||||
expect(screen.getByText('header: USER')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('loads again after a login or "view as" in another tab, and drops the user after a logout there', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
let answer = json(200, {role: 'PLATFORM_ADMIN'});
|
||||
fetchRoutes({'GET /api/users/me': () => answer});
|
||||
renderTwo();
|
||||
await screen.findByText('header: PLATFORM_ADMIN');
|
||||
|
||||
answer = json(200, {role: 'USER'});
|
||||
localStorage.setItem('tbp_session', '2');
|
||||
await act(async () => window.dispatchEvent(new StorageEvent('storage', {key: 'tbp_session'})));
|
||||
expect(screen.getByText('header: USER')).toBeTruthy();
|
||||
|
||||
localStorage.removeItem('tbp_session');
|
||||
act(() => window.dispatchEvent(new StorageEvent('storage', {key: 'tbp_session'})));
|
||||
expect(screen.getByText('header: none')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('takes a 200 without a body as a failed load', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
fetchRoutes({'GET /api/users/me': {ok: true, status: 200, json: async () => { throw new SyntaxError('empty'); }}});
|
||||
renderTwo();
|
||||
|
||||
let result;
|
||||
await act(async () => {
|
||||
result = await context.current.reload();
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(context.current.user).toBeNull();
|
||||
});
|
||||
|
||||
it('changes nothing when the user is gone before a save answers', async () => {
|
||||
fetchRoutes({});
|
||||
renderTwo();
|
||||
const change = vi.fn((user) => ({...user, displayName: 'x'}));
|
||||
|
||||
act(() => context.current.updateUser(change));
|
||||
|
||||
expect(change).not.toHaveBeenCalled();
|
||||
expect(context.current.user).toBeNull();
|
||||
});
|
||||
|
||||
it('takes only the answer of the last reload', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
const answers = [];
|
||||
fetchRoutes({'GET /api/users/me': () => new Promise((resolve) => answers.push(resolve))});
|
||||
renderTwo();
|
||||
await waitFor(() => expect(answers).toHaveLength(1));
|
||||
|
||||
act(() => {
|
||||
context.current.reload();
|
||||
});
|
||||
await act(async () => answers[1](json(200, {role: 'USER'})));
|
||||
expect(screen.getByText('header: USER')).toBeTruthy();
|
||||
// The first load answers last.
|
||||
await act(async () => answers[0](json(200, {role: 'PLATFORM_ADMIN'})));
|
||||
|
||||
expect(screen.getByText('header: USER')).toBeTruthy();
|
||||
});
|
||||
it('keeps the loaded user when a reload gets a 5xx', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
let answer = json(200, {role: 'PLATFORM_ADMIN'});
|
||||
fetchRoutes({'GET /api/users/me': () => answer});
|
||||
renderTwo();
|
||||
await screen.findByText('header: PLATFORM_ADMIN');
|
||||
|
||||
answer = json(503, {});
|
||||
await act(() => context.current.reload());
|
||||
|
||||
expect(screen.getByText('header: PLATFORM_ADMIN')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+10
-29
@@ -1,37 +1,18 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {sessionStamp} from '../utils/auth.js';
|
||||
import {useMemo} from 'react';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
||||
|
||||
/**
|
||||
* The stored role, the active role ("view as"), the display name and the public profile style of the logged-in
|
||||
* user, or null while loading and on any error.
|
||||
* Loads again after a new login or a role switch (see sessionStamp) on the next render.
|
||||
* user, or null while loading, on any error and when not `enabled`.
|
||||
*/
|
||||
export default function useCurrentUser(enabled) {
|
||||
const [user, setUser] = useState(null);
|
||||
const stamp = sessionStamp();
|
||||
const {user} = useCurrentUserContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !stamp) {
|
||||
setUser(null);
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`);
|
||||
const data = response.ok ? await response.json() : null;
|
||||
if (!cancelled) setUser(data && {role: data.role, activeRole: data.activeRole ?? data.role, displayName: data.displayName,
|
||||
profileStyle: normalizeProfileStyle(data.settings?.profileStyle)});
|
||||
} catch {
|
||||
if (!cancelled) setUser(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, stamp]);
|
||||
|
||||
return user;
|
||||
return useMemo(() => (enabled && user ? {
|
||||
role: user.role,
|
||||
activeRole: user.activeRole ?? user.role,
|
||||
displayName: user.displayName,
|
||||
profileStyle: normalizeProfileStyle(user.settings?.profileStyle),
|
||||
} : null), [enabled, user]);
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import {useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import useSessionExpired from './useSessionExpired.js';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const RELOAD_DELAY_MS = 4000;
|
||||
|
||||
@@ -14,24 +11,15 @@ const RELOAD_DELAY_MS = 4000;
|
||||
*/
|
||||
export default function useForceSync(onStarted) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const trigger = async () => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 429) {
|
||||
const json = await response.json().catch(() => ({}));
|
||||
toast.warning(t('LIBRARY.SYNC_COOLDOWN', {seconds: json.retryAfterSeconds ?? 60}));
|
||||
|
||||
@@ -15,16 +15,13 @@ const INITIAL_SYNC_GRACE_MS = 15_000;
|
||||
* @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the
|
||||
* gap between async sync dispatch and first DB write)
|
||||
* @param {() => void} opts.onComplete - called once per transition to OK/FAILED
|
||||
* @param {() => void} [opts.onUnauthorized] - called on a 401; polling stops
|
||||
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, error: string|null}}
|
||||
*/
|
||||
export default function useLibrarySync({enabled, expectInitialSync = false, onComplete, onUnauthorized}) {
|
||||
export default function useLibrarySync({enabled, expectInitialSync = false, onComplete}) {
|
||||
const [state, setState] = useState({status: null, lastSyncedAt: null, error: null});
|
||||
const previousRunningRef = useRef(null);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
onCompleteRef.current = onComplete;
|
||||
const onUnauthorizedRef = useRef(onUnauthorized);
|
||||
onUnauthorizedRef.current = onUnauthorized;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
@@ -42,10 +39,8 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
||||
);
|
||||
if (cancelled) return;
|
||||
|
||||
if (response.status === 401) {
|
||||
onUnauthorizedRef.current?.();
|
||||
return;
|
||||
}
|
||||
// The session is over; polling stops.
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
setState((s) => ({...s, error: 'sync-status fetch failed'}));
|
||||
return;
|
||||
|
||||
@@ -2,25 +2,19 @@ import {useCallback} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useSessionExpired from './useSessionExpired.js';
|
||||
|
||||
/**
|
||||
* Returns `save(changes)`: changeAppearance plus the error messages and the session end on a 401. Resolves to
|
||||
* true when saved.
|
||||
* Returns `save(changes)`: changeAppearance plus the error messages. Resolves to true when saved.
|
||||
*/
|
||||
export default function useSaveAppearance() {
|
||||
const {t} = useTranslation();
|
||||
const {changeAppearance} = usePreferences();
|
||||
const expireSession = useSessionExpired();
|
||||
|
||||
return useCallback(async (changes) => {
|
||||
try {
|
||||
const response = await changeAppearance(changes);
|
||||
if (!response) return false;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return false;
|
||||
}
|
||||
if (response.status === 401) return false;
|
||||
if (!response.ok) {
|
||||
toast.error(t('SETTINGS.ERROR_SAVE'));
|
||||
return false;
|
||||
@@ -31,5 +25,5 @@ export default function useSaveAppearance() {
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
return false;
|
||||
}
|
||||
}, [changeAppearance, expireSession, t]);
|
||||
}, [changeAppearance, t]);
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, renderHook} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import {PreferencesProvider, usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useSaveAppearance from './useSaveAppearance.js';
|
||||
import RequireSession from '../components/RequireSession.jsx';
|
||||
import {DEFAULT_APPEARANCE} from '../utils/appearance.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
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>{children}</PreferencesProvider></MemoryRouter>
|
||||
<MemoryRouter><PreferencesProvider>
|
||||
<Routes><Route element={<RequireSession/>}><Route path="*" element={children}/></Route></Routes>
|
||||
</PreferencesProvider></MemoryRouter>
|
||||
);
|
||||
|
||||
const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper});
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function useSaveResponse() {
|
||||
try {
|
||||
const response = await send();
|
||||
if (response.status === 401) {
|
||||
// Also on the public profile, which has no RequireSession.
|
||||
expireSession();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
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. The function is as stable
|
||||
* as `navigate`, so an effect that lists `navigate` can list it too.
|
||||
* 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;
|
||||
@@ -19,6 +20,7 @@ export default function useSessionExpired() {
|
||||
const {pathname, search} = locationRef.current;
|
||||
clearSession();
|
||||
resetAppearance();
|
||||
setUser(null);
|
||||
navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}});
|
||||
}, [navigate, resetAppearance]);
|
||||
}, [navigate, resetAppearance, setUser]);
|
||||
}
|
||||
@@ -1,23 +1,20 @@
|
||||
import {useCallback} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import useSessionExpired from './useSessionExpired.js';
|
||||
import {createImport} from '../utils/imports.js';
|
||||
|
||||
/**
|
||||
* Returns `start(source, rows)`: it creates an import in Core and resolves to its id, or shows why not (or ends the
|
||||
* session on a 401) and resolves to null.
|
||||
* Returns `start(source, rows)`: it creates an import in Core and resolves to its id, or shows why not and
|
||||
* resolves to null.
|
||||
*/
|
||||
export default function useStartImport() {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
|
||||
return useCallback(async (source, rows) => {
|
||||
try {
|
||||
const response = await createImport(source, rows);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
} else if (response.status === 409) {
|
||||
if (response.status === 401) return null;
|
||||
if (response.status === 409) {
|
||||
toast.error(t('IMPORT.RUNNING'));
|
||||
} else if (response.status === 503) {
|
||||
toast.error(t('MANUAL_GAME.UNAVAILABLE'));
|
||||
@@ -31,5 +28,5 @@ export default function useStartImport() {
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
}
|
||||
return null;
|
||||
}, [expireSession, t]);
|
||||
}, [t]);
|
||||
}
|
||||
@@ -4,10 +4,8 @@ import {statsUrl} from '../utils/stats.js';
|
||||
|
||||
/**
|
||||
* GET /api/library/stats for the given groups (none = all). stats is null while loading and after an error.
|
||||
*
|
||||
* @param onUnauthorized called on a 401, e.g. useSessionExpired
|
||||
*/
|
||||
export default function useUserStats(groups, onUnauthorized, enabled = true) {
|
||||
export default function useUserStats(groups, enabled = true) {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const key = groups.join(',');
|
||||
@@ -16,10 +14,7 @@ export default function useUserStats(groups, onUnauthorized, enabled = true) {
|
||||
setFailed(false);
|
||||
try {
|
||||
const response = await apiFetch(statsUrl(key));
|
||||
if (response.status === 401) {
|
||||
onUnauthorized();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
setFailed(true);
|
||||
return;
|
||||
@@ -29,7 +24,7 @@ export default function useUserStats(groups, onUnauthorized, enabled = true) {
|
||||
console.error('Stats load error:', err);
|
||||
setFailed(true);
|
||||
}
|
||||
}, [key, onUnauthorized]);
|
||||
}, [key]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) load();
|
||||
|
||||
+4
-1
@@ -3,6 +3,7 @@ import {createRoot} from 'react-dom/client';
|
||||
import {BrowserRouter} from 'react-router-dom';
|
||||
import App from './App.jsx';
|
||||
import {PreferencesProvider} from './context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from './context/CurrentUserContext.jsx';
|
||||
import './i18n';
|
||||
import './style.css';
|
||||
|
||||
@@ -10,7 +11,9 @@ createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<PreferencesProvider>
|
||||
<App/>
|
||||
<CurrentUserProvider>
|
||||
<App/>
|
||||
</CurrentUserProvider>
|
||||
</PreferencesProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Loader2, Send, Square} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
@@ -12,8 +11,6 @@ import {Label} from '@/components/ui/label';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import HintCard from '../components/HintCard.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
|
||||
import {MAX_HINT_LENGTH} from '../utils/limits.js';
|
||||
|
||||
@@ -35,8 +32,6 @@ const emptyForm = () => {
|
||||
*/
|
||||
export default function AdminHintsPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [hints, setHints] = useState([]);
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
@@ -45,10 +40,7 @@ export default function AdminHintsPage() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchAdminHints();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
setStatus('forbidden');
|
||||
return;
|
||||
@@ -63,24 +55,17 @@ export default function AdminHintsPage() {
|
||||
console.error('Admin hints error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [expireSession]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
load();
|
||||
}, [load, navigate]);
|
||||
}, [load]);
|
||||
|
||||
const run = async (request, doneKey, onOk) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t(doneKey));
|
||||
onOk?.();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
@@ -19,9 +18,7 @@ import {
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const REFUSAL_KEY = {
|
||||
@@ -37,8 +34,6 @@ const formatDate = (value, locale) => (value ? new Date(value).toLocaleDateStrin
|
||||
*/
|
||||
export default function AdminUsersPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [query, setQuery] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -56,10 +51,7 @@ export default function AdminUsersPage() {
|
||||
try {
|
||||
const response = await fetchUsers(search, page, PAGE_SIZE);
|
||||
if (current !== loadCount.current) return;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
setStatus('forbidden');
|
||||
return;
|
||||
@@ -76,15 +68,11 @@ export default function AdminUsersPage() {
|
||||
console.error('Admin users error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [search, page, expireSession]);
|
||||
}, [search, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
load();
|
||||
}, [load, navigate]);
|
||||
}, [load]);
|
||||
|
||||
const closeDialog = () => {
|
||||
setPending(null);
|
||||
@@ -96,10 +84,7 @@ export default function AdminUsersPage() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = kind === 'delete' ? await deleteUser(user.id) : await setUserBlocked(user.id, !user.blocked);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t(kind === 'delete' ? 'ADMIN.DONE_DELETE' : user.blocked ? 'ADMIN.DONE_UNBLOCK' : 'ADMIN.DONE_BLOCK'));
|
||||
closeDialog();
|
||||
|
||||
+33
-135
@@ -1,5 +1,5 @@
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {ArrowRight, Hash, LayoutGrid, Link2, RefreshCw, Trophy} from 'lucide-react';
|
||||
@@ -19,7 +19,6 @@ import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||
import useForceSync from '../hooks/useForceSync.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import HeroBanner from '../components/HeroBanner.jsx';
|
||||
import SteamAvatar from '../components/SteamAvatar.jsx';
|
||||
@@ -30,8 +29,8 @@ import TileGrid from '../components/TileGrid.jsx';
|
||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||
import {DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, saveTiles, statGroup} from '../utils/tiles.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import {request} from '../utils/api.js';
|
||||
|
||||
// Games per tile size; the page loads the most for the large tile.
|
||||
const TOP_GAMES = {S: 3, M: 5, L: 10};
|
||||
@@ -40,13 +39,15 @@ const PLAYING_GAMES = {S: 2, M: 4, L: 8};
|
||||
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
|
||||
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
||||
|
||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||
// A card whose request fails or has no JSON body stays empty.
|
||||
async function loadInto(path, set, pick = (data) => data) {
|
||||
const {ok, data} = await request(`${import.meta.env.VITE_CORE_API_URL}${path}`);
|
||||
if (ok && data != null) set(pick(data));
|
||||
}
|
||||
|
||||
const Dashboard = () => {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [userData, setUserData] = useState(null);
|
||||
const {user: userData, updateUser, reload: reloadUser} = useCurrentUserContext();
|
||||
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
|
||||
const [topGames, setTopGames] = useState([]);
|
||||
const [playing, setPlaying] = useState([]);
|
||||
@@ -67,109 +68,26 @@ const Dashboard = () => {
|
||||
])].sort();
|
||||
// No groups would mean "all groups" to the stats endpoint.
|
||||
const hasStatGroups = statGroups.length > 0;
|
||||
const stats = useUserStats(statGroups, expireSession, hasSession() && userData != null && hasStatGroups);
|
||||
const stats = useUserStats(statGroups, userData != null && hasStatGroups);
|
||||
const reloadStats = stats.reload;
|
||||
|
||||
const fetchSteamCount = useCallback(async () => {
|
||||
try {
|
||||
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {headers: JSON_HEADERS});
|
||||
if (r.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (r.ok) {
|
||||
setSteamData(await r.json());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching steam data:', err);
|
||||
}
|
||||
}, [expireSession]);
|
||||
|
||||
const fetchTopGames = useCallback(async () => {
|
||||
try {
|
||||
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}${TOP_GAMES_URL}`, {headers: JSON_HEADERS});
|
||||
if (r.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (r.ok) {
|
||||
const page = await r.json();
|
||||
setTopGames((page.content ?? []).slice(0, TOP_GAMES.L));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching top games:', err);
|
||||
}
|
||||
}, [expireSession]);
|
||||
|
||||
const fetchPlaying = useCallback(async () => {
|
||||
try {
|
||||
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}${PLAYING_URL}`, {headers: JSON_HEADERS});
|
||||
if (r.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (r.ok) {
|
||||
const page = await r.json();
|
||||
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES.L));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching playing games:', err);
|
||||
}
|
||||
}, [expireSession]);
|
||||
const fetchSteamCount = useCallback(() => loadInto('/api/library/games-count', setSteamData), []);
|
||||
const fetchTopGames = useCallback(() => loadInto(TOP_GAMES_URL, setTopGames, (page) => (page.content ?? []).slice(0, TOP_GAMES.L)), []);
|
||||
const fetchPlaying = useCallback(() => loadInto(PLAYING_URL, setPlaying, (page) => (page.content ?? []).slice(0, PLAYING_GAMES.L)), []);
|
||||
const fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []);
|
||||
const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []);
|
||||
|
||||
/**
|
||||
* @param excludeAppid the current suggestion, so the new pick differs from it
|
||||
* @returns false when the request failed
|
||||
* @returns false when the request failed; true on a 401, which ends the session without a "failed" message
|
||||
*/
|
||||
const fetchSummary = useCallback(async (excludeAppid) => {
|
||||
const query = excludeAppid ? `?exclude=${excludeAppid}` : '';
|
||||
try {
|
||||
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`, {headers: JSON_HEADERS});
|
||||
if (r.status === 401) {
|
||||
expireSession();
|
||||
return true;
|
||||
}
|
||||
if (r.ok) {
|
||||
setSummary(await r.json());
|
||||
setSummaryFailed(false);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching library summary:', err);
|
||||
}
|
||||
setSummaryFailed(true);
|
||||
return false;
|
||||
}, [expireSession]);
|
||||
|
||||
const fetchActivity = useCallback(async () => {
|
||||
try {
|
||||
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/activity`, {headers: JSON_HEADERS});
|
||||
if (r.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (r.ok) {
|
||||
setActivity(await r.json());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching activity:', err);
|
||||
}
|
||||
}, [expireSession]);
|
||||
|
||||
const fetchAchievements = useCallback(async () => {
|
||||
try {
|
||||
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/achievements/summary`, {headers: JSON_HEADERS});
|
||||
if (r.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (r.ok) {
|
||||
setAchievements(await r.json());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching achievements:', err);
|
||||
}
|
||||
}, [expireSession]);
|
||||
const {ok, status, data} = await request(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`);
|
||||
if (ok) setSummary(data);
|
||||
setSummaryFailed(!ok && status !== 401);
|
||||
return ok || status === 401;
|
||||
}, []);
|
||||
|
||||
const shuffleSuggestion = async () => {
|
||||
setShuffling(true);
|
||||
@@ -191,40 +109,21 @@ const Dashboard = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const profileResponse = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||
method: 'GET',
|
||||
headers: JSON_HEADERS,
|
||||
});
|
||||
|
||||
if (profileResponse.ok) {
|
||||
const data = await profileResponse.json();
|
||||
setUserData(data);
|
||||
|
||||
// Games added by hand need no Steam ID; history and achievements come only from Steam.
|
||||
await Promise.all(data.steamId
|
||||
? [fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), fetchActivity(), fetchAchievements()]
|
||||
: [fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary()]);
|
||||
} else if (profileResponse.status === 401) {
|
||||
expireSession();
|
||||
} else {
|
||||
setError('INFO.ERROR.LOAD_PROFILE');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Dashboard fetch error:', err);
|
||||
const {ok, status, data} = await reloadUser();
|
||||
if (ok) {
|
||||
// Games added by hand need no Steam ID; history and achievements come only from Steam.
|
||||
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(),
|
||||
...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]);
|
||||
} else if (status === 0) {
|
||||
setError('INFO.ERROR.NETWORK_FAILURE');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
} else if (status !== 401) {
|
||||
setError('INFO.ERROR.LOAD_PROFILE');
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, [navigate, expireSession, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
||||
}, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
||||
|
||||
const hasSteamId = !!userData?.steamId;
|
||||
const showLibrary = hasSteamId || steamData.count > 0;
|
||||
@@ -237,7 +136,6 @@ const Dashboard = () => {
|
||||
enabled: hasSteamId,
|
||||
expectInitialSync: hasSteamId && steamData.count === 0,
|
||||
onComplete: reloadLibrary,
|
||||
onUnauthorized: expireSession,
|
||||
});
|
||||
const forceSync = useForceSync(reloadLibrary);
|
||||
|
||||
@@ -259,7 +157,7 @@ const Dashboard = () => {
|
||||
// The default is saved as "never changed", so a later change of the default reaches this user too.
|
||||
const tiles = JSON.stringify(next) === JSON.stringify(DEFAULT_DASHBOARD) ? null : next;
|
||||
if (!await runSave(() => saveTiles('dashboard-tiles', tiles))) return;
|
||||
setUserData((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}}));
|
||||
updateUser((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}}));
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
||||
import RequireSession from '../components/RequireSession.jsx';
|
||||
import Dashboard from './Dashboard.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||
@@ -33,12 +35,12 @@ describe('Dashboard', () => {
|
||||
globalThis.fetch = vi.fn(async (url) =>
|
||||
url.includes('/api/users/me') ? ok({email: 'a@b.de', steamId: '76561198000000000'}) : unauthorized());
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
|
||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider><CurrentUserProvider>
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<><Dashboard/><Spy/></>}/>
|
||||
<Route element={<RequireSession/>}><Route path="/dashboard" element={<><Dashboard/><Spy/></>}/></Route>
|
||||
<Route path="/login" element={<Spy/>}/>
|
||||
</Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5));
|
||||
@@ -65,9 +67,9 @@ describe('Dashboard', () => {
|
||||
return ok({content: [], total: 0, byStatus: {}});
|
||||
});
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
|
||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider><CurrentUserProvider>
|
||||
<Dashboard/>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
|
||||
).container;
|
||||
};
|
||||
|
||||
@@ -150,4 +152,23 @@ describe('Dashboard', () => {
|
||||
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
|
||||
expect(container.querySelector('img[src*="library_hero"]')).toBeNull();
|
||||
});
|
||||
it('shows the page when a card answers 200 without a JSON body', async () => {
|
||||
// No Steam ID, so no activity and achievements are asked.
|
||||
globalThis.fetch = vi.fn(async (url) => {
|
||||
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo'});
|
||||
if (url.includes('/api/library/games-count')) return ok({count: 1, hasSteamId: true});
|
||||
if (url.includes('sort=playtimeMinutes') && !url.includes('status=')) {
|
||||
return {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }};
|
||||
}
|
||||
return ok({content: [], total: 0, byStatus: {}});
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider><CurrentUserProvider>
|
||||
<Dashboard/>
|
||||
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ import PlayingHint from '../components/PlayingHint.jsx';
|
||||
import TimeToBeat from '../components/TimeToBeat.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
@@ -32,7 +31,6 @@ import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
|
||||
import {canCorrect} from '../utils/corrections.js';
|
||||
import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
|
||||
import {setGameHidden} from '../utils/hiddenGames.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {isSteamKey} from '../utils/manualGames.js';
|
||||
import {platformLabel} from '../utils/platforms.js';
|
||||
|
||||
@@ -65,7 +63,6 @@ export default function GameDetail() {
|
||||
const {appearance} = usePreferences();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
|
||||
const [data, setData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -99,10 +96,6 @@ export default function GameDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
(async () => {
|
||||
@@ -114,10 +107,7 @@ export default function GameDetail() {
|
||||
},
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 404) {
|
||||
setError('GAME_DETAIL.NOT_FOUND');
|
||||
return;
|
||||
@@ -140,7 +130,7 @@ export default function GameDetail() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [appid, navigate, expireSession, reloads]);
|
||||
}, [appid, reloads]);
|
||||
|
||||
const sortedAchievements = useMemo(() => {
|
||||
if (!data?.achievements) return [];
|
||||
@@ -166,10 +156,7 @@ export default function GameDetail() {
|
||||
setHiding(true);
|
||||
try {
|
||||
const response = await setGameHidden(data.appid, hidden);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('GAME_DETAIL.HIDE_ERROR'));
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import GameDetail from './GameDetail.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
// Stands in for a saved correction, which loads the game again.
|
||||
@@ -31,9 +32,9 @@ afterEach(() => {
|
||||
|
||||
it('keeps unsaved notes while the game loads again after a correction', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider>
|
||||
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider><CurrentUserProvider>
|
||||
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import GameDetail from './GameDetail.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
import {resetBrokenImageReports} from '../utils/brokenImages.js';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
@@ -16,12 +17,12 @@ const game = {
|
||||
};
|
||||
|
||||
const renderDetail = (appid = 220) => render(
|
||||
<MemoryRouter initialEntries={[`/library/${appid}`]}><PreferencesProvider>
|
||||
<MemoryRouter initialEntries={[`/library/${appid}`]}><PreferencesProvider><CurrentUserProvider>
|
||||
<Routes>
|
||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||
<Route path="/library" element={<p>library page</p>}/>
|
||||
</Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {Link, useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {Link, useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
||||
import useForceSync from '../hooks/useForceSync.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {libraryQuery, parseStatus} from '../utils/backlog.js';
|
||||
import {GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
|
||||
@@ -22,7 +21,6 @@ import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {platformLabel} from '../utils/platforms.js';
|
||||
|
||||
const PAGE_SIZES = [20, 40, 80];
|
||||
@@ -42,8 +40,6 @@ const SORT_OPTIONS = [
|
||||
|
||||
export default function GameLibrary() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
|
||||
@@ -70,10 +66,6 @@ export default function GameLibrary() {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -84,10 +76,7 @@ export default function GameLibrary() {
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
|
||||
if (!response.ok) {
|
||||
setError('LIBRARY.ERROR_LOAD');
|
||||
@@ -102,14 +91,13 @@ export default function GameLibrary() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
|
||||
}, [page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) return undefined;
|
||||
let cancelled = false;
|
||||
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
@@ -127,26 +115,25 @@ export default function GameLibrary() {
|
||||
enabled: hasSteamId,
|
||||
expectInitialSync,
|
||||
onComplete: load,
|
||||
onUnauthorized: expireSession,
|
||||
});
|
||||
|
||||
const updateParams = (next) => {
|
||||
const updateParams = useCallback((next) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v))));
|
||||
setSearchParams(params);
|
||||
};
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
// Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing.
|
||||
useEffect(() => {
|
||||
setSearch((current) => (current.trim() === q ? current : q));
|
||||
}, [q]);
|
||||
|
||||
// searchParams is a dependency, so a status click during the delay restarts the timer with fresh params.
|
||||
// updateParams changes with searchParams, so a status click during the delay restarts the timer with fresh params.
|
||||
useEffect(() => {
|
||||
if (search.trim() === q) return undefined;
|
||||
const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, q, searchParams]);
|
||||
}, [search, q, updateParams]);
|
||||
|
||||
const onStatusChange = (value) => {
|
||||
updateParams({status: value, hidden: null, page: 0});
|
||||
|
||||
+22
-45
@@ -1,5 +1,5 @@
|
||||
import {memo, useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {Link, useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {Link, useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {ArrowLeft, CheckCircle2, Circle, Loader2, RotateCcw, Search, Upload, XCircle} from 'lucide-react';
|
||||
@@ -12,15 +12,14 @@ import {Textarea} from '@/components/ui/textarea';
|
||||
import {cn} from '@/lib/utils';
|
||||
import GameSearch from '../components/GameSearch.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useStartImport from '../hooks/useStartImport.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {
|
||||
checkUndo, getImport, listImports, parseCsv, parseTextList, retryImport, saveImport, undoImport,
|
||||
} from '../utils/imports.js';
|
||||
import {GAME_STATUSES, MAX_IMPORT_ROWS, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
import {hoursToMinutes, toHours} from '../utils/manualGames.js';
|
||||
import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js';
|
||||
import {preselectPlatform} from '../utils/platforms.js';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
const POLL_MS = 2000;
|
||||
// Statuses a worker still has: the page polls while it shows one.
|
||||
@@ -50,8 +49,6 @@ const SELECT_CLASS = 'h-9 w-full min-w-0 rounded-md border border-input bg-trans
|
||||
*/
|
||||
export default function ImportPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [imports, setImports] = useState(null);
|
||||
const id = searchParams.get('id');
|
||||
@@ -59,24 +56,17 @@ export default function ImportPage() {
|
||||
const loadImports = useCallback(async () => {
|
||||
try {
|
||||
const response = await listImports();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
setImports(response.ok ? (await response.json()).imports : []);
|
||||
} catch (err) {
|
||||
console.error('Import list error:', err);
|
||||
setImports([]);
|
||||
}
|
||||
}, [expireSession]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
loadImports();
|
||||
}, [navigate, loadImports]);
|
||||
}, [loadImports]);
|
||||
|
||||
// Without ?id=, an import a worker still has is shown first; one to review or failed is in the history.
|
||||
const open = imports?.find((entry) => RUNNING.includes(entry.status));
|
||||
@@ -103,14 +93,14 @@ export default function ImportPage() {
|
||||
{imports === null ? (
|
||||
<Loader2 className="animate-spin text-muted-foreground"/>
|
||||
) : shownId ? (
|
||||
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports} expireSession={expireSession}/>
|
||||
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/>
|
||||
) : (
|
||||
<ImportInput onCreated={(created) => {
|
||||
show(created);
|
||||
loadImports();
|
||||
}}/>
|
||||
)}
|
||||
{imports?.length > 0 && <History imports={imports} onOpen={show} onChanged={loadImports} expireSession={expireSession}/>}
|
||||
{imports?.length > 0 && <History imports={imports} onOpen={show} onChanged={loadImports}/>}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -176,7 +166,7 @@ function ImportInput({onCreated}) {
|
||||
}
|
||||
|
||||
// Polls while a worker has the import.
|
||||
function ImportView({id, onDone, onChanged, expireSession}) {
|
||||
function ImportView({id, onDone, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const [data, setData] = useState(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
@@ -185,11 +175,8 @@ function ImportView({id, onDone, onChanged, expireSession}) {
|
||||
const reload = () => setVersion((current) => current + 1);
|
||||
const lastStatus = useRef(null);
|
||||
// undefined while loading: the review preselects with it, so it waits.
|
||||
const [favorite, setFavorite] = useState(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
loadFavoritePlatform().then(setFavorite);
|
||||
}, []);
|
||||
const {user} = useCurrentUserContext();
|
||||
const favorite = user === undefined ? undefined : user?.settings?.favoritePlatform ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
@@ -198,10 +185,7 @@ function ImportView({id, onDone, onChanged, expireSession}) {
|
||||
try {
|
||||
const response = await getImport(id);
|
||||
if (cancelled) return;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
// A 4xx ends the polling; a 5xx while Core restarts is asked again.
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
setFailed(true);
|
||||
@@ -228,7 +212,7 @@ function ImportView({id, onDone, onChanged, expireSession}) {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [id, expireSession, version, onChanged]);
|
||||
}, [id, version, onChanged]);
|
||||
|
||||
if (failed) return <p className="text-sm text-destructive">{t('IMPORT.LOAD_FAILED')}</p>;
|
||||
if (!data || favorite === undefined) return <Loader2 className="animate-spin text-muted-foreground"/>;
|
||||
@@ -236,9 +220,9 @@ function ImportView({id, onDone, onChanged, expireSession}) {
|
||||
<div className="space-y-4">
|
||||
<ImportSteps data={data}/>
|
||||
{data.status === 'READY' ? (
|
||||
<Review data={data} favorite={favorite} onSaved={reload} onDone={onDone} expireSession={expireSession}/>
|
||||
<Review data={data} favorite={favorite} onSaved={reload} onDone={onDone}/>
|
||||
) : (
|
||||
<ImportProgress data={data} onRetried={reload} onDone={onDone} expireSession={expireSession}/>
|
||||
<ImportProgress data={data} onRetried={reload} onDone={onDone}/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -271,7 +255,7 @@ function ImportSteps({data}) {
|
||||
}
|
||||
|
||||
// What the current step does, with its numbers; Retry for a FAILED import.
|
||||
function ImportProgress({data, onRetried, onDone, expireSession}) {
|
||||
function ImportProgress({data, onRetried, onDone}) {
|
||||
const {t} = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const step = stepOf(data);
|
||||
@@ -281,9 +265,8 @@ function ImportProgress({data, onRetried, onDone, expireSession}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await retryImport(data.id);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
} else if (response.status === 409) {
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 409) {
|
||||
toast.error(t('IMPORT.RUNNING'));
|
||||
} else if (!response.ok) {
|
||||
toast.error(t('IMPORT.RETRY_FAILED'));
|
||||
@@ -356,7 +339,7 @@ export const initialChoice = (row, favorite) => {
|
||||
};
|
||||
};
|
||||
|
||||
function Review({data, favorite, onSaved, onDone, expireSession}) {
|
||||
function Review({data, favorite, onSaved, onDone}) {
|
||||
const {t} = useTranslation();
|
||||
const [choices, setChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -387,10 +370,7 @@ function Review({data, favorite, onSaved, onDone, expireSession}) {
|
||||
lineNo: row.lineNo, igdbId: choice.igdbId, platform: choice.platform.trim(), status: choice.status,
|
||||
playtimeMinutes: hoursToMinutes(choice.hours),
|
||||
})));
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('IMPORT.SAVE_FAILED'));
|
||||
return;
|
||||
@@ -491,7 +471,7 @@ const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeL
|
||||
);
|
||||
});
|
||||
|
||||
function History({imports, onOpen, onChanged, expireSession}) {
|
||||
function History({imports, onOpen, onChanged}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -500,10 +480,7 @@ function History({imports, onOpen, onChanged, expireSession}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await call();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('IMPORT.UNDO_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -5,11 +5,12 @@ import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import ImportPage, {initialChoice, stepOf} from './ImportPage.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'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
const json = (status, body) => ({ok: status < 400, status, json: async () => body});
|
||||
|
||||
const hades = {igdbId: 1, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PC', 'Switch'], inLibrary: false};
|
||||
const hades2 = {igdbId: 2, name: 'Hades II', year: 2025, imageUrl: null, platforms: ['PC'], inLibrary: false};
|
||||
@@ -25,19 +26,20 @@ const ready = {
|
||||
],
|
||||
};
|
||||
|
||||
// routes: 'METHOD path after /imports' -> response or (init) => response; any other URL (Auth) answers the favorite
|
||||
// routes: 'METHOD path after /imports' -> response or (init) => response; Auth answers the favorite
|
||||
function renderPage(routes, entry = '/import') {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
if (!url.includes('/api/library/imports')) return json(200, {settings: {favoritePlatform: 'Switch'}});
|
||||
const path = new URL(url, 'http://x').pathname.replace(/.*\/api\/library\/imports/, '');
|
||||
const answer = routes[`${init?.method ?? 'GET'} ${path}`];
|
||||
if (!answer) throw new Error(`no route ${init?.method ?? 'GET'} ${path}`);
|
||||
return typeof answer === 'function' ? answer(init) : answer;
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {favoritePlatform: 'Switch'}}),
|
||||
...Object.fromEntries(Object.entries(routes).map(([key, answer]) => {
|
||||
const [method, path] = key.split(' ');
|
||||
return [`${method} /api/library/imports${path}`,
|
||||
typeof answer === 'function' ? (url, init) => answer(init) : answer];
|
||||
})),
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path="/import" element={<PreferencesProvider><ImportPage/></PreferencesProvider>}/>
|
||||
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
|
||||
<Route path="/login" element={<p>login</p>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
@@ -250,3 +252,24 @@ describe('ImportPage', () => {
|
||||
expect(await screen.findByText('IMPORT.MATCHING', {}, {timeout: 4000})).toBeTruthy();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('review findings', () => {
|
||||
it('shows the review when Auth cannot be reached for the favorite', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
'GET /api/library/imports': json(200, {imports: []}),
|
||||
'GET /api/library/imports/5': json(200, ready),
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/import?id=5']}>
|
||||
<Routes>
|
||||
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('IMPORT.LINE: Hades', {}, {timeout: 2000})).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ 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',
|
||||
@@ -20,6 +21,7 @@ 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';
|
||||
|
||||
@@ -54,6 +56,7 @@ 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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {Check, Loader2, Play, Shuffle, Timer} from 'lucide-react';
|
||||
@@ -10,11 +10,9 @@ import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {saveGameStatus} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
// maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data.
|
||||
const BUDGETS = [
|
||||
@@ -40,8 +38,6 @@ const pickerQuery = (maxMinutes, mood, exclude) => {
|
||||
|
||||
export default function PlayNextPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [budget, setBudget] = useState(BUDGETS[3]);
|
||||
const [mood, setMood] = useState('ANY');
|
||||
const [games, setGames] = useState(null);
|
||||
@@ -52,10 +48,6 @@ export default function PlayNextPage() {
|
||||
const latestRequest = useRef(0);
|
||||
|
||||
const load = useCallback(async (maxMinutes, moodKey, exclude) => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
const request = ++latestRequest.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -63,10 +55,7 @@ export default function PlayNextPage() {
|
||||
const response = await apiFetch(
|
||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`);
|
||||
if (request !== latestRequest.current) return;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
setError('PLAY_NEXT.ERROR_LOAD');
|
||||
return;
|
||||
@@ -82,7 +71,7 @@ export default function PlayNextPage() {
|
||||
if (request === latestRequest.current) setLoading(false);
|
||||
}
|
||||
// No `t` here: a language change gives a new `t`, and a reload would replace the random picks.
|
||||
}, [navigate, expireSession]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(budget.maxMinutes, mood, []);
|
||||
@@ -161,7 +150,7 @@ export default function PlayNextPage() {
|
||||
|
||||
{games?.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{games.map((game) => <PickCard key={game.appid} game={game} expireSession={expireSession}/>)}
|
||||
{games.map((game) => <PickCard key={game.appid} game={game}/>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -197,7 +186,7 @@ const emptyKey = (budget, mood) => {
|
||||
return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY';
|
||||
};
|
||||
|
||||
function PickCard({game, expireSession}) {
|
||||
function PickCard({game}) {
|
||||
const {t} = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [started, setStarted] = useState(false);
|
||||
@@ -206,10 +195,7 @@ function PickCard({game, expireSession}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await saveGameStatus(game.appid, 'PLAYING');
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import PublicProfilePage from './PublicProfilePage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
@@ -27,9 +28,9 @@ function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer =
|
||||
}
|
||||
|
||||
const renderPage = (name = 'Lobo') => render(
|
||||
<MemoryRouter initialEntries={[`/u/${name}`]}><PreferencesProvider>
|
||||
<MemoryRouter initialEntries={[`/u/${name}`]}><PreferencesProvider><CurrentUserProvider>
|
||||
<Routes><Route path="/u/:name" element={<PublicProfilePage/>}/></Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
describe('PublicProfilePage', () => {
|
||||
|
||||
+20
-60
@@ -1,5 +1,5 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
@@ -20,7 +20,7 @@ import FormField from '../components/FormField.jsx';
|
||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||
import {settingsSchema} from '../utils/validation.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {AccentPicker, CornersPicker} from '../components/StylePickers.jsx';
|
||||
import ProfileStyleSettings from '../components/ProfileStyleSettings.jsx';
|
||||
@@ -28,8 +28,8 @@ import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
||||
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import {coverUrl} from '../utils/games.js';
|
||||
import {apiFetch, refreshSession} from '../utils/api.js';
|
||||
import {hasSession, markSession} from '../utils/auth.js';
|
||||
import {refreshSession} from '../utils/api.js';
|
||||
import {markSession} from '../utils/auth.js';
|
||||
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
|
||||
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
||||
import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
|
||||
@@ -60,14 +60,12 @@ const formatDate = (value, locale) => {
|
||||
|
||||
export default function SettingsPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
const saveStyle = useSaveProfileStyle();
|
||||
const runSave = useSaveResponse();
|
||||
|
||||
const [profile, setProfile] = useState(null);
|
||||
const {user: profile, updateUser: updateProfile, reload: reloadUser} = useCurrentUserContext();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [savingProfileStyle, setSavingProfileStyle] = useState(false);
|
||||
@@ -99,62 +97,26 @@ export default function SettingsPage() {
|
||||
}, [steamOutcome, setSearchParams, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
if (steamOutcome === 'linked') {
|
||||
// Refresh reads the new Steam ID from the database, so Core gets it in the token at once.
|
||||
await refreshSession().catch(() => false);
|
||||
markSession();
|
||||
}
|
||||
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setLoadError('INFO.ERROR.LOAD_PROFILE');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setProfile(data);
|
||||
reset({displayName: data.displayName ?? ''});
|
||||
} catch (err) {
|
||||
console.error('Settings load error:', err);
|
||||
setLoadError('INFO.ERROR.NETWORK_FAILURE');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (steamOutcome === 'linked') {
|
||||
// Refresh reads the new Steam ID from the database, so Core gets it in the token at once.
|
||||
await refreshSession().catch(() => false);
|
||||
markSession();
|
||||
}
|
||||
const {ok, status, data} = await reloadUser();
|
||||
if (ok) reset({displayName: data.displayName ?? ''});
|
||||
else if (status !== 401) setLoadError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'INFO.ERROR.LOAD_PROFILE');
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
load();
|
||||
}, [navigate, expireSession, reset, steamOutcome]);
|
||||
}, [reloadUser, reset, steamOutcome]);
|
||||
|
||||
const persistSettings = async (send) => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return {ok: false};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await send();
|
||||
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return {ok: false};
|
||||
}
|
||||
if (response.status === 401) return {ok: false};
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
@@ -185,7 +147,7 @@ export default function SettingsPage() {
|
||||
const handleFavoritePlatformChange = async (value) => {
|
||||
const platform = value === NO_FAVORITE ? null : value;
|
||||
if (await runSave(() => saveFavoritePlatform(platform))) {
|
||||
setProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}}));
|
||||
updateProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}}));
|
||||
toast.success(t('SETTINGS.SAVED'));
|
||||
}
|
||||
};
|
||||
@@ -194,14 +156,14 @@ export default function SettingsPage() {
|
||||
const next = !profile?.settings?.publicProfile;
|
||||
const result = await persistSettings(() => saveSettings({publicProfile: next}));
|
||||
if (result.ok) {
|
||||
setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
|
||||
updateProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
|
||||
}
|
||||
};
|
||||
|
||||
// Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other.
|
||||
const handleProfileStyleChange = async (changes) => {
|
||||
const previous = normalizeProfileStyle(profile?.settings?.profileStyle);
|
||||
const showProfileStyle = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}}));
|
||||
const showProfileStyle = (next) => updateProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}}));
|
||||
showProfileStyle({...previous, ...changes});
|
||||
setSavingProfileStyle(true);
|
||||
showProfileStyle(await saveStyle(changes, previous) ?? previous);
|
||||
@@ -222,9 +184,7 @@ export default function SettingsPage() {
|
||||
const publicProfile = !!profile?.settings?.publicProfile && !!values.displayName;
|
||||
const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile}));
|
||||
if (result.ok) {
|
||||
setProfile((p) => (p ? {
|
||||
...p, displayName: values.displayName, settings: {...p.settings, publicProfile},
|
||||
} : p));
|
||||
updateProfile((p) => ({...p, displayName: values.displayName, settings: {...p.settings, publicProfile}}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -382,7 +342,7 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
<SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified}
|
||||
onUnlinked={() => setProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
|
||||
onUnlinked={() => updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import SettingsPage from './SettingsPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const mock = await import('../test/i18n-mock.js');
|
||||
@@ -32,7 +33,7 @@ function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
|
||||
}
|
||||
return {ok: true, status: 200, json: async () => me};
|
||||
});
|
||||
render(<MemoryRouter initialEntries={[entry]}><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
render(<MemoryRouter initialEntries={[entry]}><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>);
|
||||
}
|
||||
|
||||
describe('SettingsPage Steam account', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import SettingsPage from './SettingsPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
// Like react-i18next: a language change re-renders every subscriber with a fresh `t`.
|
||||
vi.mock('react-i18next', async () => {
|
||||
@@ -44,7 +45,7 @@ describe('SettingsPage Steam toast', () => {
|
||||
});
|
||||
|
||||
it('shows the linked toast once, also after a language change', async () => {
|
||||
render(<MemoryRouter initialEntries={['/settings?steam=linked']}><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
render(<MemoryRouter initialEntries={['/settings?steam=linked']}><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>);
|
||||
await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED');
|
||||
expect(toast.success).toHaveBeenCalledTimes(1);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import SettingsPage from './SettingsPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const mock = await import('../test/i18n-mock.js');
|
||||
@@ -26,7 +27,7 @@ function renderPage(me) {
|
||||
// Both PUTs answer 204 without a body, like Auth does.
|
||||
? {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}
|
||||
: {ok: true, status: 200, json: async () => me}));
|
||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
render(<MemoryRouter><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +67,7 @@ describe('SettingsPage public profile', () => {
|
||||
|
||||
it('builds a save on the one before it, also while that one is still open', async () => {
|
||||
const auth = fakeAuth(profile());
|
||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
render(<MemoryRouter><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
|
||||
await waitFor(() => expect(puts()).toHaveLength(1));
|
||||
|
||||
+1
-12
@@ -1,5 +1,3 @@
|
||||
import {useEffect} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {RefreshCw} from 'lucide-react';
|
||||
|
||||
@@ -13,22 +11,13 @@ import TopGamesStats from '../components/stats/TopGamesStats.jsx';
|
||||
import TasteStats from '../components/stats/TasteStats.jsx';
|
||||
import HabitsStats from '../components/stats/HabitsStats.jsx';
|
||||
import FinishedStats from '../components/stats/FinishedStats.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const ALL_GROUPS = [];
|
||||
|
||||
export default function StatsPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const loggedIn = hasSession();
|
||||
const {stats, failed, reload} = useUserStats(ALL_GROUPS, expireSession, loggedIn);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) navigate('/login');
|
||||
}, [loggedIn, navigate]);
|
||||
const {stats, failed, reload} = useUserStats(ALL_GROUPS);
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ExternalLink, Gift, Percent} from 'lucide-react';
|
||||
|
||||
@@ -9,36 +8,25 @@ import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const formatAdded = (instant, locale) =>
|
||||
instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
|
||||
|
||||
export default function WishlistPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [games, setGames] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [onlyOnSale, setOnlyOnSale] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/wishlist`);
|
||||
if (cancelled) return;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
setError('WISHLIST.ERROR_LOAD');
|
||||
return;
|
||||
@@ -53,7 +41,7 @@ export default function WishlistPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [navigate, expireSession]);
|
||||
}, []);
|
||||
|
||||
const shown = (games ?? []).filter((g) => !onlyOnSale || g.discountPct > 0);
|
||||
const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
||||
import RequireSession from '../components/RequireSession.jsx';
|
||||
import WishlistPage from './WishlistPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
@@ -19,7 +20,7 @@ function LoginState() {
|
||||
const renderPage = () => render(
|
||||
<MemoryRouter initialEntries={['/wishlist']}><PreferencesProvider>
|
||||
<Routes>
|
||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||
<Route element={<RequireSession/>}><Route path="/wishlist" element={<WishlistPage/>}/></Route>
|
||||
<Route path="/login" element={<LoginState/>}/>
|
||||
</Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {cleanup, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {switchLanguage} from '../test/i18n-mock.js';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
import Dashboard from './Dashboard.jsx';
|
||||
import GameDetail from './GameDetail.jsx';
|
||||
import GameLibrary from './GameLibrary.jsx';
|
||||
@@ -14,12 +15,12 @@ import WishlistPage from './WishlistPage.jsx';
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
const pages = [
|
||||
['Dashboard', '/', '/', <Dashboard/>, 'INFO.ERROR.LOAD_PROFILE'],
|
||||
['GameDetail', '/games/:appid', '/games/220', <GameDetail/>, 'GAME_DETAIL.ERROR_LOAD'],
|
||||
['GameLibrary', '/library', '/library', <GameLibrary/>, 'LIBRARY.ERROR_LOAD'],
|
||||
['PublicProfilePage', '/u/:name', '/u/Lobo', <PublicProfilePage/>, 'PROFILE.ERROR_LOAD'],
|
||||
['SettingsPage', '/settings', '/settings', <SettingsPage/>, 'INFO.ERROR.LOAD_PROFILE'],
|
||||
['WishlistPage', '/wishlist', '/wishlist', <WishlistPage/>, 'WISHLIST.ERROR_LOAD'],
|
||||
['Dashboard', '/', '/', Dashboard, 'INFO.ERROR.LOAD_PROFILE'],
|
||||
['GameDetail', '/games/:appid', '/games/220', GameDetail, 'GAME_DETAIL.ERROR_LOAD'],
|
||||
['GameLibrary', '/library', '/library', GameLibrary, 'LIBRARY.ERROR_LOAD'],
|
||||
['PublicProfilePage', '/u/:name', '/u/Lobo', PublicProfilePage, 'PROFILE.ERROR_LOAD'],
|
||||
['SettingsPage', '/settings', '/settings', SettingsPage, 'INFO.ERROR.LOAD_PROFILE'],
|
||||
['WishlistPage', '/wishlist', '/wishlist', WishlistPage, 'WISHLIST.ERROR_LOAD'],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -34,10 +35,10 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('a language change', () => {
|
||||
it.each(pages)('%s: loads nothing again and shows the error in the new language', async (name, path, url, page, errorKey) => {
|
||||
it.each(pages)('%s: loads nothing again and shows the error in the new language', async (name, path, url, Page, errorKey) => {
|
||||
const tree = () => (
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<PreferencesProvider><Routes><Route path={path} element={page}/></Routes></PreferencesProvider>
|
||||
<PreferencesProvider><CurrentUserProvider><Routes><Route path={path} element={<Page/>}/></Routes></CurrentUserProvider></PreferencesProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const {rerender} = render(tree());
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {vi} from 'vitest';
|
||||
|
||||
export const json = (status, body) => ({ok: status >= 200 && status < 300, status, json: async () => body});
|
||||
|
||||
/**
|
||||
* A fetch mock that answers by method and path, e.g. `'GET /api/users/me'`. The path starts at `/api/` (the base URL
|
||||
* is not set in tests) and leaves out the query. A value is an answer or `(url, init) => answer`. Any other request
|
||||
* throws, so a test cannot pass on a request it did not expect.
|
||||
* Sets `globalThis.fetch` and returns the mock.
|
||||
*/
|
||||
export function fetchRoutes(routes) {
|
||||
const mock = vi.fn(async (url, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${new URL(url, 'http://x').pathname.replace(/^.*?(?=\/api\/)/, '')}`;
|
||||
if (!(key in routes)) throw new Error(`no route ${key}`);
|
||||
const answer = routes[key];
|
||||
return typeof answer === 'function' ? answer(url, init) : answer;
|
||||
});
|
||||
globalThis.fetch = mock;
|
||||
return mock;
|
||||
}
|
||||
+34
-7
@@ -55,14 +55,41 @@ export async function rotatingFetch(url, init = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch for the logged-in user. A 401 renews the access cookie once and sends the request again. When that fails,
|
||||
* the 401 goes back to the caller. Ending the session is the caller's job (useSessionExpired), so the user sees
|
||||
* why and comes back to the same page.
|
||||
* 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.
|
||||
*/
|
||||
export async function apiFetch(url, init = {}) {
|
||||
const response = await clientFetch(url, init);
|
||||
if (response.status !== 401 || !(await refreshSession())) {
|
||||
return response;
|
||||
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'));
|
||||
}
|
||||
return clientFetch(url, init);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* apiFetch that never throws: `{ok, status, data}` with the JSON body or null. A network error is status 0 and is
|
||||
* logged, an abort only returns.
|
||||
*/
|
||||
export async function request(url, init = {}) {
|
||||
let response;
|
||||
try {
|
||||
response = await apiFetch(url, init);
|
||||
} catch (err) {
|
||||
if (!init.signal?.aborted) console.error(`Request to ${url} failed:`, err);
|
||||
return {ok: false, status: 0, data: null};
|
||||
}
|
||||
let data = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
// no JSON body, e.g. a 204
|
||||
}
|
||||
return {ok: response.ok, status: response.status, data};
|
||||
}
|
||||
+79
-2
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {apiFetch, clientFetch, logout} from './api.js';
|
||||
import {apiFetch, clientFetch, logout, request, sessionEvents} from './api.js';
|
||||
|
||||
const response = (status) => ({ok: status >= 200 && status < 300, status, json: async () => ({})});
|
||||
const isRefresh = (url) => url.endsWith('/api/auth/refresh');
|
||||
@@ -59,7 +59,7 @@ describe('apiFetch', () => {
|
||||
expect(refreshCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns the 401 when the refresh fails, without a loop, and leaves ending the session to the caller', async () => {
|
||||
it('returns the 401 when the refresh fails, without a loop', async () => {
|
||||
globalThis.fetch = vi.fn(async () => response(401));
|
||||
|
||||
const result = await apiFetch('/api/x');
|
||||
@@ -126,6 +126,83 @@ describe('apiFetch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the expired event', () => {
|
||||
let expired;
|
||||
const count = () => expired.mock.calls.length;
|
||||
|
||||
beforeEach(() => {
|
||||
expired = vi.fn();
|
||||
sessionEvents.addEventListener('expired', expired);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionEvents.removeEventListener('expired', expired);
|
||||
});
|
||||
|
||||
it('fires once when the refresh says the session is over', async () => {
|
||||
globalThis.fetch = vi.fn(async () => response(401));
|
||||
|
||||
await apiFetch('/api/x');
|
||||
|
||||
expect(count()).toBe(1);
|
||||
});
|
||||
|
||||
it('fires 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);
|
||||
});
|
||||
|
||||
it('does not fire when the refresh has no network, or for clientFetch', async () => {
|
||||
globalThis.fetch = vi.fn(async (url) => {
|
||||
if (isRefresh(url)) throw new TypeError('Failed to fetch');
|
||||
return response(401);
|
||||
});
|
||||
|
||||
await expect(apiFetch('/api/x')).rejects.toThrow();
|
||||
await clientFetch('/api/x');
|
||||
|
||||
expect(count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request', () => {
|
||||
it('gives the status and the JSON body', async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({a: 1})}));
|
||||
|
||||
expect(await request('/api/x')).toEqual({ok: true, status: 200, data: {a: 1}});
|
||||
});
|
||||
|
||||
it('gives null data for an answer without a JSON body', async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({ok: true, status: 204, json: async () => { throw new SyntaxError('empty'); }}));
|
||||
|
||||
expect(await request('/api/x')).toEqual({ok: true, status: 204, data: null});
|
||||
});
|
||||
|
||||
it('gives the error body of a refused request', async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({ok: false, status: 409, json: async () => ({code: 'CONFLICT'})}));
|
||||
|
||||
expect(await request('/api/x', {method: 'PUT'})).toEqual({ok: false, status: 409, data: {code: 'CONFLICT'}});
|
||||
});
|
||||
|
||||
it('gives status 0 on a network error and logs it, but not an abort', async () => {
|
||||
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
globalThis.fetch = vi.fn(async () => { throw new TypeError('Failed to fetch'); });
|
||||
|
||||
expect(await request('/api/x')).toEqual({ok: false, status: 0, data: null});
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
globalThis.fetch = vi.fn(async () => { throw new DOMException('aborted', 'AbortError'); });
|
||||
expect(await request('/api/x', {signal: controller.signal})).toEqual({ok: false, status: 0, data: null});
|
||||
expect(log).toHaveBeenCalledTimes(1);
|
||||
log.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('posts to Auth and ignores a network error', async () => {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
// The tokens live in HttpOnly cookies that no script can read. This flag only remembers that a login happened; a
|
||||
// stale flag costs one 401 and a failed refresh, then the login page opens.
|
||||
const SESSION_KEY = 'tbp_session';
|
||||
export const SESSION_KEY = 'tbp_session';
|
||||
// Where the token lived before the cookies. Removed on the next login or logout.
|
||||
const LEGACY_TOKEN_KEY = 'tbp_jwt';
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const COLUMNS = {
|
||||
* @return {rows} with title, platform, playtimeMinutes and status where the file has them, or {error} as an i18n key
|
||||
*/
|
||||
export const parseCsv = (text) => {
|
||||
const clean = text.replace(/^/, '');
|
||||
const clean = text.replace(/^\uFEFF/, '');
|
||||
const firstLine = clean.split(/\r?\n/, 1)[0];
|
||||
const delimiter = [',', ';', '\t'].reduce((best, next) =>
|
||||
firstLine.split(next).length > firstLine.split(best).length ? next : best);
|
||||
|
||||
@@ -30,18 +30,6 @@ export const preselectPlatform = (own, platforms, favorite) => {
|
||||
// A Steam game has no stored platform.
|
||||
export const platformLabel = (game) => (game.source === 'MANUAL' ? game.platform : 'Steam');
|
||||
|
||||
/**
|
||||
* The user's favorite platform from Auth; null when none is set or the load failed, so a caller can go on.
|
||||
*/
|
||||
export const loadFavoritePlatform = async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${AUTH_URL()}/api/users/me?v=1`);
|
||||
return response.ok ? (await response.json()).settings?.favoritePlatform ?? null : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /api/users/settings/favorite-platform; null clears it. The caller handles the response status.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {apiFetch} from './api.js';
|
||||
import {ACCENTS, CORNERS, DEFAULT_APPEARANCE, appid} from './appearance.js';
|
||||
import {fetchMe} from './settings.js';
|
||||
|
||||
export const DEFAULT_PROFILE_STYLE = {
|
||||
accent: DEFAULT_APPEARANCE.accent, corners: DEFAULT_APPEARANCE.corners, bannerAppid: null, backgroundAppid: null,
|
||||
@@ -16,7 +17,7 @@ export const normalizeProfileStyle = (value) => ({
|
||||
* The stored style from GET /api/users/me; null when there is no answer to use.
|
||||
*/
|
||||
export const loadProfileStyle = async () => {
|
||||
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`);
|
||||
const response = await fetchMe();
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return normalizeProfileStyle(data.settings?.profileStyle);
|
||||
|
||||
@@ -22,10 +22,12 @@ export const cacheLanguage = (language) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const ME_URL = () => `${import.meta.env.VITE_API_URL}/api/users/me?v=1`;
|
||||
|
||||
/**
|
||||
* GET /api/users/me on Auth. The caller handles the response status.
|
||||
*/
|
||||
export const fetchMe = () => apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||
export const fetchMe = () => apiFetch(ME_URL(), {
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user