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
127 changes: 123 additions & 4 deletions frontend/package-lock.json

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
"@radix-ui/react-select": "^2.2.6",
"@tanstack/react-table": "^8.21.3",
"antd": "^6.1.3",
"autoprefixer": "^10.4.14",
"axios": "^1.3.4",
"fuse.js": "^7.1.0",
"html2canvas": "^1.4.1",
Expand All @@ -21,8 +20,7 @@
"leaflet.markercluster": "^1.5.3",
"lodash": "^4.17.21",
"lucide-react": "^0.539.0",
"playwright": "^1.56.1",
"postcss": "^8.4.31",
"prop-types": "^15.8.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-geolocated": "^4.3.0",
Expand All @@ -34,7 +32,6 @@
"react-router-dom": "^7.8.1",
"react-slider": "^2.0.6",
"recharts": "^3.2.1",
"tailwindcss": "^3.4.17",
"yet-another-react-lightbox": "^3.26.0",
"zod": "^4.2.1"
},
Expand All @@ -45,6 +42,7 @@
"@types/lodash": "^4.17.21",
"@types/node": "^25.0.3",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.14",
"eslint": "^9.33.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-exports": "^1.0.0-beta.5",
Expand All @@ -54,8 +52,11 @@
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^27.0.1",
"playwright": "^1.56.1",
"postcss": "^8.4.31",
"prettier": "^3.2.5",
"puppeteer": "^24.15.0",
"tailwindcss": "^3.4.17",
"vite": "^7.3.0",
"vite-plugin-svgr": "^4.5.0",
"vitest": "^3.2.4"
Expand Down
679 changes: 347 additions & 332 deletions frontend/src/App.js

Large diffs are not rendered by default.

284 changes: 153 additions & 131 deletions frontend/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,96 @@
return Math.min(1000 * Math.pow(2, attempt), 10000);
};

// Helper to handle 401 unauthorized errors and token refresh
const handle401Error = async (error, originalRequest) => {
if (isRefreshing) {
// If already refreshing, queue this request
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
})
.catch(err => {
return Promise.reject(err);
});
}

originalRequest._retry = true;
isRefreshing = true;

try {
// Attempt to renew token using refresh token from cookies
const response = await api.post(
'/api/v1/auth/refresh',
{},
{
withCredentials: true, // Important: include cookies
}
);
const { access_token } = response.data;

// Update localStorage with new token
localStorage.setItem('access_token', access_token);

// Dispatch custom event to notify AuthContext of token refresh
window.dispatchEvent(
new window.CustomEvent('tokenRefreshed', {
detail: { access_token },
})
);

// Process queued requests
processQueue(null, access_token);

// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return api(originalRequest);
} catch (refreshError) {
// Process queued requests with error
processQueue(refreshError, null);

// Refresh failed, redirect to login
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
};

// Helper to handle retryable errors (gateway timeouts, server errors)
const handleRetryableError = async (error, originalRequest) => {
const requestKey = `${originalRequest.method}:${originalRequest.url}`;
const attempt = retryAttempts.get(requestKey) || 0;
const maxRetries = 3;

if (attempt < maxRetries) {
// Mark as gateway retry to prevent infinite loops
retryAttempts.set(requestKey, attempt + 1);

const delay = getRetryDelay(attempt);

// Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, delay));

// Retry the request (don't clear _gatewayRetry, it prevents infinite loops)
return api(originalRequest);
} else {
// Max retries reached, clear tracking and reject
retryAttempts.delete(requestKey);
error.isGatewayTimeout = true;
error.isServerError = true;
return Promise.reject(error);
}
};

// Response interceptor for successful responses
api.interceptors.response.use(
response => {
if (response.config.url?.includes('/auth/login') && response.status === 200) {
}

// If we get a successful response after backend was down, notify AuthContext
// This helps recover user session when backend comes back
if (response.status >= 200 && response.status < 300) {
Expand All @@ -111,96 +195,22 @@
url.includes('/auth/register') ||
url.includes('/auth/google-login');

// Handle 401 Unauthorized
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
if (isRefreshing) {
// If already refreshing, queue this request
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then(token => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
})
.catch(err => {
return Promise.reject(err);
});
}

originalRequest._retry = true;
isRefreshing = true;
return handle401Error(error, originalRequest);
}

try {
// Attempt to renew token using refresh token from cookies

const response = await api.post(
'/api/v1/auth/refresh',
{},
{
withCredentials: true, // Important: include cookies
}
);
const { access_token } = response.data;

// Update localStorage with new token
localStorage.setItem('access_token', access_token);

// Dispatch custom event to notify AuthContext of token refresh
window.dispatchEvent(
new window.CustomEvent('tokenRefreshed', {
detail: { access_token },
})
);

// Process queued requests
processQueue(null, access_token);

// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${access_token}`;
return api(originalRequest);
} catch (refreshError) {
// Process queued requests with error
processQueue(refreshError, null);

// Refresh failed, redirect to login
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
} else if (error.response?.status === 429) {
// Rate limiting - extract retry after information if available
// Handle 429 Rate Limiting
if (error.response?.status === 429) {
const retryAfter =
error.response.headers['retry-after'] || error.response.data?.retry_after || 30;
error.retryAfter = retryAfter;
error.isRateLimited = true;
} else if (isRetryableError(error) && !originalRequest._gatewayRetry && !isAuthEndpoint) {
// Handle gateway timeouts (504) and server errors (5xx)
// These often happen when backend is cold-starting on Fly.io
const requestKey = `${originalRequest.method}:${originalRequest.url}`;
const attempt = retryAttempts.get(requestKey) || 0;
const maxRetries = 3;

if (attempt < maxRetries) {
// Mark as gateway retry to prevent infinite loops
originalRequest._gatewayRetry = true;
retryAttempts.set(requestKey, attempt + 1);

const delay = getRetryDelay(attempt);

// Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, delay));
}

// Retry the request (don't clear _gatewayRetry, it prevents infinite loops)
return api(originalRequest);
} else {
// Max retries reached, clear tracking and reject
retryAttempts.delete(requestKey);
error.isGatewayTimeout = true;
error.isServerError = true;
}
// Handle Retryable Errors (5xx, timeouts)
if (isRetryableError(error) && !originalRequest._gatewayRetry && !isAuthEndpoint) {
return handleRetryableError(error, originalRequest);
}

// Clear retry tracking on final failure (if not already cleared)
Expand All @@ -220,7 +230,7 @@
timeout: 5000, // 5 second timeout
});
return response.data;
} catch (error) {
} catch {
// Silently fail - this is just a keepalive
return null;
}
Expand Down Expand Up @@ -276,6 +286,58 @@
return fieldErrors;
};

// Helper to extract error message from FastAPI detail
const extractDetailMessage = detail => {
// Handle Pydantic validation errors (array of error objects)
if (Array.isArray(detail)) {
// Extract the first validation error message with field name
const firstError = detail[0];
if (firstError && typeof firstError === 'object') {
if (firstError.loc && Array.isArray(firstError.loc)) {
const fieldDisplayName = getFieldNameFromLoc(firstError.loc);
const errorMsg = firstError.msg || 'Validation error';
return `${fieldDisplayName}: ${errorMsg}`;
}
return firstError.msg || 'Validation error';
}
return 'Validation error';
}
// Handle simple string error messages
if (typeof detail === 'string') {
return detail;
}
// If detail is an object (not array, not string), try to extract message
if (typeof detail === 'object' && detail !== null) {
return detail.msg || detail.message || JSON.stringify(detail);
}
return null;
};

// Helper to extract message from general response data
const extractDataMessage = (data, defaultMessage) => {
if (typeof data === 'string') return data;
if (data.detail) {
if (typeof data.detail === 'string') return data.detail;
if (Array.isArray(data.detail) && data.detail.length > 0) {
const first = data.detail[0];
if (first?.msg) return first.msg;
try {
return JSON.stringify(data.detail);
} catch {
return defaultMessage;
}
}
try {
return JSON.stringify(data.detail);
} catch {
return defaultMessage;
}
}
if (data.msg) return data.msg;
if (data.message) return data.message;
return null;
};

// Utility function to extract error message from API responses
// Supports FastAPI/axios error payloads, Pydantic validation errors, and various error formats
export const extractErrorMessage = (error, defaultMessage = 'An error occurred') => {
Expand All @@ -287,54 +349,14 @@

// Handle error.response.data.detail (FastAPI standard)
if (error.response?.data?.detail) {
const detail = error.response.data.detail;
// Handle Pydantic validation errors (array of error objects)
if (Array.isArray(detail)) {
// Extract the first validation error message with field name
const firstError = detail[0];
if (firstError && typeof firstError === 'object') {
if (firstError.loc && Array.isArray(firstError.loc)) {
const fieldDisplayName = getFieldNameFromLoc(firstError.loc);
const errorMsg = firstError.msg || 'Validation error';
return `${fieldDisplayName}: ${errorMsg}`;
}
return firstError.msg || 'Validation error';
}
return 'Validation error';
}
// Handle simple string error messages
if (typeof detail === 'string') {
return detail;
}
// If detail is an object (not array, not string), try to extract message
if (typeof detail === 'object' && detail !== null) {
return detail.msg || detail.message || JSON.stringify(detail);
}
const detailMsg = extractDetailMessage(error.response.data.detail);
if (detailMsg) return detailMsg;
}

// Handle error.response.data (alternative location)
if (error.response?.data) {
const data = error.response.data;
if (typeof data === 'string') return data;
if (data.detail) {
if (typeof data.detail === 'string') return data.detail;
if (Array.isArray(data.detail) && data.detail.length > 0) {
const first = data.detail[0];
if (first?.msg) return first.msg;
try {
return JSON.stringify(data.detail);
} catch {
return defaultMessage;
}
}
try {
return JSON.stringify(data.detail);
} catch {
return defaultMessage;
}
}
if (data.msg) return data.msg;
if (data.message) return data.message;
const dataMsg = extractDataMessage(error.response.data, defaultMessage);
if (dataMsg) return dataMsg;
}

// Handle error.detail (direct property)
Expand Down Expand Up @@ -876,7 +898,7 @@
};

export const updateUserNotificationPreference = async (userId, category, preferenceData) => {
const response = await api.put(

Check warning on line 901 in frontend/src/api.js

View workflow job for this annotation

GitHub Actions / Lint and Format

File has too many lines (950). Maximum allowed is 900
`/api/v1/notifications/admin/users/${userId}/preferences/${category}`,
preferenceData
);
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/components/BackgroundLogo.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import PropTypes from 'prop-types';

const BackgroundLogo = ({ opacity = 0.03, size = 'large', className = '' }) => {
const sizeClasses = {
Expand All @@ -22,4 +22,10 @@ const BackgroundLogo = ({ opacity = 0.03, size = 'large', className = '' }) => {
);
};

BackgroundLogo.propTypes = {
opacity: PropTypes.number,
size: PropTypes.oneOf(['small', 'medium', 'large', 'xlarge']),
className: PropTypes.string,
};

export default BackgroundLogo;
Loading
Loading