Public Access
refactor(auth): send login token through apiFetch
All authenticated calls now use apiFetch, and only utils/auth.js touches the stored token. The cookie login can then change these two files instead of every page.
This commit is contained in:
1 parent
085fcf15a4
commit
5ff8d48c53
18 files changed
+127
-70
No files matched your search
+3
-2
@@ -18,6 +18,7 @@ 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';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
@@ -31,7 +32,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(!!localStorage.getItem('tbp_jwt'));
|
setIsAuthenticated(!!getToken());
|
||||||
}, [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,7 +41,7 @@ function App() {
|
|||||||
}, [isAuthenticated, syncAppearance]);
|
}, [isAuthenticated, syncAppearance]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('tbp_jwt');
|
clearToken();
|
||||||
resetAppearance();
|
resetAppearance();
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
|
|||||||
@@ -13,6 +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';
|
||||||
|
|
||||||
const NOTES_MAX = 2000;
|
const NOTES_MAX = 2000;
|
||||||
|
|
||||||
@@ -41,8 +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 () => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {useNavigate} from 'react-router-dom';
|
|||||||
import {useTranslation} from 'react-i18next';
|
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 {getToken} from '../utils/auth.js';
|
||||||
|
|
||||||
const RELOAD_DELAY_MS = 4000;
|
const RELOAD_DELAY_MS = 4000;
|
||||||
|
|
||||||
@@ -17,16 +19,14 @@ export default function useForceSync(onStarted) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const trigger = async () => {
|
const trigger = async () => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, {
|
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Authorization': `Bearer ${token}`},
|
|
||||||
});
|
});
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {useEffect, useRef, useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
|
import {apiFetch} from '../utils/api.js';
|
||||||
|
import {getToken} 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;
|
||||||
@@ -32,13 +34,11 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
||||||
const fetchOnce = async () => {
|
const fetchOnce = async () => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) return;
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await apiFetch(
|
||||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/sync-status`,
|
`${import.meta.env.VITE_CORE_API_URL}/api/library/sync-status`
|
||||||
{headers: {'Authorization': `Bearer ${token}`}}
|
|
||||||
);
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -16,7 +17,7 @@ export default function useSessionExpired() {
|
|||||||
|
|
||||||
return useCallback(() => {
|
return useCallback(() => {
|
||||||
const {pathname, search} = locationRef.current;
|
const {pathname, search} = locationRef.current;
|
||||||
localStorage.removeItem('tbp_jwt');
|
clearToken();
|
||||||
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]);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
|
import {apiFetch} from '../utils/api.js';
|
||||||
|
import {getToken} 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
|
||||||
@@ -7,7 +9,7 @@ import {useEffect, useState} from 'react';
|
|||||||
*/
|
*/
|
||||||
export default function useSteamProfile(enabled) {
|
export default function useSteamProfile(enabled) {
|
||||||
const [profile, setProfile] = useState(null);
|
const [profile, setProfile] = useState(null);
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
const token = getToken();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || !token) {
|
if (!enabled || !token) {
|
||||||
@@ -17,9 +19,7 @@ export default function useSteamProfile(enabled) {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/steam-profile`, {
|
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/steam-profile`);
|
||||||
headers: {'Authorization': `Bearer ${token}`},
|
|
||||||
});
|
|
||||||
const data = response.ok ? await response.json() : null;
|
const data = response.ok ? await response.json() : null;
|
||||||
if (!cancelled) setProfile(data);
|
if (!cancelled) setProfile(data);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+12
-13
@@ -25,6 +25,8 @@ import SteamAvatar from '../components/SteamAvatar.jsx';
|
|||||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
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 {getToken} 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.
|
||||||
@@ -32,10 +34,7 @@ const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2C
|
|||||||
const PLAYING_GAMES = 4;
|
const PLAYING_GAMES = 4;
|
||||||
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
||||||
|
|
||||||
const authHeaders = () => ({
|
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||||
'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
});
|
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
@@ -55,7 +54,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
const fetchSteamCount = useCallback(async () => {
|
const fetchSteamCount = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {headers: authHeaders()});
|
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {headers: JSON_HEADERS});
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
return;
|
return;
|
||||||
@@ -70,7 +69,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
const fetchTopGames = useCallback(async () => {
|
const fetchTopGames = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}${TOP_GAMES_URL}`, {headers: authHeaders()});
|
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}${TOP_GAMES_URL}`, {headers: JSON_HEADERS});
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
return;
|
return;
|
||||||
@@ -86,7 +85,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
const fetchPlaying = useCallback(async () => {
|
const fetchPlaying = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}${PLAYING_URL}`, {headers: authHeaders()});
|
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}${PLAYING_URL}`, {headers: JSON_HEADERS});
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
return;
|
return;
|
||||||
@@ -107,7 +106,7 @@ const Dashboard = () => {
|
|||||||
const fetchSummary = useCallback(async (excludeAppid) => {
|
const fetchSummary = useCallback(async (excludeAppid) => {
|
||||||
const query = excludeAppid ? `?exclude=${excludeAppid}` : '';
|
const query = excludeAppid ? `?exclude=${excludeAppid}` : '';
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`, {headers: authHeaders()});
|
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`, {headers: JSON_HEADERS});
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
return true;
|
return true;
|
||||||
@@ -126,7 +125,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
const fetchActivity = useCallback(async () => {
|
const fetchActivity = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/activity`, {headers: authHeaders()});
|
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/activity`, {headers: JSON_HEADERS});
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
return;
|
return;
|
||||||
@@ -141,7 +140,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
const fetchAchievements = useCallback(async () => {
|
const fetchAchievements = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/achievements/summary`, {headers: authHeaders()});
|
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/achievements/summary`, {headers: JSON_HEADERS});
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
return;
|
return;
|
||||||
@@ -173,15 +172,15 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUserData = async () => {
|
const fetchUserData = async () => {
|
||||||
if (!localStorage.getItem('tbp_jwt')) {
|
if (!getToken()) {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const profileResponse = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
const profileResponse = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: authHeaders(),
|
headers: JSON_HEADERS,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (profileResponse.ok) {
|
if (profileResponse.ok) {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import useSessionExpired from '../hooks/useSessionExpired.js';
|
|||||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
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 {getToken} from '../utils/auth.js';
|
||||||
|
|
||||||
const formatUnlockedAt = (instant, locale) => {
|
const formatUnlockedAt = (instant, locale) => {
|
||||||
if (!instant) return '';
|
if (!instant) return '';
|
||||||
@@ -43,8 +45,7 @@ export default function GameDetail() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -53,9 +54,8 @@ export default function GameDetail() {
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`;
|
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`;
|
||||||
const response = await fetch(url, {
|
const response = await apiFetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ 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 {getToken} from '../utils/auth.js';
|
||||||
|
|
||||||
const PAGE_SIZES = [20, 40, 80];
|
const PAGE_SIZES = [20, 40, 80];
|
||||||
const DEFAULT_SIZE = 20;
|
const DEFAULT_SIZE = 20;
|
||||||
@@ -58,8 +60,7 @@ export default function GameLibrary() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -67,9 +68,8 @@ export default function GameLibrary() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status, q, maxMinutes: shortOnly ? SHORT_GAME_MINUTES : null})}`;
|
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status, q, maxMinutes: shortOnly ? SHORT_GAME_MINUTES : null})}`;
|
||||||
const response = await fetch(url, {
|
const response = await apiFetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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} from '../utils/auth.js';
|
import {getValidToken, safeReturnPath, setToken} from '../utils/auth.js';
|
||||||
|
|
||||||
const LOGIN_ERROR_KEY = {
|
const LOGIN_ERROR_KEY = {
|
||||||
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
|
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
|
||||||
@@ -51,7 +51,7 @@ export default function LoginPage() {
|
|||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
localStorage.setItem('tbp_jwt', data.token);
|
setToken(data.token);
|
||||||
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);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import PageContainer from '../components/PageContainer.jsx';
|
|||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
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 {getToken} 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 = [
|
||||||
@@ -45,8 +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) => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -54,9 +55,8 @@ export default function PlayNextPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await apiFetch(
|
||||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, exclude)}`,
|
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, exclude)}`);
|
||||||
{headers: {'Authorization': `Bearer ${token}`}});
|
|
||||||
if (request !== latestRequest.current) return;
|
if (request !== latestRequest.current) return;
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
|||||||
import {ACCENTS, CORNERS} from '../utils/appearance.js';
|
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 {getToken, setToken} 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',
|
||||||
@@ -61,18 +63,16 @@ export default function SettingsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -107,17 +107,15 @@ export default function SettingsPage() {
|
|||||||
}, [navigate, expireSession, reset, t]);
|
}, [navigate, expireSession, reset, t]);
|
||||||
|
|
||||||
const persistSettings = async (payload) => {
|
const persistSettings = async (payload) => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return {ok: false};
|
return {ok: false};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
|
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -136,7 +134,7 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
localStorage.setItem('tbp_jwt', data.token); // Core reads the steamId from the token
|
setToken(data.token); // Core reads the steamId from the token
|
||||||
toast.success(t('SETTINGS.SAVED'));
|
toast.success(t('SETTINGS.SAVED'));
|
||||||
return {ok: true};
|
return {ok: true};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import GameCover from '../components/GameCover.jsx';
|
|||||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
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 {getToken} 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;
|
||||||
@@ -24,17 +26,14 @@ export default function WishlistPage() {
|
|||||||
const [onlyOnSale, setOnlyOnSale] = useState(false);
|
const [onlyOnSale, setOnlyOnSale] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('tbp_jwt');
|
if (!getToken()) {
|
||||||
if (!token) {
|
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/wishlist`, {
|
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/wishlist`);
|
||||||
headers: {'Authorization': `Bearer ${token}`},
|
|
||||||
});
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
expireSession();
|
expireSession();
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import {getToken} from './auth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch with the login token. The caller handles the response status, 401 included.
|
||||||
|
*/
|
||||||
|
export function apiFetch(url, init = {}) {
|
||||||
|
const token = getToken();
|
||||||
|
const headers = token ? {...init.headers, 'Authorization': `Bearer ${token}`} : init.headers;
|
||||||
|
return fetch(url, {...init, headers});
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {apiFetch} from './api.js';
|
||||||
|
|
||||||
|
describe('apiFetch', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.fetch = vi.fn(async () => new Response(null, {status: 204}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the stored token and keeps the other headers and options', async () => {
|
||||||
|
localStorage.setItem('tbp_jwt', 'the-token');
|
||||||
|
|
||||||
|
await apiFetch('/api/x', {method: 'PUT', headers: {'Content-Type': 'application/json'}, body: '{}'});
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith('/api/x', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer the-token'},
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends no Authorization header without a token', async () => {
|
||||||
|
await apiFetch('/api/x');
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith('/api/x', {headers: undefined});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the token on every call', async () => {
|
||||||
|
localStorage.setItem('tbp_jwt', 'old');
|
||||||
|
await apiFetch('/api/x');
|
||||||
|
localStorage.setItem('tbp_jwt', 'new');
|
||||||
|
await apiFetch('/api/x');
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenLastCalledWith('/api/x', {headers: {'Authorization': 'Bearer new'}});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {apiFetch} from './api.js';
|
||||||
|
|
||||||
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
|
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
|
||||||
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
|
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
|
||||||
export const CORNERS = ['sharp', 'default', 'round'];
|
export const CORNERS = ['sharp', 'default', 'round'];
|
||||||
@@ -49,9 +51,9 @@ export const applyAppearance = (appearance, root = document.documentElement) =>
|
|||||||
* PUT /api/users/settings/appearance on Auth. The caller handles the response status.
|
* PUT /api/users/settings/appearance on Auth. The caller handles the response status.
|
||||||
*/
|
*/
|
||||||
export const saveAppearance = (appearance) =>
|
export const saveAppearance = (appearance) =>
|
||||||
fetch(`${import.meta.env.VITE_API_URL}/api/users/settings/appearance`, {
|
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/appearance`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify(appearance),
|
body: JSON.stringify(appearance),
|
||||||
// Saves run one after another; a hung request must not hold back the next ones.
|
// Saves run one after another; a hung request must not hold back the next ones.
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
@@ -61,8 +63,7 @@ export const saveAppearance = (appearance) =>
|
|||||||
* GET /api/users/me on Auth, only for its appearance. Null when there is no answer to use.
|
* GET /api/users/me on Auth, only for its appearance. Null when there is no answer to use.
|
||||||
*/
|
*/
|
||||||
export const loadAppearance = async () => {
|
export const loadAppearance = async () => {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||||
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`},
|
|
||||||
// Saves wait for this load.
|
// Saves wait for this load.
|
||||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-2
@@ -1,5 +1,11 @@
|
|||||||
const TOKEN_KEY = 'tbp_jwt';
|
const TOKEN_KEY = 'tbp_jwt';
|
||||||
|
|
||||||
|
export const getToken = () => localStorage.getItem(TOKEN_KEY);
|
||||||
|
|
||||||
|
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.
|
* Only checks `exp`. The signature is checked by the backends.
|
||||||
*/
|
*/
|
||||||
@@ -17,9 +23,9 @@ export function isTokenValid(token, nowMs = Date.now()) {
|
|||||||
* Returns the stored token, or null. Removes an expired token.
|
* Returns the stored token, or null. Removes an expired token.
|
||||||
*/
|
*/
|
||||||
export function getValidToken() {
|
export function getValidToken() {
|
||||||
const token = localStorage.getItem(TOKEN_KEY);
|
const token = getToken();
|
||||||
if (token && !isTokenValid(token)) {
|
if (token && !isTokenValid(token)) {
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
clearToken();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {apiFetch} from './api.js';
|
||||||
|
|
||||||
export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
|
export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
|
||||||
export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW'];
|
export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW'];
|
||||||
|
|
||||||
@@ -19,9 +21,9 @@ export const toBacklogRequest = ({status, rating, notes, priority}) => ({
|
|||||||
* @param entry a backlog entry in request form, see toBacklogRequest
|
* @param entry a backlog entry in request form, see toBacklogRequest
|
||||||
*/
|
*/
|
||||||
export const saveBacklogEntry = (appid, entry) =>
|
export const saveBacklogEntry = (appid, entry) =>
|
||||||
fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/backlog`, {
|
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/backlog`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify(entry),
|
body: JSON.stringify(entry),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,9 +31,9 @@ export const saveBacklogEntry = (appid, entry) =>
|
|||||||
* PUT /api/library/games/{appid}/status. The caller handles the response status.
|
* PUT /api/library/games/{appid}/status. The caller handles the response status.
|
||||||
*/
|
*/
|
||||||
export const saveGameStatus = (appid, status) =>
|
export const saveGameStatus = (appid, status) =>
|
||||||
fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/status`, {
|
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/status`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`, 'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({status}),
|
body: JSON.stringify({status}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user