updates
This commit is contained in:
@@ -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" />
|
||||
|
||||
2951
Frontend/package-lock.json
generated
2951
Frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
25
Frontend/src/components/common/ErrorBoundaryRoute.tsx
Normal file
25
Frontend/src/components/common/ErrorBoundaryRoute.tsx
Normal 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;
|
||||
|
||||
53
Frontend/src/components/common/ErrorMessage.tsx
Normal file
53
Frontend/src/components/common/ErrorMessage.tsx
Normal 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;
|
||||
|
||||
52
Frontend/src/components/common/LoadingButton.tsx
Normal file
52
Frontend/src/components/common/LoadingButton.tsx
Normal 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
172
Frontend/src/hooks/useApiCall.ts
Normal file
172
Frontend/src/hooks/useApiCall.ts
Normal 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 };
|
||||
}
|
||||
|
||||
@@ -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 />'))}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
101
Frontend/src/utils/errorSanitizer.ts
Normal file
101
Frontend/src/utils/errorSanitizer.ts
Normal 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));
|
||||
}
|
||||
|
||||
41
Frontend/src/utils/htmlSanitizer.ts
Normal file
41
Frontend/src/utils/htmlSanitizer.ts
Normal 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)
|
||||
};
|
||||
};
|
||||
|
||||
164
Frontend/src/utils/logger.ts
Normal file
164
Frontend/src/utils/logger.ts
Normal 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();
|
||||
|
||||
4
Frontend/src/vite-env.d.ts
vendored
4
Frontend/src/vite-env.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user