From 1bd77933aa64898d486817fe5b50b0ecc969523d Mon Sep 17 00:00:00 2001 From: gitaddremote Date: Thu, 14 May 2026 19:38:21 -0400 Subject: [PATCH 1/2] fix: align frontend auth with httpOnly cookie strategy (ISSUE-158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend switched to httpOnly cookie auth in bc64610 but the frontend was never updated — login stored data.access_token (undefined) in localStorage, causing every request to send Authorization: Bearer undefined and get a 401. - Remove all localStorage token reads/writes from Login, Register, Dashboard, Profile, and Inventory pages - Add credentials: 'include' to all fetch calls - Add withCredentials: true to all axios calls in service files - Replace ProtectedRoute localStorage check with GET /auth/me so auth state is verified via cookie (httpOnly cookies are unreadable from JS) - Call POST /auth/logout on logout to revoke the refresh token and blacklist the JTI in Redis --- frontend/src/components/ProtectedRoute.tsx | 16 ++++++---- frontend/src/pages/Dashboard.tsx | 13 ++++---- frontend/src/pages/Inventory.tsx | 13 ++++---- frontend/src/pages/Login.tsx | 8 +---- frontend/src/pages/Profile.tsx | 19 +++++------ frontend/src/pages/Register.tsx | 10 ++---- frontend/src/services/api.service.ts | 8 ++--- frontend/src/services/inventory.service.ts | 33 ++++++++------------ frontend/src/services/location.service.ts | 15 ++------- frontend/src/services/permissions.service.ts | 11 +------ frontend/src/services/uex.service.ts | 11 ++----- 11 files changed, 54 insertions(+), 103 deletions(-) diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index 3fdc0d4..ac0b2ac 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -1,14 +1,18 @@ +import { useEffect, useState } from 'react'; import { Navigate, Outlet } from 'react-router-dom'; +import { API_URL } from '../config/api'; const ProtectedRoute = () => { - const token = localStorage.getItem('access_token'); + const [authed, setAuthed] = useState(null); - if (!token) { - // Redirect to login if no token found - return ; - } + useEffect(() => { + fetch(`${API_URL}/auth/me`, { credentials: 'include' }) + .then((res) => setAuthed(res.ok)) + .catch(() => setAuthed(false)); + }, []); - // If token exists, render the child routes + if (authed === null) return null; + if (!authed) return ; return ; }; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 3093ae2..f15582c 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -36,11 +36,8 @@ const Dashboard = () => { useEffect(() => { const fetchUserProfile = async () => { try { - const token = localStorage.getItem('access_token'); const response = await fetch(`${API_URL}/users/profile`, { - headers: { - Authorization: `Bearer ${token}`, - }, + credentials: 'include', }); if (response.ok) { @@ -68,9 +65,11 @@ const Dashboard = () => { setAnchorEl(null); }; - const handleLogout = () => { - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); + const handleLogout = async () => { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + credentials: 'include', + }); navigate('/login'); }; diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index b3b3f9c..fa69105 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -440,9 +440,11 @@ const InventoryPage = () => { [], ); - const handleLogout = () => { - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); + const handleLogout = async () => { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + credentials: 'include', + }); navigate('/login'); }; @@ -479,11 +481,8 @@ const InventoryPage = () => { const fetchProfile = useCallback(async () => { try { - const token = localStorage.getItem('access_token'); const response = await fetch(`${API_URL}/users/profile`, { - headers: { - Authorization: `Bearer ${token}`, - }, + credentials: 'include', }); if (!response.ok) { diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 7636726..675fe27 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -44,17 +44,11 @@ const Login = () => { headers: { 'Content-Type': 'application/json', }, + credentials: 'include', body: JSON.stringify({ username, password }), }); if (response.ok) { - const data = await response.json(); - - // Store tokens - localStorage.setItem('access_token', data.access_token); - localStorage.setItem('refresh_token', data.refresh_token); - - // Redirect to dashboard navigate('/dashboard'); } else { const errorData = await response.json(); diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 7874397..6715709 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -52,11 +52,8 @@ const Profile = () => { useEffect(() => { const fetchUserProfile = async () => { try { - const token = localStorage.getItem('access_token'); const response = await fetch(`${API_URL}/users/profile`, { - headers: { - Authorization: `Bearer ${token}`, - }, + credentials: 'include', }); if (response.ok) { @@ -91,9 +88,11 @@ const Profile = () => { setAnchorEl(null); }; - const handleLogout = () => { - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); + const handleLogout = async () => { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + credentials: 'include', + }); navigate('/login'); }; @@ -115,13 +114,12 @@ const Profile = () => { setMessage({ type: '', text: '' }); try { - const token = localStorage.getItem('access_token'); const response = await fetch(`${API_URL}/users/profile`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, }, + credentials: 'include', body: JSON.stringify({ firstName: profile.firstName, lastName: profile.lastName, @@ -172,13 +170,12 @@ const Profile = () => { setChangingPassword(true); try { - const token = localStorage.getItem('access_token'); const response = await fetch(`${API_URL}/auth/change-password`, { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, }, + credentials: 'include', body: JSON.stringify({ currentPassword, newPassword, diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 2d68906..97b2fac 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -33,27 +33,21 @@ const Register = () => { headers: { 'Content-Type': 'application/json', }, + credentials: 'include', body: JSON.stringify({ username, password, email }), }); if (registerResponse.ok) { - // Auto-login after successful registration const loginResponse = await fetch(`${API_URL}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, + credentials: 'include', body: JSON.stringify({ username, password }), }); if (loginResponse.ok) { - const data = await loginResponse.json(); - - // Store tokens - localStorage.setItem('access_token', data.access_token); - localStorage.setItem('refresh_token', data.refresh_token); - - // Redirect to dashboard navigate('/dashboard'); } else { // Registration succeeded but login failed, redirect to login page diff --git a/frontend/src/services/api.service.ts b/frontend/src/services/api.service.ts index 1b1c5cd..7f23ded 100644 --- a/frontend/src/services/api.service.ts +++ b/frontend/src/services/api.service.ts @@ -3,14 +3,10 @@ import { API_URL } from '../config/api'; export const api = axios.create({ baseURL: API_URL, + withCredentials: true, }); export const login = (username: string, password: string) => api.post('/auth/login', { username, password }); -export const getProfile = (token: string) => - api.get('/users/profile', { - headers: { - Authorization: `Bearer ${token}`, - }, - }); +export const getProfile = () => api.get('/users/profile'); diff --git a/frontend/src/services/inventory.service.ts b/frontend/src/services/inventory.service.ts index 436cf43..acb39ec 100644 --- a/frontend/src/services/inventory.service.ts +++ b/frontend/src/services/inventory.service.ts @@ -105,13 +105,6 @@ const buildInventoryQuery = (params: InventorySearchParams) => { return query; }; -const getAuthHeader = () => { - const token = localStorage.getItem('access_token'); - return { - Authorization: `Bearer ${token}`, - }; -}; - const buildOrgInventoryQuery = (params: { gameId: number; uexItemId?: number; @@ -154,7 +147,7 @@ export const inventoryService = { ): Promise { const response = await axios.get(`${API_URL}/api/inventory`, { params: buildInventoryQuery(params), - headers: getAuthHeader(), + withCredentials: true, }); return response.data; }, @@ -164,7 +157,7 @@ export const inventoryService = { */ async getCategories(): Promise { const response = await axios.get(`${API_URL}/api/uex/categories`, { - headers: getAuthHeader(), + withCredentials: true, }); return response.data; }, @@ -176,7 +169,7 @@ export const inventoryService = { const response = await axios.get( `${API_URL}/api/inventory/summary/${gameId}`, { - headers: getAuthHeader(), + withCredentials: true, }, ); return response.data; @@ -192,7 +185,7 @@ export const inventoryService = { >, ): Promise { const response = await axios.post(`${API_URL}/api/inventory`, item, { - headers: getAuthHeader(), + withCredentials: true, }); return response.data; }, @@ -208,7 +201,7 @@ export const inventoryService = { `${API_URL}/api/inventory/${id}`, updates, { - headers: getAuthHeader(), + withCredentials: true, }, ); return response.data; @@ -219,7 +212,7 @@ export const inventoryService = { */ async deleteItem(id: string): Promise { await axios.delete(`${API_URL}/api/inventory/${id}`, { - headers: getAuthHeader(), + withCredentials: true, }); }, @@ -231,7 +224,7 @@ export const inventoryService = { `${API_URL}/api/inventory/${itemId}/share`, { orgId, quantity }, { - headers: getAuthHeader(), + withCredentials: true, }, ); }, @@ -241,7 +234,7 @@ export const inventoryService = { */ async unshareItem(itemId: string) { await axios.delete(`${API_URL}/api/inventory/${itemId}/share`, { - headers: getAuthHeader(), + withCredentials: true, }); }, @@ -254,7 +247,7 @@ export const inventoryService = { const response = await axios.get( `${API_URL}/user-organization-roles/user/${userId}/organizations`, { - headers: getAuthHeader(), + withCredentials: true, }, ); return response.data; @@ -282,7 +275,7 @@ export const inventoryService = { ): Promise { const response = await axios.get(`${API_URL}/api/orgs/${orgId}/inventory`, { params: buildOrgInventoryQuery(params), - headers: getAuthHeader(), + withCredentials: true, }); return response.data; @@ -309,7 +302,7 @@ export const inventoryService = { const response = await axios.post( `${API_URL}/api/orgs/${orgId}/inventory`, item, - { headers: getAuthHeader() }, + { withCredentials: true }, ); return response.data; }, @@ -325,7 +318,7 @@ export const inventoryService = { const response = await axios.put( `${API_URL}/api/orgs/${orgId}/inventory/${id}`, updates, - { headers: getAuthHeader() }, + { withCredentials: true }, ); return response.data; }, @@ -335,7 +328,7 @@ export const inventoryService = { */ async deleteOrgItem(orgId: number, id: string): Promise { await axios.delete(`${API_URL}/api/orgs/${orgId}/inventory/${id}`, { - headers: getAuthHeader(), + withCredentials: true, }); }, }; diff --git a/frontend/src/services/location.service.ts b/frontend/src/services/location.service.ts index 4c6b96c..17e562b 100644 --- a/frontend/src/services/location.service.ts +++ b/frontend/src/services/location.service.ts @@ -1,13 +1,6 @@ import axios from 'axios'; import { API_URL } from '../config/api'; -const getAuthHeader = () => { - const token = localStorage.getItem('access_token'); - return { - Authorization: `Bearer ${token}`, - }; -}; - export interface LocationRecord { id: string; gameId: number; @@ -36,7 +29,7 @@ export const locationService = { }): Promise { const response = await axios.get(`${API_URL}/api/locations`, { params, - headers: getAuthHeader(), + withCredentials: true, }); return response.data; }, @@ -47,10 +40,8 @@ export const locationService = { }): Promise { const response = await axios.get(`${API_URL}/api/locations/storable`, { params: { gameId: params.gameId }, - headers: { - ...getAuthHeader(), - ...(params.etag ? { 'If-None-Match': params.etag } : {}), - }, + headers: params.etag ? { 'If-None-Match': params.etag } : {}, + withCredentials: true, validateStatus: (status) => status === 200 || status === 304, }); diff --git a/frontend/src/services/permissions.service.ts b/frontend/src/services/permissions.service.ts index e7325ca..2f3c893 100644 --- a/frontend/src/services/permissions.service.ts +++ b/frontend/src/services/permissions.service.ts @@ -10,13 +10,6 @@ export const OrgPermission = { export type OrgPermission = (typeof OrgPermission)[keyof typeof OrgPermission]; -const getAuthHeader = () => { - const token = localStorage.getItem('access_token'); - return { - Authorization: `Bearer ${token}`, - }; -}; - export const permissionsService = { async getUserPermissions( userId: number, @@ -24,9 +17,7 @@ export const permissionsService = { ): Promise { const response = await axios.get( `${API_URL}/permissions/user/${userId}/organization/${organizationId}`, - { - headers: getAuthHeader(), - }, + { withCredentials: true }, ); const permissions = response.data?.permissions; return Array.isArray(permissions) ? permissions : []; diff --git a/frontend/src/services/uex.service.ts b/frontend/src/services/uex.service.ts index c88f503..26ac2d0 100644 --- a/frontend/src/services/uex.service.ts +++ b/frontend/src/services/uex.service.ts @@ -1,13 +1,6 @@ import axios from 'axios'; import { API_URL } from '../config/api'; -const getAuthHeader = () => { - const token = localStorage.getItem('access_token'); - return { - Authorization: `Bearer ${token}`, - }; -}; - export interface CatalogItem { id: number; uexId: number; @@ -44,14 +37,14 @@ export const uexService = { ): Promise { const response = await axios.get(`${API_URL}/api/uex/items`, { params, - headers: getAuthHeader(), + withCredentials: true, }); return response.data; }, async getStarSystems(): Promise { const response = await axios.get(`${API_URL}/api/uex/star-systems`, { - headers: getAuthHeader(), + withCredentials: true, }); return response.data; }, From 147c3bbcf8ddd5a070fc8ab4327306c5712af0c3 Mon Sep 17 00:00:00 2001 From: gitaddremote Date: Thu, 14 May 2026 20:16:16 -0400 Subject: [PATCH 2/2] fix: address PR 159 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap all three handleLogout fetch calls in try/finally so navigate('/login') always runs regardless of network errors or 401 from RefreshTokenAuthGuard - ProtectedRoute: show CircularProgress spinner while auth check is in flight instead of rendering null; only redirect to /login on explicit 401 — network errors and 5xx leave the spinner up to avoid booting users on transient failures --- frontend/src/components/ProtectedRoute.tsx | 32 ++++++++++++++++++++-- frontend/src/pages/Dashboard.tsx | 13 +++++---- frontend/src/pages/Inventory.tsx | 13 +++++---- frontend/src/pages/Profile.tsx | 13 +++++---- 4 files changed, 53 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index ac0b2ac..49548df 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { Navigate, Outlet } from 'react-router-dom'; +import { Box, CircularProgress } from '@mui/material'; import { API_URL } from '../config/api'; const ProtectedRoute = () => { @@ -7,11 +8,36 @@ const ProtectedRoute = () => { useEffect(() => { fetch(`${API_URL}/auth/me`, { credentials: 'include' }) - .then((res) => setAuthed(res.ok)) - .catch(() => setAuthed(false)); + .then((res) => { + if (res.status === 401) { + setAuthed(false); + } else if (res.ok) { + setAuthed(true); + } + // leave authed as null on other errors (5xx, network) — keep spinner + }) + .catch(() => { + // network error — don't redirect, keep spinner to avoid booting + // users on a transient failure + }); }, []); - if (authed === null) return null; + if (authed === null) { + return ( + + + + ); + } + if (!authed) return ; return ; }; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f15582c..cda71a1 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -66,11 +66,14 @@ const Dashboard = () => { }; const handleLogout = async () => { - await fetch(`${API_URL}/auth/logout`, { - method: 'POST', - credentials: 'include', - }); - navigate('/login'); + try { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + credentials: 'include', + }); + } finally { + navigate('/login'); + } }; const handleProfile = () => { diff --git a/frontend/src/pages/Inventory.tsx b/frontend/src/pages/Inventory.tsx index fa69105..2cacbee 100644 --- a/frontend/src/pages/Inventory.tsx +++ b/frontend/src/pages/Inventory.tsx @@ -441,11 +441,14 @@ const InventoryPage = () => { ); const handleLogout = async () => { - await fetch(`${API_URL}/auth/logout`, { - method: 'POST', - credentials: 'include', - }); - navigate('/login'); + try { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + credentials: 'include', + }); + } finally { + navigate('/login'); + } }; const closeActionMenu = () => { diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 6715709..1745ad3 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -89,11 +89,14 @@ const Profile = () => { }; const handleLogout = async () => { - await fetch(`${API_URL}/auth/logout`, { - method: 'POST', - credentials: 'include', - }); - navigate('/login'); + try { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + credentials: 'include', + }); + } finally { + navigate('/login'); + } }; const handleDashboard = () => {