feat(auth): tell the user when the session expired

Every 401 from Auth or Core now goes through useSessionExpired: the login page
says the session expired and returns to the last page after login. App no
longer removes a token that only the browser clock calls expired, so a backend
401 is the one signal. A 403 on the profile now shows an error instead.
This commit is contained in:
lobo committed 2026-09-24 13:29:09 +02:00
1 parent 03259d0515
commit d4c0f8d8f3
19 files changed
+351 -35

No files matched your search

+26 -8
View File
@@ -16,6 +16,7 @@ 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';
const TOP_GAMES = 5;
// The games endpoint accepts only the page sizes 20, 40 and 80.
@@ -31,6 +32,7 @@ const authHeaders = () => ({
const Dashboard = () => {
const {t} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [userData, setUserData] = useState(null);
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
const [topGames, setTopGames] = useState([]);
@@ -44,17 +46,25 @@ const Dashboard = () => {
const fetchSteamCount = useCallback(async () => {
try {
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {headers: authHeaders()});
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 fetch(`${import.meta.env.VITE_CORE_API_URL}${TOP_GAMES_URL}`, {headers: authHeaders()});
if (r.status === 401) {
expireSession();
return;
}
if (r.ok) {
const page = await r.json();
setTopGames((page.content ?? []).slice(0, TOP_GAMES));
@@ -62,11 +72,15 @@ const Dashboard = () => {
} catch (err) {
console.error('Error fetching top games:', err);
}
}, []);
}, [expireSession]);
const fetchPlaying = useCallback(async () => {
try {
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}${PLAYING_URL}`, {headers: authHeaders()});
if (r.status === 401) {
expireSession();
return;
}
if (r.ok) {
const page = await r.json();
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES));
@@ -74,7 +88,7 @@ const Dashboard = () => {
} catch (err) {
console.error('Error fetching playing games:', err);
}
}, []);
}, [expireSession]);
/**
* @param excludeAppid the current suggestion, so the new pick differs from it
@@ -84,6 +98,10 @@ const Dashboard = () => {
const query = excludeAppid ? `?exclude=${excludeAppid}` : '';
try {
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`, {headers: authHeaders()});
if (r.status === 401) {
expireSession();
return true;
}
if (r.ok) {
setSummary(await r.json());
setSummaryFailed(false);
@@ -94,7 +112,7 @@ const Dashboard = () => {
}
setSummaryFailed(true);
return false;
}, []);
}, [expireSession]);
const shuffleSuggestion = async () => {
setShuffling(true);
@@ -131,9 +149,8 @@ const Dashboard = () => {
if (data.steamId) {
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary()]);
}
} else if (profileResponse.status === 401 || profileResponse.status === 403) {
localStorage.removeItem('tbp_jwt');
navigate('/login');
} else if (profileResponse.status === 401) {
expireSession();
} else {
setError(t('INFO.ERROR.LOAD_PROFILE'));
}
@@ -146,13 +163,14 @@ const Dashboard = () => {
};
fetchUserData();
}, [navigate, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary]);
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary]);
const hasSteamId = !!userData?.steamId;
const sync = useLibrarySync({
enabled: hasSteamId,
expectInitialSync: hasSteamId && steamData.count === 0,
onComplete: reloadLibrary,
onUnauthorized: expireSession,
});
const forceSync = useForceSync(reloadLibrary);
+49
View File
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import Dashboard from './Dashboard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
const current = {location: null};
function Spy() {
current.location = useLocation();
return null;
}
const ok = (body) => ({ok: true, status: 200, json: async () => body});
const unauthorized = () => ({ok: false, status: 401, json: async () => ({})});
beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token');
});
afterEach(() => {
cleanup();
localStorage.clear();
});
describe('Dashboard', () => {
it('opens the login page when the core API answers 401', async () => {
globalThis.fetch = vi.fn(async (url) =>
url.includes('/api/users/me') ? ok({email: 'a@b.de', steamId: '76561198000000000'}) : unauthorized());
render(
<MemoryRouter initialEntries={['/dashboard']}>
<Routes>
<Route path="/dashboard" element={<><Dashboard/><Spy/></>}/>
<Route path="/login" element={<Spy/>}/>
</Routes>
</MemoryRouter>,
);
await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5));
await waitFor(() => expect(current.location.pathname).toBe('/login'));
expect(current.location.state).toEqual({sessionExpired: true, from: '/dashboard'});
expect(screen.queryByText('LIBRARY.EMPTY')).toBeNull();
});
});
+4 -3
View File
@@ -12,6 +12,7 @@ import BacklogCard from '../components/BacklogCard.jsx';
import GameCover from '../components/GameCover.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import {formatPlaytime} from '../utils/games.js';
import useSessionExpired from '../hooks/useSessionExpired.js';
const formatUnlockedAt = (instant, locale) => {
if (!instant) return '';
@@ -26,6 +27,7 @@ export default function GameDetail() {
const {t, i18n} = useTranslation();
const {appid} = useParams();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
@@ -51,8 +53,7 @@ export default function GameDetail() {
});
if (cancelled) return;
if (response.status === 401) {
localStorage.removeItem('tbp_jwt');
navigate('/login');
expireSession();
return;
}
if (response.status === 404) {
@@ -77,7 +78,7 @@ export default function GameDetail() {
return () => {
cancelled = true;
};
}, [appid, navigate, t]);
}, [appid, navigate, expireSession, t]);
const sortedAchievements = useMemo(() => {
if (!data?.achievements) return [];
+5 -3
View File
@@ -4,6 +4,7 @@ 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 {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw, Search, SearchX} from 'lucide-react';
@@ -33,6 +34,7 @@ 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);
@@ -68,8 +70,7 @@ export default function GameLibrary() {
});
if (response.status === 401) {
localStorage.removeItem('tbp_jwt');
navigate('/login');
expireSession();
return;
}
@@ -86,7 +87,7 @@ export default function GameLibrary() {
} finally {
setIsLoading(false);
}
}, [navigate, page, size, sort, status, q, t]);
}, [navigate, expireSession, page, size, sort, status, q, t]);
useEffect(() => {
load();
@@ -97,6 +98,7 @@ export default function GameLibrary() {
enabled: true,
expectInitialSync,
onComplete: load,
onUnauthorized: expireSession,
});
const updateParams = (next) => {
+11 -5
View File
@@ -1,14 +1,14 @@
import {useTranslation} from 'react-i18next';
import {useState} from 'react';
import {Link, Navigate, useNavigate} from 'react-router-dom';
import {Loader2} from 'lucide-react';
import {Link, Navigate, useLocation, useNavigate} from 'react-router-dom';
import {Clock, Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import AuthLayout from '../components/AuthLayout.jsx';
import FeedbackAlert from '../components/FeedbackAlert.jsx';
import {getValidToken} from '../utils/auth.js';
import {getValidToken, safeReturnPath} from '../utils/auth.js';
const LOGIN_ERROR_KEY = {
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
@@ -18,6 +18,8 @@ const LOGIN_ERROR_KEY = {
export default function LoginPage() {
const {t} = useTranslation();
const navigate = useNavigate();
const {state} = useLocation();
const target = safeReturnPath(state?.from) ?? '/dashboard';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -50,7 +52,7 @@ export default function LoginPage() {
if (response.ok) {
localStorage.setItem('tbp_jwt', data.token);
navigate('/dashboard', {replace: true});
navigate(target, {replace: true});
} else {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
setFeedback({
@@ -71,7 +73,7 @@ export default function LoginPage() {
};
if (getValidToken()) {
return <Navigate to="/dashboard" replace/>;
return <Navigate to={target} replace/>;
}
return (
@@ -84,6 +86,10 @@ export default function LoginPage() {
</Link>
</>}
>
{state?.sessionExpired && !feedback.message && (
<FeedbackAlert message={t('LOGIN.SESSION_EXPIRED')} isError={false} icon={Clock}/>
)}
<form onSubmit={handleLogin} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="email">{t('LOGIN.EMAIL')}</Label>
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
import LoginPage from './LoginPage.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const b64 = (o) => btoa(JSON.stringify(o)).replace(/=+$/, '');
const TOKEN = `${b64({alg: 'EdDSA'})}.${b64({exp: Math.floor(Date.now() / 1000) + 3600})}.sig`;
function CurrentPath() {
const location = useLocation();
return <div data-testid="path">{location.pathname + location.search}</div>;
}
function renderLogin(state) {
render(
<MemoryRouter initialEntries={[{pathname: '/login', state}]}>
<Routes>
<Route path="/login" element={<LoginPage/>}/>
<Route path="*" element={<CurrentPath/>}/>
</Routes>
</MemoryRouter>,
);
}
function submit() {
fireEvent.change(screen.getByLabelText('LOGIN.EMAIL'), {target: {value: 'a@b.de'}});
fireEvent.change(screen.getByLabelText('LOGIN.PASSWORD'), {target: {value: 'secret'}});
fireEvent.click(screen.getByRole('button', {name: 'LOGIN.SUBMIT'}));
}
describe('LoginPage', () => {
beforeEach(() => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({token: TOKEN})}));
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('says that the session expired', () => {
renderLogin({sessionExpired: true, from: '/library'});
expect(screen.getByText('LOGIN.SESSION_EXPIRED')).toBeTruthy();
});
it('shows no message on a normal visit', () => {
renderLogin(undefined);
expect(screen.queryByText('LOGIN.SESSION_EXPIRED')).toBeNull();
});
it('goes back to the page of the expired session after login', async () => {
renderLogin({sessionExpired: true, from: '/library?status=PLAYING'});
submit();
await waitFor(() => expect(screen.getByTestId('path').textContent).toBe('/library?status=PLAYING'));
expect(localStorage.getItem('tbp_jwt')).toBe(TOKEN);
});
it('ignores a return path outside the app', async () => {
renderLogin({sessionExpired: true, from: '//evil.example'});
submit();
await waitFor(() => expect(screen.getByTestId('path').textContent).toBe('/dashboard'));
});
it('hides the expired message once a login attempt fails', async () => {
fetch.mockResolvedValueOnce({ok: false, status: 401, headers: new Headers(), json: async () => ({code: 'INVALID_CREDENTIALS'})});
renderLogin({sessionExpired: true, from: '/library'});
submit();
await waitFor(() => expect(screen.getByText('LOGIN.ERROR_INVALID_CREDENTIALS')).toBeTruthy());
expect(screen.queryByText('LOGIN.SESSION_EXPIRED')).toBeNull();
});
});
+5 -5
View File
@@ -17,6 +17,7 @@ import {Switch} from '@/components/ui/switch';
import FormField from '../components/FormField.jsx';
import {settingsSchema} from '../utils/validation.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
const BACKEND_ERROR_KEY = {
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
@@ -37,6 +38,7 @@ const formatDate = (value, locale) => {
export default function SettingsPage() {
const {t, i18n} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
const [profile, setProfile] = useState(null);
@@ -71,8 +73,7 @@ export default function SettingsPage() {
});
if (response.status === 401) {
localStorage.removeItem('tbp_jwt');
navigate('/login');
expireSession();
return;
}
@@ -98,7 +99,7 @@ export default function SettingsPage() {
};
load();
}, [navigate, reset, t]);
}, [navigate, expireSession, reset, t]);
const persistSettings = async (payload) => {
const token = localStorage.getItem('tbp_jwt');
@@ -118,8 +119,7 @@ export default function SettingsPage() {
});
if (response.status === 401) {
localStorage.removeItem('tbp_jwt');
navigate('/login');
expireSession();
return {ok: false};
}