WIP
This commit is contained in:
495
frontend/auth-error-handler.js
Normal file
495
frontend/auth-error-handler.js
Normal file
@ -0,0 +1,495 @@
|
||||
/**
|
||||
* Client-side error boundaries for authentication failures
|
||||
* Provides centralized error handling for authentication-related errors
|
||||
*/
|
||||
|
||||
class AuthErrorHandler {
|
||||
constructor() {
|
||||
this.errorContainer = null;
|
||||
this.retryAttempts = 0;
|
||||
this.maxRetryAttempts = 3;
|
||||
this.retryDelay = 1000; // 1 second
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize error handler
|
||||
*/
|
||||
init() {
|
||||
// Create error container if it doesn't exist
|
||||
this.createErrorContainer();
|
||||
|
||||
// Set up global error handlers
|
||||
this.setupGlobalErrorHandlers();
|
||||
|
||||
// Set up API interceptors
|
||||
this.setupAPIInterceptors();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error display container
|
||||
*/
|
||||
createErrorContainer() {
|
||||
if (document.getElementById('auth-error-container')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.id = 'auth-error-container';
|
||||
container.className = 'auth-error-container';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
max-width: 400px;
|
||||
z-index: 10000;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
`;
|
||||
|
||||
document.body.appendChild(container);
|
||||
this.errorContainer = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up global error handlers
|
||||
*/
|
||||
setupGlobalErrorHandlers() {
|
||||
// Handle unhandled promise rejections
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
if (this.isAuthError(event.reason)) {
|
||||
this.handleAuthError(event.reason);
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle general errors
|
||||
window.addEventListener('error', (event) => {
|
||||
if (this.isAuthError(event.error)) {
|
||||
this.handleAuthError(event.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up API request interceptors
|
||||
*/
|
||||
setupAPIInterceptors() {
|
||||
// Override fetch to intercept API calls
|
||||
const originalFetch = window.fetch;
|
||||
|
||||
window.fetch = async (...args) => {
|
||||
try {
|
||||
const response = await originalFetch(...args);
|
||||
|
||||
// Check for authentication errors
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const errorData = await response.clone().json().catch(() => ({}));
|
||||
this.handleAuthError({
|
||||
status: response.status,
|
||||
message: errorData.error || 'Authentication failed',
|
||||
code: errorData.code,
|
||||
url: args[0]
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (this.isAuthError(error)) {
|
||||
this.handleAuthError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is authentication-related
|
||||
*/
|
||||
isAuthError(error) {
|
||||
if (!error) return false;
|
||||
|
||||
const authErrorCodes = [
|
||||
'INVALID_TOKEN',
|
||||
'TOKEN_EXPIRED',
|
||||
'TOKEN_NOT_ACTIVE',
|
||||
'AUTH_ERROR',
|
||||
'INVALID_CREDENTIALS',
|
||||
'EMAIL_NOT_VERIFIED',
|
||||
'RATE_LIMIT_EXCEEDED'
|
||||
];
|
||||
|
||||
const authErrorMessages = [
|
||||
'authentication',
|
||||
'unauthorized',
|
||||
'token',
|
||||
'login',
|
||||
'session'
|
||||
];
|
||||
|
||||
// Check error code
|
||||
if (error.code && authErrorCodes.includes(error.code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check error message
|
||||
if (error.message) {
|
||||
const message = error.message.toLowerCase();
|
||||
return authErrorMessages.some(keyword => message.includes(keyword));
|
||||
}
|
||||
|
||||
// Check HTTP status
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication errors
|
||||
*/
|
||||
handleAuthError(error) {
|
||||
console.error('Authentication error:', error);
|
||||
|
||||
const errorInfo = this.parseError(error);
|
||||
|
||||
// Show error message
|
||||
this.showError(errorInfo);
|
||||
|
||||
// Handle specific error types
|
||||
switch (errorInfo.code) {
|
||||
case 'TOKEN_EXPIRED':
|
||||
this.handleTokenExpired();
|
||||
break;
|
||||
case 'INVALID_TOKEN':
|
||||
case 'AUTH_ERROR':
|
||||
this.handleInvalidAuth();
|
||||
break;
|
||||
case 'EMAIL_NOT_VERIFIED':
|
||||
this.handleEmailNotVerified();
|
||||
break;
|
||||
case 'RATE_LIMIT_EXCEEDED':
|
||||
this.handleRateLimit(errorInfo);
|
||||
break;
|
||||
default:
|
||||
this.handleGenericAuthError(errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse error object
|
||||
*/
|
||||
parseError(error) {
|
||||
return {
|
||||
message: error.message || error.error || 'Authentication failed',
|
||||
code: error.code || 'AUTH_ERROR',
|
||||
status: error.status,
|
||||
url: error.url,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message to user
|
||||
*/
|
||||
showError(errorInfo) {
|
||||
const errorElement = document.createElement('div');
|
||||
errorElement.className = 'auth-error-message';
|
||||
errorElement.style.cssText = `
|
||||
background: #fee;
|
||||
border: 1px solid #fcc;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 10px;
|
||||
color: #c33;
|
||||
position: relative;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
`;
|
||||
|
||||
// Add animation keyframes
|
||||
if (!document.getElementById('auth-error-styles')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'auth-error-styles';
|
||||
style.textContent = `
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.auth-error-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: #c33;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
errorElement.innerHTML = `
|
||||
<strong>Authentication Error</strong><br>
|
||||
${this.escapeHtml(errorInfo.message)}
|
||||
<button class="auth-error-close" onclick="this.parentElement.remove()">×</button>
|
||||
`;
|
||||
|
||||
this.errorContainer.appendChild(errorElement);
|
||||
|
||||
// Auto-remove after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (errorElement.parentElement) {
|
||||
errorElement.remove();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle token expired error
|
||||
*/
|
||||
handleTokenExpired() {
|
||||
// Try to refresh token
|
||||
this.attemptTokenRefresh()
|
||||
.then(success => {
|
||||
if (!success) {
|
||||
this.redirectToLogin('Your session has expired. Please log in again.');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.redirectToLogin('Your session has expired. Please log in again.');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invalid authentication
|
||||
*/
|
||||
handleInvalidAuth() {
|
||||
this.clearAuthData();
|
||||
this.redirectToLogin('Please log in to continue.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle email not verified error
|
||||
*/
|
||||
handleEmailNotVerified() {
|
||||
this.showError({
|
||||
message: 'Please verify your email address before continuing.',
|
||||
code: 'EMAIL_NOT_VERIFIED'
|
||||
});
|
||||
|
||||
// Optionally redirect to verification page
|
||||
setTimeout(() => {
|
||||
if (confirm('Would you like to go to the email verification page?')) {
|
||||
window.location.href = '/verify-email.html';
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle rate limit error
|
||||
*/
|
||||
handleRateLimit(errorInfo) {
|
||||
const retryAfter = this.calculateRetryDelay();
|
||||
|
||||
this.showError({
|
||||
message: `${errorInfo.message} Please try again in ${Math.ceil(retryAfter / 1000)} seconds.`,
|
||||
code: 'RATE_LIMIT_EXCEEDED'
|
||||
});
|
||||
|
||||
// Disable forms temporarily
|
||||
this.disableAuthForms(retryAfter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle generic authentication error
|
||||
*/
|
||||
handleGenericAuthError(errorInfo) {
|
||||
// Log error for debugging
|
||||
console.error('Generic auth error:', errorInfo);
|
||||
|
||||
// Show user-friendly message
|
||||
this.showError({
|
||||
message: 'An authentication error occurred. Please try again.',
|
||||
code: errorInfo.code
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to refresh authentication token
|
||||
*/
|
||||
async attemptTokenRefresh() {
|
||||
if (this.retryAttempts >= this.maxRetryAttempts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.retryAttempts++;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.retryAttempts = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication data
|
||||
*/
|
||||
clearAuthData() {
|
||||
// Clear any stored auth tokens
|
||||
localStorage.removeItem('authToken');
|
||||
sessionStorage.removeItem('authToken');
|
||||
|
||||
// Clear any user data
|
||||
localStorage.removeItem('userData');
|
||||
sessionStorage.removeItem('userData');
|
||||
|
||||
// Clear cookies by making a logout request
|
||||
fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
}).catch(() => {
|
||||
// Ignore errors during cleanup
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to login page
|
||||
*/
|
||||
redirectToLogin(message) {
|
||||
// Store message for display on login page
|
||||
if (message) {
|
||||
sessionStorage.setItem('loginMessage', message);
|
||||
}
|
||||
|
||||
// Store current page for redirect after login
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath !== '/login.html' && currentPath !== '/register.html') {
|
||||
sessionStorage.setItem('redirectAfterLogin', currentPath);
|
||||
}
|
||||
|
||||
// Redirect to login
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate retry delay for rate limiting
|
||||
*/
|
||||
calculateRetryDelay() {
|
||||
return Math.min(this.retryDelay * Math.pow(2, this.retryAttempts), 30000); // Max 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable authentication forms temporarily
|
||||
*/
|
||||
disableAuthForms(duration) {
|
||||
const forms = document.querySelectorAll('form[data-auth-form]');
|
||||
const buttons = document.querySelectorAll('button[data-auth-button]');
|
||||
|
||||
forms.forEach(form => {
|
||||
form.style.opacity = '0.5';
|
||||
form.style.pointerEvents = 'none';
|
||||
});
|
||||
|
||||
buttons.forEach(button => {
|
||||
button.disabled = true;
|
||||
const originalText = button.textContent;
|
||||
let countdown = Math.ceil(duration / 1000);
|
||||
|
||||
const updateButton = () => {
|
||||
button.textContent = `Try again in ${countdown}s`;
|
||||
countdown--;
|
||||
|
||||
if (countdown < 0) {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(updateButton, 1000);
|
||||
};
|
||||
|
||||
updateButton();
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
forms.forEach(form => {
|
||||
form.style.opacity = '';
|
||||
form.style.pointerEvents = '';
|
||||
});
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
*/
|
||||
async checkAuthStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/user/verify-token', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Authentication check failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.valid;
|
||||
} catch (error) {
|
||||
this.handleAuthError(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize authentication check on page load
|
||||
*/
|
||||
initAuthCheck() {
|
||||
// Skip auth check on public pages
|
||||
const publicPages = ['/login.html', '/register.html', '/forgot-password.html', '/reset-password.html'];
|
||||
const currentPath = window.location.pathname;
|
||||
|
||||
if (publicPages.includes(currentPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check authentication status
|
||||
this.checkAuthStatus().then(isAuthenticated => {
|
||||
if (!isAuthenticated) {
|
||||
this.redirectToLogin('Please log in to access this page.');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize error handler when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.authErrorHandler = new AuthErrorHandler();
|
||||
window.authErrorHandler.initAuthCheck();
|
||||
});
|
||||
|
||||
// Export for use in other scripts
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = AuthErrorHandler;
|
||||
}
|
||||
Reference in New Issue
Block a user