Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 88 additions & 4 deletions frontend/src/components/LoadingOverlay.js
Original file line number Diff line number Diff line change
@@ -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 (
<Box
role="alert"
aria-live="polite"
aria-busy="true"
sx={{
position: 'fixed',
top: 0,
Expand All @@ -19,12 +61,54 @@ function LoadingOverlay({ loading }) {
flexDirection: 'column',
gap: 2,
zIndex: 9999,
px: 2,
textAlign: 'center',
}}
>
<CircularProgress size={60} />
<Typography variant="body2" sx={{ color: '#f7f6f2' }}>
Loading data…
{message}
</Typography>

<Fade in={stage !== 'initial'} timeout={400} unmountOnExit>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{
mt: 1,
px: 2,
py: 1,
borderRadius: 2,
maxWidth: 460,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
border: '1px solid rgba(255, 255, 255, 0.18)',
}}
>
<Typography variant="caption" sx={{ color: '#f7f6f2', lineHeight: 1.4 }}>
{stage === 'cold'
? `Hang tight — the backend may be cold-starting (${elapsed}s elapsed).`
: 'First request after idle can be slow.'}
</Typography>
<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 +93 to +109
</Stack>
</Fade>
</Box>
);
}
Expand Down
58 changes: 23 additions & 35 deletions frontend/src/components/Navbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)');
Expand All @@ -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]);
Comment on lines 48 to +64
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);
    };
  }, []);


Comment on lines 48 to 65
const handleLogout = () => {
localStorage.removeItem('token');
setIsLoggedIn(false);
logout();
navigate('/login');
};

Expand Down
16 changes: 14 additions & 2 deletions frontend/src/services/api.js
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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'));
}
Comment on lines +14 to +20
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).

config.headers.Authorization = `Bearer ${token}`;
}
return config;
Expand All @@ -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);
}
Expand Down
95 changes: 91 additions & 4 deletions frontend/src/services/auth.js
Original file line number Diff line number Diff line change
@@ -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');
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);

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);
};
Comment on lines +29 to +69

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