Skip to content

feat(frontend): event-driven JWT auth and cold-start loading tip#31

Merged
hoangsonww merged 1 commit into
masterfrom
feat/jwt-polling-loading-ux
May 23, 2026
Merged

feat(frontend): event-driven JWT auth and cold-start loading tip#31
hoangsonww merged 1 commit into
masterfrom
feat/jwt-polling-loading-ux

Conversation

@hoangsonww
Copy link
Copy Markdown
Owner

Summary

  • Kills the JWT polling. The Navbar was POSTing /api/auth/verify-token on every route change because location was in its useEffect deps — one network roundtrip per click. It's now event-driven: client-side JWT decoding for expiry, a custom auth-change event for in-tab login/logout, and the storage event for cross-tab sync. The 401 interceptor in api.js remains the source of truth for server-side revocation (Redis blacklist).
  • Pre-flight expiry check in api.js. Requests with a locally-expired token are short-circuited before they hit the network.
  • Cold-start UX in LoadingOverlay. Messaging escalates as the wait grows:
    • 0–4s: standard "Loading data…"
    • 4s+: a tip badge fades in ("First request after idle can be slow.")
    • 10s+: switches to "Still waking up the server…" with a live elapsed-seconds counter.
    • An (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 — adds decodeToken, getTokenExpiry, isTokenExpired, onAuthChange; emits auth-change from setToken/removeToken.
  • frontend/src/services/api.js — request interceptor short-circuits expired tokens.
  • frontend/src/components/Navbar.js — subscribes to onAuthChange once instead of re-verifying on every navigation; sets a single timer that fires at the JWT exp.
  • frontend/src/components/LoadingOverlay.js — staged messaging + info tooltip.

Test plan

  • Log in, navigate between /budgets, /expenses, /dashboard, /profile — confirm no /api/auth/verify-token requests in the Network tab on navigation (was firing every click before).
  • Open the app in two tabs, log out in one — the other tab's Navbar should flip to logged-out state without a refresh (storage event).
  • Manually tamper with localStorage.token to an expired JWT, attempt any api.* call — request should not be sent; user redirected to /login.
  • Hit /api/users with a Redis-blacklisted token — 401 interceptor clears the token and bounces to /login.
  • Trigger a cold start (idle backend, then any data fetch): verify the overlay shows the "slow" badge around 4s and the "waking up" message at 10s, and that the (i) tooltip renders the free-tier note on hover/tap.
  • LoadingOverlay loading={false} still renders nothing; existing tests in App.test.js (isLoggedIn/setToken/logout) still pass — non-JWT strings yield no exp claim and are treated as non-expiring.

Generated by Claude Code

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>
Copilot AI review requested due to automatic review settings May 23, 2026 03:33
@netlify
Copy link
Copy Markdown

netlify Bot commented May 23, 2026

Deploy Preview for budget-management-system ready!

Name Link
🔨 Latest commit f63656e
🔍 Latest deploy log https://app.netlify.com/projects/budget-management-system/deploys/6a111ffd8558440009ba6f0f
😎 Deploy Preview https://deploy-preview-31--budget-management-system.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
budget-management-backend-api Ignored Ignored May 23, 2026 3:33am

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 48 to +64
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]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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);

Comment on lines +14 to +20
if (isTokenExpired(token)) {
logout();
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login';
}
return Promise.reject(new Error('Token expired'));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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).

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.js to short-circuit locally-expired tokens before sending network requests.
  • Enhance LoadingOverlay with 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.

Comment on lines 48 to 65
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"
Comment on lines +93 to +109
<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>
Comment on lines +29 to +69
// 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);
};
@hoangsonww hoangsonww merged commit 00c08b0 into master May 23, 2026
16 checks passed
@hoangsonww hoangsonww self-assigned this May 23, 2026
@hoangsonww hoangsonww added bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request help wanted Extra attention is needed good first issue Good for newcomers labels May 23, 2026
@hoangsonww hoangsonww added this to the v1.0.0 - Stable Release milestone May 23, 2026
@hoangsonww hoangsonww moved this from Done to In progress in Budget Management API Project Board May 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request good first issue Good for newcomers help wanted Extra attention is needed

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

3 participants