Public Access
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:
1 parent
03259d0515
commit
d4c0f8d8f3
19 files changed
+351
-35
No files matched your search
+26
-8
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user