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);
+ });
+});