Public Access
refactor(frontend): lint, one session guard, one request helper, one user context
This commit is contained in:
1 parent
111ab1e200
commit
d0ea9e900d
78 files changed
+2138
-624
No files matched your search
@@ -11,11 +11,11 @@ import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import GameSearch from './GameSearch.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useStartImport from '../hooks/useStartImport.js';
|
||||
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
import {addGame, hoursToMinutes} from '../utils/manualGames.js';
|
||||
import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js';
|
||||
import {preselectPlatform} from '../utils/platforms.js';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
/**
|
||||
* Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch; or check several and review
|
||||
@@ -24,16 +24,12 @@ import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js';
|
||||
export default function AddGamePanel({onClose}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [query, setQuery] = useState('');
|
||||
const [picked, setPicked] = useState(null);
|
||||
// igdbId -> hit, over several searches.
|
||||
const [selected, setSelected] = useState(() => new Map());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [favorite, setFavorite] = useState(null);
|
||||
useEffect(() => {
|
||||
loadFavoritePlatform().then(setFavorite);
|
||||
}, []);
|
||||
const favorite = useCurrentUserContext().user?.settings?.favoritePlatform ?? null;
|
||||
const startImport = useStartImport();
|
||||
|
||||
const toggle = (hit) => {
|
||||
@@ -64,8 +60,7 @@ export default function AddGamePanel({onClose}) {
|
||||
</div>
|
||||
|
||||
{picked ? (
|
||||
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}
|
||||
expireSession={expireSession}/>
|
||||
<AddForm hit={picked} favorite={favorite} onBack={() => setPicked(null)} navigate={navigate}/>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p>
|
||||
@@ -85,7 +80,7 @@ export default function AddGamePanel({onClose}) {
|
||||
);
|
||||
}
|
||||
|
||||
function AddForm({hit, favorite, onBack, navigate, expireSession}) {
|
||||
function AddForm({hit, favorite, onBack, navigate}) {
|
||||
const {t} = useTranslation();
|
||||
const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite));
|
||||
// The favorite can arrive after the pick; it fills only a platform the user has not picked yet.
|
||||
@@ -103,10 +98,7 @@ function AddForm({hit, favorite, onBack, navigate, expireSession}) {
|
||||
const response = await addGame({
|
||||
igdbId: hit.igdbId, platform: platform.trim(), status, playtimeMinutes: hoursToMinutes(hours),
|
||||
});
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 409) {
|
||||
toast.error(t('MANUAL_GAME.ALREADY_IN_LIBRARY'));
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,8 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import AddGamePanel from './AddGamePanel.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
import {json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
@@ -14,7 +16,6 @@ const zelda = {
|
||||
};
|
||||
const portal = {...zelda, appid: 400, igdbId: 71, name: 'Portal', inLibrary: true};
|
||||
|
||||
const json = (status, body) => ({ok: status < 400, status, json: async () => body});
|
||||
|
||||
function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Nintendo Switch') {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
@@ -22,13 +23,13 @@ function renderPanel(post = json(201, {appid: -1025}), favoritePlatform = 'Ninte
|
||||
return init?.method === 'POST' ? post : json(200, {available: true, results: [zelda, portal]});
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/library']}>
|
||||
<MemoryRouter initialEntries={['/library']}><CurrentUserProvider>
|
||||
<Routes>
|
||||
<Route path="/library" element={<AddGamePanel onClose={() => {}}/>}/>
|
||||
<Route path="/library/:appid" element={<p>detail page</p>}/>
|
||||
<Route path="/import" element={<p>import page</p>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
</CurrentUserProvider></MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {usePreferences} from '@/context/PreferencesContext.jsx';
|
||||
import {useCurrentUserActions} from '@/context/CurrentUserContext.jsx';
|
||||
import {cn} from '@/lib/utils';
|
||||
import Logo from './Logo.jsx';
|
||||
import PageContainer from './PageContainer.jsx';
|
||||
@@ -47,6 +48,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
const {pathname} = useLocation();
|
||||
const steamProfile = useSteamProfile(isAuthenticated);
|
||||
const currentUser = useCurrentUser(isAuthenticated);
|
||||
const {reload: reloadUser} = useCurrentUserActions();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
|
||||
@@ -56,8 +58,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
try {
|
||||
const response = await viewAs(role === currentUser.role ? null : role);
|
||||
if (response.ok) {
|
||||
// New cookies with the other role; the new stamp makes the header and pages load again.
|
||||
// New cookies with the other role; the new stamp makes the Steam data load again.
|
||||
markSession();
|
||||
reloadUser();
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {toast} from 'sonner';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import AppHeader from './AppHeader.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
@@ -23,7 +24,7 @@ const loggedInAs = (me) => {
|
||||
}));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PreferencesProvider><AppHeader isAuthenticated onLogout={() => {}}/></PreferencesProvider>
|
||||
<PreferencesProvider><CurrentUserProvider><AppHeader isAuthenticated onLogout={() => {}}/></CurrentUserProvider></PreferencesProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
@@ -48,7 +49,7 @@ describe('AppHeader', () => {
|
||||
it('keeps a name on the home link when a narrow phone hides the app name', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PreferencesProvider><AppHeader isAuthenticated={false} onLogout={() => {}}/></PreferencesProvider>
|
||||
<PreferencesProvider><CurrentUserProvider><AppHeader isAuthenticated={false} onLogout={() => {}}/></CurrentUserProvider></PreferencesProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
screen.getByText('ToBePlayed').style.display = 'none';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {ListTodo, Loader2, Star} from 'lucide-react';
|
||||
@@ -13,8 +12,6 @@ import {cn} from '@/lib/utils';
|
||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||
import {NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
|
||||
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const toForm = (entry) => ({
|
||||
status: entry.status ?? 'BACKLOG',
|
||||
@@ -25,8 +22,6 @@ const toForm = (entry) => ({
|
||||
|
||||
export default function BacklogCard({game, onSaved}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [saved, setSaved] = useState(() => toForm(game));
|
||||
const [form, setForm] = useState(saved);
|
||||
|
||||
@@ -41,17 +36,10 @@ export default function BacklogCard({game, onSaved}) {
|
||||
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
|
||||
|
||||
const save = async () => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await saveBacklogEntry(game.appid, toBacklogRequest(form));
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,6 @@ import {Loader2, Trophy} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {saveGameStatus} from '../utils/backlog.js';
|
||||
import {allAchievementsUnlocked} from '../utils/confetti.js';
|
||||
|
||||
@@ -16,7 +15,6 @@ import {allAchievementsUnlocked} from '../utils/confetti.js';
|
||||
*/
|
||||
export default function CompletedHint({game, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null;
|
||||
@@ -25,10 +23,7 @@ export default function CompletedHint({game, onChanged}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await saveGameStatus(game.appid, 'COMPLETED');
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -49,6 +49,9 @@ function pieces(size, width, height) {
|
||||
export default function Confetti({size, onDone}) {
|
||||
const canvasRef = useRef(null);
|
||||
const [reduce] = useState(reducedMotion);
|
||||
// In a ref: a new function from a re-render must not restart the animation, and the latest one runs at the end.
|
||||
const onDoneRef = useRef(onDone);
|
||||
onDoneRef.current = onDone;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -67,7 +70,7 @@ export default function Confetti({size, onDone}) {
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (elapsed >= DURATION_MS) {
|
||||
onDone?.();
|
||||
onDoneRef.current?.();
|
||||
return;
|
||||
}
|
||||
context.globalAlpha = 1 - elapsed / DURATION_MS;
|
||||
@@ -88,7 +91,6 @@ export default function Confetti({size, onDone}) {
|
||||
};
|
||||
frame = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
// Not on onDone: a new function from a re-render must not restart the animation.
|
||||
}, [size]);
|
||||
|
||||
if (reduce) return null;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {useResetAppearance} from '../context/PreferencesContext.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {useCurrentUserActions} from '../context/CurrentUserContext.jsx';
|
||||
import {deleteAccount} from '../utils/account.js';
|
||||
import {clearSession} from '../utils/auth.js';
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function DeleteAccountCard({email}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const resetAppearance = useResetAppearance();
|
||||
const expireSession = useSessionExpired();
|
||||
const {setUser} = useCurrentUserActions();
|
||||
const [typedEmail, setTypedEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -45,18 +45,17 @@ export default function DeleteAccountCard({email}) {
|
||||
if (response.status === 204) {
|
||||
clearSession();
|
||||
resetAppearance();
|
||||
setUser(null);
|
||||
toast.success(t('SETTINGS.DELETE_ACCOUNT.DONE'));
|
||||
navigate('/', {replace: true});
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
} else if (response.status === 403) {
|
||||
if (response.status === 403) {
|
||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD'));
|
||||
} else if (response.status === 429) {
|
||||
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes}));
|
||||
} else {
|
||||
} else if (response.status !== 401) {
|
||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.ERROR'));
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import RequireSession from './RequireSession.jsx';
|
||||
import {toast} from 'sonner';
|
||||
import DeleteAccountCard from './DeleteAccountCard.jsx';
|
||||
|
||||
@@ -15,7 +16,7 @@ const answer = (status, headers = {}) => ({ok: status < 300, status, headers: ne
|
||||
const renderCard = () => render(
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<Routes>
|
||||
<Route path="/settings" element={<DeleteAccountCard email={EMAIL}/>}/>
|
||||
<Route element={<RequireSession/>}><Route path="/settings" element={<DeleteAccountCard email={EMAIL}/>}/></Route>
|
||||
<Route path="/" element={<p>landing</p>}/>
|
||||
<Route path="/login" element={<p>login</p>}/>
|
||||
</Routes>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {Flag, Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {dismissFinishedHint, saveGameStatus} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
|
||||
@@ -17,7 +16,6 @@ import {formatPlaytime} from '../utils/games.js';
|
||||
*/
|
||||
export default function FinishedHint({game, onChanged, onDismissed}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const hint = game.finishedHint;
|
||||
@@ -28,10 +26,7 @@ export default function FinishedHint({game, onChanged, onDismissed}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import RequireSession from './RequireSession.jsx';
|
||||
import FinishedHint from './FinishedHint.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
@@ -12,7 +13,9 @@ const ending = {appid: 322110, status: 'PLAYING', playtimeMinutes: 600, timeToBe
|
||||
const renderHint = (props) => render(
|
||||
<MemoryRouter initialEntries={['/library/322110']}>
|
||||
<Routes>
|
||||
<Route path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
|
||||
<Route element={<RequireSession/>}>
|
||||
<Route path="/library/:appid" element={<FinishedHint game={ending} onChanged={() => {}} onDismissed={() => {}} {...props}/>}/>
|
||||
</Route>
|
||||
<Route path="/login" element={<p>login page</p>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {Button} from '@/components/ui/button';
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
|
||||
import {genreKey} from '../utils/stats.js';
|
||||
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js';
|
||||
@@ -35,7 +34,6 @@ const toggled = (list, item) => (list.includes(item) ? list.filter((each) => eac
|
||||
*/
|
||||
export default function GameCorrectionDialog({game, open, onOpenChange, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [values, setValues] = useState(() => initialValues(game));
|
||||
const [busy, setBusy] = useState(null);
|
||||
// The field whose save or reset the next game from GameDetail answers; the other fields keep their edits.
|
||||
@@ -44,6 +42,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
||||
useEffect(() => {
|
||||
if (open) setValues(initialValues(game));
|
||||
// Only when the dialog opens; a reload after a save is handled below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,10 +60,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
||||
setBusy(field);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t('CORRECTION.SAVED'));
|
||||
sentField.current = field;
|
||||
|
||||
@@ -6,7 +6,6 @@ import {toast} from 'sonner';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
||||
import GameSearch from './GameSearch.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {mergeGame} from '../utils/corrections.js';
|
||||
import {isSteamKey} from '../utils/manualGames.js';
|
||||
|
||||
@@ -18,7 +17,6 @@ const KNOWN_ERRORS = new Set(['SAME_GAME', 'STEAM_GAMES', 'GAME_NOT_FOUND']);
|
||||
*/
|
||||
export default function GameMergeDialog({game, open, onOpenChange, onMerged}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [query, setQuery] = useState('');
|
||||
const [picked, setPicked] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -34,10 +32,7 @@ export default function GameMergeDialog({game, open, onOpenChange, onMerged}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await mergeGame(game.appid, picked.appid);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (response.ok) {
|
||||
toast.success(t('MERGE.DONE'));
|
||||
|
||||
@@ -5,7 +5,6 @@ import {Loader2, Search} from 'lucide-react';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {searchGames} from '../utils/manualGames.js';
|
||||
import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js';
|
||||
|
||||
@@ -81,7 +80,6 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
|
||||
|
||||
// Asks Core 400 ms after the last key press; searchState is idle, loading, done, unavailable or failed.
|
||||
function useGameSearch(query) {
|
||||
const expireSession = useSessionExpired();
|
||||
const [searchState, setSearchState] = useState('idle');
|
||||
const [results, setResults] = useState([]);
|
||||
|
||||
@@ -97,10 +95,7 @@ function useGameSearch(query) {
|
||||
setSearchState('loading');
|
||||
try {
|
||||
const response = await searchGames(trimmed, controller.signal);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
setSearchState('failed');
|
||||
return;
|
||||
@@ -116,7 +111,7 @@ function useGameSearch(query) {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [query, expireSession]);
|
||||
}, [query]);
|
||||
|
||||
return {searchState, results};
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js';
|
||||
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
|
||||
@@ -20,7 +19,6 @@ import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
export default function ManualGameCard({game, onSaved}) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [platform, setPlatform] = useState(game.platform ?? '');
|
||||
const [hours, setHours] = useState(toHours(game.playtimeMinutes));
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -33,10 +31,7 @@ export default function ManualGameCard({game, onSaved}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -5,7 +5,6 @@ import {Gamepad2, Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {saveBacklogEntry} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
|
||||
@@ -16,7 +15,6 @@ import {formatPlaytime} from '../utils/games.js';
|
||||
*/
|
||||
export default function PlayingHint({game, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (game.status !== 'BACKLOG' || !(game.playtime2Weeks > 0)) return null;
|
||||
@@ -27,10 +25,7 @@ export default function PlayingHint({game, onChanged}) {
|
||||
const response = await saveBacklogEntry(game.appid, {
|
||||
status: 'PLAYING', rating: game.rating ?? null, notes: game.notes ?? null, priority: game.priority ?? null,
|
||||
});
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import {useEffect, useRef} from 'react';
|
||||
import {Navigate, Outlet, useLocation} from 'react-router-dom';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {sessionEvents} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
/**
|
||||
* Layout route of the pages that need a login. Without a session it opens the login page, which comes back here;
|
||||
* a 401 on one of these pages ends the session (see apiFetch).
|
||||
*/
|
||||
export default function RequireSession() {
|
||||
const {pathname, search} = useLocation();
|
||||
const expireSession = useSessionExpired();
|
||||
// In a ref, so the listener is added once and not again on every navigation.
|
||||
const expireRef = useRef(expireSession);
|
||||
expireRef.current = expireSession;
|
||||
// A render before the login page shows must not redirect again without the "session expired" state.
|
||||
const expiring = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Parallel requests each fire; only the first ends the session. Another tab may have removed the flag already.
|
||||
const onExpired = () => {
|
||||
if (expiring.current) return;
|
||||
expiring.current = true;
|
||||
expireRef.current();
|
||||
};
|
||||
sessionEvents.addEventListener('expired', onExpired);
|
||||
return () => sessionEvents.removeEventListener('expired', onExpired);
|
||||
}, []);
|
||||
|
||||
return hasSession() || expiring.current
|
||||
? <Outlet/>
|
||||
: <Navigate to="/login" replace state={{from: pathname + search}}/>;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
import {act, cleanup, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter, Outlet, Route, Routes, useLocation} from 'react-router-dom';
|
||||
import RequireSession from './RequireSession.jsx';
|
||||
import {sessionEvents} from '../utils/api.js';
|
||||
|
||||
const visitedLogin = [];
|
||||
|
||||
function LoginState() {
|
||||
const location = useLocation();
|
||||
visitedLogin.push(location.key);
|
||||
return <div data-testid="state">{JSON.stringify(location.state)}</div>;
|
||||
}
|
||||
|
||||
function renderAt(entry) {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route element={<Outlet/>}>
|
||||
<Route element={<RequireSession/>}>
|
||||
<Route path="/library" element={<p>library</p>}/>
|
||||
</Route>
|
||||
<Route path="/u/:name" element={<p>profile</p>}/>
|
||||
<Route path="/login" element={<LoginState/>}/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
const loginState = () => JSON.parse(screen.getByTestId('state').textContent);
|
||||
const expire = () => act(() => {
|
||||
sessionEvents.dispatchEvent(new Event('expired'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
visitedLogin.length = 0;
|
||||
});
|
||||
|
||||
describe('RequireSession', () => {
|
||||
it('sends a deep link without a session to the login page, which comes back to it', () => {
|
||||
renderAt('/library?status=PLAYING');
|
||||
|
||||
expect(loginState()).toEqual({from: '/library?status=PLAYING'});
|
||||
});
|
||||
|
||||
it('shows the page with a session', () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
renderAt('/library');
|
||||
|
||||
expect(screen.getByText('library')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('ends the session once when requests fail with a 401', () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
renderAt('/library?status=PLAYING');
|
||||
|
||||
expire();
|
||||
expire();
|
||||
|
||||
expect(loginState()).toEqual({sessionExpired: true, from: '/library?status=PLAYING'});
|
||||
expect(new Set(visitedLogin).size).toBe(1);
|
||||
expect(localStorage.getItem('tbp_session')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a public page alone on a 401', () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
renderAt('/u/someone');
|
||||
|
||||
expire();
|
||||
|
||||
expect(screen.getByText('profile')).toBeTruthy();
|
||||
expect(localStorage.getItem('tbp_session')).toBe('1');
|
||||
});
|
||||
it('ends the session when another tab already removed the session flag', () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
renderAt('/library');
|
||||
|
||||
localStorage.removeItem('tbp_session');
|
||||
expire();
|
||||
|
||||
expect(screen.queryByText('library')).toBeNull();
|
||||
expect(loginState()).toEqual({sessionExpired: true, from: '/library'});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {toast} from 'sonner';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {markSession} from '../utils/auth.js';
|
||||
import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
|
||||
|
||||
@@ -16,7 +15,6 @@ import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
|
||||
*/
|
||||
export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
||||
const {t} = useTranslation();
|
||||
const expireSession = useSessionExpired();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const link = async () => {
|
||||
@@ -24,11 +22,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
||||
try {
|
||||
const response = await startSteamLink();
|
||||
if (response.ok) return; // on the way to Steam: the button stays busy
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
} else {
|
||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||
}
|
||||
if (response.status !== 401) toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||
} catch (err) {
|
||||
console.error('Steam link error:', err);
|
||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||
@@ -40,10 +34,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await unlinkSteam();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
|
||||
Reference in new issue
Block a user