From 8fa605aff069fae53ad32985c1bbaa23d904861e Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 24 Sep 2026 08:57:03 +0200 Subject: [PATCH] feat(nav): add header navigation and show Steam ID on dashboard Header links to Dashboard, Library and Settings. The logo, the login page and the landing page lead logged-in users to the dashboard. Expired tokens are removed, and a 403 on the dashboard logs out. Remove the dev dummy token. --- public/i18n/de.json | 9 ++++++++- public/i18n/en.json | 9 ++++++++- src/App.jsx | 38 ++++++++++++++++++++++++++------------ src/pages/Dashboard.jsx | 13 ++++++++++++- src/pages/LandingPage.jsx | 13 ++++++++++--- src/pages/LoginPage.jsx | 12 +++++++----- src/utils/auth.js | 26 ++++++++++++++++++++++++++ src/utils/auth.test.js | 27 +++++++++++++++++++++++++++ 8 files changed, 124 insertions(+), 23 deletions(-) create mode 100644 src/utils/auth.js create mode 100644 src/utils/auth.test.js diff --git a/public/i18n/de.json b/public/i18n/de.json index 8ed7b52..1842f8f 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -69,7 +69,8 @@ "DASHBOARD": { "LOADING": "Lade Dashboard ...", "HELLO": "Hallo", - "STEAM_GAMES": "Steam Spiele" + "STEAM_GAMES": "Steam Spiele", + "NO_STEAM_ID": "Keine Steam-ID hinterlegt – jetzt in den Einstellungen eintragen" }, "LIBRARY": { "TITLE": "Spielebibliothek", @@ -130,5 +131,11 @@ "GET_STARTED": "Jetzt loslegen", "LOGIN": "Anmelden", "LOGOUT": "Abmelden" + }, + "NAV": { + "DASHBOARD": "Dashboard", + "LIBRARY": "Bibliothek", + "SETTINGS": "Einstellungen", + "TO_DASHBOARD": "Zum Dashboard" } } diff --git a/public/i18n/en.json b/public/i18n/en.json index 366bd53..1c8ac19 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -69,7 +69,8 @@ "DASHBOARD": { "LOADING": "Loading dashboard ...", "HELLO": "Hello", - "STEAM_GAMES": "Steam Games" + "STEAM_GAMES": "Steam Games", + "NO_STEAM_ID": "No Steam ID saved yet – add it in the settings" }, "LIBRARY": { "TITLE": "Game Library", @@ -130,5 +131,11 @@ "GET_STARTED": "Get Started", "LOGIN": "Login", "LOGOUT": "Logout" + }, + "NAV": { + "DASHBOARD": "Dashboard", + "LIBRARY": "Library", + "SETTINGS": "Settings", + "TO_DASHBOARD": "Go to dashboard" } } diff --git a/src/App.jsx b/src/App.jsx index df16c52..bdde179 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,4 @@ -import {Link, Route, Routes, useLocation, useNavigate} from 'react-router-dom'; +import {Link, NavLink, Route, Routes, useLocation, useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; import {useEffect, useState} from 'react'; @@ -11,6 +11,12 @@ import SettingsPage from './pages/SettingsPage.jsx'; import GameLibrary from './pages/GameLibrary.jsx'; import GameDetail from './pages/GameDetail.jsx'; import {usePreferences} from './context/PreferencesContext.jsx'; +import {getValidToken} from './utils/auth.js'; + +const navLinkClass = ({isActive}) => + `flex items-center gap-1 text-sm font-bold transition-colors ${ + isActive ? 'text-blue-600 dark:text-blue-400' : 'text-slate-600 dark:text-slate-300 hover:text-blue-500' + }`; function App() { const {t, i18n} = useTranslation(); @@ -22,13 +28,7 @@ function App() { const location = useLocation(); useEffect(() => { - // Dev mode: inject dummy token if none exists - if (import.meta.env.MODE === 'development' && !localStorage.getItem('tbp_jwt')) { - localStorage.setItem('tbp_jwt', 'dev-dummy-token'); - } - - const token = localStorage.getItem('tbp_jwt'); - setIsAuthenticated(!!token); // Converts the token string (or null) to a boolean + setIsAuthenticated(!!getValidToken()); }, [location]); const toggleLanguage = () => { @@ -44,10 +44,27 @@ function App() { return (
- + Game Log + {isAuthenticated && ( + + )} +
{isAuthenticated ? ( <> - - - diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index fcebf59..6e0787c 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -56,7 +56,7 @@ const Dashboard = () => { if (data.steamId) { await fetchSteamCount(); } - } else if (profileResponse.status === 401) { + } else if (profileResponse.status === 401 || profileResponse.status === 403) { localStorage.removeItem('tbp_jwt'); navigate('/login'); } else { @@ -107,6 +107,17 @@ const Dashboard = () => {

{userData?.dashboardMessage}

+ {userData?.steamId ? ( +

+ + {t('SETTINGS.STEAM_ID')}: {userData.steamId} +

+ ) : ( + + + {t('DASHBOARD.NO_STEAM_ID')} + + )} {userData?.steamId && (
{t('MAIN.DESCRIPTION')}

- - {t('MAIN.GET_STARTED')} - + {getValidToken() ? ( + + {t('NAV.TO_DASHBOARD')} + + ) : ( + + {t('MAIN.GET_STARTED')} + + )}
diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index 3179ee9..4368e1c 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -1,6 +1,7 @@ import {useTranslation} from 'react-i18next'; import {useState} from 'react'; -import {Link, useNavigate} from 'react-router-dom'; +import {Link, Navigate, useNavigate} from 'react-router-dom'; +import {getValidToken} from '../utils/auth.js'; export default function LoginPage() { const {t} = useTranslation(); @@ -38,10 +39,7 @@ export default function LoginPage() { if (response.ok) { // HTTP 200 OK localStorage.setItem('tbp_jwt', data.token); - setFeedback({message: 'Login successful!', isError: false}); - setTimeout(() => { - navigate('/dashboard'); - }, 500); + navigate('/dashboard', {replace: true}); } else { // HTTP 401 Unauthorized or other errors setFeedback({ @@ -61,6 +59,10 @@ export default function LoginPage() { } }; + if (getValidToken()) { + return ; + } + return (
diff --git a/src/utils/auth.js b/src/utils/auth.js new file mode 100644 index 0000000..48a42ad --- /dev/null +++ b/src/utils/auth.js @@ -0,0 +1,26 @@ +const TOKEN_KEY = 'tbp_jwt'; + +/** + * Only checks `exp`. The signature is checked by the backends. + */ +export function isTokenValid(token, nowMs = Date.now()) { + 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; + } +} + +/** + * Returns the stored token, or null. Removes an expired token. + */ +export function getValidToken() { + const token = localStorage.getItem(TOKEN_KEY); + if (token && !isTokenValid(token)) { + localStorage.removeItem(TOKEN_KEY); + return null; + } + return token; +} diff --git a/src/utils/auth.test.js b/src/utils/auth.test.js new file mode 100644 index 0000000..15631a9 --- /dev/null +++ b/src/utils/auth.test.js @@ -0,0 +1,27 @@ +import {describe, expect, it} from 'vitest'; +import {isTokenValid} from './auth.js'; + +const tokenWithPayload = (payload) => { + const encode = (obj) => btoa(JSON.stringify(obj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + return `${encode({alg: 'EdDSA'})}.${encode(payload)}.signature`; +}; + +describe('isTokenValid', () => { + const now = Date.UTC(2026, 8, 24, 12, 0, 0); + + it('accepts a token that expires in the future', () => { + expect(isTokenValid(tokenWithPayload({exp: now / 1000 + 60}), now)).toBe(true); + }); + + it('rejects an expired token', () => { + expect(isTokenValid(tokenWithPayload({exp: now / 1000 - 60}), now)).toBe(false); + }); + + it('rejects a token without exp', () => { + expect(isTokenValid(tokenWithPayload({sub: 'a@b.de'}), now)).toBe(false); + }); + + it.each([null, '', 'not-a-jwt', 'a.%%%.c'])('rejects %j', (token) => { + expect(isTokenValid(token, now)).toBe(false); + }); +});