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
+2138 -624

No files matched your search

+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;
+4 -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 FinishedHint from './FinishedHint.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
@@ -12,7 +13,9 @@ const ending = {appid: 322110, status: 'PLAYING', playtimeMinutes: 600, timeToBe
const renderHint = (props) => render(
<MemoryRouter initialEntries={['/library/322110']}>
<Routes>
<Route path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
<Route element={<RequireSession/>}>
<Route path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
</Route>
<Route path="/login" element={<p>login page</p>}/>
</Routes>
</MemoryRouter>);
+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"