This commit is contained in:
Iliyan Angelov
2025-11-28 02:40:05 +02:00
parent 627959f52b
commit 312f85530c
246 changed files with 23535 additions and 3428 deletions

View File

@@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Content Security Policy - Additional layer of XSS protection -->
<!-- Allows HTTP localhost connections for development, HTTPS for production -->
<!-- Note: Backend CSP headers (production only) will override/merge with this meta tag -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https: http: blob:; connect-src 'self' https: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* wss:; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self';" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600;700;800;900&family=Cormorant+Garamond:wght@300;400;500;600;700&family=Cinzel:wght@400;500;600;700&family=Poppins:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />

File diff suppressed because it is too large Load Diff

View File

@@ -14,10 +14,12 @@
"@paypal/react-paypal-js": "^8.1.3",
"@stripe/react-stripe-js": "^2.9.0",
"@stripe/stripe-js": "^2.4.0",
"@types/dompurify": "^3.0.5",
"@types/react-datepicker": "^6.2.0",
"@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.6.2",
"date-fns": "^2.30.0",
"dompurify": "^3.3.0",
"lucide-react": "^0.294.0",
"react": "^18.3.1",
"react-datepicker": "^8.9.0",

View File

@@ -22,6 +22,7 @@ import Preloader from './components/common/Preloader';
import ScrollToTop from './components/common/ScrollToTop';
import AuthModalManager from './components/modals/AuthModalManager';
import ResetPasswordRouteHandler from './components/auth/ResetPasswordRouteHandler';
import ErrorBoundaryRoute from './components/common/ErrorBoundaryRoute';
import useAuthStore from './store/useAuthStore';
import useFavoritesStore from './store/useFavoritesStore';
@@ -70,7 +71,6 @@ const InvoiceManagementPage = lazy(() => import('./pages/admin/InvoiceManagement
const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage'));
const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
const GuestProfilePage = lazy(() => import('./pages/admin/GuestProfilePage'));
const BookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage'));
const GroupBookingManagementPage = lazy(() => import('./pages/admin/GroupBookingManagementPage'));
const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard'));
const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage'));
@@ -206,19 +206,35 @@ function App() {
/>
<Route
path="payment-result"
element={<PaymentResultPage />}
element={
<ErrorBoundaryRoute>
<PaymentResultPage />
</ErrorBoundaryRoute>
}
/>
<Route
path="payment/paypal/return"
element={<PayPalReturnPage />}
element={
<ErrorBoundaryRoute>
<PayPalReturnPage />
</ErrorBoundaryRoute>
}
/>
<Route
path="payment/paypal/cancel"
element={<PayPalCancelPage />}
element={
<ErrorBoundaryRoute>
<PayPalCancelPage />
</ErrorBoundaryRoute>
}
/>
<Route
path="payment/borica/return"
element={<BoricaReturnPage />}
element={
<ErrorBoundaryRoute>
<BoricaReturnPage />
</ErrorBoundaryRoute>
}
/>
<Route
path="invoices/:id"
@@ -273,41 +289,51 @@ function App() {
<Route
path="booking-success/:id"
element={
<CustomerRoute>
<BookingSuccessPage />
</CustomerRoute>
<ErrorBoundaryRoute>
<CustomerRoute>
<BookingSuccessPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
path="bookings"
element={
<CustomerRoute>
<MyBookingsPage />
</CustomerRoute>
<ErrorBoundaryRoute>
<CustomerRoute>
<MyBookingsPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
path="bookings/:id"
element={
<CustomerRoute>
<BookingDetailPage />
</CustomerRoute>
<ErrorBoundaryRoute>
<CustomerRoute>
<BookingDetailPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
path="payment/:bookingId"
element={
<CustomerRoute>
<FullPaymentPage />
</CustomerRoute>
<ErrorBoundaryRoute>
<CustomerRoute>
<FullPaymentPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
path="payment-confirmation/:id"
element={
<CustomerRoute>
<PaymentConfirmationPage />
</CustomerRoute>
<ErrorBoundaryRoute>
<CustomerRoute>
<PaymentConfirmationPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
@@ -346,9 +372,11 @@ function App() {
<Route
path="/admin"
element={
<AdminRoute>
<AdminLayout />
</AdminRoute>
<ErrorBoundaryRoute>
<AdminRoute>
<AdminLayout />
</AdminRoute>
</ErrorBoundaryRoute>
}
>
<Route
@@ -442,9 +470,11 @@ function App() {
<Route
path="/staff"
element={
<StaffRoute>
<StaffLayout />
</StaffRoute>
<ErrorBoundaryRoute>
<StaffRoute>
<StaffLayout />
</StaffRoute>
</ErrorBoundaryRoute>
}
>
<Route
@@ -490,9 +520,11 @@ function App() {
<Route
path="/accountant"
element={
<AccountantRoute>
<AccountantLayout />
</AccountantRoute>
<ErrorBoundaryRoute>
<AccountantRoute>
<AccountantLayout />
</AccountantRoute>
</ErrorBoundaryRoute>
}
>
<Route

View File

@@ -1,5 +1,6 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { logger } from '../../utils/logger';
interface Props {
children: ReactNode;
@@ -31,7 +32,10 @@ class ErrorBoundary extends Component<Props, State> {
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
logger.error('ErrorBoundary caught an error', error, {
componentStack: errorInfo.componentStack,
errorBoundary: true,
});
this.setState({
error,
errorInfo,

View File

@@ -0,0 +1,25 @@
import React, { ReactNode } from 'react';
import ErrorBoundary from './ErrorBoundary';
interface ErrorBoundaryRouteProps {
children: ReactNode;
fallback?: ReactNode;
}
/**
* Wrapper component that wraps a route with an ErrorBoundary
* Use this for critical routes that need isolated error handling
*/
const ErrorBoundaryRoute: React.FC<ErrorBoundaryRouteProps> = ({
children,
fallback
}) => {
return (
<ErrorBoundary fallback={fallback}>
{children}
</ErrorBoundary>
);
};
export default ErrorBoundaryRoute;

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { AlertCircle, X } from 'lucide-react';
interface ErrorMessageProps {
message: string;
onDismiss?: () => void;
className?: string;
variant?: 'error' | 'warning' | 'info';
}
const ErrorMessage: React.FC<ErrorMessageProps> = ({
message,
onDismiss,
className = '',
variant = 'error',
}) => {
const variantStyles = {
error: 'bg-red-50 border-red-200 text-red-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
};
const iconColors = {
error: 'text-red-600',
warning: 'text-yellow-600',
info: 'text-blue-600',
};
return (
<div
className={`flex items-start gap-3 p-4 rounded-lg border ${variantStyles[variant]} ${className}`}
role="alert"
aria-live="polite"
>
<AlertCircle className={`w-5 h-5 flex-shrink-0 mt-0.5 ${iconColors[variant]}`} />
<div className="flex-1">
<p className="text-sm font-medium">{message}</p>
</div>
{onDismiss && (
<button
onClick={onDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Dismiss error"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
};
export default ErrorMessage;

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
interface LoadingButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
isLoading?: boolean;
loadingText?: string;
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'danger' | 'outline';
size?: 'sm' | 'md' | 'lg';
}
const LoadingButton: React.FC<LoadingButtonProps> = ({
isLoading = false,
loadingText,
children,
variant = 'primary',
size = 'md',
disabled,
className = '',
...props
}) => {
const variantStyles = {
primary: 'bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500',
secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
outline: 'border-2 border-indigo-600 text-indigo-600 hover:bg-indigo-50 focus:ring-indigo-500',
};
const sizeStyles = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
const baseStyles =
'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
return (
<button
{...props}
disabled={disabled || isLoading}
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
aria-busy={isLoading}
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{isLoading && loadingText ? loadingText : children}
</button>
);
};
export default LoadingButton;

View File

@@ -14,6 +14,7 @@ import {
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useResponsive } from '../../hooks';
import { logger } from '../../utils/logger';
interface SidebarAccountantProps {
isCollapsed?: boolean;
@@ -39,7 +40,7 @@ const SidebarAccountant: React.FC<SidebarAccountantProps> = ({
setIsMobileOpen(false);
}
} catch (error) {
console.error('Logout error:', error);
logger.error('Logout error', error);
}
};

View File

@@ -30,6 +30,7 @@ import {
} from 'lucide-react';
import useAuthStore from '../../store/useAuthStore';
import { useResponsive } from '../../hooks';
import { logger } from '../../utils/logger';
interface SidebarAdminProps {
isCollapsed?: boolean;
@@ -65,7 +66,7 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
setIsMobileOpen(false);
}
} catch (error) {
console.error('Logout error:', error);
logger.error('Logout error', error);
}
};

View File

@@ -19,6 +19,7 @@ import {
import useAuthStore from '../../store/useAuthStore';
import { useChatNotifications } from '../../contexts/ChatNotificationContext';
import { useResponsive } from '../../hooks';
import { logger } from '../../utils/logger';
interface SidebarStaffProps {
isCollapsed?: boolean;
@@ -45,7 +46,7 @@ const SidebarStaff: React.FC<SidebarStaffProps> = ({
setIsMobileOpen(false);
}
} catch (error) {
console.error('Logout error:', error);
logger.error('Logout error', error);
}
};

View File

@@ -0,0 +1,172 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { toast } from 'react-toastify';
import { logger } from '../utils/logger';
interface UseApiCallOptions {
showSuccessToast?: boolean;
successMessage?: string;
showErrorToast?: boolean;
onSuccess?: (data: any) => void;
onError?: (error: any) => void;
cancelOnUnmount?: boolean; // Cancel request on component unmount
}
interface UseApiCallReturn<T> {
execute: (...args: any[]) => Promise<T | undefined>;
isLoading: boolean;
error: Error | null;
clearError: () => void;
cancel: () => void; // Manual cancellation function
}
/**
* Custom hook for handling API calls with loading states and error handling
* Includes request cancellation on component unmount to prevent memory leaks
*
* @param apiFunction - The async API function to call
* @param options - Configuration options
* @returns Object with execute function, loading state, error state, clearError function, and cancel function
*/
export function useApiCall<T = any>(
apiFunction: (...args: any[]) => Promise<T>,
options: UseApiCallOptions = {}
): UseApiCallReturn<T> {
const {
showSuccessToast = false,
successMessage = 'Operation completed successfully',
showErrorToast = true,
onSuccess,
onError,
cancelOnUnmount = true, // Default to true for safety
} = options;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const mountedRef = useRef(true);
// Track component mount status
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
// Cancel pending request on unmount if enabled
if (cancelOnUnmount && abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
}, [cancelOnUnmount]);
const cancel = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsLoading(false);
}
}, []);
const execute = useCallback(
async (...args: any[]): Promise<T | undefined> => {
// Cancel any previous request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new AbortController for this request
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
setIsLoading(true);
setError(null);
// Check if component is still mounted before making request
if (!mountedRef.current) {
return undefined;
}
// Check if apiFunction supports AbortSignal
// If the function accepts a signal parameter, pass it
let result: T;
if (args.length > 0 && typeof args[args.length - 1] === 'object' && 'signal' in args[args.length - 1]) {
// Function already has signal in arguments
result = await apiFunction(...args);
} else {
// Try to pass signal as last argument if function supports it
try {
result = await apiFunction(...args, { signal: abortController.signal });
} catch (err: any) {
// If function doesn't support signal, try without it
if (err.name === 'TypeError' && err.message.includes('signal')) {
result = await apiFunction(...args);
} else {
throw err;
}
}
}
// Check if component is still mounted before updating state
if (!mountedRef.current) {
return undefined;
}
// Check if request was cancelled
if (abortController.signal.aborted) {
return undefined;
}
if (showSuccessToast) {
toast.success(successMessage);
}
if (onSuccess) {
onSuccess(result);
}
abortControllerRef.current = null;
return result;
} catch (err: any) {
// Don't handle errors if component is unmounted or request was cancelled
if (!mountedRef.current || abortController.signal.aborted || err.name === 'AbortError') {
return undefined;
}
const errorMessage =
err?.response?.data?.message ||
err?.response?.data?.detail ||
err?.message ||
'An error occurred. Please try again.';
const apiError = err instanceof Error ? err : new Error(errorMessage);
setError(apiError);
logger.error('API call failed', err);
if (showErrorToast && !err.isCancelled) {
toast.error(errorMessage);
}
if (onError && !err.isCancelled) {
onError(err);
}
throw err;
} finally {
// Only update loading state if component is still mounted
if (mountedRef.current) {
setIsLoading(false);
}
abortControllerRef.current = null;
}
},
[apiFunction, showSuccessToast, successMessage, showErrorToast, onSuccess, onError]
);
const clearError = useCallback(() => {
setError(null);
}, []);
return { execute, isLoading, error, clearError, cancel };
}

View File

@@ -13,6 +13,7 @@ import { Link } from 'react-router-dom';
import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import { createSanitizedHtml } from '../utils/htmlSanitizer';
const AboutPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -206,7 +207,7 @@ const AboutPage: React.FC = () => {
{pageContent?.story_content ? (
<div
className="text-lg md:text-xl leading-relaxed font-light tracking-wide"
dangerouslySetInnerHTML={{ __html: pageContent.story_content.replace(/\n/g, '<br />') }}
dangerouslySetInnerHTML={createSanitizedHtml(pageContent.story_content.replace(/\n/g, '<br />'))}
/>
) : (
<>

View File

@@ -5,6 +5,7 @@ import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import Loading from '../components/common/Loading';
import { createSanitizedHtml } from '../utils/htmlSanitizer';
const AccessibilityPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -153,9 +154,9 @@ const AccessibilityPage: React.FC = () => {
[&_*]:text-gray-300 [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_h4]:text-white [&_h5]:text-white [&_h6]:text-white
[&_strong]:text-[#d4af37] [&_b]:text-[#d4af37] [&_a]:text-[#d4af37]"
style={{ color: '#d1d5db' }}
dangerouslySetInnerHTML={{
__html: pageContent.content || pageContent.description || '<p style="color: #d1d5db;">No content available.</p>'
}}
dangerouslySetInnerHTML={createSanitizedHtml(
pageContent.content || pageContent.description || '<p style="color: #d1d5db;">No content available.</p>'
)}
/>
</div>

View File

@@ -5,6 +5,7 @@ import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import Loading from '../components/common/Loading';
import { createSanitizedHtml } from '../utils/htmlSanitizer';
const CancellationPolicyPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -154,9 +155,9 @@ const CancellationPolicyPage: React.FC = () => {
[&_*]:text-gray-300 [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_h4]:text-white [&_h5]:text-white [&_h6]:text-white
[&_strong]:text-[#d4af37] [&_b]:text-[#d4af37] [&_a]:text-[#d4af37]"
style={{ color: '#d1d5db' }}
dangerouslySetInnerHTML={{
__html: pageContent.content || pageContent.description || '<p style="color: #d1d5db;">No content available.</p>'
}}
dangerouslySetInnerHTML={createSanitizedHtml(
pageContent.content || pageContent.description || '<p style="color: #d1d5db;">No content available.</p>'
)}
/>
</div>

View File

@@ -5,6 +5,7 @@ import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import Loading from '../components/common/Loading';
import { createSanitizedHtml } from '../utils/htmlSanitizer';
const FAQPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -153,9 +154,9 @@ const FAQPage: React.FC = () => {
[&_*]:text-gray-300 [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_h4]:text-white [&_h5]:text-white [&_h6]:text-white
[&_strong]:text-[#d4af37] [&_b]:text-[#d4af37] [&_a]:text-[#d4af37]"
style={{ color: '#d1d5db' }}
dangerouslySetInnerHTML={{
__html: pageContent.content || pageContent.description || '<p style="color: #d1d5db;">No content available.</p>'
}}
dangerouslySetInnerHTML={createSanitizedHtml(
pageContent.content || pageContent.description || '<p style="color: #d1d5db;">No content available.</p>'
)}
/>
</div>

View File

@@ -5,6 +5,7 @@ import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import Loading from '../components/common/Loading';
import { createSanitizedHtml } from '../utils/htmlSanitizer';
const PrivacyPolicyPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -161,9 +162,9 @@ const PrivacyPolicyPage: React.FC = () => {
[&_*]:text-gray-300 [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_h4]:text-white [&_h5]:text-white [&_h6]:text-white
[&_strong]:text-[#d4af37] [&_b]:text-[#d4af37] [&_a]:text-[#d4af37]"
style={{ color: '#d1d5db' }}
dangerouslySetInnerHTML={{
__html: pageContent.content || pageContent.description || '<p style="color: #d1d5db;">No content available.</p>'
}}
dangerouslySetInnerHTML={createSanitizedHtml(
pageContent.content || pageContent.description || '<p style="color: #d1d5db;">No content available.</p>'
)}
/>
</div>

View File

@@ -5,6 +5,7 @@ import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import Loading from '../components/common/Loading';
import { createSanitizedHtml } from '../utils/htmlSanitizer';
const RefundsPolicyPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -161,9 +162,9 @@ const RefundsPolicyPage: React.FC = () => {
[&_*]:text-gray-300 [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_h4]:text-white [&_h5]:text-white [&_h6]:text-white
[&_strong]:text-[#d4af37] [&_b]:text-[#d4af37] [&_a]:text-[#d4af37]"
style={{ color: '#d1d5db' }}
dangerouslySetInnerHTML={{
__html: pageContent.content || pageContent.description || '<p style="color: #d1d5db;">No content available.</p>'
}}
dangerouslySetInnerHTML={createSanitizedHtml(
pageContent.content || pageContent.description || '<p style="color: #d1d5db;">No content available.</p>'
)}
/>
</div>

View File

@@ -5,6 +5,7 @@ import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import Loading from '../components/common/Loading';
import { createSanitizedHtml } from '../utils/htmlSanitizer';
const TermsPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -161,9 +162,9 @@ const TermsPage: React.FC = () => {
[&_*]:text-gray-300 [&_h1]:text-white [&_h2]:text-white [&_h3]:text-white [&_h4]:text-white [&_h5]:text-white [&_h6]:text-white
[&_strong]:text-[#d4af37] [&_b]:text-[#d4af37] [&_a]:text-[#d4af37]"
style={{ color: '#d1d5db' }}
dangerouslySetInnerHTML={{
__html: pageContent.content || pageContent.description || '<p style="color: #d1d5db;">No content available.</p>'
}}
dangerouslySetInnerHTML={createSanitizedHtml(
pageContent.content || pageContent.description || '<p style="color: #d1d5db;">No content available.</p>'
)}
/>
</div>

View File

@@ -33,6 +33,7 @@ import InspectionManagement from '../../components/shared/InspectionManagement';
import Pagination from '../../components/common/Pagination';
import apiClient from '../../services/api/apiClient';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { logger } from '../../utils/logger';
type Tab = 'status-board' | 'maintenance' | 'housekeeping' | 'inspections' | 'rooms';
@@ -107,7 +108,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
setFloors(uniqueFloors);
}
} catch (error) {
console.error('Failed to fetch floors:', error);
logger.error('Failed to fetch floors', error);
}
};
@@ -217,7 +218,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
setAvailableAmenities(response.data.amenities);
}
} catch (error) {
console.error('Failed to fetch amenities:', error);
logger.error('Failed to fetch amenities', error);
}
}, []);
@@ -294,7 +295,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
}
});
} catch (err) {
console.error(`Failed to fetch page ${page}:`, err);
logger.error(`Failed to fetch page ${page}`, err);
}
}
}
@@ -308,7 +309,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
return prev;
});
} catch (error) {
console.error('Failed to fetch room types:', error);
logger.error('Failed to fetch room types', error);
}
};
@@ -335,7 +336,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
const updatedRoom = await roomService.getRoomByNumber(editingRoom.room_number);
setEditingRoom(updatedRoom.data.room);
} catch (err) {
console.error('Failed to refresh room data:', err);
logger.error('Failed to refresh room data', err);
}
} else {
const createData = {
@@ -476,7 +477,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
setEditingRoom(roomData);
} catch (error) {
console.error('Failed to fetch full room details:', error);
logger.error('Failed to fetch full room details', error);
}
};
@@ -617,7 +618,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
const response = await roomService.getRoomByNumber(editingRoom.room_number);
setEditingRoom(response.data.room);
} catch (error: any) {
console.error('Error deleting image:', error);
logger.error('Error deleting image', error);
toast.error(error.response?.data?.message || error.response?.data?.detail || 'Unable to delete image');
}
};

View File

@@ -38,6 +38,7 @@ import { reportService, ReportData, reviewService, Review } from '../../services
import { auditService, AuditLog, AuditLogFilters } from '../../services/api/auditService';
import { formatDate } from '../../utils/format';
import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { logger } from '../../utils/logger';
import analyticsService, {
ComprehensiveAnalyticsData,
RevPARData,
@@ -250,7 +251,7 @@ const AnalyticsDashboardPage: React.FC = () => {
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
console.error('Error fetching audit logs:', error);
logger.error('Error fetching audit logs', error);
toast.error(error.response?.data?.message || 'Unable to load audit logs');
} finally {
setAuditLoading(false);

View File

@@ -8,6 +8,7 @@ import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { parseDateLocal } from '../../utils/format';
import { useNavigate } from 'react-router-dom';
import CreateBookingModal from '../../components/shared/CreateBookingModal';
import { logger } from '../../utils/logger';
const BookingManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -105,7 +106,7 @@ const BookingManagementPage: React.FC = () => {
} catch (error: any) {
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || 'Unable to create invoice';
toast.error(errorMessage);
console.error('Invoice creation error:', error);
logger.error('Invoice creation error', error);
} finally {
setCreatingInvoice(false);
}

View File

@@ -19,6 +19,7 @@ import { useFormatCurrency } from '../../hooks/useFormatCurrency';
import { useAsync } from '../../hooks/useAsync';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/useAuthStore';
import { logger } from '../../utils/logger';
const DashboardPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -36,7 +37,7 @@ const DashboardPage: React.FC = () => {
await logout();
navigate('/');
} catch (error) {
console.error('Logout error:', error);
logger.error('Logout error', error);
}
};
@@ -71,7 +72,7 @@ const DashboardPage: React.FC = () => {
setRecentPayments(response.data.payments);
}
} catch (err: any) {
console.error('Error fetching payments:', err);
logger.error('Error fetching payments', err);
} finally {
setLoadingPayments(false);
}

View File

@@ -28,6 +28,7 @@ import { formatDate } from '../../utils/format';
import TaskDetailModal from '../../components/tasks/TaskDetailModal';
import CreateTaskModal from '../../components/tasks/CreateTaskModal';
import TaskFilters from '../../components/tasks/TaskFilters';
import { logger } from '../../utils/logger';
type TaskStatus = 'pending' | 'assigned' | 'in_progress' | 'completed' | 'cancelled' | 'overdue';
type TaskPriority = 'low' | 'medium' | 'high' | 'urgent';
@@ -61,7 +62,7 @@ const TaskManagementPage: React.FC = () => {
const tasksArray = responseData?.data || responseData || [];
return Array.isArray(tasksArray) ? tasksArray : [];
}).catch(error => {
console.error('Error fetching tasks:', error);
logger.error('Error fetching tasks', error);
return [];
}),
{ immediate: true }

View File

@@ -3,15 +3,40 @@ import { Plus, Search, Edit, Trash2, X } from 'lucide-react';
import { userService, User } from '../../services/api';
import { toast } from 'react-toastify';
import Loading from '../../components/common/Loading';
import LoadingButton from '../../components/common/LoadingButton';
import ErrorMessage from '../../components/common/ErrorMessage';
import Pagination from '../../components/common/Pagination';
import useAuthStore from '../../store/useAuthStore';
import { logger } from '../../utils/logger';
import { useApiCall } from '../../hooks/useApiCall';
const UserManagementPage: React.FC = () => {
const { userInfo } = useAuthStore();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deletingUserId, setDeletingUserId] = useState<number | null>(null);
const { execute: executeSubmit, isLoading: isSubmitting } = useApiCall(
async (data: any, isEdit: boolean) => {
if (isEdit && editingUser) {
return await userService.updateUser(editingUser.id, data);
} else {
return await userService.createUser(data);
}
},
{
showSuccessToast: true,
successMessage: (editingUser ? 'User updated' : 'User added') + ' successfully',
onSuccess: () => {
setShowModal(false);
resetForm();
setTimeout(() => fetchUsers(), 300);
},
}
);
const [filters, setFilters] = useState({
search: '',
role: '',
@@ -42,21 +67,23 @@ const UserManagementPage: React.FC = () => {
const fetchUsers = async () => {
try {
setLoading(true);
console.log('Fetching users with filters:', filters, 'page:', currentPage);
setError(null);
logger.debug('Fetching users', { filters, page: currentPage });
const response = await userService.getUsers({
...filters,
page: currentPage,
limit: itemsPerPage,
});
console.log('Users response:', response);
setUsers(response.data.users);
if (response.data.pagination) {
setTotalPages(response.data.pagination.totalPages);
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
console.error('Error fetching users:', error);
toast.error(error.response?.data?.message || 'Unable to load users list');
logger.error('Error fetching users', error);
const errorMessage = error.response?.data?.message || 'Unable to load users list';
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
@@ -64,49 +91,34 @@ const UserManagementPage: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingUser && (!formData.password || formData.password.trim() === '')) {
toast.error('Please enter password');
return;
}
try {
const submitData: any = {
full_name: formData.full_name,
email: formData.email,
phone_number: formData.phone_number,
role: formData.role,
status: formData.status,
};
if (editingUser) {
const updateData: any = {
full_name: formData.full_name,
email: formData.email,
phone_number: formData.phone_number,
role: formData.role,
status: formData.status,
};
if (formData.password && formData.password.trim() !== '') {
updateData.password = formData.password;
submitData.password = formData.password;
}
console.log('Updating user:', editingUser.id, 'with data:', updateData);
const response = await userService.updateUser(editingUser.id, updateData);
console.log('Update response:', response);
toast.success('User updated successfully');
logger.debug('Updating user', { userId: editingUser.id, updateData: submitData });
} else {
if (!formData.password || formData.password.trim() === '') {
toast.error('Please enter password');
return;
}
console.log('Creating user with data:', formData);
const response = await userService.createUser(formData);
console.log('Create response:', response);
toast.success('User added successfully');
submitData.password = formData.password;
logger.debug('Creating user', { formData: submitData });
}
setShowModal(false);
resetForm();
setTimeout(() => {
fetchUsers();
}, 300);
await executeSubmit(submitData, !!editingUser);
} catch (error: any) {
console.error('Error submitting user:', error);
toast.error(error.response?.data?.message || 'An error occurred');
logger.error('Error submitting user', error);
}
};
@@ -124,7 +136,6 @@ const UserManagementPage: React.FC = () => {
};
const handleDelete = async (id: number) => {
if (userInfo?.id === id) {
toast.error('You cannot delete your own account');
return;
@@ -133,13 +144,16 @@ const UserManagementPage: React.FC = () => {
if (!window.confirm('Are you sure you want to delete this user?')) return;
try {
console.log('Deleting user:', id);
setDeletingUserId(id);
logger.debug('Deleting user', { userId: id });
await userService.deleteUser(id);
toast.success('User deleted successfully');
fetchUsers();
} catch (error: any) {
console.error('Error deleting user:', error);
logger.error('Error deleting user', error);
toast.error(error.response?.data?.message || 'Unable to delete user');
} finally {
setDeletingUserId(null);
}
};
@@ -248,6 +262,14 @@ const UserManagementPage: React.FC = () => {
</div>
</div>
{error && (
<ErrorMessage
message={error}
onDismiss={() => setError(null)}
className="animate-fade-in"
/>
)}
{}
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden animate-fade-in" style={{ animationDelay: '0.2s' }}>
<div className="overflow-x-auto">
@@ -307,14 +329,17 @@ const UserManagementPage: React.FC = () => {
>
<Edit className="w-5 h-5" />
</button>
<button
<LoadingButton
onClick={() => handleDelete(user.id)}
className="p-2 rounded-lg text-rose-600 hover:text-rose-700 hover:bg-rose-50 transition-all duration-200 shadow-sm hover:shadow-md border border-rose-200 hover:border-rose-300 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={userInfo?.id === user.id}
isLoading={deletingUserId === user.id}
disabled={userInfo?.id === user.id || deletingUserId === user.id}
variant="danger"
size="sm"
className="p-2"
title="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
</LoadingButton>
</div>
</td>
</tr>
@@ -439,16 +464,20 @@ const UserManagementPage: React.FC = () => {
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 px-6 py-3 border-2 border-slate-300 rounded-xl text-slate-700 font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md"
disabled={isSubmitting}
className="flex-1 px-6 py-3 border-2 border-slate-300 rounded-xl text-slate-700 font-semibold hover:bg-slate-50 transition-all duration-200 shadow-sm hover:shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
<LoadingButton
type="submit"
className="flex-1 px-6 py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl"
isLoading={isSubmitting}
loadingText={editingUser ? 'Updating...' : 'Creating...'}
variant="primary"
className="flex-1"
>
{editingUser ? 'Update' : 'Create'}
</button>
</LoadingButton>
</div>
</form>
</div>

View File

@@ -7,12 +7,44 @@ const API_BASE_URL = /\/api(\/?$)/i.test(normalized)
? normalized
: normalized + '/api';
// Validate HTTPS in production
if (import.meta.env.MODE === 'production' && !normalized.startsWith('https://')) {
console.error(
'⚠️ SECURITY WARNING: API URL is not using HTTPS in production!',
'This exposes sensitive data to interception.',
'Please configure VITE_API_URL to use HTTPS.'
);
// Only show warning, don't block - some deployments may use reverse proxy
// But log the security concern
if (typeof window !== 'undefined') {
console.warn('API calls will be made over HTTP. Consider using HTTPS.');
}
}
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
// Note: 503 is excluded because it's used for "service unavailable" (like disabled features)
// and should not be retried - it's an intentional state, not a transient error
const RETRYABLE_STATUS_CODES = [408, 429, 500, 502, 504];
// Helper function to get CSRF token from cookies
const getCsrfToken = (): string | null => {
if (typeof document === 'undefined') {
return null;
}
// CSRF token cookie name (matches backend XSRF-TOKEN)
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return decodeURIComponent(value);
}
}
return null;
};
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
@@ -22,6 +54,9 @@ const apiClient = axios.create({
withCredentials: true,
});
// Map to store AbortControllers for cancelable requests
const cancelTokenMap = new Map<string, AbortController>();
const retryRequest = async (
error: AxiosError,
retryCount: number = 0
@@ -71,13 +106,51 @@ apiClient.interceptors.request.use(
config.headers.Authorization = `Bearer ${token}`;
}
// Add CSRF token for state-changing requests (POST, PUT, DELETE, PATCH)
// Skip for GET, HEAD, OPTIONS (safe methods)
const method = (config.method || 'GET').toUpperCase();
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
// Skip CSRF token for auth endpoints (they're exempt)
const path = config.url || '';
const isAuthEndpoint = path.startsWith('/auth/') || path.startsWith('/api/auth/');
if (!isAuthEndpoint) {
const csrfToken = getCsrfToken();
if (csrfToken) {
config.headers['X-XSRF-TOKEN'] = csrfToken;
} else {
// CSRF token not found - this will be handled in error interceptor
// The backend will return 403 and we'll retry after fetching the token
if (import.meta.env.DEV) {
console.warn('CSRF token not found in cookies. Request may fail and retry after fetching token.');
}
}
}
}
const requestId = crypto.randomUUID ? crypto.randomUUID() :
`${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
config.headers['X-Request-ID'] = requestId;
(config as any).metadata = { startTime: new Date() };
// Create AbortController for request cancellation
// Skip if signal is already provided
if (!config.signal) {
const abortController = new AbortController();
config.signal = abortController.signal;
// Store controller for potential cancellation
const cancelKey = `${config.method || 'GET'}_${config.url}_${requestId}`;
cancelTokenMap.set(cancelKey, abortController);
// Clean up after request completes (set timeout to avoid memory leaks)
setTimeout(() => {
cancelTokenMap.delete(cancelKey);
}, 60000); // Clean up after 60 seconds
}
(config as any).metadata = { startTime: new Date(), requestId };
return config;
},
@@ -90,6 +163,13 @@ apiClient.interceptors.response.use(
(response) => {
const config = response.config as InternalAxiosRequestConfig & { metadata?: any };
// Clean up abort controller on successful response
if (config.metadata?.requestId && config.url) {
const cancelKey = `${config.method || 'GET'}_${config.url}_${config.metadata.requestId}`;
cancelTokenMap.delete(cancelKey);
}
if (config.metadata?.startTime) {
const duration = new Date().getTime() - config.metadata.startTime.getTime();
if (duration > 1000) {
@@ -107,6 +187,21 @@ apiClient.interceptors.response.use(
},
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Clean up abort controller on error
if (originalRequest && (originalRequest as any).metadata?.requestId && originalRequest.url) {
const cancelKey = `${originalRequest.method || 'GET'}_${originalRequest.url}_${(originalRequest as any).metadata.requestId}`;
cancelTokenMap.delete(cancelKey);
}
// Don't process cancellation errors
if (error.code === 'ERR_CANCELED' || error.message === 'canceled') {
return Promise.reject({
...error,
message: 'Request was cancelled',
isCancelled: true,
});
}
if (!error.response) {
@@ -163,6 +258,43 @@ apiClient.interceptors.response.use(
if (status === 403) {
const errorMessage = (error.response?.data as any)?.message || 'You do not have permission to access this resource.';
// Handle CSRF token missing/invalid errors - retry after getting token from error response
if (errorMessage.includes('CSRF token') && originalRequest && !originalRequest._retry) {
// The backend sets the CSRF cookie in the error response, so wait a moment for browser to process it
await new Promise(resolve => setTimeout(resolve, 200));
// Check if we now have the CSRF token
const csrfToken = getCsrfToken();
if (csrfToken && originalRequest.headers) {
// Retry the original request with the CSRF token
originalRequest._retry = true;
originalRequest.headers['X-XSRF-TOKEN'] = csrfToken;
return apiClient.request(originalRequest);
} else {
// If still no token, try fetching it via GET request
try {
await apiClient.get('/health', {
headers: { ...originalRequest.headers },
timeout: 5000
});
await new Promise(resolve => setTimeout(resolve, 100));
const newToken = getCsrfToken();
if (newToken && originalRequest.headers) {
originalRequest._retry = true;
originalRequest.headers['X-XSRF-TOKEN'] = newToken;
return apiClient.request(originalRequest);
}
} catch (e) {
// Ignore errors from token fetch attempts
if (import.meta.env.DEV) {
console.warn('Failed to fetch CSRF token:', e);
}
}
}
}
return Promise.reject({
...error,
message: errorMessage,
@@ -236,4 +368,31 @@ apiClient.interceptors.response.use(
}
);
/**
* Cancel all pending requests (useful for cleanup on unmount)
*/
export const cancelAllPendingRequests = (): void => {
cancelTokenMap.forEach((controller) => {
controller.abort();
});
cancelTokenMap.clear();
};
/**
* Cancel requests matching a pattern (useful for canceling specific endpoint requests)
*/
export const cancelRequestsByPattern = (pattern: string | RegExp): void => {
const regex = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern;
const keysToCancel: string[] = [];
cancelTokenMap.forEach((controller, key) => {
if (regex.test(key)) {
controller.abort();
keysToCancel.push(key);
}
});
keysToCancel.forEach((key) => cancelTokenMap.delete(key));
};
export default apiClient;

View File

@@ -221,10 +221,16 @@ const useAuthStore = create<AuthState>((set, get) => ({
} catch (error) {
console.error('Logout error:', error);
} finally {
// Clear all auth-related localStorage items
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
// Also clear any other potential auth-related items
localStorage.removeItem('refreshToken');
localStorage.removeItem('authToken');
localStorage.removeItem('user');
// Clear sessionStorage as well for extra security
sessionStorage.clear();
set({
token: null,
@@ -232,6 +238,9 @@ const useAuthStore = create<AuthState>((set, get) => ({
isAuthenticated: false,
isLoading: false,
error: null,
requiresMFA: false,
mfaUserId: null,
pendingCredentials: null,
});
toast.info('Logged out');

View File

@@ -0,0 +1,101 @@
/**
* Error sanitization utility
* Removes sensitive information and internal details from error messages
* before displaying them to users
*/
const INTERNAL_PATTERNS = [
// File paths and stack traces
/\/[a-z0-9\-_/\\]+\.(ts|tsx|js|jsx|py|java|cpp|h)/gi,
/at\s+\w+.*\(.*\)/gi,
/at Object\.<anonymous>/gi,
// Database information
/SQL.*error/gi,
/database.*error/gi,
/connection.*pool/gi,
// Internal server details
/localhost:\d+/gi,
/127\.0\.0\.1:\d+/gi,
/\[.*\].*Error/gi,
// System paths
/\/home\/|\/usr\/|\/var\/|\/tmp\//gi,
// Stack trace markers
/Stack trace:/gi,
/Traceback.*most recent call/gi,
];
/**
* Sanitize error message to remove internal details
*/
export function sanitizeErrorMessage(message: string): string {
if (!message || typeof message !== 'string') {
return 'An unexpected error occurred. Please try again.';
}
let sanitized = message;
// Remove internal patterns
for (const pattern of INTERNAL_PATTERNS) {
sanitized = sanitized.replace(pattern, '');
}
// Remove excessive whitespace
sanitized = sanitized.replace(/\s+/g, ' ').trim();
// If message appears to be internal/technical, replace with generic message
if (
sanitized.includes('Traceback') ||
sanitized.includes('at ') ||
sanitized.includes('.py:') ||
sanitized.includes('.ts:') ||
sanitized.includes('.js:')
) {
return 'An unexpected error occurred. Please try again.';
}
// Return sanitized message or generic fallback
return sanitized || 'An unexpected error occurred. Please try again.';
}
/**
* Extract user-friendly error message from error object
*/
export function getUserFriendlyError(error: any): string {
if (!error) {
return 'An unexpected error occurred. Please try again.';
}
// Check for user-friendly message in error.response.data
if (error?.response?.data?.message) {
return sanitizeErrorMessage(error.response.data.message);
}
if (error?.response?.data?.detail) {
return sanitizeErrorMessage(error.response.data.detail);
}
// Check error message
if (error?.message) {
return sanitizeErrorMessage(error.message);
}
// Check error string
if (typeof error === 'string') {
return sanitizeErrorMessage(error);
}
// Generic fallback
return 'An unexpected error occurred. Please try again.';
}
/**
* Check if error message contains internal/technical details
*/
export function isInternalError(message: string): boolean {
if (!message || typeof message !== 'string') {
return false;
}
return INTERNAL_PATTERNS.some(pattern => pattern.test(message));
}

View File

@@ -0,0 +1,41 @@
import DOMPurify from 'dompurify';
/**
* Sanitize HTML content to prevent XSS attacks
* @param html - HTML string to sanitize
* @returns Sanitized HTML string safe for rendering
*/
export const sanitizeHtml = (html: string): string => {
if (!html) return '';
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'a', 'blockquote', 'pre', 'code',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'div', 'span', 'img', 'hr'
],
ALLOWED_ATTR: [
'href', 'target', 'rel', 'src', 'alt', 'title', 'class', 'id',
'style', 'width', 'height', 'colspan', 'rowspan'
],
ALLOW_DATA_ATTR: false,
// Allow safe CSS styles
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|data):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
// Remove dangerous attributes and scripts
FORBID_TAGS: ['script', 'object', 'embed', 'form', 'input', 'button'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
});
};
/**
* Create a sanitized HTML object for dangerouslySetInnerHTML
* @param html - HTML string to sanitize
* @returns Object with __html property containing sanitized HTML
*/
export const createSanitizedHtml = (html: string): { __html: string } => {
return {
__html: sanitizeHtml(html)
};
};

View File

@@ -0,0 +1,164 @@
/**
* Logger utility for frontend application
* Replaces console statements with proper logging that can be controlled in production
* Sanitizes sensitive data from logs to prevent information disclosure
*/
type LogLevel = 'error' | 'warn' | 'info' | 'debug';
interface LogEntry {
level: LogLevel;
message: string;
error?: any;
extra?: Record<string, any>;
timestamp: string;
}
// Patterns to identify and redact sensitive data
const SENSITIVE_PATTERNS = [
/password/gi,
/token/gi,
/secret/gi,
/api[_-]?key/gi,
/authorization/gi,
/auth[_-]?token/gi,
/access[_-]?token/gi,
/refresh[_-]?token/gi,
/credit[_-]?card/gi,
/card[_-]?number/gi,
/cvv/gi,
/cvc/gi,
/ssn/gi,
/social[_-]?security/gi,
];
const REDACTED_VALUE = '[REDACTED]';
class Logger {
private isDevelopment = import.meta.env.DEV || import.meta.env.MODE === 'development';
/**
* Sanitize sensitive data from log entries
*/
private sanitize(data: any): any {
if (!data || typeof data !== 'object') {
if (typeof data === 'string') {
// Check if string contains sensitive patterns
for (const pattern of SENSITIVE_PATTERNS) {
if (pattern.test(data)) {
return REDACTED_VALUE;
}
}
}
return data;
}
if (Array.isArray(data)) {
return data.map(item => this.sanitize(item));
}
const sanitized: Record<string, any> = {};
for (const [key, value] of Object.entries(data)) {
// Check if key indicates sensitive data
const isSensitiveKey = SENSITIVE_PATTERNS.some(pattern => pattern.test(key));
if (isSensitiveKey) {
sanitized[key] = REDACTED_VALUE;
} else if (typeof value === 'object' && value !== null) {
sanitized[key] = this.sanitize(value);
} else if (typeof value === 'string') {
// Check if value contains sensitive patterns
const isSensitiveValue = SENSITIVE_PATTERNS.some(pattern => pattern.test(value));
sanitized[key] = isSensitiveValue ? REDACTED_VALUE : value;
} else {
sanitized[key] = value;
}
}
return sanitized;
}
/**
* Sanitize error objects
*/
private sanitizeError(error: any): any {
if (!error) return error;
if (error instanceof Error) {
const sanitizedError: any = {
name: error.name,
message: this.sanitize(error.message),
};
// Sanitize stack trace in production
if (this.isDevelopment && error.stack) {
sanitizedError.stack = error.stack;
}
return sanitizedError;
}
return this.sanitize(error);
}
private log(level: LogLevel, message: string, error?: any, extra?: Record<string, any>) {
// Sanitize all data before logging
const sanitizedMessage = typeof message === 'string'
? (SENSITIVE_PATTERNS.some(p => p.test(message)) ? REDACTED_VALUE : message)
: message;
const sanitizedError = this.sanitizeError(error);
const sanitizedExtra = extra ? this.sanitize(extra) : undefined;
const entry: LogEntry = {
level,
message: sanitizedMessage as string,
error: sanitizedError,
extra: sanitizedExtra,
timestamp: new Date().toISOString(),
};
// In development, log to console (with sanitization)
if (this.isDevelopment) {
switch (level) {
case 'error':
console.error(`[${entry.timestamp}] ERROR: ${entry.message}`, sanitizedError || '', sanitizedExtra || '');
break;
case 'warn':
console.warn(`[${entry.timestamp}] WARN: ${entry.message}`, sanitizedExtra || '');
break;
case 'info':
console.info(`[${entry.timestamp}] INFO: ${entry.message}`, sanitizedExtra || '');
break;
case 'debug':
console.debug(`[${entry.timestamp}] DEBUG: ${entry.message}`, sanitizedExtra || '');
break;
}
}
// In production, you could send to a logging service
// Example: sendToLoggingService(entry);
}
error(message: string, error?: any, extra?: Record<string, any>) {
this.log('error', message, error, extra);
// In production, could send to error tracking service (e.g., Sentry)
// if (window.Sentry) {
// window.Sentry.captureException(error || new Error(message), { extra });
// }
}
warn(message: string, extra?: Record<string, any>) {
this.log('warn', message, undefined, extra);
}
info(message: string, extra?: Record<string, any>) {
this.log('info', message, undefined, extra);
}
debug(message: string, extra?: Record<string, any>) {
this.log('debug', message, undefined, extra);
}
}
export const logger = new Logger();

View File

@@ -1,7 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly MODE: string
readonly DEV: boolean
readonly PROD: boolean
}
interface ImportMeta {

View File

@@ -10,8 +10,23 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
optimizeDeps: {
include: ['dompurify'],
},
server: {
port: 5173,
// Direct requests to FastAPI backend - no proxy needed
},
build: {
// Disable source maps in production for security
sourcemap: false,
// Production optimizations
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // Remove console.log in production
drop_debugger: true,
},
},
},
})