feat(frontend): event-driven JWT auth and cold-start loading tip#31
Conversation
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 <hoangson091104@gmail.com>
✅ Deploy Preview for budget-management-system ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
There was a problem hiding this comment.
Code Review
This pull request enhances the application's loading state feedback and refactors the authentication system to be event-driven and more efficient. A new multi-stage LoadingOverlay provides context for backend cold starts, while the auth service now utilizes client-side JWT decoding and custom events for state synchronization. Review feedback identified a logic flaw in the Navbar's expiry timer, potential environment compatibility issues with the Buffer object in the auth service, and opportunities to reduce code duplication in the API interceptors.
| 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]); |
There was a problem hiding this comment.
The current useEffect implementation has a logic flaw: it only recalculates the expiryTimer when the isLoggedIn boolean changes. If the token is updated (e.g., via a refresh or re-login) while the user is already logged in, the timer will not be reset to the new expiration time. Additionally, resubscribing to onAuthChange on every state change is unnecessary.
It is better to subscribe once and manage the timer lifecycle within the event handler itself.
useEffect(() => {
let expiryTimer;
const sync = () => {
const loggedIn = checkLoggedIn();
setIsLoggedIn(loggedIn);
if (expiryTimer) clearTimeout(expiryTimer);
if (loggedIn) {
const expiryMs = getTokenExpiry();
if (expiryMs) {
const delay = Math.max(0, expiryMs - Date.now());
expiryTimer = setTimeout(sync, delay + 250);
}
}
};
const unsubscribe = onAuthChange(sync);
sync(); // Initial check and timer setup
return () => {
unsubscribe();
if (expiryTimer) clearTimeout(expiryTimer);
};
}, []);| 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'); |
There was a problem hiding this comment.
Referencing Buffer directly in a frontend service can lead to ReferenceError in browser environments or build failures with modern bundlers (like Vite or Webpack 5) that do not include Node.js polyfills by default. Since atob is available in all modern browsers and Node.js 16+, you should either rely solely on atob or use a guarded check for Buffer.
| const json = typeof atob === 'function' ? atob(padded) : Buffer.from(padded, 'base64').toString('binary'); | |
| const json = typeof atob === 'function' ? atob(padded) : (typeof Buffer !== 'undefined' ? Buffer.from(padded, 'base64').toString('binary') : null); |
| if (isTokenExpired(token)) { | ||
| logout(); | ||
| if (typeof window !== 'undefined' && window.location.pathname !== '/login') { | ||
| window.location.href = '/login'; | ||
| } | ||
| return Promise.reject(new Error('Token expired')); | ||
| } |
There was a problem hiding this comment.
The logic for handling unauthorized states (logging out and redirecting) is duplicated in both the request and response interceptors. This duplication makes the code harder to maintain and increases the risk of inconsistent behavior. Consider extracting this into a shared utility function (e.g., handleUnauthorized in auth.js).
There was a problem hiding this comment.
Pull request overview
This PR updates the frontend’s authentication and loading UX by removing per-navigation JWT verification calls, switching auth state updates to an event-driven model, adding a client-side token-expiry preflight check in the API layer, and improving the loading overlay messaging for backend cold starts.
Changes:
- Replace Navbar JWT polling/route-change verification with event-driven auth state updates and a single JWT-expiry timer.
- Add a request interceptor in
api.jsto short-circuit locally-expired tokens before sending network requests. - Enhance
LoadingOverlaywith staged messaging, elapsed-time display, and a tooltip explaining cold starts.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| frontend/src/services/auth.js | Adds JWT decode/expiry helpers and an auth-change subscription mechanism for in-tab and cross-tab auth sync. |
| frontend/src/services/api.js | Adds a request-time expiry precheck and keeps 401 handling for server-side revocation/logout. |
| frontend/src/components/Navbar.js | Removes verify-token calls and uses auth-change events + a JWT exp timer to update logged-in state. |
| frontend/src/components/LoadingOverlay.js | Adds staged long-wait/cold-start messaging, elapsed seconds, and an informational tooltip. |
Comments suppressed due to low confidence (1)
frontend/src/services/auth.js:41
- JWT base64url padding: when the payload length mod 4 is 1, the token is invalid per base64 rules. The current padding expression will append three '=' and attempt to decode anyway, which can yield inconsistent results for malformed tokens. Consider explicitly treating the mod-4==1 case as invalid (return null) and computing padding with the standard (4 - (len % 4)) % 4 formula.
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(
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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]); | ||
|
|
|
|
||
| return ( | ||
| <Box | ||
| role="alert" |
| <Tooltip | ||
| arrow | ||
| enterTouchDelay={0} | ||
| leaveTouchDelay={6000} | ||
| title={FREE_TIER_INFO} | ||
| componentsProps={{ | ||
| tooltip: { | ||
| sx: { fontSize: 12, maxWidth: 320, lineHeight: 1.5, p: 1.25 }, | ||
| }, | ||
| }} | ||
| > | ||
| <InfoOutlinedIcon | ||
| fontSize="small" | ||
| aria-label="Why is this slow?" | ||
| sx={{ color: '#f7f6f2', cursor: 'help', flexShrink: 0 }} | ||
| /> | ||
| </Tooltip> |
| // 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); | ||
| }; |
Summary
Navbarwas POSTing/api/auth/verify-tokenon every route change becauselocationwas in itsuseEffectdeps — one network roundtrip per click. It's now event-driven: client-side JWT decoding for expiry, a customauth-changeevent for in-tab login/logout, and thestorageevent for cross-tab sync. The 401 interceptor inapi.jsremains the source of truth for server-side revocation (Redis blacklist).api.js. Requests with a locally-expired token are short-circuited before they hit the network.LoadingOverlay. Messaging escalates as the wait grows:(i)icon shows a tooltip explaining the Render free-tier specs (0.1 CPU / 512 MB RAM, 30–60s cold start) — numbers sourced from the README's "Live API" note.Files changed
frontend/src/services/auth.js— addsdecodeToken,getTokenExpiry,isTokenExpired,onAuthChange; emitsauth-changefromsetToken/removeToken.frontend/src/services/api.js— request interceptor short-circuits expired tokens.frontend/src/components/Navbar.js— subscribes toonAuthChangeonce instead of re-verifying on every navigation; sets a single timer that fires at the JWTexp.frontend/src/components/LoadingOverlay.js— staged messaging + info tooltip.Test plan
/budgets,/expenses,/dashboard,/profile— confirm no/api/auth/verify-tokenrequests in the Network tab on navigation (was firing every click before).localStorage.tokento an expired JWT, attempt anyapi.*call — request should not be sent; user redirected to/login./api/userswith a Redis-blacklisted token — 401 interceptor clears the token and bounces to/login.(i)tooltip renders the free-tier note on hover/tap.LoadingOverlay loading={false}still renders nothing; existing tests inApp.test.js(isLoggedIn/setToken/logout) still pass — non-JWT strings yield noexpclaim and are treated as non-expiring.Generated by Claude Code