Compare commits

...
2 Commits
Author SHA1 Message Date
lobo 444b03acee feat(library): search IGDB and add games by hand
Build, Test & Push Frontend / docker (push) Successful in 23s
Build, Test & Push Frontend / quality-check (push) Successful in 13s
2026-09-25 14:37:21 +02:00
lobo d9a39dda22 feat(auth): use the cookie session and refresh on 401
The token is no longer stored; a tbp_session flag remembers the
login. apiFetch renews the session once on a 401 and sends the
request again. Parallel requests and other tabs share one refresh,
because Auth revokes the session when a refresh token is used twice.
Only a 401 from the refresh ends the session; a 502 or no network
shows the normal error.
2026-09-25 13:45:08 +02:00
44 changed files with 983 additions and 169 deletions

No files matched your search

+3 -2
View File
@@ -34,6 +34,8 @@ To Be Played is split into three repositories:
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
Saved in Auth, so it follows you to every device. Saved in Auth, so it follows you to every device.
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout. - German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
- **Session**: the tokens are HttpOnly cookies that no script can read. `apiFetch` renews an expired access token
once and sends the request again; parallel requests and other tabs share one refresh.
## Tech stack ## Tech stack
@@ -85,8 +87,7 @@ npm run build
## Roadmap ## Roadmap
- Mood filter for "What to play next". - Mood filter for "What to play next".
- Move the JWT from `localStorage` to an httpOnly cookie (needs Auth changes) and add a Content Security Policy in - A Content Security Policy in `nginx.conf`.
`nginx.conf`.
- Installable PWA. - Installable PWA.
## License ## License
+28 -1
View File
@@ -150,7 +150,7 @@
"LIBRARY": { "LIBRARY": {
"TITLE": "Spielebibliothek", "TITLE": "Spielebibliothek",
"LOADING": "Lade Spiele …", "LOADING": "Lade Spiele …",
"EMPTY": "Noch keine Spiele. Synchronisiere deine Steam-Bibliothek.", "EMPTY": "Noch keine Spiele. Synchronisiere deine Steam-Bibliothek oder füge ein Spiel hinzu.",
"ERROR_LOAD": "Spiele konnten nicht geladen werden.", "ERROR_LOAD": "Spiele konnten nicht geladen werden.",
"PER_PAGE": "Pro Seite:", "PER_PAGE": "Pro Seite:",
"SORT_BY": "Sortieren:", "SORT_BY": "Sortieren:",
@@ -305,5 +305,32 @@
"EMPTY": "Dein Backlog ist leer. Stark!", "EMPTY": "Dein Backlog ist leer. Stark!",
"EMPTY_BUDGET": "Kein Backlog-Spiel passt zu dieser Dauer. Versuch eine längere.", "EMPTY_BUDGET": "Kein Backlog-Spiel passt zu dieser Dauer. Versuch eine längere.",
"ERROR_LOAD": "Die Vorschläge konnten nicht geladen werden." "ERROR_LOAD": "Die Vorschläge konnten nicht geladen werden."
},
"MANUAL_GAME": {
"ADD_BUTTON": "Spiel hinzufügen",
"ADD_TITLE": "Spiel hinzufügen",
"ADD_HINT": "Für Spiele, die du außerhalb von Steam besitzt, z. B. auf PlayStation, Switch oder Xbox.",
"SEARCH_PLACEHOLDER": "Spiel nach Namen suchen",
"SEARCHING": "Suche…",
"NO_HITS": "Kein Spiel gefunden.",
"UNAVAILABLE": "Spiele hinzufügen ist auf diesem Server aus.",
"SEARCH_FAILED": "Die Spielesuche antwortet nicht. Versuche es später noch einmal.",
"IN_LIBRARY": "In der Bibliothek",
"BACK_TO_SEARCH": "Zurück zur Suche",
"ADD": "Hinzufügen",
"ADDED": "{{name}} ist jetzt in deiner Bibliothek.",
"ADD_FAILED": "Das Spiel konnte nicht hinzugefügt werden.",
"ALREADY_IN_LIBRARY": "Dieses Spiel ist schon in deiner Bibliothek.",
"CLOSE": "Schließen",
"TITLE": "Selbst hinzugefügt",
"HINT": "Du hast dieses Spiel selbst hinzugefügt. Steam erfasst es nicht, trage die Stunden hier ein.",
"PLATFORM": "Plattform",
"HOURS": "Gespielte Stunden",
"REMOVE": "Aus der Bibliothek entfernen",
"REMOVE_CONFIRM": "Dieses Spiel mit Status, Bewertung und Notizen entfernen?",
"REMOVE_YES": "Ja, entfernen",
"REMOVE_NO": "Abbrechen",
"REMOVED": "Spiel entfernt.",
"NO_ACHIEVEMENTS": "Erfolge gibt es nur für Steam-Spiele."
} }
} }
+28 -1
View File
@@ -150,7 +150,7 @@
"LIBRARY": { "LIBRARY": {
"TITLE": "Game Library", "TITLE": "Game Library",
"LOADING": "Loading games …", "LOADING": "Loading games …",
"EMPTY": "No games yet. Try syncing your Steam library.", "EMPTY": "No games yet. Sync your Steam library or add a game.",
"ERROR_LOAD": "Could not load your games.", "ERROR_LOAD": "Could not load your games.",
"PER_PAGE": "Per page:", "PER_PAGE": "Per page:",
"SORT_BY": "Sort by:", "SORT_BY": "Sort by:",
@@ -305,5 +305,32 @@
"EMPTY": "Your backlog is empty. Nice!", "EMPTY": "Your backlog is empty. Nice!",
"EMPTY_BUDGET": "No backlog game fits this length. Try a longer one.", "EMPTY_BUDGET": "No backlog game fits this length. Try a longer one.",
"ERROR_LOAD": "Could not load the picks." "ERROR_LOAD": "Could not load the picks."
},
"MANUAL_GAME": {
"ADD_BUTTON": "Add game",
"ADD_TITLE": "Add a game",
"ADD_HINT": "For games you own outside Steam, e.g. on PlayStation, Switch or Xbox.",
"SEARCH_PLACEHOLDER": "Search a game by name",
"SEARCHING": "Searching…",
"NO_HITS": "No game found.",
"UNAVAILABLE": "Adding games is off on this server.",
"SEARCH_FAILED": "The game search did not answer. Try again later.",
"IN_LIBRARY": "In library",
"BACK_TO_SEARCH": "Back to search",
"ADD": "Add",
"ADDED": "{{name}} added to your library.",
"ADD_FAILED": "Could not add the game.",
"ALREADY_IN_LIBRARY": "This game is already in your library.",
"CLOSE": "Close",
"TITLE": "Added by hand",
"HINT": "You added this game yourself. Steam does not track it, so set the hours here.",
"PLATFORM": "Platform",
"HOURS": "Hours played",
"REMOVE": "Remove from library",
"REMOVE_CONFIRM": "Remove this game with its status, rating and notes?",
"REMOVE_YES": "Yes, remove",
"REMOVE_NO": "Cancel",
"REMOVED": "Game removed.",
"NO_ACHIEVEMENTS": "Achievements exist only for Steam games."
} }
} }
+7 -4
View File
@@ -18,7 +18,8 @@ import {Toaster} from '@/components/ui/sonner';
import {TooltipProvider} from '@/components/ui/tooltip'; import {TooltipProvider} from '@/components/ui/tooltip';
import PageBackground from './components/PageBackground.jsx'; import PageBackground from './components/PageBackground.jsx';
import {usePreferences} from './context/PreferencesContext.jsx'; import {usePreferences} from './context/PreferencesContext.jsx';
import {clearToken, getToken} from './utils/auth.js'; import {clearSession, hasSession} from './utils/auth.js';
import {logout} from './utils/api.js';
function App() { function App() {
const {t} = useTranslation(); const {t} = useTranslation();
@@ -32,7 +33,7 @@ function App() {
useEffect(() => { useEffect(() => {
// No local expiry check here: a 401 from the backend ends the session (useSessionExpired), and a browser // No local expiry check here: a 401 from the backend ends the session (useSessionExpired), and a browser
// clock that runs ahead would otherwise end it silently. // clock that runs ahead would otherwise end it silently.
setIsAuthenticated(!!getToken()); setIsAuthenticated(hasSession());
}, [location]); }, [location]);
// On login and on page load; the cached look shows until Auth answers. // On login and on page load; the cached look shows until Auth answers.
@@ -40,8 +41,10 @@ function App() {
if (isAuthenticated) syncAppearance(); if (isAuthenticated) syncAppearance();
}, [isAuthenticated, syncAppearance]); }, [isAuthenticated, syncAppearance]);
const handleLogout = () => { // Waits for Auth: a late logout answer would clear the cookies of a login that came after it.
clearToken(); const handleLogout = async () => {
await logout();
clearSession();
resetAppearance(); resetAppearance();
setIsAuthenticated(false); setIsAuthenticated(false);
navigate('/login'); navigate('/login');
+4 -7
View File
@@ -10,9 +10,6 @@ vi.mock('./hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', last
vi.mock('./hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})})); vi.mock('./hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
const DEBOUNCE_WAIT_MS = 700; const DEBOUNCE_WAIT_MS = 700;
const b64 = (o) => btoa(JSON.stringify(o)).replace(/=+$/, '');
// Expired by the browser clock. A clock that runs ahead of the server makes the backend still accept it.
const EXPIRED_BY_LOCAL_CLOCK = `${b64({alg: 'EdDSA'})}.${b64({exp: Math.floor(Date.now() / 1000) - 60})}.sig`;
const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1}; const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1};
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms))); const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -25,7 +22,7 @@ function Spy() {
} }
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', EXPIRED_BY_LOCAL_CLOCK); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page})); globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page}));
}); });
@@ -35,14 +32,13 @@ afterEach(() => {
}); });
describe('App', () => { describe('App', () => {
it('keeps a token that only the browser clock calls expired; a 401 from the backend ends the session', async () => { it('ends the session on a 401 that the refresh cannot fix', async () => {
render( render(
<MemoryRouter initialEntries={['/library']}> <MemoryRouter initialEntries={['/library']}>
<PreferencesProvider><App/><Spy/></PreferencesProvider> <PreferencesProvider><App/><Spy/></PreferencesProvider>
</MemoryRouter>, </MemoryRouter>,
); );
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy()); await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
expect(localStorage.getItem('tbp_jwt')).toBe(EXPIRED_BY_LOCAL_CLOCK);
fetch.mockImplementation(async () => ({ok: false, status: 401, json: async () => ({})})); fetch.mockImplementation(async () => ({ok: false, status: 401, json: async () => ({})}));
fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'half'}}); fireEvent.change(screen.getByRole('searchbox'), {target: {value: 'half'}});
@@ -50,6 +46,7 @@ describe('App', () => {
await waitFor(() => expect(current.location.pathname).toBe('/login')); await waitFor(() => expect(current.location.pathname).toBe('/login'));
expect(current.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'}); expect(current.location.state).toEqual({sessionExpired: true, from: '/library?q=half&page=0'});
expect(localStorage.getItem('tbp_jwt')).toBeNull(); expect(localStorage.getItem('tbp_session')).toBeNull();
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/refresh'), expect.objectContaining({method: 'POST'}));
}); });
}); });
+212
View File
@@ -0,0 +1,212 @@
import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ArrowLeft, Loader2, Plus, Search, X} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
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 {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import GameCover from './GameCover.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {GAME_STATUSES} from '../utils/backlog.js';
import {addGame, hoursToMinutes, searchGames} from '../utils/manualGames.js';
const SEARCH_DEBOUNCE_MS = 400;
const MIN_QUERY = 2;
/**
* Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch.
*/
export default function AddGamePanel({onClose}) {
const {t} = useTranslation();
const navigate = useNavigate();
const expireSession = useSessionExpired();
const [query, setQuery] = useState('');
// idle | loading | done | unavailable | failed
const [searchState, setSearchState] = useState('idle');
const [results, setResults] = useState([]);
const [picked, setPicked] = useState(null);
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < MIN_QUERY) {
setSearchState('idle');
setResults([]);
return undefined;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
setSearchState('loading');
try {
const response = await searchGames(trimmed, controller.signal);
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
setSearchState('failed');
return;
}
const data = await response.json();
setResults(data.results ?? []);
setSearchState(data.available ? 'done' : 'unavailable');
} catch (err) {
if (err.name !== 'AbortError') setSearchState('failed');
}
}, SEARCH_DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query, expireSession]);
return (
<Card className="p-5 gap-4">
<div className="flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-semibold">
<Plus className="size-4 text-primary"/>
{t('MANUAL_GAME.ADD_TITLE')}
</h2>
<Button variant="ghost" size="icon" onClick={onClose} aria-label={t('MANUAL_GAME.CLOSE')}>
<X/>
</Button>
</div>
{picked ? (
<AddForm hit={picked} onBack={() => setPicked(null)} navigate={navigate} expireSession={expireSession}/>
) : (
<>
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input type="search" autoFocus value={query} maxLength={100} className="pl-8"
placeholder={t('MANUAL_GAME.SEARCH_PLACEHOLDER')}
aria-label={t('MANUAL_GAME.SEARCH_PLACEHOLDER')}
onChange={(e) => setQuery(e.target.value)}/>
</div>
{searchState === 'loading' && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin"/>{t('MANUAL_GAME.SEARCHING')}
</p>
)}
{searchState === 'unavailable' && <p className="text-sm text-muted-foreground">{t('MANUAL_GAME.UNAVAILABLE')}</p>}
{searchState === 'failed' && <p className="text-sm text-destructive">{t('MANUAL_GAME.SEARCH_FAILED')}</p>}
{searchState === 'done' && results.length === 0 && (
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.NO_HITS')}</p>
)}
{searchState === 'done' && results.length > 0 && (
<ul className="divide-y rounded-md border">
{results.map((hit) => (
<li key={hit.igdbId}>
<button type="button" disabled={hit.inLibrary} onClick={() => setPicked(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{hit.name}{hit.year ? ` (${hit.year})` : ''}
</span>
<span className="block truncate text-xs text-muted-foreground">
{hit.platforms.join(' · ')}
</span>
</span>
{hit.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
</button>
</li>
))}
</ul>
)}
</>
)}
</Card>
);
}
function AddForm({hit, onBack, navigate, expireSession}) {
const {t} = useTranslation();
const [platform, setPlatform] = useState(hit.platforms[0] ?? '');
const [status, setStatus] = useState('BACKLOG');
const [hours, setHours] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault();
setBusy(true);
try {
const response = await addGame({
igdbId: hit.igdbId, platform: platform.trim(), status, playtimeMinutes: hoursToMinutes(hours),
});
if (response.status === 401) {
expireSession();
return;
}
if (response.status === 409) {
toast.error(t('MANUAL_GAME.ALREADY_IN_LIBRARY'));
return;
}
if (!response.ok) {
toast.error(t('MANUAL_GAME.ADD_FAILED'));
return;
}
const {appid} = await response.json();
toast.success(t('MANUAL_GAME.ADDED', {name: hit.name}));
navigate(`/library/${appid}`);
} catch (err) {
console.error('add game error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<form onSubmit={submit} className="space-y-4">
<div className="flex items-center gap-3">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-24 shrink-0"/>
<div className="min-w-0 font-medium">{hit.name}{hit.year ? ` (${hit.year})` : ''}</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="add-platform">{t('MANUAL_GAME.PLATFORM')}</Label>
{hit.platforms.length > 0 ? (
<Select value={platform} onValueChange={setPlatform}>
<SelectTrigger id="add-platform" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent>
{hit.platforms.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input id="add-platform" value={platform} maxLength={32} onChange={(e) => setPlatform(e.target.value)}/>
)}
</div>
<div className="space-y-2">
<Label htmlFor="add-status">{t('BACKLOG.STATUS_LABEL')}</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger id="add-status" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent>
{GAME_STATUSES.map((s) => <SelectItem key={s} value={s}>{t(`BACKLOG.STATUS.${s}`)}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="add-hours">{t('MANUAL_GAME.HOURS')}</Label>
<Input id="add-hours" type="number" min="0" step="0.5" inputMode="decimal" value={hours}
onChange={(e) => setHours(e.target.value)}/>
</div>
</div>
<div className="flex justify-between gap-3">
<Button type="button" variant="ghost" onClick={onBack} disabled={busy}>
<ArrowLeft/>{t('MANUAL_GAME.BACK_TO_SEARCH')}
</Button>
<Button type="submit" disabled={busy || !platform.trim()}>
{busy && <Loader2 className="animate-spin"/>}
<Plus/>{t('MANUAL_GAME.ADD')}
</Button>
</div>
</form>
);
}
+100
View File
@@ -0,0 +1,100 @@
// @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} from 'react-router-dom';
import {toast} from 'sonner';
import AddGamePanel from './AddGamePanel.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const zelda = {
appid: -1025, igdbId: 1025, name: 'Zelda', year: 2017, imageUrl: 'https://images.igdb.com/z.jpg',
platforms: ['Nintendo Switch', 'Wii U'], inLibrary: false,
};
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})) {
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST'
? post
: json(200, {available: true, results: [zelda, portal]}));
render(
<MemoryRouter initialEntries={['/library']}>
<Routes>
<Route path="/library" element={<AddGamePanel onClose={() => {}}/>}/>
<Route path="/library/:appid" element={<p>detail page</p>}/>
</Routes>
</MemoryRouter>,
);
}
const search = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('AddGamePanel', () => {
it('does not search below two characters', async () => {
renderPanel();
search('z');
await new Promise((resolve) => setTimeout(resolve, 600));
expect(fetch).not.toHaveBeenCalled();
});
it('searches once after typing stops and blocks games already in the library', async () => {
renderPanel();
search('ze');
search('zel');
const hit = await screen.findByRole('button', {name: /Zelda \(2017\)/});
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch.mock.calls[0][0]).toContain('/api/library/game-search?q=zel');
expect(hit.disabled).toBe(false);
expect(screen.getByRole('button', {name: /Portal/}).disabled).toBe(true);
});
it('adds the picked game with the first platform and the hours, then opens its page', async () => {
renderPanel();
search('zelda');
fireEvent.click(await screen.findByRole('button', {name: /Zelda/}));
fireEvent.change(screen.getByLabelText('MANUAL_GAME.HOURS'), {target: {value: '1.5'}});
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.ADD'}));
expect(await screen.findByText('detail page')).toBeTruthy();
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'POST');
expect(url).toContain('/api/library/games');
expect(JSON.parse(init.body)).toEqual({
igdbId: 1025, platform: 'Nintendo Switch', status: 'BACKLOG', playtimeMinutes: 90,
});
});
it('says so when the game is already in the library', async () => {
renderPanel(json(409, {code: 'ALREADY_IN_LIBRARY'}));
search('zelda');
fireEvent.click(await screen.findByRole('button', {name: /Zelda/}));
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.ADD'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('MANUAL_GAME.ALREADY_IN_LIBRARY'));
expect(screen.queryByText('detail page')).toBeNull();
});
it('shows that adding is off when Core has no IGDB app', async () => {
renderPanel();
fetch.mockImplementation(async () => json(200, {available: false, results: []}));
search('zelda');
expect(await screen.findByText('MANUAL_GAME.UNAVAILABLE')).toBeTruthy();
});
});
+2 -2
View File
@@ -13,7 +13,7 @@ import {cn} from '@/lib/utils';
import {STATUS_META} from './GameStatusBadge.jsx'; import {STATUS_META} from './GameStatusBadge.jsx';
import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js'; import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const NOTES_MAX = 2000; const NOTES_MAX = 2000;
@@ -42,7 +42,7 @@ export default function BacklogCard({game, onSaved}) {
const set = (field) => (value) => setForm((f) => ({...f, [field]: value})); const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
const save = async () => { const save = async () => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
+1 -1
View File
@@ -12,7 +12,7 @@ const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
describe('BacklogCard', () => { describe('BacklogCard', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)})); globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)}));
}); });
+1 -1
View File
@@ -12,7 +12,7 @@ const renderHint = (props) => render(<MemoryRouter><CompletedHint game={game} on
describe('CompletedHint', () => { describe('CompletedHint', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => ({ globalThis.fetch = vi.fn(async (url, init) => ({
ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null}), ok: true, status: 200, json: async () => ({...JSON.parse(init.body), rating: 5, notes: 'keep', priority: null}),
})); }));
+1 -1
View File
@@ -30,7 +30,7 @@ export default function CurrentlyPlayingCard({games, syncing}) {
{games.map((g) => ( {games.map((g) => (
<li key={g.appid}> <li key={g.appid}>
<Link to={`/library/${g.appid}`} className="group block space-y-1.5"> <Link to={`/library/${g.appid}`} className="group block space-y-1.5">
<GameCover appid={g.appid} className="w-full transition-opacity group-hover:opacity-80"/> <GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-full transition-opacity group-hover:opacity-80"/>
<div className="truncate text-sm font-medium group-hover:text-primary transition-colors"> <div className="truncate text-sm font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`} {g.name ?? `App ${g.appid}`}
</div> </div>
+4 -2
View File
@@ -6,8 +6,10 @@ import {coverUrl} from '@/utils/games.js';
/** /**
* Steam store header (460x215). Falls back to a neutral tile when the app has no header image. * Steam store header (460x215). Falls back to a neutral tile when the app has no header image.
*
* @param imageUrl picture for a game that has no Steam art (added by hand from IGDB); wins over the Steam header
*/ */
export default function GameCover({appid, className}) { export default function GameCover({appid, imageUrl, className}) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
return ( return (
@@ -18,7 +20,7 @@ export default function GameCover({appid, className}) {
</div> </div>
) : ( ) : (
<img <img
src={coverUrl(appid)} src={imageUrl ?? coverUrl(appid)}
alt="" alt=""
loading="lazy" loading="lazy"
className="size-full object-cover" className="size-full object-cover"
+109
View File
@@ -0,0 +1,109 @@
import {useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {Hand, Loader2, Trash2} from 'lucide-react';
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, updateManualGame} from '../utils/manualGames.js';
// Two decimals: then hoursToMinutes gives back the same minute, so an unchanged form is not dirty.
const toHours = (minutes) => (minutes > 0 ? String(Math.round((minutes / 60) * 100) / 100) : '');
/**
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
*
* @param onSaved gets the saved platform and playtimeMinutes
*/
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);
const [confirmRemove, setConfirmRemove] = useState(false);
const playtimeMinutes = hoursToMinutes(hours);
const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes;
const run = async (request, onOk) => {
setBusy(true);
try {
const response = await request();
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
toast.error(t('BACKLOG.SAVE_FAILED'));
return;
}
onOk();
} catch (err) {
console.error('manual game error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const save = () => run(() => updateManualGame(game.appid, {platform: platform.trim(), playtimeMinutes}), () => {
onSaved({platform: platform.trim(), playtimeMinutes});
toast.success(t('BACKLOG.SAVED'));
});
const remove = () => run(() => removeGame(game.appid), () => {
toast.success(t('MANUAL_GAME.REMOVED'));
navigate('/library', {replace: true});
});
return (
<Card className="p-6 gap-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Hand className="size-4 text-primary"/>
{t('MANUAL_GAME.TITLE')}
</h2>
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.HINT')}</p>
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="manual-platform">{t('MANUAL_GAME.PLATFORM')}</Label>
<Input id="manual-platform" value={platform} maxLength={32}
onChange={(e) => setPlatform(e.target.value)}/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-hours">{t('MANUAL_GAME.HOURS')}</Label>
<Input id="manual-hours" type="number" min="0" step="0.5" inputMode="decimal" value={hours}
onChange={(e) => setHours(e.target.value)}/>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
{confirmRemove ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm">{t('MANUAL_GAME.REMOVE_CONFIRM')}</span>
<Button variant="destructive" size="sm" onClick={remove} disabled={busy}>
<Trash2/>{t('MANUAL_GAME.REMOVE_YES')}
</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirmRemove(false)} disabled={busy}>
{t('MANUAL_GAME.REMOVE_NO')}
</Button>
</div>
) : (
<Button variant="outline" size="sm" onClick={() => setConfirmRemove(true)} disabled={busy}>
<Trash2/>{t('MANUAL_GAME.REMOVE')}
</Button>
)}
<Button onClick={save} disabled={busy || !dirty || !platform.trim()}>
{busy && <Loader2 className="animate-spin"/>}
{t('BACKLOG.SAVE')}
</Button>
</div>
</Card>
);
}
+1 -1
View File
@@ -81,7 +81,7 @@ export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}
to={`/library/${suggestion.appid}`} to={`/library/${suggestion.appid}`}
className="group flex items-center gap-3 rounded-lg border p-2 transition-colors hover:bg-muted/50" className="group flex items-center gap-3 rounded-lg border p-2 transition-colors hover:bg-muted/50"
> >
<GameCover appid={suggestion.appid} className="w-24 shrink-0"/> <GameCover appid={suggestion.appid} imageUrl={suggestion.imageUrl} className="w-24 shrink-0"/>
<span className="min-w-0 truncate font-medium group-hover:text-primary transition-colors"> <span className="min-w-0 truncate font-medium group-hover:text-primary transition-colors">
{suggestion.name ?? `App ${suggestion.appid}`} {suggestion.name ?? `App ${suggestion.appid}`}
</span> </span>
+1 -1
View File
@@ -12,7 +12,7 @@ const renderHint = (props) => render(<MemoryRouter><PlayingHint game={game} onCh
describe('PlayingHint', () => { describe('PlayingHint', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)})); globalThis.fetch = vi.fn(async (url, init) => ({ok: true, status: 200, json: async () => JSON.parse(init.body)}));
}); });
+2 -2
View File
@@ -4,7 +4,7 @@ import {useTranslation} from 'react-i18next';
import {toast} from 'sonner'; import {toast} from 'sonner';
import useSessionExpired from './useSessionExpired.js'; import useSessionExpired from './useSessionExpired.js';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const RELOAD_DELAY_MS = 4000; const RELOAD_DELAY_MS = 4000;
@@ -19,7 +19,7 @@ export default function useForceSync(onStarted) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const trigger = async () => { const trigger = async () => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
+2 -2
View File
@@ -1,6 +1,6 @@
import {useEffect, useRef, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const POLL_INTERVAL_MS = 3000; const POLL_INTERVAL_MS = 3000;
const MAX_POLL_DURATION_MS = 120_000; const MAX_POLL_DURATION_MS = 120_000;
@@ -34,7 +34,7 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
const startedAt = Date.now(); const startedAt = Date.now();
const fetchOnce = async () => { const fetchOnce = async () => {
if (!getToken()) return; if (!hasSession()) return;
try { try {
const response = await apiFetch( const response = await apiFetch(
+1 -1
View File
@@ -17,7 +17,7 @@ const wrapper = ({children}) => (
const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper}); const renderSave = () => renderHook(() => ({save: useSaveAppearance(), preferences: usePreferences()}), {wrapper});
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
}); });
afterEach(() => { afterEach(() => {
+2 -2
View File
@@ -1,7 +1,7 @@
import {useCallback, useRef} from 'react'; import {useCallback, useRef} from 'react';
import {useLocation, useNavigate} from 'react-router-dom'; import {useLocation, useNavigate} from 'react-router-dom';
import {useResetAppearance} from '../context/PreferencesContext.jsx'; import {useResetAppearance} from '../context/PreferencesContext.jsx';
import {clearToken} from '../utils/auth.js'; import {clearSession} from '../utils/auth.js';
/** /**
* Returns a function for a 401 answer: it removes the token and the user's look and opens the login page, which * Returns a function for a 401 answer: it removes the token and the user's look and opens the login page, which
@@ -17,7 +17,7 @@ export default function useSessionExpired() {
return useCallback(() => { return useCallback(() => {
const {pathname, search} = locationRef.current; const {pathname, search} = locationRef.current;
clearToken(); clearSession();
resetAppearance(); resetAppearance();
navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}}); navigate('/login', {replace: true, state: {sessionExpired: true, from: pathname + search}});
}, [navigate, resetAppearance]); }, [navigate, resetAppearance]);
+6 -6
View File
@@ -1,18 +1,18 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {sessionStamp} from '../utils/auth.js';
/** /**
* The user's Steam name and avatar from Core, or null while loading, without a Steam ID or on any error. Only * The user's Steam name and avatar from Core, or null while loading, without a Steam ID or on any error. Only
* decoration, so a failed call shows the default icon and never ends the session. Loads again for a new token * decoration, so a failed call shows the default icon and never ends the session. Loads again after a new login
* (new Steam ID or another login) on the next render, e.g. the next navigation. * or a settings save (see sessionStamp) on the next render, e.g. the next navigation.
*/ */
export default function useSteamProfile(enabled) { export default function useSteamProfile(enabled) {
const [profile, setProfile] = useState(null); const [profile, setProfile] = useState(null);
const token = getToken(); const stamp = sessionStamp();
useEffect(() => { useEffect(() => {
if (!enabled || !token) { if (!enabled || !stamp) {
setProfile(null); setProfile(null);
return undefined; return undefined;
} }
@@ -29,7 +29,7 @@ export default function useSteamProfile(enabled) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [enabled, token]); }, [enabled, stamp]);
return profile; return profile;
} }
+8 -8
View File
@@ -6,7 +6,7 @@ import useSteamProfile from './useSteamProfile.js';
const ok = (body) => ({ok: true, status: 200, json: async () => body}); const ok = (body) => ({ok: true, status: 200, json: async () => body});
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'old-token'); localStorage.setItem('tbp_session', 'first-login');
}); });
afterEach(() => { afterEach(() => {
@@ -15,21 +15,21 @@ afterEach(() => {
}); });
describe('useSteamProfile', () => { describe('useSteamProfile', () => {
it('loads again after a new token was stored, e.g. with a new Steam ID', async () => { it('loads again after the session stamp changed, e.g. with a new Steam ID', async () => {
globalThis.fetch = vi.fn(async (url, init) => ok(init.headers.Authorization === 'Bearer new-token' let steamName = 'old';
? {personaName: 'New', avatarUrl: 'https://avatars.steamstatic.com/new_full.jpg'} globalThis.fetch = vi.fn(async () => ok({personaName: steamName, avatarUrl: `https://avatars.steamstatic.com/${steamName}_full.jpg`}));
: {personaName: 'Old', avatarUrl: 'https://avatars.steamstatic.com/old_full.jpg'}));
const {result, rerender} = renderHook(() => useSteamProfile(true)); const {result, rerender} = renderHook(() => useSteamProfile(true));
await waitFor(() => expect(result.current?.avatarUrl).toContain('old_full')); await waitFor(() => expect(result.current?.avatarUrl).toContain('old_full'));
localStorage.setItem('tbp_jwt', 'new-token'); steamName = 'new';
localStorage.setItem('tbp_session', 'settings-saved');
rerender(); rerender();
await waitFor(() => expect(result.current?.avatarUrl).toContain('new_full')); await waitFor(() => expect(result.current?.avatarUrl).toContain('new_full'));
}); });
it('sends nothing without a token, e.g. right after the session expired', async () => { it('sends nothing without a session, e.g. right after it expired', async () => {
localStorage.removeItem('tbp_jwt'); localStorage.removeItem('tbp_session');
globalThis.fetch = vi.fn(async () => ({ok: false, status: 401, json: async () => ({})})); globalThis.fetch = vi.fn(async () => ({ok: false, status: 401, json: async () => ({})}));
renderHook(() => useSteamProfile(true)); renderHook(() => useSteamProfile(true));
+10 -8
View File
@@ -26,7 +26,7 @@ import useSteamProfile from '../hooks/useSteamProfile.js';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const TOP_GAMES = 5; const TOP_GAMES = 5;
// The games endpoint accepts only the page sizes 20, 40 and 80. // The games endpoint accepts only the page sizes 20, 40 and 80.
@@ -172,7 +172,7 @@ const Dashboard = () => {
useEffect(() => { useEffect(() => {
const fetchUserData = async () => { const fetchUserData = async () => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
@@ -187,9 +187,10 @@ const Dashboard = () => {
const data = await profileResponse.json(); const data = await profileResponse.json();
setUserData(data); setUserData(data);
if (data.steamId) { // Games added by hand need no Steam ID; history and achievements come only from Steam.
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), fetchActivity(), fetchAchievements()]); await Promise.all(data.steamId
} ? [fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), fetchActivity(), fetchAchievements()]
: [fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary()]);
} else if (profileResponse.status === 401) { } else if (profileResponse.status === 401) {
expireSession(); expireSession();
} else { } else {
@@ -207,6 +208,7 @@ const Dashboard = () => {
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]); }, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
const hasSteamId = !!userData?.steamId; const hasSteamId = !!userData?.steamId;
const showLibrary = hasSteamId || steamData.count > 0;
const {appearance} = usePreferences(); const {appearance} = usePreferences();
const steamProfile = useSteamProfile(hasSteamId); const steamProfile = useSteamProfile(hasSteamId);
const bannerAppid = appearance.showBanner const bannerAppid = appearance.showBanner
@@ -284,7 +286,7 @@ const Dashboard = () => {
)} )}
</Card> </Card>
{hasSteamId && ( {showLibrary && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<SteamGameCount targetCount={steamData.count}/> <SteamGameCount targetCount={steamData.count}/>
@@ -314,7 +316,7 @@ const Dashboard = () => {
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums"> <span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">
{index + 1} {index + 1}
</span> </span>
<GameCover appid={g.appid} className="w-16 sm:w-24 shrink-0"/> <GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate font-medium group-hover:text-primary transition-colors"> <div className="truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`} {g.name ?? `App ${g.appid}`}
@@ -338,7 +340,7 @@ const Dashboard = () => {
</div> </div>
)} )}
{hasSteamId && ( {showLibrary && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/> <CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/>
{summary && ( {summary && (
+1 -1
View File
@@ -20,7 +20,7 @@ const ok = (body) => ({ok: true, status: 200, json: async () => body});
const unauthorized = () => ({ok: false, status: 401, json: async () => ({})}); const unauthorized = () => ({ok: false, status: 401, json: async () => ({})});
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
}); });
afterEach(() => { afterEach(() => {
+20 -7
View File
@@ -1,7 +1,7 @@
import {useEffect, useMemo, useState} from 'react'; import {useEffect, useMemo, useState} from 'react';
import {Link, useNavigate, useParams} from 'react-router-dom'; import {Link, useNavigate, useParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {ArrowLeft, Clock, ExternalLink, Image as ImageIcon, ImageOff, PanelTop, RotateCcw, ShieldOff, Trophy} from 'lucide-react'; import {ArrowLeft, Clock, ExternalLink, Gamepad2, Image as ImageIcon, ImageOff, PanelTop, RotateCcw, ShieldOff, Trophy} from 'lucide-react';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -11,6 +11,7 @@ import {cn} from '@/lib/utils';
import BacklogCard from '../components/BacklogCard.jsx'; import BacklogCard from '../components/BacklogCard.jsx';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx';
import ManualGameCard from '../components/ManualGameCard.jsx';
import CompletedHint from '../components/CompletedHint.jsx'; import CompletedHint from '../components/CompletedHint.jsx';
import PlayingHint from '../components/PlayingHint.jsx'; import PlayingHint from '../components/PlayingHint.jsx';
import TimeToBeat from '../components/TimeToBeat.jsx'; import TimeToBeat from '../components/TimeToBeat.jsx';
@@ -20,7 +21,8 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import {isSteamKey} from '../utils/manualGames.js';
const formatUnlockedAt = (instant, locale) => { const formatUnlockedAt = (instant, locale) => {
if (!instant) return ''; if (!instant) return '';
@@ -45,7 +47,7 @@ export default function GameDetail() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
@@ -99,6 +101,8 @@ export default function GameDetail() {
}); });
}, [data]); }, [data]);
const manual = data?.source === 'MANUAL';
const onSteam = !!data && isSteamKey(data.appid);
const isBackground = !!data && appearance.backgroundAppid === data.appid; const isBackground = !!data && appearance.backgroundAppid === data.appid;
const isBanner = !!data && appearance.showBanner && appearance.bannerAppid === data.appid; const isBanner = !!data && appearance.showBanner && appearance.bannerAppid === data.appid;
@@ -131,7 +135,7 @@ export default function GameDetail() {
{!isLoading && !error && data && ( {!isLoading && !error && data && (
<> <>
<Card className="relative overflow-hidden p-0 gap-0 md:flex-row"> <Card className="relative overflow-hidden p-0 gap-0 md:flex-row">
<GameCover appid={data.appid} className="w-full rounded-none md:w-96 md:shrink-0"/> <GameCover appid={data.appid} imageUrl={data.imageUrl} className="w-full rounded-none md:w-96 md:shrink-0"/>
<div className="flex flex-1 flex-col justify-between gap-4 p-6"> <div className="flex flex-1 flex-col justify-between gap-4 p-6">
<div> <div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight"> <h1 className="text-2xl md:text-3xl font-bold tracking-tight">
@@ -143,12 +147,16 @@ export default function GameDetail() {
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})} {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})}
</Badge> </Badge>
<GameStatusBadge status={data.status}/> <GameStatusBadge status={data.status}/>
{data.platform && (
<Badge variant="outline"><Gamepad2 className="size-3"/>{data.platform}</Badge>
)}
</div> </div>
<div className="mt-3"> <div className="mt-3">
<TimeToBeat time={data.timeToBeat}/> <TimeToBeat time={data.timeToBeat}/>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> {/* Store page, background and banner need Steam art, so a game off Steam has none of them. */}
{onSteam && <div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer"> <a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/> {t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
@@ -173,7 +181,7 @@ export default function GameDetail() {
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')} <PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
</Button> </Button>
)} )}
</div> </div>}
</div> </div>
</Card> </Card>
@@ -183,10 +191,15 @@ export default function GameDetail() {
<BacklogCard key={data.appid} game={data} <BacklogCard key={data.appid} game={data}
onSaved={(entry) => setData((d) => ({...d, ...entry}))}/> onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
{manual && (
<ManualGameCard key={`manual-${data.appid}`} game={data}
onSaved={(changes) => setData((d) => ({...d, ...changes}))}/>
)}
{!data.hasAchievements && ( {!data.hasAchievements && (
<Card className="items-center py-10 text-center text-sm text-muted-foreground"> <Card className="items-center py-10 text-center text-sm text-muted-foreground">
<Trophy className="size-8"/> <Trophy className="size-8"/>
{t('GAME_DETAIL.NO_ACHIEVEMENTS')} {t(manual ? 'MANUAL_GAME.NO_ACHIEVEMENTS' : 'GAME_DETAIL.NO_ACHIEVEMENTS')}
</Card> </Card>
)} )}
+75 -4
View File
@@ -13,14 +13,17 @@ const game = {
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [], rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
}; };
const renderDetail = () => render( const renderDetail = (appid = 220) => render(
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider> <MemoryRouter initialEntries={[`/library/${appid}`]}><PreferencesProvider>
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes> <Routes>
<Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/library" element={<p>library page</p>}/>
</Routes>
</PreferencesProvider></MemoryRouter>, </PreferencesProvider></MemoryRouter>,
); );
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT' globalThis.fetch = vi.fn(async (url, init) => init?.method === 'PUT'
? {ok: true, status: 200, json: async () => JSON.parse(init.body)} ? {ok: true, status: 200, json: async () => JSON.parse(init.body)}
: {ok: true, status: 200, json: async () => game}); : {ok: true, status: 200, json: async () => game});
@@ -81,3 +84,71 @@ describe('GameDetail', () => {
expect(JSON.parse(init.body)).toMatchObject({bannerAppid: 220, showBanner: true}); expect(JSON.parse(init.body)).toMatchObject({bannerAppid: 220, showBanner: true});
}); });
}); });
describe('GameDetail of a game added by hand', () => {
const manualGame = {
...game, appid: -1025, name: 'Zelda', source: 'MANUAL', platform: 'Nintendo Switch', playtimeMinutes: 90,
playtime2Weeks: 0, imageUrl: 'https://images.igdb.com/z.jpg',
};
beforeEach(() => {
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT' || init?.method === 'DELETE')
? {ok: true, status: 204, json: async () => { throw new Error('204 has no body'); }}
: {ok: true, status: 200, json: async () => manualGame});
});
it('shows platform and IGDB picture, and offers no Steam actions', async () => {
const {container} = renderDetail(-1025);
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
expect(screen.getAllByText('Nintendo Switch').length).toBeGreaterThan(0);
expect(container.querySelector('img[src="https://images.igdb.com/z.jpg"]')).toBeTruthy();
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeNull();
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_BANNER'})).toBeNull();
expect(screen.queryByText('GAME_DETAIL.OPEN_IN_STEAM')).toBeNull();
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
});
it('saves platform and hours', async () => {
renderDetail(-1025);
fireEvent.change(await screen.findByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2.5'}});
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: ' PS5 '}});
const saveButtons = screen.getAllByRole('button', {name: 'BACKLOG.SAVE'});
fireEvent.click(saveButtons.at(-1));
await waitFor(() => expect(fetch.mock.calls.some(([, call]) => call?.method === 'PUT')).toBe(true));
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/library/games/-1025/manual');
expect(JSON.parse(init.body)).toEqual({platform: 'PS5', playtimeMinutes: 150});
});
it('removes the game only after the confirm step and goes back to the library', async () => {
renderDetail(-1025);
fireEvent.click(await screen.findByRole('button', {name: 'MANUAL_GAME.REMOVE'}));
expect(fetch.mock.calls.some(([, call]) => call?.method === 'DELETE')).toBe(false);
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'}));
expect(await screen.findByText('library page')).toBeTruthy();
const [url] = fetch.mock.calls.find(([, call]) => call?.method === 'DELETE');
expect(url).toContain('/api/library/games/-1025');
});
it('keeps Save off for an unchanged game with 75 minutes', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...manualGame, playtimeMinutes: 75})}));
renderDetail(-1025);
await screen.findByLabelText('MANUAL_GAME.HOURS');
expect(screen.getAllByRole('button', {name: 'BACKLOG.SAVE'}).at(-1).disabled).toBe(true);
});
it('shows no manual card for a Steam game', async () => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({...game, source: 'STEAM'})}));
renderDetail();
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull();
});
});
+37 -5
View File
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js'; import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw, Search, SearchX, Timer, Trophy} from 'lucide-react'; import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Gamepad2, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy} from 'lucide-react';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -15,12 +15,13 @@ import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
import AddGamePanel from '../components/AddGamePanel.jsx';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx'; import LibraryTabs from '../components/LibraryTabs.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const PAGE_SIZES = [20, 40, 80]; const PAGE_SIZES = [20, 40, 80];
const DEFAULT_SIZE = 20; const DEFAULT_SIZE = 20;
@@ -54,13 +55,16 @@ export default function GameLibrary() {
const filtered = Boolean(status || q.trim() || shortOnly); const filtered = Boolean(status || q.trim() || shortOnly);
const librarySearch = searchParams.toString(); const librarySearch = searchParams.toString();
const [search, setSearch] = useState(q); const [search, setSearch] = useState(q);
const [adding, setAdding] = useState(false);
// Starts true until /games-count answers, so a Steam user does not see the sync controls vanish and come back.
const [hasSteamId, setHasSteamId] = useState(true);
const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0}); const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const load = useCallback(async () => { const load = useCallback(async () => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
@@ -98,9 +102,23 @@ export default function GameLibrary() {
load(); load();
}, [load]); }, [load]);
useEffect(() => {
if (!hasSession()) return undefined;
let cancelled = false;
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`)
.then((response) => (response.ok ? response.json() : null))
.then((json) => {
if (!cancelled && json) setHasSteamId(json.hasSteamId);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0; const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0;
const sync = useLibrarySync({ const sync = useLibrarySync({
enabled: true, enabled: hasSteamId,
expectInitialSync, expectInitialSync,
onComplete: load, onComplete: load,
onUnauthorized: expireSession, onUnauthorized: expireSession,
@@ -153,6 +171,8 @@ export default function GameLibrary() {
<LibraryTabs/> <LibraryTabs/>
<h1 className="text-3xl font-bold tracking-tight">{t('LIBRARY.TITLE')}</h1> <h1 className="text-3xl font-bold tracking-tight">{t('LIBRARY.TITLE')}</h1>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{hasSteamId && (
<>
<SyncStatusBadge <SyncStatusBadge
status={sync.status} status={sync.status}
lastSyncedAt={sync.lastSyncedAt} lastSyncedAt={sync.lastSyncedAt}
@@ -162,6 +182,11 @@ export default function GameLibrary() {
<RefreshCw className={syncing ? 'animate-spin' : ''}/> <RefreshCw className={syncing ? 'animate-spin' : ''}/>
{t('LIBRARY.RESYNC')} {t('LIBRARY.RESYNC')}
</Button> </Button>
</>
)}
<Button variant="outline" size="sm" onClick={() => setAdding(true)} disabled={adding}>
<Plus/>{t('MANUAL_GAME.ADD_BUTTON')}
</Button>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -189,6 +214,8 @@ export default function GameLibrary() {
</div> </div>
</div> </div>
{adding && <AddGamePanel onClose={() => setAdding(false)}/>}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div role="group" aria-label={t('BACKLOG.STATUS_LABEL')} className="flex flex-wrap gap-1.5"> <div role="group" aria-label={t('BACKLOG.STATUS_LABEL')} className="flex flex-wrap gap-1.5">
{[null, ...GAME_STATUSES].map((s) => ( {[null, ...GAME_STATUSES].map((s) => (
@@ -261,10 +288,15 @@ export default function GameLibrary() {
state={{librarySearch}} state={{librarySearch}}
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50" className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
> >
<GameCover appid={g.appid} className="w-16 sm:w-24 shrink-0"/> <GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors"> <span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`} {g.name ?? `App ${g.appid}`}
</span> </span>
{g.platform && (
<Badge variant="outline" className="hidden sm:inline-flex" title={g.platform}>
<Gamepad2 className="size-3"/>{g.platform}
</Badge>
)}
<GameStatusBadge status={g.status} compact/> <GameStatusBadge status={g.status} compact/>
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && ( {g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
<Badge variant="secondary" className="hidden sm:inline-flex tabular-nums" <Badge variant="secondary" className="hidden sm:inline-flex tabular-nums"
+37 -1
View File
@@ -41,7 +41,7 @@ function renderLibrary(initialEntry = '/library') {
const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}}); const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page})); globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => page}));
}); });
@@ -127,3 +127,39 @@ describe('GameLibrary short games', () => {
expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_SHORT'}).getAttribute('aria-pressed')).toBe('true'); expect(screen.getByRole('button', {name: 'LIBRARY.FILTER_SHORT'}).getAttribute('aria-pressed')).toBe('true');
}); });
}); });
describe('GameLibrary with games added by hand', () => {
const answer = (games, hasSteamId) => vi.fn(async (url) => ({
ok: true, status: 200,
json: async () => (String(url).includes('/games-count') ? {count: games.length, hasSteamId} : {...page, content: games}),
}));
const zelda = {appid: -1025, name: 'Zelda', playtimeMinutes: 90, status: 'BACKLOG', source: 'MANUAL',
platform: 'Nintendo Switch', imageUrl: 'https://images.igdb.com/z.jpg'};
it('shows the platform and the IGDB picture of a manual game', async () => {
globalThis.fetch = answer([zelda], true);
renderLibrary();
expect(await screen.findByText('Nintendo Switch')).toBeTruthy();
expect(document.querySelector('img[src="https://images.igdb.com/z.jpg"]')).toBeTruthy();
});
it('hides the Steam sync for a user without a Steam ID but still offers adding a game', async () => {
globalThis.fetch = answer([zelda], false);
renderLibrary();
await screen.findByText('Zelda');
await waitFor(() => expect(screen.queryByText('LIBRARY.RESYNC')).toBeNull());
expect(screen.getByRole('button', {name: 'MANUAL_GAME.ADD_BUTTON'})).toBeTruthy();
});
it('opens the add panel', async () => {
globalThis.fetch = answer([zelda], true);
renderLibrary();
fireEvent.click(await screen.findByRole('button', {name: 'MANUAL_GAME.ADD_BUTTON'}));
expect(screen.getByText('MANUAL_GAME.ADD_TITLE')).toBeTruthy();
expect(screen.getByText('LIBRARY.RESYNC')).toBeTruthy();
});
});
+3 -3
View File
@@ -10,7 +10,7 @@ import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import StatCard from '../components/StatCard.jsx'; import StatCard from '../components/StatCard.jsx';
import {getValidToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
import usePublicStats from '../hooks/usePublicStats.js'; import usePublicStats from '../hooks/usePublicStats.js';
@@ -25,7 +25,7 @@ function RankedGames({title, games, formatValue}) {
{games.map((g, idx) => ( {games.map((g, idx) => (
<li key={g.appid} className="flex items-center gap-3 px-5 py-3"> <li key={g.appid} className="flex items-center gap-3 px-5 py-3">
<span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">{idx + 1}</span> <span className="w-4 text-right text-sm font-semibold text-muted-foreground tabular-nums">{idx + 1}</span>
<GameCover appid={g.appid} className="w-16 shrink-0"/> <GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span> <span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span>
<span className="text-sm text-muted-foreground tabular-nums">{formatValue(g.value ?? 0)}</span> <span className="text-sm text-muted-foreground tabular-nums">{formatValue(g.value ?? 0)}</span>
</li> </li>
@@ -64,7 +64,7 @@ export default function LandingPage() {
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1> <h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p> <p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p>
<Button asChild size="lg" className="mt-10"> <Button asChild size="lg" className="mt-10">
{getValidToken() ? ( {hasSession() ? (
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link> <Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
) : ( ) : (
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link> <Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
+5 -4
View File
@@ -8,7 +8,8 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label'; import {Label} from '@/components/ui/label';
import AuthLayout from '../components/AuthLayout.jsx'; import AuthLayout from '../components/AuthLayout.jsx';
import FeedbackAlert from '../components/FeedbackAlert.jsx'; import FeedbackAlert from '../components/FeedbackAlert.jsx';
import {getValidToken, safeReturnPath, setToken} from '../utils/auth.js'; import {hasSession, markSession, safeReturnPath} from '../utils/auth.js';
import {clientFetch} from '../utils/api.js';
const LOGIN_ERROR_KEY = { const LOGIN_ERROR_KEY = {
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS', INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
@@ -39,7 +40,7 @@ export default function LoginPage() {
// Build the API URL using the Vite environment variable // Build the API URL using the Vite environment variable
const apiUrl = `${import.meta.env.VITE_API_URL}/api/auth/login`; const apiUrl = `${import.meta.env.VITE_API_URL}/api/auth/login`;
const response = await fetch(apiUrl, { const response = await clientFetch(apiUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -51,7 +52,7 @@ export default function LoginPage() {
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (response.ok) { if (response.ok) {
setToken(data.token); markSession();
navigate(target, {replace: true}); navigate(target, {replace: true});
} else { } else {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60); const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
@@ -72,7 +73,7 @@ export default function LoginPage() {
} }
}; };
if (getValidToken()) { if (hasSession()) {
return <Navigate to={target} replace/>; return <Navigate to={target} replace/>;
} }
+6 -5
View File
@@ -6,9 +6,6 @@ import LoginPage from './LoginPage.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); 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() { function CurrentPath() {
const location = useLocation(); const location = useLocation();
return <div data-testid="path">{location.pathname + location.search}</div>; return <div data-testid="path">{location.pathname + location.search}</div>;
@@ -33,7 +30,7 @@ function submit() {
describe('LoginPage', () => { describe('LoginPage', () => {
beforeEach(() => { beforeEach(() => {
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({token: TOKEN})})); globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({message: 'Login successful'})}));
}); });
afterEach(() => { afterEach(() => {
@@ -59,7 +56,11 @@ describe('LoginPage', () => {
submit(); submit();
await waitFor(() => expect(screen.getByTestId('path').textContent).toBe('/library?status=PLAYING')); await waitFor(() => expect(screen.getByTestId('path').textContent).toBe('/library?status=PLAYING'));
expect(localStorage.getItem('tbp_jwt')).toBe(TOKEN); expect(localStorage.getItem('tbp_session')).not.toBeNull();
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/login'), expect.objectContaining({
credentials: 'include',
headers: expect.objectContaining({'X-TBP-Client': 'web'}),
}));
}); });
it('ignores a return path outside the app', async () => { it('ignores a return path outside the app', async () => {
+3 -3
View File
@@ -14,7 +14,7 @@ import useSessionExpired from '../hooks/useSessionExpired.js';
import {saveGameStatus} from '../utils/backlog.js'; import {saveGameStatus} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
// maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data. // maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data.
const BUDGETS = [ const BUDGETS = [
@@ -47,7 +47,7 @@ export default function PlayNextPage() {
const latestRequest = useRef(0); const latestRequest = useRef(0);
const load = useCallback(async (maxMinutes, exclude) => { const load = useCallback(async (maxMinutes, exclude) => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
@@ -187,7 +187,7 @@ function PickCard({game, expireSession}) {
return ( return (
<Card className="gap-3 overflow-hidden p-0"> <Card className="gap-3 overflow-hidden p-0">
<Link to={`/library/${game.appid}`} className="block"> <Link to={`/library/${game.appid}`} className="block">
<GameCover appid={game.appid} className="w-full"/> <GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-full"/>
</Link> </Link>
<div className="flex flex-1 flex-col gap-3 px-4 pb-4"> <div className="flex flex-1 flex-col gap-3 px-4 pb-4">
<Link to={`/library/${game.appid}`} className="font-semibold hover:text-primary transition-colors"> <Link to={`/library/${game.appid}`} className="font-semibold hover:text-primary transition-colors">
+1 -1
View File
@@ -17,7 +17,7 @@ const pickerCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.incl
describe('PlayNextPage on a language change', () => { describe('PlayNextPage on a language change', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
i18n.t = (key) => key; i18n.t = (key) => key;
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({games: [game(1)]})})); globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => ({games: [game(1)]})}));
}); });
+1 -1
View File
@@ -36,7 +36,7 @@ function renderPage() {
describe('PlayNextPage', () => { describe('PlayNextPage', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
}); });
afterEach(() => { afterEach(() => {
+2 -2
View File
@@ -149,7 +149,7 @@ export default function PublicProfilePage() {
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{library.playing.map((g) => ( {library.playing.map((g) => (
<li key={g.appid} className="space-y-1.5"> <li key={g.appid} className="space-y-1.5">
<GameCover appid={g.appid} className="w-full"/> <GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-full"/>
<div className="truncate text-sm font-medium">{g.name ?? `App ${g.appid}`}</div> <div className="truncate text-sm font-medium">{g.name ?? `App ${g.appid}`}</div>
<div className="text-xs text-muted-foreground tabular-nums">{formatPlaytime(g.playtimeMinutes)}</div> <div className="text-xs text-muted-foreground tabular-nums">{formatPlaytime(g.playtimeMinutes)}</div>
</li> </li>
@@ -166,7 +166,7 @@ export default function PublicProfilePage() {
<ul className="divide-y"> <ul className="divide-y">
{library.finished.map((g) => ( {library.finished.map((g) => (
<li key={g.appid} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3"> <li key={g.appid} className="flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3">
<GameCover appid={g.appid} className="w-16 sm:w-24 shrink-0"/> <GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
<span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span> <span className="min-w-0 flex-1 truncate font-medium">{g.name ?? `App ${g.appid}`}</span>
{g.rating && ( {g.rating && (
<span className="flex items-center gap-0.5" aria-label={t('BACKLOG.RATING_STARS', {count: g.rating})}> <span className="flex items-center gap-0.5" aria-label={t('BACKLOG.RATING_STARS', {count: g.rating})}>
+2 -1
View File
@@ -12,6 +12,7 @@ import AuthLayout from '../components/AuthLayout.jsx';
import FeedbackAlert from '../components/FeedbackAlert.jsx'; import FeedbackAlert from '../components/FeedbackAlert.jsx';
import FormField from '../components/FormField.jsx'; import FormField from '../components/FormField.jsx';
import {registrationSchema} from '../utils/validation.js'; import {registrationSchema} from '../utils/validation.js';
import {clientFetch} from '../utils/api.js';
const BACKEND_ERROR_KEY = { const BACKEND_ERROR_KEY = {
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE', EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE',
@@ -46,7 +47,7 @@ export default function RegistrationPage() {
setFeedback({message: '', isError: false}); setFeedback({message: '', isError: false});
try { try {
const apiUrl = `${import.meta.env.VITE_API_URL}/api/registration/new`; const apiUrl = `${import.meta.env.VITE_API_URL}/api/registration/new`;
const response = await fetch(apiUrl, { const response = await clientFetch(apiUrl, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ body: JSON.stringify({
+5 -5
View File
@@ -23,7 +23,7 @@ import {ACCENTS, CORNERS} from '../utils/appearance.js';
import {coverUrl} from '../utils/games.js'; import {coverUrl} from '../utils/games.js';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken, setToken} from '../utils/auth.js'; import {hasSession, markSession} from '../utils/auth.js';
const BACKEND_ERROR_KEY = { const BACKEND_ERROR_KEY = {
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE', STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
@@ -63,7 +63,7 @@ export default function SettingsPage() {
}); });
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return; return;
} }
@@ -107,7 +107,7 @@ export default function SettingsPage() {
}, [navigate, expireSession, reset, t]); }, [navigate, expireSession, reset, t]);
const persistSettings = async (payload) => { const persistSettings = async (payload) => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return {ok: false}; return {ok: false};
} }
@@ -133,8 +133,8 @@ export default function SettingsPage() {
return {ok: false}; return {ok: false};
} }
const data = await response.json(); // Auth set a new access cookie for the new Steam ID; the new stamp makes the header load it again.
setToken(data.token); // Core reads the steamId from the token markSession();
toast.success(t('SETTINGS.SAVED')); toast.success(t('SETTINGS.SAVED'));
return {ok: true}; return {ok: true};
} catch (err) { } catch (err) {
+4 -3
View File
@@ -21,13 +21,14 @@ const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT')
function renderPage(me) { function renderPage(me) {
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT' globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
? {ok: true, status: 200, json: async () => ({token: 'token'})} // Both PUTs answer 204 without a body, like Auth does.
? {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}
: {ok: true, status: 200, json: async () => me})); : {ok: true, status: 200, json: async () => me}));
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>); render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
} }
describe('SettingsPage public profile', () => { describe('SettingsPage public profile', () => {
beforeEach(() => localStorage.setItem('tbp_jwt', 'token')); beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -76,7 +77,7 @@ describe('SettingsPage public profile', () => {
}); });
describe('SettingsPage appearance', () => { describe('SettingsPage appearance', () => {
beforeEach(() => localStorage.setItem('tbp_jwt', 'token')); beforeEach(() => localStorage.setItem('tbp_session', '1'));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
+2 -2
View File
@@ -12,7 +12,7 @@ import LibraryTabs from '../components/LibraryTabs.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js'; import {apiFetch} from '../utils/api.js';
import {getToken} from '../utils/auth.js'; import {hasSession} from '../utils/auth.js';
const formatAdded = (instant, locale) => const formatAdded = (instant, locale) =>
instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null; instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
@@ -26,7 +26,7 @@ export default function WishlistPage() {
const [onlyOnSale, setOnlyOnSale] = useState(false); const [onlyOnSale, setOnlyOnSale] = useState(false);
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
return undefined; return undefined;
} }
+3 -3
View File
@@ -27,7 +27,7 @@ const renderPage = () => render(
describe('WishlistPage', () => { describe('WishlistPage', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('tbp_jwt', 'token'); localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => wishlist})); globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => wishlist}));
}); });
@@ -64,8 +64,8 @@ describe('WishlistPage', () => {
expect(await screen.findByText('WISHLIST.EMPTY')).toBeTruthy(); expect(await screen.findByText('WISHLIST.EMPTY')).toBeTruthy();
}); });
it('opens the login page on a 401', async () => { it('opens the login page on a 401 that the refresh cannot fix', async () => {
fetch.mockResolvedValueOnce({ok: false, status: 401, json: async () => ({})}); fetch.mockResolvedValue({ok: false, status: 401, json: async () => ({})});
renderPage(); renderPage();
await waitFor(() => expect(JSON.parse(screen.getByTestId('login').textContent).sessionExpired).toBe(true)); await waitFor(() => expect(JSON.parse(screen.getByTestId('login').textContent).sessionExpired).toBe(true));
+54 -6
View File
@@ -1,10 +1,58 @@
import {getToken} from './auth.js'; // Auth and Core refuse a request that changes data without this header (CSRF guard, see ClientHeaderFilter).
export const CLIENT_HEADER = 'X-TBP-Client';
/** /**
* fetch with the login token. The caller handles the response status, 401 included. * fetch with the login cookies and the client header, without a refresh. For login, registration, refresh and
* logout, where a 401 is an answer and not an expired session.
*/ */
export function apiFetch(url, init = {}) { export function clientFetch(url, init = {}) {
const token = getToken(); return fetch(url, {...init, credentials: 'include', headers: {...init.headers, [CLIENT_HEADER]: 'web'}});
const headers = token ? {...init.headers, 'Authorization': `Bearer ${token}`} : init.headers; }
return fetch(url, {...init, headers});
/**
* Revokes the session in Auth and clears both cookies. A failure is ignored: the page logs out anyway.
*/
export const logout = () =>
clientFetch(`${import.meta.env.VITE_API_URL}/api/auth/logout`, {method: 'POST'}).catch(() => undefined);
const REFRESH_TIMEOUT_MS = 10_000;
let runningRefresh = null;
/**
* Renews the access cookie. False when Auth says the session is over (401). Any other failure, e.g. a 502 during a
* deploy or no network, rejects: the session may still be fine, so the caller shows its network error instead.
* Parallel callers share one request. The lock also holds other tabs back: Auth revokes the whole session when one
* refresh token is used twice.
*/
export function refreshSession() {
if (!runningRefresh) {
const refresh = async () => {
// A hung request would hold the lock, and with it the refresh in every tab.
const response = await clientFetch(`${import.meta.env.VITE_API_URL}/api/auth/refresh`,
{method: 'POST', signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS)});
if (response.ok || response.status === 401) {
return response.ok;
}
throw new Error(`Session refresh failed with ${response.status}`);
};
const locked = globalThis.navigator?.locks ? navigator.locks.request('tbp-refresh', refresh) : refresh();
runningRefresh = locked.finally(() => {
runningRefresh = null;
});
}
return runningRefresh;
}
/**
* fetch for the logged-in user. A 401 renews the access cookie once and sends the request again. When that fails,
* the 401 goes back to the caller. Ending the session is the caller's job (useSessionExpired), so the user sees
* why and comes back to the same page.
*/
export async function apiFetch(url, init = {}) {
const response = await clientFetch(url, init);
if (response.status !== 401 || !(await refreshSession())) {
return response;
}
return clientFetch(url, init);
} }
+113 -15
View File
@@ -1,40 +1,138 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {apiFetch} from './api.js'; import {apiFetch, clientFetch, logout} from './api.js';
const response = (status) => ({ok: status >= 200 && status < 300, status, json: async () => ({})});
const isRefresh = (url) => url.endsWith('/api/auth/refresh');
const dataCalls = () => fetch.mock.calls.filter(([url]) => !isRefresh(url));
const refreshCalls = () => fetch.mock.calls.filter(([url]) => isRefresh(url));
describe('apiFetch', () => {
beforeEach(() => { beforeEach(() => {
globalThis.fetch = vi.fn(async () => new Response(null, {status: 204})); localStorage.setItem('tbp_session', '1');
}); });
afterEach(() => { afterEach(() => {
localStorage.clear(); localStorage.clear();
}); });
it('sends the stored token and keeps the other headers and options', async () => { describe('clientFetch', () => {
localStorage.setItem('tbp_jwt', 'the-token'); it('sends the cookies and the client header and keeps the other options', async () => {
globalThis.fetch = vi.fn(async () => response(204));
await apiFetch('/api/x', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: '{}'}); await clientFetch('/api/x', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: '{}'});
expect(fetch).toHaveBeenCalledWith('/api/x', { expect(fetch).toHaveBeenCalledWith('/api/x', {
method: 'PUT', method: 'PUT',
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer the-token'}, headers: {'Content-Type': 'application/json', 'X-TBP-Client': 'web'},
body: '{}', body: '{}',
credentials: 'include',
});
}); });
}); });
it('sends no Authorization header without a token', async () => { describe('apiFetch', () => {
it('renews the session once on a 401 and sends the request again', async () => {
let dataAnswers = [401, 200];
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 204 : dataAnswers.shift()));
const result = await apiFetch('/api/x', {method: 'PUT', body: '{"a":1}'});
expect(result.status).toBe(200);
expect(refreshCalls()).toHaveLength(1);
expect(dataCalls()).toHaveLength(2);
expect(dataCalls()[1][1]).toMatchObject({method: 'PUT', body: '{"a":1}'});
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('asks only once when parallel calls get a 401', async () => {
const seen = new Set();
globalThis.fetch = vi.fn(async (url) => {
if (isRefresh(url)) return response(204);
if (seen.has(url)) return response(200);
seen.add(url);
return response(401);
});
const results = await Promise.all(['/a', '/b', '/c'].map((url) => apiFetch(url)));
expect(results.map((r) => r.status)).toEqual([200, 200, 200]);
expect(refreshCalls()).toHaveLength(1);
});
it('returns the 401 when the refresh fails, without a loop, and leaves ending the session to the caller', async () => {
globalThis.fetch = vi.fn(async () => response(401));
const result = await apiFetch('/api/x');
expect(result.status).toBe(401);
expect(refreshCalls()).toHaveLength(1);
expect(dataCalls()).toHaveLength(1);
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('keeps the session and rejects when the refresh has no network', async () => {
globalThis.fetch = vi.fn(async (url) => {
if (isRefresh(url)) throw new TypeError('offline');
return response(401);
});
await expect(apiFetch('/api/x')).rejects.toThrow('offline');
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('keeps the session and rejects when Auth is down during a deploy', async () => {
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 502 : 401));
await expect(apiFetch('/api/x')).rejects.toThrow('502');
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('tries the refresh again on the next 401 after a failed one', async () => {
const refreshAnswers = [502, 204];
const dataAnswers = [401, 401, 200];
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? refreshAnswers.shift() : dataAnswers.shift()));
await expect(apiFetch('/api/x')).rejects.toThrow();
expect((await apiFetch('/api/x')).status).toBe(200);
});
it('passes other errors through without a refresh', async () => {
globalThis.fetch = vi.fn(async () => response(403));
expect((await apiFetch('/api/x')).status).toBe(403);
expect(refreshCalls()).toHaveLength(0);
});
it('gives up on a refresh that hangs, so the lock does not block every tab', async () => {
globalThis.fetch = vi.fn(async (url, init) => {
if (!isRefresh(url)) return response(401);
expect(init.signal).toBeInstanceOf(AbortSignal);
throw new DOMException('timed out', 'TimeoutError');
});
await expect(apiFetch('/api/x')).rejects.toThrow('timed out');
});
it('runs the refresh under a lock that other tabs share', async () => {
const request = vi.fn((name, callback) => callback());
vi.stubGlobal('navigator', {...navigator, locks: {request}});
let dataAnswers = [401, 200];
globalThis.fetch = vi.fn(async (url) => response(isRefresh(url) ? 204 : dataAnswers.shift()));
await apiFetch('/api/x'); await apiFetch('/api/x');
expect(fetch).toHaveBeenCalledWith('/api/x', {headers: undefined}); expect(request).toHaveBeenCalledWith('tbp-refresh', expect.any(Function));
vi.unstubAllGlobals();
});
}); });
it('reads the token on every call', async () => { describe('logout', () => {
localStorage.setItem('tbp_jwt', 'old'); it('posts to Auth and ignores a network error', async () => {
await apiFetch('/api/x'); globalThis.fetch = vi.fn(async () => {
localStorage.setItem('tbp_jwt', 'new'); throw new TypeError('offline');
await apiFetch('/api/x'); });
expect(fetch).toHaveBeenLastCalledWith('/api/x', {headers: {'Authorization': 'Bearer new'}}); await expect(logout()).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/auth/logout'), expect.objectContaining({method: 'POST'}));
}); });
}); });
+17 -27
View File
@@ -1,35 +1,25 @@
const TOKEN_KEY = 'tbp_jwt'; // The tokens live in HttpOnly cookies that no script can read. This flag only remembers that a login happened; a
// stale flag costs one 401 and a failed refresh, then the login page opens.
const SESSION_KEY = 'tbp_session';
// Where the token lived before the cookies. Removed on the next login or logout.
const LEGACY_TOKEN_KEY = 'tbp_jwt';
export const getToken = () => localStorage.getItem(TOKEN_KEY); export const hasSession = () => localStorage.getItem(SESSION_KEY) !== null;
export const setToken = (token) => localStorage.setItem(TOKEN_KEY, token);
export const clearToken = () => localStorage.removeItem(TOKEN_KEY);
/** /**
* Only checks `exp`. The signature is checked by the backends. * Changes on every login and on every settings save, so a hook that shows the user's Steam data can load again.
*/ */
export function isTokenValid(token, nowMs = Date.now()) { export const sessionStamp = () => localStorage.getItem(SESSION_KEY);
try {
const payload = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const {exp} = JSON.parse(atob(payload));
return typeof exp === 'number' && exp * 1000 > nowMs;
} catch {
return false;
}
}
/** export const markSession = () => {
* Returns the stored token, or null. Removes an expired token. localStorage.setItem(SESSION_KEY, String(Date.now()));
*/ localStorage.removeItem(LEGACY_TOKEN_KEY);
export function getValidToken() { };
const token = getToken();
if (token && !isTokenValid(token)) { export const clearSession = () => {
clearToken(); localStorage.removeItem(SESSION_KEY);
return null; localStorage.removeItem(LEGACY_TOKEN_KEY);
} };
return token;
}
/** /**
* Returns the path when it is a path inside this app, else null. The login page uses it to go back after a * Returns the path when it is a path inside this app, else null. The login page uses it to go back after a
+26 -17
View File
@@ -1,28 +1,37 @@
import {describe, expect, it} from 'vitest'; // @vitest-environment jsdom
import {isTokenValid, safeReturnPath} from './auth.js'; import {afterEach, describe, expect, it} from 'vitest';
import {clearSession, hasSession, markSession, safeReturnPath, sessionStamp} from './auth.js';
const tokenWithPayload = (payload) => { afterEach(() => localStorage.clear());
const encode = (obj) => btoa(JSON.stringify(obj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return `${encode({alg: 'EdDSA'})}.${encode(payload)}.signature`;
};
describe('isTokenValid', () => { describe('session flag', () => {
const now = Date.UTC(2026, 8, 24, 12, 0, 0); it('is set by a login and removed by clearSession', () => {
expect(hasSession()).toBe(false);
it('accepts a token that expires in the future', () => { markSession();
expect(isTokenValid(tokenWithPayload({exp: now / 1000 + 60}), now)).toBe(true); expect(hasSession()).toBe(true);
clearSession();
expect(hasSession()).toBe(false);
}); });
it('rejects an expired token', () => { it('removes the token of the old localStorage login', () => {
expect(isTokenValid(tokenWithPayload({exp: now / 1000 - 60}), now)).toBe(false); localStorage.setItem('tbp_jwt', 'old');
markSession();
expect(localStorage.getItem('tbp_jwt')).toBeNull();
localStorage.setItem('tbp_jwt', 'old');
clearSession();
expect(localStorage.getItem('tbp_jwt')).toBeNull();
}); });
it('rejects a token without exp', () => { it('gives a new stamp on every mark', async () => {
expect(isTokenValid(tokenWithPayload({sub: 'a@b.de'}), now)).toBe(false); markSession();
}); const first = sessionStamp();
await new Promise((resolve) => setTimeout(resolve, 2));
markSession();
it.each([null, '', 'not-a-jwt', 'a.%%%.c'])('rejects %j', (token) => { expect(sessionStamp()).not.toBe(first);
expect(isTokenValid(token, now)).toBe(false);
}); });
}); });
+33
View File
@@ -0,0 +1,33 @@
import {apiFetch} from './api.js';
const LIBRARY_URL = () => `${import.meta.env.VITE_CORE_API_URL}/api/library`;
const JSON_HEADERS = {'Content-Type': 'application/json'};
// A game that is not on Steam has a negative key (minus its IGDB id). Steam art and the Steam store exist only for
// positive keys; Auth also takes only those as background or banner.
export const isSteamKey = (appid) => Number(appid) > 0;
export const hoursToMinutes = (hours) => {
const value = Number(String(hours).replace(',', '.'));
return Number.isFinite(value) && value > 0 ? Math.round(value * 60) : 0;
};
// The calls return the raw response; the caller handles the status.
export const searchGames = (query, signal) =>
apiFetch(`${LIBRARY_URL()}/game-search?${new URLSearchParams({q: query.trim()})}`, {signal});
export const addGame = ({igdbId, platform, status, playtimeMinutes}) =>
apiFetch(`${LIBRARY_URL()}/games`, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({igdbId, platform, status, playtimeMinutes}),
});
export const updateManualGame = (appid, {platform, playtimeMinutes}) =>
apiFetch(`${LIBRARY_URL()}/games/${appid}/manual`, {
method: 'PUT',
headers: JSON_HEADERS,
body: JSON.stringify({platform, playtimeMinutes}),
});
export const removeGame = (appid) => apiFetch(`${LIBRARY_URL()}/games/${appid}`, {method: 'DELETE'});