refactor(frontend): lint, one session guard, one request helper, one user context

This commit is contained in:
lobo committed 2026-09-29 15:40:55 +02:00
1 parent 111ab1e200
commit d0ea9e900d
78 files changed
+2119 -605

No files matched your search

+11 -3
View File
@@ -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
```
+28
View File
@@ -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}},
];
+1312
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -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",
+7 -1
View File
@@ -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,6 +71,7 @@ function App() {
<Route path="/" element={<LandingPage/>}/>
<Route path="/login" element={<LoginPage/>}/>
<Route path="/register" element={<RegistrationPage/>}/>
<Route element={<RequireSession/>}>
<Route path="/dashboard" element={<Dashboard/>}/>
<Route path="/library" element={<GameLibrary/>}/>
<Route path="/library/:appid" element={<GameDetail/>}/>
@@ -74,10 +79,11 @@ function App() {
<Route path="/play-next" element={<PlayNextPage/>}/>
<Route path="/stats" element={<StatsPage/>}/>
<Route path="/import" element={<ImportPage/>}/>
<Route path="/u/:name" element={<PublicProfilePage/>}/>
<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="/impressum" element={<ImprintPage/>}/>
</Routes>
</main>
+6 -14
View File
@@ -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 -3
View File
@@ -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>,
);
}
+4 -1
View File
@@ -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;
}
+3 -2
View File
@@ -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 -13
View File
@@ -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;
+1 -6
View File
@@ -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;
+4 -2
View File
@@ -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;
+5 -6
View File
@@ -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 -1
View File
@@ -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>
+1 -6
View File
@@ -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;
+3
View File
@@ -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 element={<RequireSession/>}>
<Route path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
</Route>
<Route path="/login" element={<p>login page</p>}/>
</Routes>
</MemoryRouter>);
+2 -6
View File
@@ -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;
+1 -6
View File
@@ -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'));
+2 -7
View File
@@ -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};
}
+1 -6
View File
@@ -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;
+1 -6
View File
@@ -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;
+34
View File
@@ -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}}/>;
}
+88
View File
@@ -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'});
});
});
+2 -11
View File
@@ -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
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
-1
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
-1
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
-1
View File
@@ -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
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({
-1
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Label as LabelPrimitive } from "radix-ui"
-1
View File
@@ -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
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Separator as SeparatorPrimitive } from "radix-ui"
-1
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Switch as SwitchPrimitive } from "radix-ui"
-2
View File
@@ -1,5 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }) {
-1
View File
@@ -1,4 +1,3 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Tooltip as TooltipPrimitive } from "radix-ui"
+74
View File
@@ -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)};
}
+154
View File
@@ -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
View File
@@ -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 -13
View File
@@ -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}));
+3 -8
View File
@@ -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;
+3 -9
View File
@@ -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]);
}
+6 -2
View File
@@ -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});
+1
View File
@@ -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;
}
+5 -3
View File
@@ -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]);
}
+5 -8
View File
@@ -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]);
}
+3 -8
View File
@@ -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();
+3
View File
@@ -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>
<CurrentUserProvider>
<App/>
</CurrentUserProvider>
</PreferencesProvider>
</BrowserRouter>
</StrictMode>
+4 -19
View File
@@ -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?.();
+4 -19
View File
@@ -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();
+31 -133
View File
@@ -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);
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(data.steamId
? [fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), fetchActivity(), fetchAchievements()]
: [fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary()]);
} else if (profileResponse.status === 401) {
expireSession();
} else {
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(),
...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]);
} else if (status === 0) {
setError('INFO.ERROR.NETWORK_FAILURE');
} else if (status !== 401) {
setError('INFO.ERROR.LOAD_PROFILE');
}
} catch (err) {
console.error('Dashboard fetch error:', err);
setError('INFO.ERROR.NETWORK_FAILURE');
} finally {
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);
};
+26 -5
View File
@@ -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();
});
});
+3 -16
View File
@@ -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;
+3 -2
View File
@@ -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'}});
+3 -2
View File
@@ -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(() => {
+7 -20
View File
@@ -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
View File
@@ -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;
+32 -9
View File
@@ -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();
});
});
+3
View File
@@ -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);
+6 -20
View File
@@ -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;
+3 -2
View File
@@ -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', () => {
+15 -55
View File
@@ -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 {
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>
+2 -1
View File
@@ -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', () => {
+2 -1
View File
@@ -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);
+3 -2
View File
@@ -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
View File
@@ -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">
+2 -14
View File
@@ -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 -1
View File
@@ -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>,
+9 -8
View File
@@ -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());
+20
View File
@@ -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;
}
+33 -6
View File
@@ -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())) {
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 response;
}
return clientFetch(url, init);
/**
* 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
View File
@@ -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
View File
@@ -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';
+1 -1
View File
@@ -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);
-12
View File
@@ -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.
*/
+2 -1
View File
@@ -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);
+3 -1
View File
@@ -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),
});