From f63656e28d40c51fe3d60c3cc876d55f8e6eb892 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 03:06:40 +0000 Subject: [PATCH] feat(frontend): event-driven JWT auth and cold-start loading tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navbar previously called /api/auth/verify-token on every route change because location was in the useEffect deps — that's a network roundtrip per click. Replace the polling with client-side JWT decoding plus an auth-change event bus so the Navbar updates instantly on login/logout (and across tabs via the storage event). Token expiry is checked locally before each api request to avoid wasting roundtrips on a token the server will reject; the 401 interceptor stays as the source of truth for server-side revocation (blacklist). LoadingOverlay now escalates its messaging: after 4s it admits things are slow, after 10s it tells the user the Render free-tier backend is likely cold-starting, and an info icon explains the 0.1 CPU / 512 MB limits and 30–60s wake-up window in a tooltip. Co-authored-by: David Nguyen --- frontend/src/components/LoadingOverlay.js | 92 +++++++++++++++++++++- frontend/src/components/Navbar.js | 58 ++++++-------- frontend/src/services/api.js | 16 +++- frontend/src/services/auth.js | 95 ++++++++++++++++++++++- 4 files changed, 216 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/LoadingOverlay.js b/frontend/src/components/LoadingOverlay.js index d48f4eb..49e9c3d 100644 --- a/frontend/src/components/LoadingOverlay.js +++ b/frontend/src/components/LoadingOverlay.js @@ -1,10 +1,52 @@ -import React from 'react'; -import { Box, CircularProgress, Typography } from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import { Box, CircularProgress, Typography, Tooltip, Fade, Stack } from '@mui/material'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; + +// Render free-tier specs — surface a friendly note when a request hangs long +// enough that the user might think the app is broken. Numbers come from +// Render's published free-instance limits (see README "Live API" section). +const FREE_TIER_INFO = "We're on Render's free tier (0.1 CPU / 512 MB RAM). When the service has been idle, the first request triggers a cold start that usually takes 30–60 seconds. Subsequent requests are fast."; + +const LONG_LOAD_MS = 4000; +const COLD_START_MS = 10000; + +function LoadingOverlay({ loading, longLoadMs = LONG_LOAD_MS, coldStartMs = COLD_START_MS }) { + const [stage, setStage] = useState('initial'); + const [elapsed, setElapsed] = useState(0); + + useEffect(() => { + if (!loading) { + setStage('initial'); + setElapsed(0); + return; + } + + const startedAt = Date.now(); + const tick = setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000); + const longTimer = setTimeout(() => setStage(prev => (prev === 'initial' ? 'slow' : prev)), longLoadMs); + const coldTimer = setTimeout(() => setStage('cold'), coldStartMs); + + return () => { + clearInterval(tick); + clearTimeout(longTimer); + clearTimeout(coldTimer); + }; + }, [loading, longLoadMs, coldStartMs]); -function LoadingOverlay({ loading }) { if (!loading) return null; + + const message = + stage === 'cold' + ? 'Still waking up the server…' + : stage === 'slow' + ? 'Render is taking a while to load up…' + : 'Loading data…'; + return ( - Loading data… + {message} + + + + + {stage === 'cold' + ? `Hang tight — the backend may be cold-starting (${elapsed}s elapsed).` + : 'First request after idle can be slow.'} + + + + + + ); } diff --git a/frontend/src/components/Navbar.js b/frontend/src/components/Navbar.js index ecdd4b8..08dd5fd 100644 --- a/frontend/src/components/Navbar.js +++ b/frontend/src/components/Navbar.js @@ -27,11 +27,11 @@ import ReceiptLongIcon from '@mui/icons-material/ReceiptLong'; import GroupIcon from '@mui/icons-material/Group'; import DashboardIcon from '@mui/icons-material/Dashboard'; import LogoutIcon from '@mui/icons-material/Logout'; -import api from '../services/api'; +import { isLoggedIn as checkLoggedIn, getTokenExpiry, logout, onAuthChange } from '../services/auth'; function Navbar({ mode, setMode }) { const [drawerOpen, setDrawerOpen] = useState(false); - const [isLoggedIn, setIsLoggedIn] = useState(!!localStorage.getItem('token')); + const [isLoggedIn, setIsLoggedIn] = useState(() => checkLoggedIn()); const navigate = useNavigate(); const location = useLocation(); const isMobileNav = useMediaQuery('(max-width:1350px)'); @@ -40,43 +40,31 @@ function Navbar({ mode, setMode }) { setMode(prev => (prev === 'light' ? 'dark' : 'light')); }; + // Auth state is event-driven: we listen for login/logout in this tab, + // storage changes from other tabs, and a single timer that fires exactly + // when the JWT's exp claim is reached. No per-navigation network calls, + // no polling — the server's 401 response (handled in api.js) is the + // source of truth for server-side revocation. useEffect(() => { - const checkToken = async () => { - const token = localStorage.getItem('token'); - if (!token) { - setIsLoggedIn(false); - return; - } - try { - const res = await api.post( - '/api/auth/verify-token', - { token }, - { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - } - ); - if (res.data.valid) { - setIsLoggedIn(true); - } else { - localStorage.removeItem('token'); - setIsLoggedIn(false); - navigate('/login'); - } - } catch (err) { - localStorage.removeItem('token'); - setIsLoggedIn(false); - navigate('/login'); - } + const sync = () => setIsLoggedIn(checkLoggedIn()); + + const unsubscribe = onAuthChange(sync); + + let expiryTimer; + const expiryMs = getTokenExpiry(); + if (expiryMs) { + const delay = Math.max(0, expiryMs - Date.now()); + expiryTimer = setTimeout(sync, delay + 250); + } + + return () => { + unsubscribe(); + if (expiryTimer) clearTimeout(expiryTimer); }; - checkToken(); - }, [navigate, location]); + }, [isLoggedIn]); const handleLogout = () => { - localStorage.removeItem('token'); - setIsLoggedIn(false); + logout(); navigate('/login'); }; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 0069790..d86306e 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1,5 +1,5 @@ import axios from 'axios'; -import { getToken, logout } from './auth'; +import { getToken, isTokenExpired, logout } from './auth'; const api = axios.create({ baseURL: 'https://budget-management-backend-api.onrender.com', @@ -8,6 +8,16 @@ const api = axios.create({ api.interceptors.request.use(config => { const token = getToken(); if (token) { + // Short-circuit expired tokens client-side so we don't burn a round trip + // on a request the server will reject. The 401 interceptor below is the + // fallback for tokens revoked server-side (e.g. blacklist). + if (isTokenExpired(token)) { + logout(); + if (typeof window !== 'undefined' && window.location.pathname !== '/login') { + window.location.href = '/login'; + } + return Promise.reject(new Error('Token expired')); + } config.headers.Authorization = `Bearer ${token}`; } return config; @@ -18,7 +28,9 @@ api.interceptors.response.use( error => { if (error.response && error.response.status === 401) { logout(); - window.location.href = '/login'; + if (typeof window !== 'undefined' && window.location.pathname !== '/login') { + window.location.href = '/login'; + } } return Promise.reject(error); } diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index e86e70d..796aa3f 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -1,5 +1,92 @@ -export const getToken = () => localStorage.getItem('token'); -export const setToken = token => localStorage.setItem('token', token); -export const removeToken = () => localStorage.removeItem('token'); -export const isLoggedIn = () => !!getToken(); +// Lightweight, polling-free auth state. +// +// Tokens are validated on the client by decoding the JWT and inspecting the +// `exp` claim. The server still does the real check on every protected +// request (and the api.js 401 interceptor clears the token if the server ever +// rejects it), so we never need to poll /api/auth/verify-token from the UI. +// +// An "auth-change" CustomEvent is dispatched whenever the token mutates, so +// components subscribe once instead of re-checking on every navigation. The +// browser `storage` event keeps multiple tabs in sync automatically. + +const TOKEN_KEY = 'token'; +const AUTH_EVENT = 'auth-change'; + +export const getToken = () => localStorage.getItem(TOKEN_KEY); + +export const setToken = token => { + localStorage.setItem(TOKEN_KEY, token); + emitAuthChange(); +}; + +export const removeToken = () => { + localStorage.removeItem(TOKEN_KEY); + emitAuthChange(); +}; + export const logout = () => removeToken(); + +// Decode the JWT payload without verifying the signature. The signature is +// verified server-side; client-side decoding only powers UI decisions like +// "is the token already expired, don't bother sending the request". +export const decodeToken = token => { + const raw = token ?? getToken(); + if (!raw || typeof raw !== 'string') return null; + const parts = raw.split('.'); + if (parts.length !== 3) return null; + try { + const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '==='.slice((base64.length + 3) % 4); + const json = typeof atob === 'function' ? atob(padded) : Buffer.from(padded, 'base64').toString('binary'); + const decoded = decodeURIComponent( + json + .split('') + .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ); + return JSON.parse(decoded); + } catch { + return null; + } +}; + +export const getTokenExpiry = token => { + const payload = decodeToken(token); + if (!payload || typeof payload.exp !== 'number') return null; + return payload.exp * 1000; +}; + +export const isTokenExpired = token => { + const expMs = getTokenExpiry(token); + if (expMs == null) return false; // no exp claim → treat as non-expiring, let server decide + return Date.now() >= expMs; +}; + +export const isLoggedIn = () => { + const token = getToken(); + if (!token) return false; + return !isTokenExpired(token); +}; + +const emitAuthChange = () => { + if (typeof window === 'undefined') return; + window.dispatchEvent(new CustomEvent(AUTH_EVENT)); +}; + +// Subscribe to login/logout transitions. Returns an unsubscribe function. +// Fires on: +// • setToken / removeToken in the current tab (CustomEvent) +// • storage mutations from other tabs (storage event) +export const onAuthChange = handler => { + if (typeof window === 'undefined') return () => {}; + const customHandler = () => handler(); + const storageHandler = e => { + if (e.key === TOKEN_KEY || e.key === null) handler(); + }; + window.addEventListener(AUTH_EVENT, customHandler); + window.addEventListener('storage', storageHandler); + return () => { + window.removeEventListener(AUTH_EVENT, customHandler); + window.removeEventListener('storage', storageHandler); + }; +};