This commit is contained in:
Iliyan Angelov
2025-12-01 06:50:10 +02:00
parent 91f51bc6fe
commit 62c1fe5951
4682 changed files with 544807 additions and 31208 deletions

View File

@@ -4,10 +4,33 @@
<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' https://js.stripe.com; 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: https://js.stripe.com https://hooks.stripe.com; frame-src 'self' https://js.stripe.com https://hooks.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self';" />
<!-- SECURITY: Security headers for payment pages and general security -->
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
<meta http-equiv="X-Frame-Options" content="SAMEORIGIN" />
<meta http-equiv="X-XSS-Protection" content="1; mode=block" />
<!-- SECURITY: Prevent caching of sensitive pages (payment pages should set no-cache) -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- SECURITY: Content Security Policy - XSS protection -->
<!--
IMPORTANT: This CSP is permissive for development compatibility.
For production, the backend MUST set strict CSP headers that override this.
Production CSP should:
- Remove 'unsafe-inline' and 'unsafe-eval' from script-src
- Use nonces or hashes for inline scripts
- Remove 'unsafe-inline' from style-src (use nonces)
- Set report-uri for violation reporting
Current CSP allows:
- unsafe-inline/unsafe-eval: Required for Vite HMR in development
- ws://localhost: Required for Vite HMR websocket
Backend should set headers like:
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{NONCE}' https://js.stripe.com ...; ...
-->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.google.com https://www.gstatic.com; 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: https://js.stripe.com https://hooks.stripe.com https://www.google.com; frame-src 'self' https://js.stripe.com https://hooks.stripe.com https://www.google.com; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;" />
<!-- Preconnect to external resources for faster loading -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@@ -4454,9 +4454,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6134,9 +6134,9 @@
}
},
"node_modules/sucrase/node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {

View File

@@ -14,6 +14,7 @@ import { CurrencyProvider } from './features/payments/contexts/CurrencyContext';
import { CompanySettingsProvider } from './shared/contexts/CompanySettingsContext';
import { AuthModalProvider } from './features/auth/contexts/AuthModalContext';
import { AntibotProvider } from './features/auth/contexts/AntibotContext';
import { logDebug } from './shared/utils/errorReporter';
import OfflineIndicator from './shared/components/OfflineIndicator';
import CookieConsentBanner from './shared/components/CookieConsentBanner';
import CookiePreferencesModal from './shared/components/CookiePreferencesModal';
@@ -61,6 +62,7 @@ const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage'));
const GroupBookingPage = lazy(() => import('./pages/customer/GroupBookingPage'));
const ComplaintPage = lazy(() => import('./pages/customer/ComplaintPage'));
const GDPRPage = lazy(() => import('./pages/customer/GDPRPage'));
const GDPRDeletionConfirmPage = lazy(() => import('./pages/customer/GDPRDeletionConfirmPage'));
const AboutPage = lazy(() => import('./features/content/pages/AboutPage'));
const ContactPage = lazy(() => import('./features/content/pages/ContactPage'));
const PrivacyPolicyPage = lazy(() => import('./features/content/pages/PrivacyPolicyPage'));
@@ -168,9 +170,10 @@ function App() {
useEffect(() => {
// Initialize auth asynchronously to validate cookies
initializeAuth().catch((error) => {
// Silently handle auth initialization errors
// SECURITY: Silently handle auth initialization errors
// User will be prompted to login if needed
console.debug('Auth initialization failed:', error);
// Only log in development to prevent information disclosure
logDebug('Auth initialization failed', { error: error instanceof Error ? error.message : String(error) });
});
}, [initializeAuth]);
@@ -282,9 +285,11 @@ function App() {
<Route
path="invoices/:id"
element={
<ProtectedRoute>
<InvoicePage />
</ProtectedRoute>
<ErrorBoundaryRoute>
<CustomerRoute>
<InvoicePage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
@@ -332,9 +337,11 @@ function App() {
<Route
path="dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
<ErrorBoundaryRoute>
<CustomerRoute>
<DashboardPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
@@ -390,41 +397,59 @@ function App() {
<Route
path="profile"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
<ErrorBoundaryRoute>
<CustomerRoute>
<ProfilePage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
path="loyalty"
element={
<CustomerRoute>
<LoyaltyPage />
</CustomerRoute>
<ErrorBoundaryRoute>
<CustomerRoute>
<LoyaltyPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
path="group-bookings"
element={
<CustomerRoute>
<GroupBookingPage />
</CustomerRoute>
<ErrorBoundaryRoute>
<CustomerRoute>
<GroupBookingPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
path="complaints"
element={
<CustomerRoute>
<ComplaintPage />
</CustomerRoute>
<ErrorBoundaryRoute>
<CustomerRoute>
<ComplaintPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
path="gdpr"
element={
<ProtectedRoute>
<GDPRPage />
</ProtectedRoute>
<ErrorBoundaryRoute>
<CustomerRoute>
<GDPRPage />
</CustomerRoute>
</ErrorBoundaryRoute>
}
/>
<Route
path="gdpr/delete/confirm"
element={
<ErrorBoundaryRoute>
<GDPRDeletionConfirmPage />
</ErrorBoundaryRoute>
}
/>
</Route>

View File

@@ -7,13 +7,21 @@ interface AdminRouteProps {
children: React.ReactNode;
}
/**
* SECURITY NOTE: This component performs CLIENT-SIDE authorization checks only.
* These checks are for UX purposes (showing/hiding UI elements).
*
* ALL authorization must be enforced server-side. Client-side checks can be bypassed
* by modifying localStorage or browser DevTools. The backend API must validate
* user roles and permissions for every request.
*/
const AdminRoute: React.FC<AdminRouteProps> = ({
children
}) => {
const { isAuthenticated, userInfo, isLoading } = useAuthStore();
const { openModal } = useAuthModal();
// SECURITY: Client-side role check - backend must also validate
useEffect(() => {
if (!isLoading && !isAuthenticated) {
openModal('login');
@@ -45,6 +53,8 @@ const AdminRoute: React.FC<AdminRouteProps> = ({
}
// SECURITY: Client-side role check - MUST be validated server-side
// This check can be bypassed by modifying localStorage
const isAdmin = userInfo?.role === 'admin';
if (!isAdmin) {
// Redirect to appropriate dashboard based on role

View File

@@ -179,7 +179,8 @@ const authService = {
const formData = new FormData();
formData.append('image', file);
// FormData handling: apiClient interceptor will remove Content-Type header
// to let browser set it automatically with boundary
const response = await apiClient.post<AuthResponse>(
'/api/auth/avatar/upload',
formData

View File

@@ -5,7 +5,7 @@ import pageContentService from '../services/pageContentService';
import type { PageContent } from '../services/pageContentService';
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
import Loading from '../../../shared/components/Loading';
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
import { createSanitizedHtml, sanitizeHtml } from '../../../shared/utils/htmlSanitizer';
const AccessibilityPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -21,8 +21,10 @@ const AccessibilityPage: React.FC = () => {
const content = response.data.page_content;
if (content.content) {
// SECURITY: Sanitize HTML before assigning to innerHTML to prevent XSS
const sanitizedContent = sanitizeHtml(content.content);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content.content;
tempDiv.innerHTML = sanitizedContent;
const allElements = tempDiv.querySelectorAll('*');
allElements.forEach((el) => {

View File

@@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
import pageContentService, { PageContent } from '../services/pageContentService';
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
import Loading from '../../../shared/components/Loading';
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
import { createSanitizedHtml, sanitizeHtml } from '../../../shared/utils/htmlSanitizer';
const CancellationPolicyPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -21,8 +21,10 @@ const CancellationPolicyPage: React.FC = () => {
// Process HTML content to ensure text is visible
if (content.content) {
// SECURITY: Sanitize HTML before assigning to innerHTML to prevent XSS
const sanitizedContent = sanitizeHtml(content.content);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content.content;
tempDiv.innerHTML = sanitizedContent;
const allElements = tempDiv.querySelectorAll('*');
allElements.forEach((el) => {

View File

@@ -5,7 +5,7 @@ import pageContentService from '../services/pageContentService';
import type { PageContent } from '../services/pageContentService';
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
import Loading from '../../../shared/components/Loading';
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
import { createSanitizedHtml, sanitizeHtml } from '../../../shared/utils/htmlSanitizer';
const FAQPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -21,8 +21,10 @@ const FAQPage: React.FC = () => {
const content = response.data.page_content;
if (content.content) {
// SECURITY: Sanitize HTML before assigning to innerHTML to prevent XSS
const sanitizedContent = sanitizeHtml(content.content);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content.content;
tempDiv.innerHTML = sanitizedContent;
const allElements = tempDiv.querySelectorAll('*');
allElements.forEach((el) => {

View File

@@ -5,7 +5,7 @@ import pageContentService from '../services/pageContentService';
import type { PageContent } from '../services/pageContentService';
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
import Loading from '../../../shared/components/Loading';
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
import { createSanitizedHtml, sanitizeHtml } from '../../../shared/utils/htmlSanitizer';
const PrivacyPolicyPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -22,9 +22,11 @@ const PrivacyPolicyPage: React.FC = () => {
// Process HTML content to ensure text is visible
if (content.content) {
// SECURITY: Sanitize HTML before assigning to innerHTML to prevent XSS
const sanitizedContent = sanitizeHtml(content.content);
// Create a temporary div to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content.content;
tempDiv.innerHTML = sanitizedContent;
// Add color styles to elements that don't have them
const allElements = tempDiv.querySelectorAll('*');
@@ -169,16 +171,27 @@ const PrivacyPolicyPage: React.FC = () => {
</div>
{/* Footer Note */}
{settings.company_email && (
<div className="mt-8 text-center">
<p className="text-sm text-gray-400 font-light">
For questions about this policy, contact us at{' '}
<a href={`mailto:${settings.company_email}`} className="text-[#d4af37] hover:underline">
{settings.company_email}
</a>
</p>
<div className="mt-8 space-y-4">
{settings.company_email && (
<div className="text-center">
<p className="text-sm text-gray-400 font-light">
For questions about this policy, contact us at{' '}
<a href={`mailto:${settings.company_email}`} className="text-[#d4af37] hover:underline">
{settings.company_email}
</a>
</p>
</div>
)}
<div className="text-center">
<Link
to="/gdpr"
className="inline-flex items-center gap-2 text-sm text-[#d4af37] hover:text-[#f5d76e] transition-colors font-light"
>
<Shield className="w-4 h-4" />
<span>Manage Your Data Privacy (GDPR)</span>
</Link>
</div>
)}
</div>
</div>
</div>
);

View File

@@ -5,7 +5,7 @@ import pageContentService from '../services/pageContentService';
import type { PageContent } from '../services/pageContentService';
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
import Loading from '../../../shared/components/Loading';
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
import { createSanitizedHtml, sanitizeHtml } from '../../../shared/utils/htmlSanitizer';
const RefundsPolicyPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -22,9 +22,11 @@ const RefundsPolicyPage: React.FC = () => {
// Process HTML content to ensure text is visible
if (content.content) {
// SECURITY: Sanitize HTML before assigning to innerHTML to prevent XSS
const sanitizedContent = sanitizeHtml(content.content);
// Create a temporary div to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content.content;
tempDiv.innerHTML = sanitizedContent;
// Add color styles to elements that don't have them
const allElements = tempDiv.querySelectorAll('*');

View File

@@ -5,7 +5,7 @@ import pageContentService from '../services/pageContentService';
import type { PageContent } from '../services/pageContentService';
import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext';
import Loading from '../../../shared/components/Loading';
import { createSanitizedHtml } from '../../../shared/utils/htmlSanitizer';
import { createSanitizedHtml, sanitizeHtml } from '../../../shared/utils/htmlSanitizer';
const TermsPage: React.FC = () => {
const { settings } = useCompanySettings();
@@ -22,9 +22,11 @@ const TermsPage: React.FC = () => {
// Process HTML content to ensure text is visible
if (content.content) {
// SECURITY: Sanitize HTML before assigning to innerHTML to prevent XSS
const sanitizedContent = sanitizeHtml(content.content);
// Create a temporary div to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content.content;
tempDiv.innerHTML = sanitizedContent;
// Add color styles to elements that don't have them
const allElements = tempDiv.querySelectorAll('*');

View File

@@ -59,8 +59,12 @@ const PayPalPaymentModal: React.FC<PayPalPaymentModalProps> = ({
throw new Error(response.message || 'Failed to initialize PayPal payment');
}
} catch (err: any) {
console.error('Error initializing PayPal:', err);
const errorMessage = err.response?.data?.message || err.message || 'Failed to initialize PayPal payment';
// SECURITY: Don't log payment errors with sensitive data in production
if (import.meta.env.DEV) {
console.error('Error initializing PayPal:', err);
}
// SECURITY: Sanitize error message to prevent information disclosure
const errorMessage = 'Failed to initialize PayPal payment. Please try again.';
setError(errorMessage);
toast.error(errorMessage);
} finally {

View File

@@ -55,8 +55,12 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
throw new Error(response.message || 'Failed to initialize PayPal payment');
}
} catch (err: any) {
console.error('Error initializing PayPal:', err);
const errorMessage = err.response?.data?.message || err.message || 'Failed to initialize PayPal payment';
// SECURITY: Don't log payment errors with sensitive data in production
if (import.meta.env.DEV) {
console.error('Error initializing PayPal:', err);
}
// SECURITY: Sanitize error message to prevent information disclosure
const errorMessage = 'Failed to initialize PayPal payment. Please try again.';
setError(errorMessage);
if (onError) {
onError(errorMessage);
@@ -169,9 +173,21 @@ const PayPalPaymentWrapper: React.FC<PayPalPaymentWrapperProps> = ({
</svg>
Pay with PayPal
</button>
<p className="text-xs text-gray-400/70 mt-6 font-light tracking-wide">
Secure payment powered by PayPal
</p>
<div className="mt-6 space-y-2">
<p className="text-xs text-gray-400/70 font-light tracking-wide text-center">
Secure payment powered by PayPal
</p>
<div className="flex items-center justify-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
</svg>
PCI DSS Compliant
</span>
<span className="text-gray-600"></span>
<span>SSL Encrypted</span>
</div>
</div>
</div>
);
};

View File

@@ -82,7 +82,12 @@ const StripePaymentForm: React.FC<StripePaymentFormProps> = ({
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<form
onSubmit={handleSubmit}
className="space-y-4"
autoComplete="off"
// SECURITY: Prevent form caching and autocomplete for payment forms
>
<div className="mb-4 p-4 bg-[#d4af37]/10 border border-[#d4af37]/20 rounded-lg">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-300">
@@ -154,9 +159,21 @@ const StripePaymentForm: React.FC<StripePaymentFormProps> = ({
)}
</button>
<p className="text-xs text-gray-500 text-center">
Your payment is secure and encrypted
</p>
<div className="mt-4 space-y-2">
<p className="text-xs text-gray-500 text-center">
Your payment is secure and encrypted
</p>
<div className="flex items-center justify-center gap-3 text-xs text-gray-400">
<span className="flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
</svg>
PCI DSS Compliant
</span>
<span className="text-gray-600"></span>
<span>Powered by Stripe</span>
</div>
</div>
</form>
);
};

View File

@@ -49,7 +49,13 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
if (response.success && response.data) {
const { publishable_key, client_secret } = response.data;
console.log('Payment intent response:', { publishable_key: publishable_key ? 'present' : 'missing', client_secret: client_secret ? 'present' : 'missing' });
// SECURITY: Don't log payment data - only log presence in development
if (import.meta.env.DEV) {
console.log('Payment intent response:', {
publishable_key: publishable_key ? 'present' : 'missing',
client_secret: client_secret ? 'present' : 'missing'
});
}
if (!client_secret) {
throw new Error('Client secret not received from server');
@@ -76,8 +82,12 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
throw new Error(response.message || 'Failed to initialize payment');
}
} catch (err: any) {
console.error('Error initializing Stripe:', err);
const errorMessage = err.response?.data?.message || err.message || 'Failed to initialize payment';
// SECURITY: Don't log payment errors with sensitive data in production
if (import.meta.env.DEV) {
console.error('Error initializing Stripe:', err);
}
// SECURITY: Sanitize error message to prevent information disclosure
const errorMessage = 'Failed to initialize payment. Please try again.';
setError(errorMessage);
if (onError) {
onError(errorMessage);
@@ -92,12 +102,15 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
useEffect(() => {
if (clientSecret && stripePromise) {
console.log('Stripe initialized successfully', { hasClientSecret: !!clientSecret, hasStripePromise: !!stripePromise });
} else {
console.log('Stripe not ready', { hasClientSecret: !!clientSecret, hasStripePromise: !!stripePromise, error });
// SECURITY: Only log in development, never log client_secret or sensitive data
if (import.meta.env.DEV) {
if (clientSecret && stripePromise) {
console.log('Stripe initialized successfully', { hasClientSecret: !!clientSecret, hasStripePromise: !!stripePromise });
} else {
console.log('Stripe not ready', { hasClientSecret: !!clientSecret, hasStripePromise: !!stripePromise });
}
}
}, [clientSecret, stripePromise, error]);
}, [clientSecret, stripePromise]);
const handlePaymentSuccess = async (paymentIntentId: string) => {
try {
@@ -114,10 +127,14 @@ const StripePaymentWrapper: React.FC<StripePaymentWrapperProps> = ({
throw new Error(response.message || 'Payment confirmation failed');
}
} catch (err: any) {
console.error('Error confirming payment:', err);
// SECURITY: Don't log payment errors with sensitive data in production
if (import.meta.env.DEV) {
console.error('Error confirming payment:', err);
}
setPaymentCompleted(false);
const errorMessage = err.response?.data?.message || err.message || 'Payment confirmation failed';
// SECURITY: Sanitize error message to prevent information disclosure
const errorMessage = 'Payment confirmation failed. Please contact support if the issue persists.';
setError(errorMessage);
if (onError) {
onError(errorMessage);

View File

@@ -40,6 +40,7 @@ import reviewService, { Review } from '../../features/reviews/services/reviewSer
import { auditService, AuditLog, AuditLogFilters } from '../../features/analytics/services/auditService';
import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { logger } from '../../shared/utils/logger';
import analyticsService, {
ComprehensiveAnalyticsData,
RevPARData,
@@ -252,7 +253,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

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import {
CreditCard,
Receipt,
@@ -20,6 +20,8 @@ import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { useAsync } from '../../shared/hooks/useAsync';
import { useNavigate } from 'react-router-dom';
import { logger } from '../../shared/utils/logger';
import { getPaymentStatusColor } from '../../shared/utils/paymentUtils';
const AccountantDashboardPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -32,6 +34,8 @@ const AccountantDashboardPage: React.FC = () => {
const [recentInvoices, setRecentInvoices] = useState<Invoice[]>([]);
const [loadingPayments, setLoadingPayments] = useState(false);
const [loadingInvoices, setLoadingInvoices] = useState(false);
const paymentsAbortRef = useRef<AbortController | null>(null);
const invoicesAbortRef = useRef<AbortController | null>(null);
const [financialSummary, setFinancialSummary] = useState({
totalRevenue: 0,
totalPayments: 0,
@@ -64,6 +68,14 @@ const AccountantDashboardPage: React.FC = () => {
}, [dateRange]);
useEffect(() => {
// Cancel previous request if exists
if (paymentsAbortRef.current) {
paymentsAbortRef.current.abort();
}
// Create new abort controller
paymentsAbortRef.current = new AbortController();
const fetchPayments = async () => {
try {
setLoadingPayments(true);
@@ -71,7 +83,7 @@ const AccountantDashboardPage: React.FC = () => {
if (response.success && response.data?.payments) {
setRecentPayments(response.data.payments);
// Calculate financial summary
const completedPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'completed');
const completedPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'completed' || p.payment_status === 'paid');
const pendingPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'pending');
const totalRevenue = completedPayments.reduce((sum: number, p: Payment) => sum + (p.amount || 0), 0);
@@ -83,15 +95,34 @@ const AccountantDashboardPage: React.FC = () => {
}));
}
} catch (err: any) {
console.error('Error fetching payments:', err);
// Handle AbortError silently
if (err.name === 'AbortError') {
return;
}
logger.error('Error fetching payments', err);
} finally {
setLoadingPayments(false);
}
};
fetchPayments();
// Cleanup: abort request on unmount
return () => {
if (paymentsAbortRef.current) {
paymentsAbortRef.current.abort();
}
};
}, []);
useEffect(() => {
// Cancel previous request if exists
if (invoicesAbortRef.current) {
invoicesAbortRef.current.abort();
}
// Create new abort controller
invoicesAbortRef.current = new AbortController();
const fetchInvoices = async () => {
try {
setLoadingInvoices(true);
@@ -110,32 +141,29 @@ const AccountantDashboardPage: React.FC = () => {
}));
}
} catch (err: any) {
console.error('Error fetching invoices:', err);
// Handle AbortError silently
if (err.name === 'AbortError') {
return;
}
logger.error('Error fetching invoices', err);
} finally {
setLoadingInvoices(false);
}
};
fetchInvoices();
// Cleanup: abort request on unmount
return () => {
if (invoicesAbortRef.current) {
invoicesAbortRef.current.abort();
}
};
}, []);
const handleRefresh = () => {
execute();
};
const getPaymentStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200';
case 'pending':
return 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200';
case 'failed':
return 'bg-gradient-to-r from-rose-50 to-red-50 text-rose-800 border-rose-200';
case 'refunded':
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
default:
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
}
};
const getInvoiceStatusColor = (status: string) => {
switch (status) {

View File

@@ -8,6 +8,7 @@ import ExportButton from '../../shared/components/ExportButton';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { useNavigate } from 'react-router-dom';
import { formatDate } from '../../shared/utils/format';
import { logger } from '../../shared/utils/logger';
const InvoiceManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -22,13 +23,29 @@ const InvoiceManagementPage: React.FC = () => {
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 10;
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchInvoices();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [filters, currentPage]);
const fetchInvoices = async () => {
@@ -58,6 +75,11 @@ const InvoiceManagementPage: React.FC = () => {
setTotalItems(response.data.total || 0);
}
} catch (error: any) {
// Handle AbortError silently
if (error.name === 'AbortError') {
return;
}
logger.error('Error fetching invoices', error);
toast.error(error.response?.data?.message || 'Unable to load invoices');
} finally {
setLoading(false);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { Search } from 'lucide-react';
import paymentService from '../../features/payments/services/paymentService';
import type { Payment } from '../../features/payments/services/paymentService';
@@ -8,6 +8,8 @@ import Pagination from '../../shared/components/Pagination';
import ExportButton from '../../shared/components/ExportButton';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { formatDate } from '../../shared/utils/format';
import { logger } from '../../shared/utils/logger';
import { getPaymentStatusColor, getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
const PaymentManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -23,13 +25,29 @@ const PaymentManagementPage: React.FC = () => {
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchPayments();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [filters, currentPage]);
const fetchPayments = async () => {
@@ -53,77 +71,55 @@ const PaymentManagementPage: React.FC = () => {
};
const getMethodBadge = (method: string) => {
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
const label = getPaymentMethodLabel(method);
const badges: Record<string, { bg: string; text: string; border: string }> = {
cash: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Cash',
border: 'border-emerald-200'
},
bank_transfer: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Bank transfer',
border: 'border-blue-200'
},
stripe: {
bg: 'bg-gradient-to-r from-indigo-50 to-purple-50',
text: 'text-indigo-800',
label: 'Stripe',
border: 'border-indigo-200'
},
paypal: {
bg: 'bg-gradient-to-r from-blue-50 to-cyan-50',
text: 'text-blue-800',
label: 'PayPal',
border: 'border-blue-200'
},
credit_card: {
bg: 'bg-gradient-to-r from-purple-50 to-pink-50',
text: 'text-purple-800',
label: 'Credit card',
border: 'border-purple-200'
},
};
const badge = badges[method] || badges.cash;
return (
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
{badge.label}
{label}
</span>
);
};
const getPaymentStatusBadge = (status: string) => {
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
completed: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: ' Paid',
border: 'border-emerald-200'
},
pending: {
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
text: 'text-amber-800',
label: '⏳ Pending',
border: 'border-amber-200'
},
failed: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: '❌ Failed',
border: 'border-rose-200'
},
refunded: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: '💰 Refunded',
border: 'border-slate-200'
},
const colorClasses = getPaymentStatusColor(status);
const labels: Record<string, string> = {
completed: '✅ Paid',
paid: '✅ Paid',
pending: ' Pending',
failed: '❌ Failed',
refunded: '💰 Refunded',
};
const config = statusConfig[status] || statusConfig.pending;
const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1);
return (
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.bg} ${config.text} ${config.border}`}>
{config.label}
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${colorClasses}`}>
{label}
</span>
);
};

View File

@@ -11,6 +11,7 @@ const APIKeyManagementPage: React.FC = () => {
const [showModal, setShowModal] = useState(false);
const [editingKey, setEditingKey] = useState<APIKey | null>(null);
const [newKey, setNewKey] = useState<string | null>(null);
const [showFullKey, setShowFullKey] = useState<{ [key: number]: boolean }>({});
const [formData, setFormData] = useState<CreateAPIKeyData>({
name: '',
scopes: [],
@@ -58,7 +59,11 @@ const APIKeyManagementPage: React.FC = () => {
} else {
const response = await apiKeyService.createAPIKey(formData);
toast.success('API key created successfully');
// SECURITY: Store key temporarily, clear after 5 minutes or on close
setNewKey(response.data.key);
setTimeout(() => {
setNewKey(null);
}, 5 * 60 * 1000); // Clear after 5 minutes
setShowModal(false);
resetForm();
fetchAPIKeys();
@@ -143,19 +148,29 @@ const APIKeyManagementPage: React.FC = () => {
{newKey && (
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 border-2 border-amber-200 rounded-xl sm:rounded-2xl p-3 sm:p-4 mb-4 sm:mb-6 animate-fade-in shadow-lg">
<p className="font-semibold mb-2 text-sm sm:text-base text-amber-900">API Key Created (save this - it won't be shown again):</p>
<p className="font-semibold mb-2 text-sm sm:text-base text-amber-900">
SECURITY WARNING: API Key Created (save this now - it will be hidden automatically)
</p>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3">
<code className="flex-1 bg-white p-2 sm:p-3 rounded-lg sm:rounded-xl border-2 border-amber-200 text-xs sm:text-sm font-mono break-all">{newKey}</code>
<code className="flex-1 bg-white p-2 sm:p-3 rounded-lg sm:rounded-xl border-2 border-amber-200 text-xs sm:text-sm font-mono break-all">
{newKey}
</code>
<div className="flex gap-2">
<button
onClick={() => copyToClipboard(newKey)}
onClick={() => {
copyToClipboard(newKey);
// SECURITY: Clear key from state after copying
setTimeout(() => setNewKey(null), 30000); // Clear after 30 seconds
}}
className="p-2 sm:p-2.5 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-lg sm:rounded-xl hover:from-blue-700 hover:to-blue-800 shadow-md hover:shadow-lg transition-all duration-200"
title="Copy and hide key"
>
<Copy className="w-4 h-4" />
</button>
<button
onClick={() => setNewKey(null)}
className="p-2 sm:p-2.5 bg-slate-200 hover:bg-slate-300 rounded-lg sm:rounded-xl transition-colors"
title="Hide key immediately"
>
</button>

View File

@@ -16,6 +16,7 @@ import EmptyState from '../../shared/components/EmptyState';
import Pagination from '../../shared/components/Pagination';
import { auditService, AuditLog, AuditLogFilters } from '../../features/analytics/services/auditService';
import { formatDate } from '../../shared/utils/format';
import { logger } from '../../shared/utils/logger';
const AuditLogsPage: React.FC = () => {
const [logs, setLogs] = useState<AuditLog[]>([]);
@@ -26,6 +27,7 @@ const AuditLogsPage: React.FC = () => {
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
const [showDetails, setShowDetails] = useState(false);
const itemsPerPage = 20;
const abortControllerRef = useRef<AbortController | null>(null);
const [filters, setFilters] = useState<AuditLogFilters>({
page: 1,
@@ -33,7 +35,22 @@ const AuditLogsPage: React.FC = () => {
});
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchLogs();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [filters, currentPage]);
useEffect(() => {
@@ -54,7 +71,11 @@ const AuditLogsPage: React.FC = () => {
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
console.error('Error fetching audit logs:', error);
// Handle AbortError silently
if (error.name === 'AbortError') {
return;
}
logger.error('Error fetching audit logs', error);
toast.error(error.response?.data?.message || 'Unable to load audit logs');
} finally {
setLoading(false);

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { Download, Trash2, Plus, HardDrive, AlertTriangle } from 'lucide-react';
import backupService, { Backup } from '../../features/system/services/backupService';
import { toast } from 'react-toastify';
import { logger } from '../../shared/utils/logger';
import Loading from '../../shared/components/Loading';
import { formatDate } from '../../shared/utils/format';
@@ -24,7 +25,7 @@ const BackupManagementPage: React.FC = () => {
toast.warning(response.data.message || 'Backup service is not available', { autoClose: 8000 });
}
} catch (error: any) {
console.error('Error checking backup status:', error);
logger.error('Error checking backup status', error);
setBackupAvailable(false);
}
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { Search, Eye, XCircle, CheckCircle, Loader2, FileText, Plus, Mail } from 'lucide-react';
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
import invoiceService from '../../features/payments/services/invoiceService';
@@ -35,13 +35,29 @@ const BookingManagementPage: React.FC = () => {
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const showOnlyWithoutInvoices = searchParams.get('createInvoice') === 'true';
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchBookings();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [filters, currentPage]);
useEffect(() => {
@@ -68,6 +84,11 @@ const BookingManagementPage: React.FC = () => {
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
// Handle AbortError silently
if (error.name === 'AbortError') {
return;
}
logger.error('Error fetching bookings', error);
toast.error(error.response?.data?.message || 'Unable to load bookings list');
} finally {
setLoading(false);
@@ -170,7 +191,6 @@ const BookingManagementPage: React.FC = () => {
const response = await invoiceService.createInvoice(invoiceData);
// Log the full response for debugging
console.log('Invoice creation response:', JSON.stringify(response, null, 2));
logger.info('Invoice creation response', { response });
// Check response structure - handle different possible formats
@@ -178,11 +198,10 @@ const BookingManagementPage: React.FC = () => {
if (response.status === 'success' && response.data) {
// Try different possible response structures
invoice = response.data.invoice || response.data.data?.invoice || response.data;
console.log('Extracted invoice:', invoice);
logger.debug('Extracted invoice', { invoice });
}
if (!invoice) {
console.error('Failed to create invoice - no invoice in response', response);
logger.error('Failed to create invoice - no invoice in response', { response });
toast.error(response.message || 'Failed to create invoice - no invoice data received');
return;
@@ -192,7 +211,6 @@ const BookingManagementPage: React.FC = () => {
let invoiceId = invoice.id;
// Log the invoice ID for debugging
console.log('Extracted invoice ID:', { invoiceId, type: typeof invoiceId, invoice });
logger.info('Extracted invoice ID', { invoiceId, type: typeof invoiceId, invoice });
// Convert to number if it's a string
@@ -202,7 +220,7 @@ const BookingManagementPage: React.FC = () => {
// Validate invoice ID before navigation
if (!invoiceId || isNaN(invoiceId) || invoiceId <= 0 || !isFinite(invoiceId)) {
console.error('Invalid invoice ID received from server', {
logger.error('Invalid invoice ID received from server', {
originalInvoiceId: invoice.id,
convertedInvoiceId: invoiceId,
type: typeof invoiceId,

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import {
BarChart3,
Users,
@@ -7,11 +7,15 @@ import {
TrendingUp,
RefreshCw,
CreditCard,
LogOut
LogOut,
Monitor,
Smartphone,
Tablet,
} from 'lucide-react';
import reportService, { ReportData } from '../../features/analytics/services/reportService';
import paymentService from '../../features/payments/services/paymentService';
import type { Payment } from '../../features/payments/services/paymentService';
import sessionService, { UserSession } from '../../features/auth/services/sessionService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
@@ -22,6 +26,7 @@ import { useAsync } from '../../shared/hooks/useAsync';
import { useNavigate } from 'react-router-dom';
import useAuthStore from '../../store/useAuthStore';
import { logger } from '../../shared/utils/logger';
import { getPaymentStatusColor, getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
const DashboardPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -33,6 +38,10 @@ const DashboardPage: React.FC = () => {
});
const [recentPayments, setRecentPayments] = useState<Payment[]>([]);
const [loadingPayments, setLoadingPayments] = useState(false);
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
const paymentsAbortRef = useRef<AbortController | null>(null);
const sessionsAbortRef = useRef<AbortController | null>(null);
const handleLogout = async () => {
try {
@@ -66,6 +75,14 @@ const DashboardPage: React.FC = () => {
}, [dateRange]);
useEffect(() => {
// Cancel previous request if exists
if (paymentsAbortRef.current) {
paymentsAbortRef.current.abort();
}
// Create new abort controller
paymentsAbortRef.current = new AbortController();
const fetchPayments = async () => {
try {
setLoadingPayments(true);
@@ -74,47 +91,81 @@ const DashboardPage: React.FC = () => {
setRecentPayments(response.data.payments);
}
} catch (err: any) {
// Handle AbortError silently
if (err.name === 'AbortError') {
return;
}
logger.error('Error fetching payments', err);
} finally {
setLoadingPayments(false);
}
};
fetchPayments();
// Cleanup: abort request on unmount
return () => {
if (paymentsAbortRef.current) {
paymentsAbortRef.current.abort();
}
};
}, []);
useEffect(() => {
// Cancel previous request if exists
if (sessionsAbortRef.current) {
sessionsAbortRef.current.abort();
}
// Create new abort controller
sessionsAbortRef.current = new AbortController();
const fetchSessions = async () => {
try {
setLoadingSessions(true);
const response = await sessionService.getMySessions();
if (response.success && response.data?.sessions) {
setSessions(response.data.sessions || []);
}
} catch (err: any) {
// Handle AbortError silently
if (err.name === 'AbortError') {
return;
}
logger.error('Error fetching sessions', err);
} finally {
setLoadingSessions(false);
}
};
fetchSessions();
// Cleanup: abort request on unmount
return () => {
if (sessionsAbortRef.current) {
sessionsAbortRef.current.abort();
}
};
}, []);
const handleRefresh = () => {
execute();
};
const getPaymentStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200';
case 'pending':
return 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200';
case 'failed':
return 'bg-gradient-to-r from-rose-50 to-red-50 text-rose-800 border-rose-200';
case 'refunded':
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
default:
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
}
const getDeviceIcon = (userAgent?: string) => {
if (!userAgent) return <Monitor className="w-4 h-4 text-slate-600" />;
if (userAgent.includes('Mobile')) return <Smartphone className="w-4 h-4 text-blue-600" />;
if (userAgent.includes('Tablet')) return <Tablet className="w-4 h-4 text-purple-600" />;
return <Monitor className="w-4 h-4 text-slate-600" />;
};
const getPaymentMethodLabel = (method: string) => {
switch (method) {
case 'stripe':
case 'credit_card':
return 'Card';
case 'paypal':
return 'PayPal';
case 'bank_transfer':
return 'Bank Transfer';
case 'cash':
return 'Cash';
default:
return method;
}
const getDeviceName = (userAgent?: string) => {
if (!userAgent) return 'Unknown Device';
if (userAgent.includes('Chrome')) return 'Chrome';
if (userAgent.includes('Firefox')) return 'Firefox';
if (userAgent.includes('Safari')) return 'Safari';
if (userAgent.includes('Edge')) return 'Edge';
if (userAgent.includes('Mobile')) return 'Mobile Device';
if (userAgent.includes('Tablet')) return 'Tablet';
return 'Web Browser';
};
if (loading) {
@@ -486,6 +537,63 @@ const DashboardPage: React.FC = () => {
/>
)}
</div>
{}
<div className="bg-white/80 backdrop-blur-sm rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in" style={{ animationDelay: '0.4s' }}>
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
<h2 className="text-sm sm:text-base md:text-base font-bold text-slate-900">Active Sessions</h2>
<div className="p-2 bg-gradient-to-br from-indigo-100 to-indigo-200 rounded-lg sm:rounded-xl flex-shrink-0">
<Monitor className="w-4 h-4 sm:w-5 sm:h-5 text-indigo-600" />
</div>
</div>
{loadingSessions ? (
<div className="flex items-center justify-center py-8">
<Loading text="Loading sessions..." />
</div>
) : sessions && sessions.length > 0 ? (
<div className="space-y-2 sm:space-y-3">
{sessions.slice(0, 3).map((session) => (
<div
key={session.id}
className="flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl hover:from-indigo-50 hover:to-purple-50 border border-slate-200 hover:border-indigo-300 hover:shadow-lg transition-all duration-200"
>
<div className="flex items-center space-x-2 sm:space-x-3 md:space-x-4 flex-1 min-w-0">
<div className="p-2 sm:p-3 bg-gradient-to-br from-indigo-100 to-indigo-200 rounded-lg sm:rounded-xl shadow-md flex-shrink-0">
{getDeviceIcon(session.user_agent)}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 text-xs sm:text-sm md:text-base truncate">
{getDeviceName(session.user_agent)}
</p>
<div className="flex items-center gap-1 sm:gap-2 mt-1 flex-wrap">
<p className="text-xs sm:text-sm text-slate-600">
{session.ip_address || 'Unknown IP'}
</p>
{session.last_activity && (
<span className="text-xs text-slate-400">
{formatDate(session.last_activity, 'short')}
</span>
)}
</div>
</div>
</div>
</div>
))}
{sessions.length > 3 && (
<div className="text-center pt-2">
<span className="text-xs sm:text-sm text-amber-600 font-semibold">
+{sessions.length - 3} more session{sessions.length - 3 !== 1 ? 's' : ''}
</span>
</div>
)}
</div>
) : (
<EmptyState
title="No Active Sessions"
description="Your active sessions will appear here"
/>
)}
</div>
</div>
</div>
);

View File

@@ -21,6 +21,7 @@ import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { logger } from '../../shared/utils/logger';
type TabType = 'list' | 'profile';
@@ -88,7 +89,7 @@ const GuestProfilePage: React.FC = () => {
const response = await guestProfileService.getAllTags();
setAllTags(response.data.tags);
} catch (error: any) {
console.error('Failed to load tags:', error);
logger.error('Failed to load tags', error);
}
};
@@ -97,7 +98,7 @@ const GuestProfilePage: React.FC = () => {
const response = await guestProfileService.getAllSegments();
setAllSegments(response.data.segments);
} catch (error: any) {
console.error('Failed to load segments:', error);
logger.error('Failed to load segments', error);
}
};

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { ArrowLeft, Save } from 'lucide-react';
import invoiceService, { Invoice, UpdateInvoiceData } from '../../features/payments/services/invoiceService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { validateInvoiceId } from '../../shared/utils/routeValidation';
import { logger } from '../../shared/utils/logger';
const InvoiceEditPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -14,6 +16,7 @@ const InvoiceEditPage: React.FC = () => {
const [invoice, setInvoice] = useState<Invoice | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const [formData, setFormData] = useState<UpdateInvoiceData>({
company_name: '',
company_address: '',
@@ -29,18 +32,44 @@ const InvoiceEditPage: React.FC = () => {
discount_amount: 0,
});
// Validate route parameter
useEffect(() => {
if (id) {
const invoiceId = Number(id);
if (!isNaN(invoiceId) && invoiceId > 0) {
fetchInvoice(invoiceId);
} else {
toast.error('Invalid invoice ID');
navigate(`${basePath}/invoices`);
}
const validatedId = validateInvoiceId(id);
if (!validatedId) {
toast.error('Invalid invoice ID');
navigate(`${basePath}/invoices`);
return;
}
}, [id, navigate]);
}, [id, navigate, basePath]);
// Fetch invoice with request cancellation
useEffect(() => {
const validatedId = validateInvoiceId(id);
if (!validatedId) {
return;
}
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchInvoice(validatedId);
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [id, basePath]);
/**
* Fetches invoice details with proper error handling and request cancellation
*/
const fetchInvoice = async (invoiceId: number) => {
try {
setLoading(true);
@@ -68,6 +97,11 @@ const InvoiceEditPage: React.FC = () => {
navigate(`${basePath}/invoices`);
}
} catch (error: any) {
// Handle AbortError silently
if (error.name === 'AbortError') {
return;
}
logger.error('Error fetching invoice', error);
toast.error(error.response?.data?.message || 'Unable to load invoice');
navigate(`${basePath}/invoices`);
} finally {
@@ -79,9 +113,14 @@ const InvoiceEditPage: React.FC = () => {
e.preventDefault();
if (!id || !invoice) return;
const invoiceId = validateInvoiceId(id);
if (!invoiceId) {
toast.error('Invalid invoice ID');
return;
}
try {
setSaving(true);
const invoiceId = Number(id);
// Remove company_logo_url from formData to prevent it from being updated
// It should always use the admin settings
const { company_logo_url, ...updateData } = formData as any;

View File

@@ -19,6 +19,7 @@ import EmptyState from '../../shared/components/EmptyState';
import ConfirmationDialog from '../../shared/components/ConfirmationDialog';
import loyaltyService, { LoyaltyTier, LoyaltyReward } from '../../features/loyalty/services/loyaltyService';
import Pagination from '../../shared/components/Pagination';
import { logger } from '../../shared/utils/logger';
type Tab = 'users' | 'tiers' | 'rewards';
@@ -116,7 +117,7 @@ const LoyaltyManagementPage: React.FC = () => {
const response = await loyaltyService.getProgramStatus();
setProgramEnabled(response.data.enabled);
} catch (error: any) {
console.error('Failed to load program status:', error);
logger.error('Failed to load program status', error);
}
};

View File

@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { logger } from '../../shared/utils/logger';
const PackageManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -85,9 +86,9 @@ const PackageManagementPage: React.FC = () => {
if (allUniqueRoomTypes.size > 0) {
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
}
} catch (err) {
console.error('Failed to fetch room types:', err);
}
} catch (err) {
logger.error('Failed to fetch room types', err);
}
};
const fetchServices = async () => {
@@ -96,9 +97,9 @@ const PackageManagementPage: React.FC = () => {
if (response.data && response.data.services) {
setServices(response.data.services);
}
} catch (err) {
console.error('Failed to fetch services:', err);
}
} catch (err) {
logger.error('Failed to fetch services', err);
}
};
const fetchPackages = async () => {

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { Search } from 'lucide-react';
import paymentService from '../../features/payments/services/paymentService';
import type { Payment } from '../../features/payments/services/paymentService';
@@ -8,6 +8,9 @@ import Pagination from '../../shared/components/Pagination';
import ExportButton from '../../shared/components/ExportButton';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { formatDate } from '../../shared/utils/format';
import { logger } from '../../shared/utils/logger';
import { getPaymentStatusColor, getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
import { PAYMENT_METHOD, PAYMENT_STATUS } from '../../shared/constants/bookingConstants';
const PaymentManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -23,13 +26,29 @@ const PaymentManagementPage: React.FC = () => {
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchPayments();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [filters, currentPage]);
const fetchPayments = async () => {
@@ -68,6 +87,11 @@ const PaymentManagementPage: React.FC = () => {
paymentsList = paymentsList.filter((p) => p.payment_method === filters.method);
}
// Filter by payment status if needed
if (filters.status) {
paymentsList = paymentsList.filter((p) => p.payment_status === filters.status);
}
if (filters.from) {
const fromDate = new Date(filters.from);
paymentsList = paymentsList.filter((p) => {
@@ -98,6 +122,11 @@ const PaymentManagementPage: React.FC = () => {
}
}
} catch (error: any) {
// Handle AbortError silently
if (error.name === 'AbortError') {
return;
}
logger.error('Error fetching payments', error);
toast.error(error.response?.data?.message || 'Unable to load payments list');
} finally {
setLoading(false);
@@ -147,7 +176,7 @@ const PaymentManagementPage: React.FC = () => {
const getPaymentStatusBadge = (status: string) => {
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
completed: {
[PAYMENT_STATUS.PAID]: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: '✅ Paid',
@@ -165,12 +194,18 @@ const PaymentManagementPage: React.FC = () => {
label: '❌ Failed',
border: 'border-rose-200'
},
refunded: {
[PAYMENT_STATUS.REFUNDED]: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: '💰 Refunded',
border: 'border-slate-200'
},
completed: { // Legacy support
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: '✅ Paid',
border: 'border-emerald-200'
},
};
const config = statusConfig[status] || statusConfig.pending;
return (
@@ -351,15 +386,15 @@ const PaymentManagementPage: React.FC = () => {
<h3 className="text-lg font-semibold mb-2 text-amber-100">Total Revenue</h3>
<p className="text-4xl font-bold">
{formatCurrency(payments
.filter(p => p.payment_status === 'completed')
.filter(p => p.payment_status === PAYMENT_STATUS.PAID || p.payment_status === 'completed')
.reduce((sum, p) => sum + p.amount, 0))}
</p>
<p className="text-sm mt-3 text-amber-100/90">
Total {payments.filter(p => p.payment_status === 'completed').length} paid transaction{payments.filter(p => p.payment_status === 'completed').length !== 1 ? 's' : ''}
Total {payments.filter(p => p.payment_status === PAYMENT_STATUS.PAID || p.payment_status === 'completed').length} paid transaction{payments.filter(p => p.payment_status === PAYMENT_STATUS.PAID || p.payment_status === 'completed').length !== 1 ? 's' : ''}
</p>
</div>
<div className="bg-white/20 backdrop-blur-sm p-6 rounded-2xl">
<div className="text-5xl font-bold text-white/80">{payments.filter(p => p.payment_status === 'completed').length}</div>
<div className="text-5xl font-bold text-white/80">{payments.filter(p => p.payment_status === PAYMENT_STATUS.PAID || p.payment_status === 'completed').length}</div>
</div>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { logger } from '../../shared/utils/logger';
const RatePlanManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -81,9 +82,9 @@ const RatePlanManagementPage: React.FC = () => {
if (allUniqueRoomTypes.size > 0) {
setRoomTypes(Array.from(allUniqueRoomTypes.values()));
}
} catch (err) {
console.error('Failed to fetch room types:', err);
}
} catch (err) {
logger.error('Failed to fetch room types', err);
}
};
const fetchRatePlans = async () => {

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { Plus, Search, Edit, Trash2, X, Upload, Image as ImageIcon, Check } from 'lucide-react';
import roomService, { Room } from '../../features/rooms/services/roomService';
import { toast } from 'react-toastify';
@@ -6,6 +6,7 @@ import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import apiClient from '../../shared/services/apiClient';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { logger } from '../../shared/utils/logger';
const RoomManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -42,6 +43,7 @@ const RoomManagementPage: React.FC = () => {
const [roomTypes, setRoomTypes] = useState<Array<{ id: number; name: string }>>([]);
const [uploadingImages, setUploadingImages] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
setCurrentPage(1);
@@ -49,8 +51,23 @@ const RoomManagementPage: React.FC = () => {
}, [filters]);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchRooms();
fetchAvailableAmenities();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [filters, currentPage]);
@@ -84,8 +101,7 @@ const RoomManagementPage: React.FC = () => {
}
});
} catch (err) {
console.error(`Failed to fetch page ${page}:`, err);
logger.error(`Failed to fetch page ${page}`, err);
}
}
}
@@ -99,7 +115,7 @@ const RoomManagementPage: React.FC = () => {
}
}
} catch (err) {
console.error('Failed to fetch room types:', err);
logger.error('Failed to fetch room types', err);
}
};
fetchAllRoomTypes();
@@ -112,7 +128,7 @@ const RoomManagementPage: React.FC = () => {
setAvailableAmenities(response.data.amenities);
}
} catch (error) {
console.error('Failed to fetch amenities:', error);
logger.error('Failed to fetch amenities', error);
}
};
@@ -185,6 +201,11 @@ const RoomManagementPage: React.FC = () => {
}
}
} catch (error: any) {
// Handle AbortError silently
if (error.name === 'AbortError') {
return;
}
logger.error('Error fetching rooms', error);
toast.error(error.response?.data?.message || 'Unable to load rooms list');
} finally {
setLoading(false);
@@ -216,7 +237,7 @@ const RoomManagementPage: 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 {
@@ -376,7 +397,7 @@ const RoomManagementPage: React.FC = () => {
setEditingRoom(roomData);
} catch (error) {
console.error('Failed to fetch full room details:', error);
logger.error('Failed to fetch full room details', error);
}
};
@@ -525,7 +546,7 @@ const RoomManagementPage: 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

@@ -19,6 +19,7 @@ import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { formatDate } from '../../shared/utils/format';
import { logger } from '../../shared/utils/logger';
type SecurityTab = 'events' | 'stats' | 'ip-whitelist' | 'ip-blacklist' | 'oauth' | 'gdpr' | 'scan';
@@ -1420,7 +1421,7 @@ const SecurityScanTab: React.FC = () => {
setScanResults(results);
toast.success(`Security scan completed: ${results.total_issues || 0} issues found`);
} catch (error: any) {
console.error('Security scan error:', error);
logger.error('Security scan error', error);
const errorMessage = error.response?.data?.detail || error.response?.data?.message || error.message || 'Failed to run security scan';
toast.error(errorMessage);
} finally {

View File

@@ -46,6 +46,7 @@ const UserManagementPage: React.FC = () => {
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const abortControllerRef = useRef<AbortController | null>(null);
const [formData, setFormData] = useState({
full_name: '',
@@ -61,7 +62,22 @@ const UserManagementPage: React.FC = () => {
}, [filters]);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchUsers();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [filters, currentPage]);
const fetchUsers = async () => {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import {
useParams,
useNavigate,
@@ -15,12 +15,7 @@ import {
Phone,
FileText,
Building2,
CheckCircle,
AlertCircle,
Clock,
XCircle,
DoorOpen,
DoorClosed,
} from 'lucide-react';
import { toast } from 'react-toastify';
import {
@@ -34,71 +29,99 @@ import PaymentStatusBadge from '../../shared/components/PaymentStatusBadge';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { parseDateLocal } from '../../shared/utils/format';
import CancelBookingModal from '../../features/bookings/components/CancelBookingModal';
import { validateBookingId } from '../../shared/utils/routeValidation';
import { validateAndHandleBookingOwnership } from '../../shared/utils/ownershipValidation';
import { getBookingStatusConfig, canCancelBooking, calculateNights } from '../../shared/utils/bookingUtils';
const BookingDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
const { isAuthenticated, userInfo } = useAuthStore();
const { openModal } = useAuthModal();
const { formatCurrency } = useFormatCurrency();
const abortControllerRef = useRef<AbortController | null>(null);
const [booking, setBooking] = useState<Booking | null>(
null
);
const [booking, setBooking] = useState<Booking | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCancelModal, setShowCancelModal] = useState(false);
// Validate route parameter
useEffect(() => {
const bookingId = validateBookingId(id);
if (!bookingId) {
setError('Invalid booking ID');
setLoading(false);
toast.error('Invalid booking ID');
navigate('/bookings');
return;
}
}, [id, navigate]);
// Fetch booking details with ownership validation
useEffect(() => {
if (!isAuthenticated) {
toast.error(
'Please login to view booking details'
);
openModal('login');
return;
}
}, [isAuthenticated, openModal, id]);
useEffect(() => {
if (id && isAuthenticated) {
fetchBookingDetails(Number(id));
const bookingId = validateBookingId(id);
if (!bookingId) {
return;
}
}, [id, isAuthenticated]);
const fetchBookingDetails = async (bookingId: number) => {
try {
setLoading(true);
setError(null);
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const response = await getBookingById(bookingId);
// Create new abort controller
abortControllerRef.current = new AbortController();
if (
response.success &&
response.data?.booking
) {
const bookingData = response.data.booking;
console.log('Booking data:', bookingData);
console.log('Service usages:', (bookingData as any).service_usages);
console.log('Total price:', bookingData.total_price);
setBooking(bookingData);
} else {
throw new Error(
'Unable to load booking information'
);
const fetchBookingDetails = async () => {
try {
setLoading(true);
setError(null);
const response = await getBookingById(bookingId);
if (response.success && response.data?.booking) {
const bookingData = response.data.booking;
// Validate ownership
if (!validateAndHandleBookingOwnership(bookingData, userInfo?.id, navigate)) {
setLoading(false);
return;
}
setBooking(bookingData);
} else {
throw new Error('Unable to load booking information');
}
} catch (err: any) {
// Don't show error if request was aborted
if (err.name === 'AbortError') {
return;
}
const message =
err.response?.data?.message ||
'Unable to load booking information';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
} catch (err: any) {
console.error('Error fetching booking:', err);
const message =
err.response?.data?.message ||
'Unable to load booking information';
setError(message);
toast.error(message);
} finally {
setLoading(false);
}
};
};
fetchBookingDetails();
// Cleanup: abort request on unmount or dependency change
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [id, isAuthenticated, userInfo?.id, navigate]);
const handleCancelBookingClick = () => {
if (!booking) {
@@ -116,7 +139,6 @@ const BookingDetailPage: React.FC = () => {
};
const formatDate = (dateString: string) => {
const date = parseDateLocal(dateString);
return date.toLocaleDateString('en-US', {
weekday: 'long',
@@ -128,17 +150,14 @@ const BookingDetailPage: React.FC = () => {
const formatPrice = (price: number) => formatCurrency(price);
const calculateNights = () => {
// Memoize nights calculation
const nights = useMemo(() => {
if (!booking) return 1;
const checkIn = parseDateLocal(booking.check_in_date);
const checkOut = parseDateLocal(booking.check_out_date);
const nights = Math.ceil((checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24));
return nights > 0 ? nights : 1;
};
return calculateNights(booking.check_in_date, booking.check_out_date);
}, [booking?.check_in_date, booking?.check_out_date]);
const calculateServicesTotal = () => {
// Memoize services total calculation
const servicesTotal = useMemo(() => {
if (!booking) return 0;
const serviceUsages = (booking as any).service_usages || (booking as any).services || [];
@@ -148,96 +167,28 @@ const BookingDetailPage: React.FC = () => {
}, 0);
}
return 0;
};
}, [booking]);
const getServiceUsages = () => {
// Memoize service usages
const serviceUsages = useMemo(() => {
if (!booking) return [];
const serviceUsages = (booking as any).service_usages || (booking as any).services || [];
return Array.isArray(serviceUsages) ? serviceUsages : [];
};
const usages = (booking as any).service_usages || (booking as any).services || [];
return Array.isArray(usages) ? usages : [];
}, [booking]);
const calculateRoomPricePerNight = () => {
// Memoize room price per night
const roomPricePerNight = useMemo(() => {
if (!booking) return 0;
const nights = calculateNights();
const servicesTotal = calculateServicesTotal();
console.log('Calculating room price:', {
total_price: booking.total_price,
servicesTotal,
nights,
roomTotal: booking.total_price - servicesTotal
});
const roomTotal = booking.total_price - servicesTotal;
return nights > 0 ? roomTotal / nights : roomTotal;
};
}, [booking?.total_price, servicesTotal, nights]);
const calculateRoomTotal = () => {
return calculateRoomPricePerNight() * calculateNights();
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return {
icon: Clock,
color: 'bg-yellow-100 text-yellow-800',
text: 'Pending confirmation',
};
case 'confirmed':
return {
icon: CheckCircle,
color: 'bg-green-100 text-green-800',
text: 'Confirmed',
};
case 'cancelled':
return {
icon: XCircle,
color: 'bg-red-100 text-red-800',
text: 'Cancelled',
};
case 'checked_in':
return {
icon: DoorOpen,
color: 'bg-blue-100 text-blue-800',
text: 'Checked in',
};
case 'checked_out':
return {
icon: DoorClosed,
color: 'bg-gray-100 text-gray-800',
text: 'Checked out',
};
default:
return {
icon: AlertCircle,
color: 'bg-gray-100 text-gray-800',
text: status,
};
}
};
const canCancelBooking = (booking: Booking) => {
const canCancel = booking.status === 'pending';
console.log('Can cancel booking?', {
status: booking.status,
canCancel,
bookingId: booking.id,
bookingNumber: booking.booking_number
});
return canCancel;
};
// Memoize room total
const roomTotal = useMemo(() => {
return roomPricePerNight * nights;
}, [roomPricePerNight, nights]);
if (loading) {
return <Loading fullScreen text="Loading..." />;
@@ -274,8 +225,9 @@ const BookingDetailPage: React.FC = () => {
const room = booking.room;
const roomType = room?.room_type;
const statusConfig = getStatusConfig(booking.status);
const statusConfig = getBookingStatusConfig(booking.status);
const StatusIcon = statusConfig.icon;
const bookingCanCancel = canCancelBooking(booking);
return (
<div className="min-h-screen bg-gray-50 py-8">
@@ -383,7 +335,7 @@ const BookingDetailPage: React.FC = () => {
Room Price
</p>
<p className="font-medium text-indigo-600">
{formatPrice(calculateRoomPricePerNight())}/night
{formatPrice(roomPricePerNight)}/night
</p>
</div>
</div>
@@ -552,25 +504,21 @@ const BookingDetailPage: React.FC = () => {
<div className="flex justify-between items-center">
<div>
<p className="text-sm font-medium text-gray-900">
Room ({calculateNights()} night{calculateNights() !== 1 ? 's' : ''})
Room ({nights} night{nights !== 1 ? 's' : ''})
</p>
<p className="text-xs text-gray-500">
{formatPrice(calculateRoomPricePerNight())} per night
{formatPrice(roomPricePerNight)} per night
</p>
</div>
<span className="text-base font-semibold text-gray-900">
{formatPrice(calculateRoomTotal())}
{formatPrice(roomTotal)}
</span>
</div>
{}
{(() => {
const services = getServiceUsages();
console.log('Services to display:', services);
if (services.length > 0) {
return (
<>
{services.map((serviceUsage: any, index: number) => (
{serviceUsages.length > 0 && (
<>
{serviceUsages.map((serviceUsage: any, index: number) => (
<div key={serviceUsage.id || index} className="flex justify-between items-center">
<div>
<p className="text-sm font-medium text-gray-900">
@@ -584,12 +532,9 @@ const BookingDetailPage: React.FC = () => {
{formatPrice(serviceUsage.total_price || (serviceUsage.unit_price || serviceUsage.price || 0) * (serviceUsage.quantity || 1))}
</span>
</div>
))}
</>
);
}
return null;
})()}
))}
</>
)}
{}
{(() => {
@@ -745,7 +690,7 @@ const BookingDetailPage: React.FC = () => {
Check-in time: 14:00 /
Check-out time: 12:00
</li>
{canCancelBooking(booking) && (
{bookingCanCancel && (
<li>
If you cancel the booking, 20% of
the total order value will be charged
@@ -772,7 +717,7 @@ const BookingDetailPage: React.FC = () => {
</Link>
)}
{canCancelBooking(booking) ? (
{bookingCanCancel ? (
<button
type="button"
onClick={(e) => {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
useParams,
useNavigate,
@@ -33,11 +33,19 @@ import Loading from '../../shared/components/Loading';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { parseDateLocal } from '../../shared/utils/format';
import DepositPaymentModal from '../../features/payments/components/DepositPaymentModal';
import { validateBookingId } from '../../shared/utils/routeValidation';
import { validateAndHandleBookingOwnership } from '../../shared/utils/ownershipValidation';
import { getBookingStatusConfig } from '../../shared/utils/bookingUtils';
import { getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
import { BOOKING_STATUS, PAYMENT_METHOD, PAYMENT_STATUS, PAYMENT_TYPE } from '../../shared/constants/bookingConstants';
import useAuthStore from '../../store/useAuthStore';
const BookingSuccessPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const { userInfo } = useAuthStore();
const abortControllerRef = useRef<AbortController | null>(null);
const [booking, setBooking] = useState<Booking | null>(
null
@@ -52,15 +60,50 @@ const BookingSuccessPage: React.FC = () => {
useState(false);
const [selectedFile, setSelectedFile] =
useState<File | null>(null);
useState<string | null>(null);
const [showDepositModal, setShowDepositModal] = useState(false);
// Validate route parameter
useEffect(() => {
if (id) {
fetchBookingDetails(Number(id));
const validatedId = validateBookingId(id);
if (!validatedId) {
setError('Invalid booking ID');
setLoading(false);
toast.error('Invalid booking ID');
navigate('/bookings');
return;
}
}, [id, navigate]);
// Fetch data with ownership validation and request cancellation
useEffect(() => {
const validatedId = validateBookingId(id);
if (!validatedId) {
return;
}
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchBookingDetails(validatedId);
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [id]);
/**
* Fetches booking details with ownership validation
* Handles automatic redirects for pending Stripe payments
* Auto-opens deposit modal for bookings requiring deposit
*/
const fetchBookingDetails = async (bookingId: number) => {
try {
setLoading(true);
@@ -73,29 +116,37 @@ const BookingSuccessPage: React.FC = () => {
response.data?.booking
) {
const bookingData = response.data.booking;
// Validate that the booking belongs to the current user
if (!validateAndHandleBookingOwnership(bookingData, userInfo?.id, navigate)) {
setLoading(false);
return; // Ownership validation handles redirect and error message
}
setBooking(bookingData);
if (bookingData.payment_method === 'stripe' && bookingData.payments) {
// If Stripe payment is pending, redirect to payment completion page
if (bookingData.payment_method === PAYMENT_METHOD.STRIPE && bookingData.payments) {
// Find any pending Stripe payment that needs completion
const pendingStripePayment = bookingData.payments.find(
(p: any) =>
p.payment_method === 'stripe' &&
p.payment_status === 'pending'
p.payment_method === PAYMENT_METHOD.STRIPE &&
p.payment_status === 'pending' // Payment record status (not booking payment_status)
);
if (pendingStripePayment) {
// Redirect to payment page to complete Stripe payment
navigate(`/payment/${bookingId}`, { replace: true });
return;
}
}
// Only auto-open deposit modal if booking is not cancelled
// Auto-open deposit payment modal for bookings requiring deposit
// Only show if booking is not cancelled and deposit hasn't been paid
if (
bookingData.requires_deposit &&
!bookingData.deposit_paid &&
bookingData.status !== 'cancelled'
bookingData.status !== BOOKING_STATUS.CANCELLED
) {
setShowDepositModal(true);
}
@@ -105,7 +156,11 @@ const BookingSuccessPage: React.FC = () => {
);
}
} catch (err: any) {
console.error('Error fetching booking:', err);
// Handle AbortError silently (request was cancelled)
if (err.name === 'AbortError') {
return;
}
const message =
err.response?.data?.message ||
'Unable to load booking information';
@@ -129,40 +184,6 @@ const BookingSuccessPage: React.FC = () => {
const formatPrice = (price: number) => formatCurrency(price);
const getStatusColor = (status: string) => {
switch (status) {
case 'confirmed':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'cancelled':
return 'bg-red-100 text-red-800';
case 'checked_in':
return 'bg-blue-100 text-blue-800';
case 'checked_out':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'confirmed':
return 'Confirmed';
case 'pending':
return 'Pending confirmation';
case 'cancelled':
return 'Cancelled';
case 'checked_in':
return 'Checked in';
case 'checked_out':
return 'Checked out';
default:
return status;
}
};
const copyBookingNumber = async () => {
if (!booking?.booking_number) return;
@@ -178,13 +199,17 @@ const BookingSuccessPage: React.FC = () => {
}
};
/**
* Handles receipt upload for bank transfer payment confirmation
* Updates local state optimistically while waiting for backend verification
*/
const handleUploadReceipt = async () => {
if (!selectedFile || !booking) return;
try {
setUploadingReceipt(true);
// Generate unique transaction ID for tracking
const transactionId =
`TXN-${booking.booking_number}-${Date.now()}`;
@@ -201,14 +226,16 @@ const BookingSuccessPage: React.FC = () => {
);
setReceiptUploaded(true);
// Optimistically update booking state
// Backend will verify and update actual status
setBooking((prev) =>
prev
? {
...prev,
payment_status: 'paid',
status: prev.status === 'pending'
? 'confirmed'
payment_status: PAYMENT_STATUS.PAID,
// Auto-confirm pending bookings after payment confirmation
status: prev.status === BOOKING_STATUS.PENDING
? BOOKING_STATUS.CONFIRMED
: prev.status
}
: null
@@ -220,7 +247,6 @@ const BookingSuccessPage: React.FC = () => {
);
}
} catch (err: any) {
console.error('Error uploading receipt:', err);
const message =
err.response?.data?.message ||
'Unable to send payment confirmation. ' +
@@ -274,42 +300,49 @@ const BookingSuccessPage: React.FC = () => {
const room = booking.room;
const roomType = room?.room_type;
// Check if payment is completed
/**
* Determines if payment is completed by checking multiple payment indicators
* Handles both full payment and deposit-only bookings
*/
const isPaymentCompleted = (() => {
// Check if booking is cancelled
if (booking.status === 'cancelled') {
// Cancelled bookings are never considered paid
if (booking.status === BOOKING_STATUS.CANCELLED) {
return false;
}
// Check payment_status
if (booking.payment_status === 'paid') {
// Check booking-level payment status
if (booking.payment_status === PAYMENT_STATUS.PAID) {
return true;
}
// Check payment_balance
// Check payment balance indicator
if (booking.payment_balance?.is_fully_paid === true) {
return true;
}
// For deposit bookings, check if deposit is paid
// For deposit bookings, check if deposit flag is set
if (booking.requires_deposit && booking.deposit_paid === true) {
return true;
}
// Check payments array
// Check individual payment records for more granular status
if (booking.payments && Array.isArray(booking.payments)) {
// Calculate total from completed payment records
const totalPaid = booking.payments
.filter((p: any) => p.payment_status === 'completed')
.filter((p: any) => p.payment_status === 'completed') // Payment record status
.reduce((sum: number, p: any) => sum + parseFloat(p.amount?.toString() || '0'), 0);
// For deposit bookings, check if deposit is paid
// For deposit bookings, check if deposit payment is completed
if (booking.requires_deposit) {
const depositPayment = booking.payments.find((p: any) => p.payment_type === 'deposit' && p.payment_status === 'completed');
const depositPayment = booking.payments.find(
(p: any) => p.payment_type === PAYMENT_TYPE.DEPOSIT && p.payment_status === 'completed'
);
if (depositPayment) {
return true;
}
} else {
// For full payment bookings, check if fully paid
// For full payment bookings, check if total paid meets or exceeds booking price
// Allow small floating point differences (0.01) for currency calculations
return totalPaid >= booking.total_price - 0.01;
}
}
@@ -317,7 +350,8 @@ const BookingSuccessPage: React.FC = () => {
return false;
})();
const isCancelled = booking.status === 'cancelled';
const isCancelled = booking.status === BOOKING_STATUS.CANCELLED;
const statusConfig = getBookingStatusConfig(booking.status);
return (
<div className="min-h-screen bg-gray-50 py-8">
@@ -424,11 +458,12 @@ const BookingSuccessPage: React.FC = () => {
{}
<div className="mt-4">
<span
className={`inline-block px-4 py-2
className={`inline-flex items-center gap-2 px-4 py-2
rounded-full text-sm font-medium
${getStatusColor(booking.status)}`}
${statusConfig.color}`}
>
{getStatusText(booking.status)}
<statusConfig.icon className="w-4 h-4" />
{statusConfig.text}
</span>
</div>
</div>
@@ -540,9 +575,9 @@ const BookingSuccessPage: React.FC = () => {
Payment Method
</p>
<p className="font-medium text-gray-900">
{booking.payment_method === 'cash'
{booking.payment_method === PAYMENT_METHOD.CASH
? '💵 Pay at hotel'
: '🏦 Bank transfer'}
: `🏦 ${getPaymentMethodLabel(booking.payment_method)}`}
</p>
</div>
@@ -622,7 +657,7 @@ const BookingSuccessPage: React.FC = () => {
)}
{}
{(booking.payment_method === 'cash' || (booking as any).payment_method === 'bank_transfer') && (
{(booking.payment_method === PAYMENT_METHOD.CASH || booking.payment_method === PAYMENT_METHOD.BANK_TRANSFER) && (
<div
className="bg-blue-50 border border-blue-200
rounded-lg p-6 mb-6"
@@ -835,7 +870,7 @@ const BookingSuccessPage: React.FC = () => {
If you cancel the booking, 20% of
the total order value will be charged
</li>
{(booking.payment_method === 'cash' || (booking as any).payment_method === 'bank_transfer') && (
{(booking.payment_method === PAYMENT_METHOD.CASH || booking.payment_method === PAYMENT_METHOD.BANK_TRANSFER) && (
<li>
Please transfer within 24 hours
to secure your room

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { confirmBoricaPayment } from '../../features/payments/services/paymentService';
import { toast } from 'react-toastify';
@@ -10,10 +10,19 @@ const BoricaReturnPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const bookingId = searchParams.get('bookingId');
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
const confirmPayment = async () => {
if (!bookingId) {
setError('Missing booking information');
@@ -54,6 +63,10 @@ const BoricaReturnPage: React.FC = () => {
toast.error(response.message || 'Payment confirmation failed');
}
} catch (err: any) {
// Don't show error if request was aborted
if (err.name === 'AbortError') {
return;
}
const errorMessage = err.response?.data?.message || err.message || 'Failed to confirm payment';
setError(errorMessage);
toast.error(errorMessage);
@@ -63,6 +76,13 @@ const BoricaReturnPage: React.FC = () => {
};
confirmPayment();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [bookingId, searchParams, navigate]);
if (loading) {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { AlertCircle, Plus, Eye, CheckCircle, Clock, XCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
@@ -7,6 +7,7 @@ import Pagination from '../../shared/components/Pagination';
import complaintService, { Complaint } from '../../features/guest_management/services/complaintService';
import bookingService from '../../features/bookings/services/bookingService';
import { formatDate } from '../../shared/utils/format';
import { logger } from '../../shared/utils/logger';
const ComplaintPage: React.FC = () => {
const [complaints, setComplaints] = useState<Complaint[]>([]);
@@ -19,10 +20,26 @@ const ComplaintPage: React.FC = () => {
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 10;
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchComplaints();
fetchBookings();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [currentPage]);
const fetchComplaints = async () => {
@@ -38,6 +55,10 @@ const ComplaintPage: React.FC = () => {
setTotalItems(response.data.pagination.total || 0);
}
} catch (error: any) {
// Don't show error if request was aborted
if (error.name === 'AbortError') {
return;
}
toast.error(error.response?.data?.message || 'Unable to load complaints');
} finally {
setLoading(false);
@@ -49,7 +70,12 @@ const ComplaintPage: React.FC = () => {
const response = await bookingService.getMyBookings();
setBookings(response.data.bookings || []);
} catch (error: any) {
// Silently fail - bookings are optional
// Don't show error if request was aborted
if (error.name === 'AbortError') {
return;
}
// Silently fail - bookings are optional, but log for debugging
logger.debug('Failed to fetch bookings for complaint form', error);
}
};

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
TrendingUp,
Hotel,
@@ -6,11 +6,15 @@ import {
Activity,
TrendingDown,
CreditCard,
Monitor,
Smartphone,
Tablet,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import dashboardService, { CustomerDashboardStats } from '../../features/analytics/services/dashboardService';
import paymentService from '../../features/payments/services/paymentService';
import type { Payment } from '../../features/payments/services/paymentService';
import sessionService, { UserSession } from '../../features/auth/services/sessionService';
import { toast } from 'react-toastify';
import { formatDate, formatRelativeTime } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
@@ -18,12 +22,16 @@ import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import CurrencyIcon from '../../shared/components/CurrencyIcon';
import { useAsync } from '../../shared/hooks/useAsync';
import { getPaymentStatusColor, getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
import { logger } from '../../shared/utils/logger';
const DashboardPage: React.FC = () => {
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const [recentPayments, setRecentPayments] = useState<Payment[]>([]);
const [loadingPayments, setLoadingPayments] = useState(false);
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
const fetchDashboardData = async (): Promise<CustomerDashboardStats> => {
const response = await dashboardService.getCustomerDashboardStats();
@@ -40,7 +48,17 @@ const DashboardPage: React.FC = () => {
}
);
const paymentsAbortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
// Cancel previous request if exists
if (paymentsAbortControllerRef.current) {
paymentsAbortControllerRef.current.abort();
}
// Create new abort controller
paymentsAbortControllerRef.current = new AbortController();
const fetchPayments = async () => {
try {
setLoadingPayments(true);
@@ -49,47 +67,80 @@ const DashboardPage: React.FC = () => {
setRecentPayments(response.data.payments);
}
} catch (err: any) {
console.error('Error fetching payments:', err);
if (err.name !== 'AbortError') {
logger.error('Error fetching payments', err);
}
} finally {
setLoadingPayments(false);
}
};
fetchPayments();
return () => {
if (paymentsAbortControllerRef.current) {
paymentsAbortControllerRef.current.abort();
}
};
}, []);
const sessionsAbortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
// Cancel previous request if exists
if (sessionsAbortControllerRef.current) {
sessionsAbortControllerRef.current.abort();
}
// Create new abort controller
sessionsAbortControllerRef.current = new AbortController();
const fetchSessions = async () => {
try {
setLoadingSessions(true);
const response = await sessionService.getMySessions();
if (response.success && response.data?.sessions) {
setSessions(response.data.sessions || []);
}
} catch (err: any) {
if (err.name !== 'AbortError') {
logger.error('Error fetching sessions', err);
}
} finally {
setLoadingSessions(false);
}
};
fetchSessions();
return () => {
if (sessionsAbortControllerRef.current) {
sessionsAbortControllerRef.current.abort();
}
};
}, []);
const handleRefresh = () => {
execute();
};
const getPaymentStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200';
case 'pending':
return 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200';
case 'failed':
return 'bg-gradient-to-r from-rose-50 to-red-50 text-rose-800 border-rose-200';
case 'refunded':
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
default:
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
}
const getDeviceIcon = (userAgent?: string) => {
if (!userAgent) return <Monitor className="w-4 h-4 text-slate-600" />;
if (userAgent.includes('Mobile')) return <Smartphone className="w-4 h-4 text-blue-600" />;
if (userAgent.includes('Tablet')) return <Tablet className="w-4 h-4 text-purple-600" />;
return <Monitor className="w-4 h-4 text-slate-600" />;
};
const getPaymentMethodLabel = (method: string) => {
switch (method) {
case 'stripe':
case 'credit_card':
return 'Card';
case 'paypal':
return 'PayPal';
case 'bank_transfer':
return 'Bank Transfer';
case 'cash':
return 'Cash';
default:
return method;
}
const getDeviceName = (userAgent?: string) => {
if (!userAgent) return 'Unknown Device';
// Try to extract browser/device info
if (userAgent.includes('Chrome')) return 'Chrome';
if (userAgent.includes('Firefox')) return 'Firefox';
if (userAgent.includes('Safari')) return 'Safari';
if (userAgent.includes('Edge')) return 'Edge';
if (userAgent.includes('Mobile')) return 'Mobile Device';
if (userAgent.includes('Tablet')) return 'Tablet';
return 'Web Browser';
};
if (loading) {
@@ -222,7 +273,7 @@ const DashboardPage: React.FC = () => {
</div>
{}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-5 md:gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-4 sm:gap-5 md:gap-6">
{}
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900 mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200 flex items-center gap-2">
@@ -378,6 +429,74 @@ const DashboardPage: React.FC = () => {
/>
)}
</div>
{}
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 animate-fade-in hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.4s' }}>
<div className="flex items-center justify-between mb-4 sm:mb-5 md:mb-6 pb-3 sm:pb-4 border-b border-slate-200">
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-slate-900 flex items-center gap-2">
<Monitor className="w-5 h-5 text-indigo-600" />
Active Sessions
</h2>
<button
onClick={() => navigate('/sessions')}
className="text-xs sm:text-sm text-blue-600 hover:text-blue-700 font-semibold hover:underline transition-colors"
>
Manage
</button>
</div>
{loadingSessions ? (
<div className="flex items-center justify-center py-8">
<Loading text="Loading sessions..." />
</div>
) : sessions && sessions.length > 0 ? (
<div className="space-y-3 sm:space-y-4">
{sessions.slice(0, 3).map((session) => (
<div
key={session.id}
className="flex items-center justify-between p-3 sm:p-4 bg-gradient-to-r from-slate-50 to-white rounded-lg sm:rounded-xl border border-slate-200 hover:from-indigo-50 hover:to-purple-50 hover:border-indigo-300 hover:shadow-md transition-all duration-200"
>
<div className="flex items-center space-x-3 sm:space-x-4 flex-1 min-w-0">
<div className="p-2 sm:p-3 bg-gradient-to-br from-indigo-100 to-indigo-200 rounded-lg sm:rounded-xl shadow-md flex-shrink-0">
{getDeviceIcon(session.user_agent)}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 text-sm sm:text-base truncate">
{getDeviceName(session.user_agent)}
</p>
<div className="flex items-center gap-1 sm:gap-2 mt-1 flex-wrap">
<p className="text-xs sm:text-sm text-slate-600">
{session.ip_address || 'Unknown IP'}
</p>
{session.last_activity && (
<span className="text-xs text-slate-400">
{formatRelativeTime(new Date(session.last_activity))}
</span>
)}
</div>
</div>
</div>
</div>
))}
{sessions.length > 3 && (
<button
onClick={() => navigate('/sessions')}
className="w-full text-xs sm:text-sm text-blue-600 hover:text-blue-700 font-semibold hover:underline transition-colors pt-2"
>
View all {sessions.length} sessions
</button>
)}
</div>
) : (
<EmptyState
title="No Active Sessions"
description="Your active sessions will appear here"
action={{
label: 'View Session Management',
onClick: () => navigate('/sessions')
}}
/>
)}
</div>
</div>
</div>
);

View File

@@ -1,10 +1,12 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
CheckCircle,
AlertCircle,
CreditCard,
ArrowLeft,
Shield,
Lock,
} from 'lucide-react';
import { toast } from 'react-toastify';
import { getBookingById, type Booking } from '../../features/bookings/services/bookingService';
@@ -16,11 +18,18 @@ import Loading from '../../shared/components/Loading';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { parseDateLocal } from '../../shared/utils/format';
import StripePaymentWrapper from '../../features/payments/components/StripePaymentWrapper';
import useAuthStore from '../../store/useAuthStore';
import { validateBookingId } from '../../shared/utils/routeValidation';
import { validateAndHandleBookingOwnership } from '../../shared/utils/ownershipValidation';
import { BOOKING_STATUS, PAYMENT_METHOD } from '../../shared/constants/bookingConstants';
import { logSecurityWarning } from '../../shared/utils/errorReporter';
const FullPaymentPage: React.FC = () => {
const { bookingId } = useParams<{ bookingId: string }>();
const navigate = useNavigate();
const { formatCurrency } = useFormatCurrency();
const { userInfo } = useAuthStore();
const abortControllerRef = useRef<AbortController | null>(null);
const [booking, setBooking] = useState<Booking | null>(null);
const [stripePayment, setStripePayment] = useState<Payment | null>(null);
@@ -28,10 +37,69 @@ const FullPaymentPage: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [paymentSuccess, setPaymentSuccess] = useState(false);
// SECURITY: Payment page security - prevent caching and verify HTTPS
useEffect(() => {
if (bookingId) {
fetchData(Number(bookingId));
// Set no-cache headers via meta tags (server should also set HTTP headers)
const metaTags = [
{ httpEquiv: 'Cache-Control', content: 'no-cache, no-store, must-revalidate' },
{ httpEquiv: 'Pragma', content: 'no-cache' },
{ httpEquiv: 'Expires', content: '0' },
];
metaTags.forEach(tag => {
let meta = document.querySelector(`meta[http-equiv="${tag.httpEquiv}"]`);
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('http-equiv', tag.httpEquiv);
document.head.appendChild(meta);
}
meta.setAttribute('content', tag.content);
});
// Verify HTTPS in production
if (import.meta.env.PROD && window.location.protocol !== 'https:') {
logSecurityWarning('Payment page should use HTTPS in production!', {
protocol: window.location.protocol,
url: window.location.href,
});
}
}, []);
// Validate route parameter
useEffect(() => {
const validatedId = validateBookingId(bookingId);
if (!validatedId) {
setError('Invalid booking ID');
setLoading(false);
toast.error('Invalid booking ID');
navigate('/bookings');
return;
}
}, [bookingId, navigate]);
// Fetch data with ownership validation
useEffect(() => {
const validatedId = validateBookingId(bookingId);
if (!validatedId) {
return;
}
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchData(validatedId);
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [bookingId]);
const fetchData = async (id: number) => {
@@ -39,61 +107,65 @@ const FullPaymentPage: React.FC = () => {
setLoading(true);
setError(null);
const bookingResponse = await getBookingById(id);
if (!bookingResponse.success || !bookingResponse.data?.booking) {
throw new Error('Booking not found');
}
const bookingData = bookingResponse.data.booking;
// Validate ownership
if (!validateAndHandleBookingOwnership(bookingData, userInfo?.id, navigate)) {
setLoading(false);
return;
}
setBooking(bookingData);
if (bookingData.status === 'confirmed' || bookingData.status === 'checked_in') {
// Check if already confirmed
if (bookingData.status === BOOKING_STATUS.CONFIRMED || bookingData.status === BOOKING_STATUS.CHECKED_IN) {
toast.success('Booking is already confirmed!');
navigate(`/bookings/${id}`);
return;
}
if (bookingData.payment_method !== 'stripe') {
// Check payment method
if (bookingData.payment_method !== PAYMENT_METHOD.STRIPE) {
toast.info('This booking does not use Stripe payment');
navigate(`/bookings/${id}`);
return;
}
// Fetch payments
const paymentsResponse = await getPaymentsByBookingId(id);
console.log('Payments response:', paymentsResponse);
if (paymentsResponse.success && paymentsResponse.data?.payments) {
const payments = paymentsResponse.data.payments;
console.log('Payments found:', payments);
// Find pending Stripe payment
const stripePaymentFound = payments.find(
(p: Payment) =>
(p.payment_method === 'stripe' || p.payment_method === 'credit_card') &&
(p.payment_method === PAYMENT_METHOD.STRIPE || p.payment_method === PAYMENT_METHOD.CREDIT_CARD) &&
p.payment_status === 'pending'
);
if (stripePaymentFound) {
console.log('Found pending Stripe payment:', stripePaymentFound);
setStripePayment(stripePaymentFound);
} else {
// Check for completed payment
const completedPayment = payments.find(
(p: Payment) =>
(p.payment_method === 'stripe' || p.payment_method === 'credit_card') &&
(p.payment_method === PAYMENT_METHOD.STRIPE || p.payment_method === PAYMENT_METHOD.CREDIT_CARD) &&
p.payment_status === 'completed'
);
if (completedPayment) {
console.log('Found completed Stripe payment:', completedPayment);
setStripePayment(completedPayment);
setPaymentSuccess(true);
if ((bookingData.status as string) === 'confirmed' || (bookingData.status as string) === 'checked_in') {
// Check if booking is already confirmed or checked in
const bookingStatus = bookingData.status as string;
if (bookingStatus === BOOKING_STATUS.CONFIRMED || bookingStatus === BOOKING_STATUS.CHECKED_IN) {
toast.info('Payment already completed. Booking is confirmed.');
setTimeout(() => {
navigate(`/bookings/${id}`);
@@ -101,23 +173,15 @@ const FullPaymentPage: React.FC = () => {
return;
}
} else {
console.warn('No Stripe payment found in payments array:', payments);
console.warn('Booking payment method:', bookingData.payment_method);
throw new Error('No Stripe payment record found for this booking. The payment may not have been created properly.');
}
}
} else {
console.warn('Payments response not successful or no payments data:', paymentsResponse);
// Fallback to payments from booking data
if (bookingData.payments && bookingData.payments.length > 0) {
console.log('Using payments from booking data:', bookingData.payments);
const stripePaymentFromBooking = bookingData.payments.find(
(p: any) =>
(p.payment_method === 'stripe' || p.payment_method === 'credit_card') &&
(p.payment_method === PAYMENT_METHOD.STRIPE || p.payment_method === PAYMENT_METHOD.CREDIT_CARD) &&
p.payment_status === 'pending'
);
@@ -127,13 +191,15 @@ const FullPaymentPage: React.FC = () => {
throw new Error('No pending Stripe payment found for this booking');
}
} else {
console.error('No payments found for booking. This might be a timing issue.');
throw new Error('Payment information not found. Please wait a moment and refresh, or contact support if the issue persists.');
}
}
} catch (err: any) {
console.error('Error fetching data:', err);
// Don't show error if request was aborted
if (err.name === 'AbortError') {
return;
}
const message =
err.response?.data?.message || err.message || 'Unable to load payment information';
setError(message);
@@ -180,27 +246,19 @@ const FullPaymentPage: React.FC = () => {
);
}
let paymentAmount = parseFloat(stripePayment.amount.toString());
const isPaymentCompleted = stripePayment.payment_status === 'completed';
console.log('Payment amount from payment record:', paymentAmount);
console.log('Booking total price:', booking?.total_price);
// Validate and correct payment amount if needed
if (paymentAmount > 999999.99 || (booking && Math.abs(paymentAmount - booking.total_price) > 0.01)) {
console.warn('Payment amount seems incorrect, using booking total price instead');
if (booking) {
paymentAmount = parseFloat(booking.total_price.toString());
console.log('Using booking total price:', paymentAmount);
}
}
// Check Stripe maximum
if (paymentAmount > 999999.99) {
const errorMsg = `Payment amount $${paymentAmount.toLocaleString()} exceeds Stripe's maximum. Please contact support.`;
console.error(errorMsg);
const errorMsg = `Payment amount exceeds Stripe's maximum. Please contact support.`;
setError(errorMsg);
return null;
}
@@ -425,6 +483,38 @@ const FullPaymentPage: React.FC = () => {
</p>
</div>
</div>
{/* PCI DSS Compliance Trust Badge */}
{!isPaymentCompleted && (
<div className="mt-6 pt-6 border-t border-[#d4af37]/20">
<div className="bg-gradient-to-r from-blue-900/20 to-indigo-900/20 border border-blue-500/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500/20 to-indigo-500/20 rounded-full flex items-center justify-center border border-blue-500/30">
<Shield className="w-5 h-5 text-blue-400" />
</div>
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold text-blue-300 mb-1 flex items-center gap-2">
<Lock className="w-4 h-4" />
Secure Payment Processing
</h4>
<p className="text-xs text-blue-400/80 font-light leading-relaxed mb-2">
Your payment is processed securely through Stripe, a PCI DSS Level 1 compliant payment processor. Your card details are encrypted and never stored on our servers.
</p>
<div className="flex flex-wrap items-center gap-2 mt-2">
<span className="text-xs px-2 py-1 bg-blue-500/20 border border-blue-500/30 rounded text-blue-300 font-light">
PCI DSS Compliant
</span>
<span className="text-xs px-2 py-1 bg-green-500/20 border border-green-500/30 rounded text-green-300 font-light">
SSL Encrypted
</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,169 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
import { CheckCircle, XCircle, AlertCircle, Loader2, Home, Shield } from 'lucide-react';
import { toast } from 'react-toastify';
import gdprService from '../../features/compliance/services/gdprService';
const GDPRDeletionConfirmPage: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [message, setMessage] = useState<string>('');
const requestId = searchParams.get('requestId');
const verificationToken = searchParams.get('token');
useEffect(() => {
const confirmDeletion = async () => {
// Validate required parameters
if (!requestId || !verificationToken) {
setStatus('error');
setMessage('Invalid confirmation link. Please check your email and try again.');
return;
}
try {
const requestIdNum = parseInt(requestId, 10);
if (isNaN(requestIdNum)) {
throw new Error('Invalid request ID');
}
// SECURITY/COMPLIANCE: Confirm deletion with verification token
// This ensures only the user who requested deletion can confirm it
await gdprService.confirmDataDeletion(requestIdNum, verificationToken);
setStatus('success');
setMessage('Your data deletion request has been confirmed. Your account and all associated data will be permanently deleted. You will be logged out shortly.');
// Logout user after successful deletion confirmation
setTimeout(() => {
// Clear local storage
localStorage.clear();
// Redirect to home page
navigate('/', { replace: true });
// Reload page to clear all state
window.location.reload();
}, 5000);
} catch (error: any) {
setStatus('error');
const errorMessage = error.response?.data?.message ||
'Unable to confirm deletion request. The link may have expired or already been used.';
setMessage(errorMessage);
toast.error(errorMessage);
}
};
confirmDeletion();
}, [requestId, verificationToken, navigate]);
if (status === 'loading') {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 flex items-center justify-center px-4">
<div className="text-center">
<Loader2 className="w-16 h-16 animate-spin text-blue-500 mx-auto mb-4" />
<p className="text-gray-600">Confirming deletion request...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 px-3 sm:px-4 md:px-6 lg:px-8 py-6 sm:py-8 md:py-10">
<div className="max-w-2xl mx-auto">
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-6 sm:p-8 md:p-10">
{/* Icon */}
<div className="flex justify-center mb-6">
{status === 'success' ? (
<div className="w-20 h-20 bg-gradient-to-br from-green-100 to-emerald-100 rounded-full flex items-center justify-center border-4 border-green-200">
<CheckCircle className="w-12 h-12 text-green-600" />
</div>
) : (
<div className="w-20 h-20 bg-gradient-to-br from-red-100 to-rose-100 rounded-full flex items-center justify-center border-4 border-red-200">
<XCircle className="w-12 h-12 text-red-600" />
</div>
)}
</div>
{/* Title */}
<h1 className="text-2xl sm:text-3xl font-bold text-center mb-4 text-slate-900">
{status === 'success' ? 'Deletion Confirmed' : 'Confirmation Failed'}
</h1>
{/* Message */}
<div className={`rounded-lg p-4 sm:p-6 mb-6 ${
status === 'success'
? 'bg-gradient-to-r from-green-50 to-emerald-50 border-2 border-green-200'
: 'bg-gradient-to-r from-red-50 to-rose-50 border-2 border-red-200'
}`}>
<div className="flex items-start gap-3">
{status === 'error' && (
<AlertCircle className="w-6 h-6 text-red-600 flex-shrink-0 mt-0.5" />
)}
<p className={`text-sm sm:text-base ${
status === 'success' ? 'text-green-800' : 'text-red-800'
}`}>
{message}
</p>
</div>
</div>
{/* Additional Information */}
{status === 'success' && (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-4 sm:p-6 mb-6">
<div className="flex items-start gap-3">
<Shield className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-semibold text-blue-900 mb-2">What happens next?</h3>
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
<li>Your account and all personal data will be permanently deleted</li>
<li>Financial records will be retained as required by law (7 years)</li>
<li>You will be automatically logged out</li>
<li>You will receive a confirmation email once deletion is complete</li>
</ul>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
{status === 'error' && (
<>
<Link
to="/gdpr"
className="px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl font-semibold hover:from-blue-700 hover:to-blue-800 shadow-lg hover:shadow-xl transition-all duration-200 text-center"
>
Go to GDPR Page
</Link>
<Link
to="/"
className="px-6 py-3 bg-gradient-to-r from-slate-600 to-slate-700 text-white rounded-xl font-semibold hover:from-slate-700 hover:to-slate-800 shadow-lg hover:shadow-xl transition-all duration-200 text-center flex items-center justify-center gap-2"
>
<Home className="w-5 h-5" />
<span>Go Home</span>
</Link>
</>
)}
{status === 'success' && (
<div className="text-center">
<p className="text-sm text-gray-600 mb-4">
You will be redirected to the home page in a few seconds...
</p>
<Link
to="/"
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-slate-600 to-slate-700 text-white rounded-xl font-semibold hover:from-slate-700 hover:to-slate-800 shadow-lg hover:shadow-xl transition-all duration-200"
>
<Home className="w-5 h-5" />
<span>Go Home Now</span>
</Link>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default GDPRDeletionConfirmPage;

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Download, Trash2, FileText, AlertCircle } from 'lucide-react';
import React, { useEffect, useState, useRef } from 'react';
import { Download, Trash2, AlertCircle, Info } from 'lucide-react';
import gdprService, { GDPRRequest } from '../../features/compliance/services/gdprService';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
@@ -9,9 +9,25 @@ const GDPRPage: React.FC = () => {
const [requests, setRequests] = useState<GDPRRequest[]>([]);
const [loading, setLoading] = useState(true);
const [requesting, setRequesting] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchRequests();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
const fetchRequests = async () => {
@@ -20,6 +36,10 @@ const GDPRPage: React.FC = () => {
const response = await gdprService.getMyRequests();
setRequests(response.data.requests || []);
} catch (error: any) {
// Don't show error if request was aborted
if (error.name === 'AbortError') {
return;
}
toast.error(error.response?.data?.message || 'Unable to load GDPR requests');
} finally {
setLoading(false);
@@ -47,7 +67,7 @@ const GDPRPage: React.FC = () => {
try {
setRequesting(true);
const response = await gdprService.requestDataDeletion();
await gdprService.requestDataDeletion();
toast.success('Deletion request created. Please check your email to confirm.');
fetchRequests();
} catch (error: any) {
@@ -64,13 +84,18 @@ const GDPRPage: React.FC = () => {
}
try {
// SECURITY/COMPLIANCE: Secure download of GDPR data export
// Export is downloaded as blob (binary data) for security
// Filename indicates JSON format (machine-readable as required by GDPR Article 20)
const blob = await gdprService.downloadExport(request.id, request.verification_token);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// GDPR Article 20: Data must be in machine-readable format (JSON)
a.download = `user_data_export_${request.id}.json`;
document.body.appendChild(a);
a.click();
// SECURITY: Revoke object URL immediately after use
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Export downloaded');
@@ -130,9 +155,12 @@ const GDPRPage: React.FC = () => {
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-xs sm:text-sm text-red-800 font-semibold mb-1">Warning: This action cannot be undone</p>
<p className="text-xs sm:text-sm text-red-700">
<p className="text-xs sm:text-sm text-red-700 mb-2">
Requesting data deletion will permanently remove your account and all associated data including bookings, payments, and personal information. This action is irreversible.
</p>
<p className="text-xs sm:text-sm text-red-700 font-medium">
📧 <strong>Email Confirmation Required:</strong> After submitting your deletion request, you will receive an email with a confirmation link. You must click the link in the email to confirm the deletion. The link will expire after 24 hours for security reasons.
</p>
</div>
</div>
</div>
@@ -147,6 +175,49 @@ const GDPRPage: React.FC = () => {
</div>
</div>
{/* Data Retention Information */}
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-blue-200/60 p-4 sm:p-5 md:p-6 lg:p-8 animate-fade-in hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>
<div className="flex flex-col sm:flex-row items-start gap-3 sm:gap-4 mb-3 sm:mb-4">
<Info className="w-6 h-6 sm:w-7 sm:h-7 text-blue-500 flex-shrink-0 mt-1" />
<div className="flex-1 min-w-0">
<h2 className="text-lg sm:text-xl md:text-2xl font-semibold mb-2 text-slate-900">Data Retention Policy</h2>
<p className="text-xs sm:text-sm md:text-base text-slate-600 mb-3 sm:mb-4">
Understanding how long we retain your data and why:
</p>
<div className="space-y-2 sm:space-y-3">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg sm:rounded-xl p-3 sm:p-4">
<p className="text-xs sm:text-sm font-semibold text-blue-900 mb-1">Personal Data</p>
<p className="text-xs sm:text-sm text-blue-800">
Deleted upon your request (GDPR Article 17 - Right to Erasure). You can request deletion at any time using the option above.
</p>
</div>
<div className="bg-gradient-to-r from-amber-50 to-yellow-50 border border-amber-200 rounded-lg sm:rounded-xl p-3 sm:p-4">
<p className="text-xs sm:text-sm font-semibold text-amber-900 mb-1">Financial Records</p>
<p className="text-xs sm:text-sm text-amber-800">
Retained for 7 years as required by law for accounting and tax purposes. This includes invoices, payment records, and transaction history.
</p>
</div>
<div className="bg-gradient-to-r from-slate-50 to-gray-50 border border-slate-200 rounded-lg sm:rounded-xl p-3 sm:p-4">
<p className="text-xs sm:text-sm font-semibold text-slate-900 mb-1">Booking Records</p>
<p className="text-xs sm:text-sm text-slate-700">
Retained for service history and customer support purposes. Personal identifiers can be anonymized upon request while maintaining service records.
</p>
</div>
<div className="bg-gradient-to-r from-purple-50 to-pink-50 border border-purple-200 rounded-lg sm:rounded-xl p-3 sm:p-4">
<p className="text-xs sm:text-sm font-semibold text-purple-900 mb-1">Security Logs</p>
<p className="text-xs sm:text-sm text-purple-800">
Retained for security and fraud prevention purposes. Logs are automatically purged after the retention period.
</p>
</div>
</div>
<p className="text-xs sm:text-sm text-slate-500 mt-3 sm:mt-4 italic">
Note: Some data may be retained longer if required by law, legal proceedings, or active disputes.
For specific questions about data retention, please contact our support team.
</p>
</div>
</div>
</div>
{/* Request History */}
{requests.length > 0 && (
<div className="bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-xl border border-slate-200/60 p-4 sm:p-5 md:p-6 lg:p-8 animate-fade-in hover:shadow-2xl transition-all duration-300" style={{ animationDelay: '0.2s' }}>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Users, Calendar, Building2, ArrowRight } from 'lucide-react';
import groupBookingService, { GroupBooking } from '../../features/bookings/services/groupBookingService';
import { toast } from 'react-toastify';
@@ -14,9 +14,25 @@ const GroupBookingPage: React.FC = () => {
const [groupBookings, setGroupBookings] = useState<GroupBooking[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchGroupBookings();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
const fetchGroupBookings = async () => {
@@ -25,6 +41,10 @@ const GroupBookingPage: React.FC = () => {
const response = await groupBookingService.getMyGroupBookings();
setGroupBookings(response.data.group_bookings);
} catch (error: any) {
// Don't show error if request was aborted
if (error.name === 'AbortError') {
return;
}
toast.error(error.response?.data?.message || 'Unable to load group bookings');
} finally {
setLoading(false);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { ArrowLeft, Download, FileText, CheckCircle, Clock, XCircle } from 'lucide-react';
import invoiceService, { Invoice } from '../../features/payments/services/invoiceService';
@@ -7,6 +7,7 @@ import Loading from '../../shared/components/Loading';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { formatDate } from '../../shared/utils/format';
import useAuthStore from '../../store/useAuthStore';
import { validateInvoiceId } from '../../shared/utils/routeValidation';
const InvoicePage: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -15,51 +16,58 @@ const InvoicePage: React.FC = () => {
const { userInfo } = useAuthStore();
const [invoice, setInvoice] = useState<Invoice | null>(null);
const [loading, setLoading] = useState(true);
const abortControllerRef = useRef<AbortController | null>(null);
// Validate route parameter and fetch invoice
useEffect(() => {
if (id) {
// Handle both string and number IDs, and check for invalid values like "undefined" or "null"
if (id === 'undefined' || id === 'null' || id === 'NaN' || id === '') {
// Don't show error toast for invalid URL parameters - just redirect
setLoading(false);
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
navigate('/admin/bookings?createInvoice=true');
} else {
navigate('/bookings');
}
return;
}
const invoiceId = Number(id);
// Validate that the ID is a valid number
if (!isNaN(invoiceId) && invoiceId > 0 && isFinite(invoiceId)) {
fetchInvoice(invoiceId);
} else {
// Invalid ID format - redirect without showing error toast
setLoading(false);
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
navigate('/admin/bookings?createInvoice=true');
} else {
navigate('/bookings');
}
}
} else {
// No ID provided - redirect without error message
const invoiceId = validateInvoiceId(id);
if (!invoiceId) {
setLoading(false);
// Redirect based on user role
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
navigate('/admin/bookings?createInvoice=true');
} else {
navigate('/bookings');
}
return;
}
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchInvoice(invoiceId);
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [id, navigate, userInfo?.role]);
const fetchInvoice = async (invoiceId: number, retryCount: number = 0) => {
try {
setLoading(true);
const response = await invoiceService.getInvoiceById(invoiceId);
if (response.status === 'success' && response.data?.invoice) {
setInvoice(response.data.invoice);
const invoiceData = response.data.invoice;
// Validate ownership for customer role
if (userInfo?.role === 'customer' && invoiceData.booking?.user_id !== userInfo.id) {
toast.error('You do not have permission to view this invoice');
navigate('/bookings');
setLoading(false);
return;
}
setInvoice(invoiceData);
setLoading(false);
} else {
// Invoice not found in response - retry if first attempt
@@ -72,6 +80,10 @@ const InvoicePage: React.FC = () => {
handleInvoiceNotFound();
}
} catch (error: any) {
// Don't show error if request was aborted
if (error.name === 'AbortError') {
return;
}
// Check if it's the "Invalid invoice ID" error from validation
const errorMessage = error.message || error.response?.data?.message || '';
if (errorMessage.includes('Invalid invoice ID') || errorMessage.includes('Invalid Invoice ID')) {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
Star,
Gift,
@@ -40,12 +40,36 @@ const LoyaltyPage: React.FC = () => {
const [anniversaryDate, setAnniversaryDate] = useState('');
const [showRedemptionModal, setShowRedemptionModal] = useState(false);
const [redemptionData, setRedemptionData] = useState<{ code: string; rewardName: string; pointsUsed: number } | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchLoyaltyStatus();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
if (activeTab === 'rewards') {
fetchRewards();
fetchRedemptions();
@@ -54,6 +78,13 @@ const LoyaltyPage: React.FC = () => {
} else if (activeTab === 'referrals') {
fetchReferrals();
}
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [activeTab]);
const fetchLoyaltyStatus = async () => {
@@ -93,6 +124,10 @@ const LoyaltyPage: React.FC = () => {
return;
}
// Don't show error if request was aborted
if (error.name === 'AbortError') {
return;
}
// Only show toast for actual errors (not disabled state)
toast.error(error.message || 'Failed to load loyalty status');
} finally {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
Calendar,
@@ -8,10 +8,6 @@ import {
Eye,
XCircle,
AlertCircle,
CheckCircle,
Clock,
DoorOpen,
DoorClosed,
Search,
Filter,
} from 'lucide-react';
@@ -22,55 +18,62 @@ import {
} from '../../features/bookings/services/bookingService';
import CancelBookingModal from '../../features/bookings/components/CancelBookingModal';
import useAuthStore from '../../store/useAuthStore';
import { useAuthModal } from '../../features/auth/contexts/AuthModalContext';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { parseDateLocal } from '../../shared/utils/format';
import { getBookingStatusConfig, canCancelBooking } from '../../shared/utils/bookingUtils';
const MyBookingsPage: React.FC = () => {
const { isAuthenticated } = useAuthStore();
const { openModal } = useAuthModal();
const { formatCurrency } = useFormatCurrency();
const [bookings, setBookings] = useState<Booking[]>([]);
const [filteredBookings, setFilteredBookings] =
useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCancelModal, setShowCancelModal] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] =
useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const abortControllerRef = useRef<AbortController | null>(null);
// Fetch bookings with request cancellation
useEffect(() => {
if (!isAuthenticated) {
toast.error('Please login to view your bookings');
openModal('login');
return;
}
}, [isAuthenticated, openModal]);
useEffect(() => {
if (isAuthenticated) {
fetchBookings();
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchBookings();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [isAuthenticated]);
useEffect(() => {
// Memoize filtered bookings to avoid recalculating on every render
const filteredBookings = useMemo(() => {
let filtered = [...bookings];
// Filter by status
if (statusFilter !== 'all') {
filtered = filtered.filter(
(b) => b.status === statusFilter
);
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
@@ -85,7 +88,7 @@ const MyBookingsPage: React.FC = () => {
);
}
setFilteredBookings(filtered);
return filtered;
}, [bookings, statusFilter, searchQuery]);
const fetchBookings = async () => {
@@ -95,18 +98,17 @@ const MyBookingsPage: React.FC = () => {
const response = await getMyBookings();
if (
response.success &&
response.data?.bookings
) {
if (response.success && response.data?.bookings) {
setBookings(response.data.bookings);
} else {
throw new Error(
'Unable to load bookings list'
);
throw new Error('Unable to load bookings list');
}
} catch (err: any) {
console.error('Error fetching bookings:', err);
// Don't show error if request was aborted
if (err.name === 'AbortError') {
return;
}
const message =
err.response?.data?.message ||
'Unable to load bookings list';
@@ -149,52 +151,6 @@ const MyBookingsPage: React.FC = () => {
const formatPrice = (price: number) => formatCurrency(price);
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return {
icon: Clock,
color: 'bg-yellow-100 text-yellow-800',
text: 'Pending confirmation',
};
case 'confirmed':
return {
icon: CheckCircle,
color: 'bg-green-100 text-green-800',
text: 'Confirmed',
};
case 'cancelled':
return {
icon: XCircle,
color: 'bg-red-100 text-red-800',
text: '❌ Canceled',
};
case 'checked_in':
return {
icon: DoorOpen,
color: 'bg-blue-100 text-blue-800',
text: 'Checked in',
};
case 'checked_out':
return {
icon: DoorClosed,
color: 'bg-gray-100 text-gray-800',
text: 'Checked out',
};
default:
return {
icon: AlertCircle,
color: 'bg-gray-100 text-gray-800',
text: status,
};
}
};
const canCancelBooking = (booking: Booking) => {
return booking.status === 'pending';
};
if (loading) {
return <Loading fullScreen text="Loading..." />;
}
@@ -352,9 +308,7 @@ const MyBookingsPage: React.FC = () => {
) : (
<div className="space-y-4">
{filteredBookings.map((booking) => {
const statusConfig = getStatusConfig(
booking.status
);
const statusConfig = getBookingStatusConfig(booking.status);
const StatusIcon = statusConfig.icon;
const room = booking.room;
const roomType = room?.room_type;

View File

@@ -1,19 +1,29 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { XCircle, ArrowLeft, Loader2 } from 'lucide-react';
import { cancelPayPalPayment } from '../../features/payments/services/paymentService';
import { toast } from 'react-toastify';
import { logger } from '../../shared/utils/logger';
const PayPalCancelPage: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const bookingId = searchParams.get('bookingId');
const [cancelling, setCancelling] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const handleCancel = async () => {
if (!bookingId) return;
if (!bookingId) return;
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
const handleCancel = async () => {
try {
setCancelling(true);
const response = await cancelPayPalPayment(Number(bookingId));
@@ -22,14 +32,24 @@ const PayPalCancelPage: React.FC = () => {
toast.info('Payment canceled. Your booking has been automatically cancelled.');
}
} catch (err: any) {
console.error('Error canceling payment:', err);
// Don't show error if request was aborted
if (err.name === 'AbortError') {
return;
}
logger.error('Error canceling payment', err);
} finally {
setCancelling(false);
}
};
handleCancel();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [bookingId]);
return (

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { capturePayPalPayment, cancelPayPalPayment } from '../../features/payments/services/paymentService';
import { toast } from 'react-toastify';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { logger } from '../../shared/utils/logger';
const PayPalReturnPage: React.FC = () => {
const [searchParams] = useSearchParams();
@@ -10,11 +11,20 @@ const PayPalReturnPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const orderId = searchParams.get('token');
const bookingId = searchParams.get('bookingId');
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
const capturePayment = async () => {
if (!orderId || !bookingId) {
setError('Missing payment information');
@@ -41,10 +51,14 @@ const PayPalReturnPage: React.FC = () => {
try {
await cancelPayPalPayment(Number(bookingId));
} catch (cancelErr) {
console.error('Error canceling payment after capture failure:', cancelErr);
logger.error('Error canceling payment after capture failure', cancelErr);
}
}
} catch (err: any) {
// Don't show error if request was aborted
if (err.name === 'AbortError') {
return;
}
const errorMessage = err.response?.data?.message || err.message || 'Failed to capture payment';
setError(errorMessage);
toast.error(errorMessage);
@@ -54,6 +68,13 @@ const PayPalReturnPage: React.FC = () => {
};
capturePayment();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [orderId, bookingId, navigate]);
if (loading) {

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
useParams,
useNavigate,
@@ -26,13 +26,18 @@ import { useAuthModal } from '../../features/auth/contexts/AuthModalContext';
import Loading from '../../shared/components/Loading';
import PaymentStatusBadge from '../../shared/components/PaymentStatusBadge';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { validateBookingId } from '../../shared/utils/routeValidation';
import { validateAndHandleBookingOwnership } from '../../shared/utils/ownershipValidation';
import { getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
import { PAYMENT_METHOD, PAYMENT_STATUS } from '../../shared/constants/bookingConstants';
const PaymentConfirmationPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated } = useAuthStore();
const { isAuthenticated, userInfo } = useAuthStore();
const { openModal } = useAuthModal();
const { formatCurrency } = useFormatCurrency();
const abortControllerRef = useRef<AbortController | null>(null);
const [booking, setBooking] = useState<Booking | null>(
null
@@ -41,9 +46,7 @@ const PaymentConfirmationPage: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadSuccess, setUploadSuccess] = useState(false);
const [selectedFile] =
useState<File | null>(null);
useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [copiedBookingNumber, setCopiedBookingNumber] =
useState(false);
@@ -56,12 +59,47 @@ const PaymentConfirmationPage: React.FC = () => {
}
}, [isAuthenticated, openModal]);
// Validate route parameter
useEffect(() => {
if (id && isAuthenticated) {
fetchBookingDetails(Number(id));
const validatedId = validateBookingId(id);
if (!validatedId) {
setError('Invalid booking ID');
setLoading(false);
toast.error('Invalid booking ID');
navigate('/bookings');
return;
}
}, [id, navigate]);
// Fetch data with ownership validation and request cancellation
useEffect(() => {
const validatedId = validateBookingId(id);
if (!validatedId || !isAuthenticated) {
return;
}
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchBookingDetails(validatedId);
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [id, isAuthenticated]);
/**
* Fetches booking details with ownership validation and payment method checks
* Only allows bank transfer payment confirmations on this page
*/
const fetchBookingDetails = async (bookingId: number) => {
try {
setLoading(true);
@@ -74,16 +112,23 @@ const PaymentConfirmationPage: React.FC = () => {
response.data?.booking
) {
const bookingData = response.data.booking;
// Validate that the booking belongs to the current user
if (!validateAndHandleBookingOwnership(bookingData, userInfo?.id, navigate)) {
setLoading(false);
return; // Ownership validation handles redirect and error message
}
if (bookingData.payment_status === 'paid') {
// Prevent confirmation if booking is already paid
if (bookingData.payment_status === PAYMENT_STATUS.PAID) {
toast.info('This booking has already been paid');
navigate(`/bookings/${bookingId}`);
return;
}
if (bookingData.payment_method === 'cash') {
// This page only handles bank transfer confirmations
// Cash payments are handled on-site, not through this confirmation flow
if (bookingData.payment_method === PAYMENT_METHOD.CASH) {
toast.info(
'This booking uses on-site payment method'
);
@@ -98,7 +143,11 @@ const PaymentConfirmationPage: React.FC = () => {
);
}
} catch (err: any) {
console.error('Error fetching booking:', err);
// Handle AbortError silently (request was cancelled)
if (err.name === 'AbortError') {
return;
}
const message =
err.response?.data?.message ||
'Unable to load booking information';
@@ -126,12 +175,17 @@ const PaymentConfirmationPage: React.FC = () => {
}
};
/**
* Handles payment confirmation by uploading receipt and notifying the system
* Creates a unique transaction ID and sends the receipt for manual verification
*/
const handleConfirmPayment = async () => {
if (!selectedFile || !booking) return;
try {
setUploading(true);
// Generate unique transaction ID for tracking
const transactionId =
`TXN-${booking.booking_number}-${Date.now()}`;
@@ -147,7 +201,7 @@ const PaymentConfirmationPage: React.FC = () => {
);
setUploadSuccess(true);
// Redirect to booking details after showing success message
setTimeout(() => {
navigate(`/bookings/${booking.id}`);
}, 2000);
@@ -158,7 +212,6 @@ const PaymentConfirmationPage: React.FC = () => {
);
}
} catch (err: any) {
console.error('Error confirming payment:', err);
const message =
err.response?.data?.message ||
'Unable to send payment confirmation';
@@ -399,7 +452,17 @@ const PaymentConfirmationPage: React.FC = () => {
id="receipt-upload"
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
}
}}
className="hidden"
/>
<span className="text-sm text-gray-600">
{selectedFile ? selectedFile.name : 'Click to upload receipt'}
</span>
</label>
{selectedFile && (
<button

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
@@ -20,11 +20,18 @@ import {
RefreshCw,
KeyRound,
LogOut,
Monitor
Monitor,
FileText,
Download,
Trash2,
Database,
Smartphone,
Tablet
} from 'lucide-react';
import { toast } from 'react-toastify';
import authService from '../../features/auth/services/authService';
import sessionService from '../../features/auth/services/sessionService';
import gdprService, { GDPRRequest } from '../../features/compliance/services/gdprService';
import useAuthStore from '../../store/useAuthStore';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
@@ -33,6 +40,7 @@ import { useGlobalLoading } from '../../shared/contexts/LoadingContext';
import { normalizeImageUrl } from '../../shared/utils/imageUtils';
import { formatDate } from '../../shared/utils/format';
import { UserSession } from '../../features/auth/services/sessionService';
import { useNavigate } from 'react-router-dom';
const profileValidationSchema = yup.object().shape({
name: yup
@@ -77,8 +85,10 @@ type PasswordFormData = yup.InferType<typeof passwordValidationSchema>;
const ProfilePage: React.FC = () => {
const { userInfo, setUser } = useAuthStore();
const { setLoading } = useGlobalLoading();
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'mfa' | 'sessions'>('profile');
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'mfa' | 'sessions' | 'gdpr'>('profile');
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [avatarError, setAvatarError] = useState<boolean>(false);
const [showPassword, setShowPassword] = useState<{
current: boolean;
new: boolean;
@@ -96,6 +106,9 @@ const ProfilePage: React.FC = () => {
const [mfaVerificationToken, setMfaVerificationToken] = useState<string>('');
const [showBackupCodes, setShowBackupCodes] = useState<string[] | null>(null);
const [showMfaSecret, setShowMfaSecret] = useState<boolean>(false);
const mfaAbortControllerRef = useRef<AbortController | null>(null);
const sessionsAbortControllerRef = useRef<AbortController | null>(null);
const gdprAbortControllerRef = useRef<AbortController | null>(null);
const fetchProfile = async () => {
@@ -158,8 +171,12 @@ const ProfilePage: React.FC = () => {
backup_codes_count: response.backup_codes_count || 0,
});
}
} catch (error) {
console.error('Failed to fetch MFA status:', error);
} catch (error: any) {
// Don't show error if request was aborted
if (error.name === 'AbortError') {
return;
}
// Error fetching MFA status - handled silently
}
};
@@ -175,8 +192,10 @@ const ProfilePage: React.FC = () => {
if ((data as any)?.avatar) {
setAvatarPreview(normalizeImageUrl((data as any).avatar));
setAvatarError(false); // Reset error state when new avatar is set
} else {
setAvatarPreview(null);
setAvatarError(false);
}
}
}, [profileData, userInfo, resetProfile]);
@@ -184,9 +203,23 @@ const ProfilePage: React.FC = () => {
useEffect(() => {
if (activeTab === 'mfa') {
// Cancel previous request if exists
if (mfaAbortControllerRef.current) {
mfaAbortControllerRef.current.abort();
}
// Create new abort controller
mfaAbortControllerRef.current = new AbortController();
fetchMFAStatus();
}
// Cleanup: abort request on unmount or tab change
return () => {
if (mfaAbortControllerRef.current) {
mfaAbortControllerRef.current.abort();
}
};
}, [activeTab]);
@@ -312,33 +345,49 @@ const ProfilePage: React.FC = () => {
setLoading(true, 'Uploading avatar...');
const response = await authService.uploadAvatar(file);
if (response.status === 'success' && response.data?.user) {
const updatedUser = response.data.user;
// Check for success response - backend returns {success: true, status: 'success', data: {...}}
if ((response.status === 'success' || response.success) && response.data) {
const updatedUser = response.data.user || response.data;
const avatarUrl = (response.data as any).full_url || (response.data as any).avatar_url || normalizeImageUrl((updatedUser as any)?.avatar);
const avatarUrl = (response.data as any).full_url || normalizeImageUrl((updatedUser as any).avatar);
setUser({
id: updatedUser.id,
name: (updatedUser as any).name || (updatedUser as any).full_name,
email: updatedUser.email,
phone: (updatedUser as any).phone || (updatedUser as any).phone_number,
avatar: avatarUrl,
role: updatedUser.role,
});
setAvatarPreview(avatarUrl || null);
toast.success('Avatar uploaded successfully!');
refetchProfile();
if (updatedUser) {
setUser({
id: updatedUser.id,
name: (updatedUser as any).name || (updatedUser as any).full_name,
email: updatedUser.email,
phone: (updatedUser as any).phone || (updatedUser as any).phone_number,
avatar: avatarUrl,
role: updatedUser.role,
});
setAvatarPreview(avatarUrl || null);
setAvatarError(false); // Reset error state on successful upload
toast.success(response.message || 'Avatar uploaded successfully!');
refetchProfile();
} else {
throw new Error('Invalid response format');
}
} else {
throw new Error(response.message || 'Upload failed');
}
} catch (error: any) {
const errorMessage =
error.response?.data?.detail ||
error.response?.data?.message ||
error.message ||
'Failed to upload avatar';
'Failed to upload avatar. Please try again.';
toast.error(errorMessage);
setAvatarPreview(userInfo?.avatar ? normalizeImageUrl(userInfo.avatar) : null);
// Revert to previous avatar or clear preview on error
if (userInfo?.avatar) {
setAvatarPreview(normalizeImageUrl(userInfo.avatar));
setAvatarError(true); // Set error state so default avatar shows if current one fails
} else {
setAvatarPreview(null);
}
} finally {
setLoading(false);
// Clear file input
e.target.value = '';
}
};
@@ -363,65 +412,79 @@ const ProfilePage: React.FC = () => {
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-gray-100 to-gray-50 py-4 sm:py-6 lg:py-8 px-3 sm:px-4 lg:px-8">
<div className="container mx-auto max-w-5xl">
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-slate-100 to-amber-50/30 py-4 sm:py-6 lg:py-8 px-3 sm:px-4 lg:px-8">
<div className="container mx-auto max-w-6xl">
{}
<div className="mb-6 sm:mb-8 animate-fade-in">
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-serif font-bold text-gray-900 tracking-tight mb-2">
Profile Settings
</h1>
<p className="text-sm sm:text-base text-gray-600 font-light tracking-wide">
Manage your account information and security preferences
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-[#d4af37] via-amber-400 to-[#d4af37] rounded-full"></div>
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-serif font-bold bg-gradient-to-r from-gray-900 via-amber-900/90 to-gray-900 bg-clip-text text-transparent tracking-tight">
Profile & Settings
</h1>
</div>
<p className="text-sm sm:text-base text-gray-600 font-light tracking-wide ml-0 sm:ml-16">
Manage your account information, security, and privacy preferences
</p>
</div>
{}
<div className="mb-4 sm:mb-6 border-b border-gray-200 overflow-x-auto">
<div className="mb-4 sm:mb-6 border-b border-[#d4af37]/20 overflow-x-auto bg-white/50 backdrop-blur-sm rounded-t-lg sm:rounded-t-xl px-4 sm:px-6">
<div className="flex space-x-4 sm:space-x-8 min-w-max">
<button
onClick={() => setActiveTab('profile')}
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-medium text-xs sm:text-sm transition-colors whitespace-nowrap ${
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
activeTab === 'profile'
? 'border-[#d4af37] text-[#d4af37]'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
}`}
>
<User className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2" />
<User className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'profile' ? 'text-[#d4af37]' : ''}`} />
Profile Information
</button>
<button
onClick={() => setActiveTab('password')}
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-medium text-xs sm:text-sm transition-colors whitespace-nowrap ${
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
activeTab === 'password'
? 'border-[#d4af37] text-[#d4af37]'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
}`}
>
<KeyRound className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2" />
<KeyRound className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'password' ? 'text-[#d4af37]' : ''}`} />
Change Password
</button>
<button
onClick={() => setActiveTab('mfa')}
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-medium text-xs sm:text-sm transition-colors whitespace-nowrap ${
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
activeTab === 'mfa'
? 'border-[#d4af37] text-[#d4af37]'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
}`}
>
<Shield className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2" />
<Shield className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'mfa' ? 'text-[#d4af37]' : ''}`} />
Two-Factor Authentication
</button>
<button
onClick={() => setActiveTab('sessions')}
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-medium text-xs sm:text-sm transition-colors whitespace-nowrap ${
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
activeTab === 'sessions'
? 'border-[#d4af37] text-[#d4af37]'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
}`}
>
<Monitor className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2" />
<Monitor className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'sessions' ? 'text-[#d4af37]' : ''}`} />
Active Sessions
</button>
<button
onClick={() => setActiveTab('gdpr')}
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
activeTab === 'gdpr'
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
}`}
>
<Database className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'gdpr' ? 'text-[#d4af37]' : ''}`} />
Data Privacy
</button>
</div>
</div>
@@ -432,11 +495,15 @@ const ProfilePage: React.FC = () => {
{}
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-6 pb-5 sm:pb-6 border-b border-gray-200">
<div className="relative">
{avatarPreview || userInfo?.avatar ? (
{(avatarPreview || userInfo?.avatar) && !avatarError ? (
<img
src={avatarPreview || normalizeImageUrl(userInfo?.avatar)}
alt="Profile"
className="w-20 h-20 sm:w-24 sm:h-24 rounded-full object-cover ring-4 ring-[#d4af37]/20 shadow-lg"
onError={() => {
// If image fails to load, show default avatar
setAvatarError(true);
}}
/>
) : (
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-full bg-gradient-to-br from-[#d4af37] to-[#c9a227] flex items-center justify-center ring-4 ring-[#d4af37]/20 shadow-lg">
@@ -992,6 +1059,11 @@ const ProfilePage: React.FC = () => {
{activeTab === 'sessions' && (
<SessionsTab />
)}
{/* GDPR Tab */}
{activeTab === 'gdpr' && (
<GDPRTab />
)}
</div>
</div>
);
@@ -1000,44 +1072,110 @@ const ProfilePage: React.FC = () => {
const SessionsTab: React.FC = () => {
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loading, setLoading] = useState(true);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchSessions();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
const fetchSessions = async () => {
try {
setLoading(true);
const response = await sessionService.getMySessions();
setSessions(response.data.sessions || []);
setSessions(response.data?.sessions || []);
} catch (error: any) {
// Don't show error if request was aborted
if (error.name === 'AbortError') {
return;
}
toast.error(error.response?.data?.message || 'Unable to load sessions');
} finally {
setLoading(false);
}
};
const getDeviceIcon = (userAgent?: string) => {
if (!userAgent) return <Monitor className="w-5 h-5 text-[#d4af37]" />;
if (userAgent.includes('Mobile')) return <Smartphone className="w-5 h-5 text-blue-500" />;
if (userAgent.includes('Tablet')) return <Tablet className="w-5 h-5 text-purple-500" />;
return <Monitor className="w-5 h-5 text-[#d4af37]" />;
};
const getDeviceName = (userAgent?: string) => {
if (!userAgent) return 'Unknown Device';
if (userAgent.includes('Chrome')) return 'Chrome Browser';
if (userAgent.includes('Firefox')) return 'Firefox Browser';
if (userAgent.includes('Safari')) return 'Safari Browser';
if (userAgent.includes('Edge')) return 'Edge Browser';
if (userAgent.includes('Mobile')) return 'Mobile Device';
if (userAgent.includes('Tablet')) return 'Tablet Device';
return 'Web Browser';
};
const handleRevoke = async (sessionId: number) => {
if (!confirm('Are you sure you want to revoke this session?')) return;
try {
await sessionService.revokeSession(sessionId);
toast.success('Session revoked');
fetchSessions();
const response = await sessionService.revokeSession(sessionId);
if (response.data?.logout_required) {
toast.warning('Your current session has been revoked. You will be logged out.');
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else {
toast.success('Session revoked successfully');
fetchSessions();
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to revoke session');
if (error.response?.status === 401) {
toast.warning('Your session has been revoked. You will be logged out.');
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else {
toast.error(error.response?.data?.message || 'Unable to revoke session');
}
}
};
const handleRevokeAll = async () => {
if (!confirm('Are you sure you want to revoke all other sessions?')) return;
if (!confirm('Are you sure you want to revoke all other sessions? This will also log you out.')) return;
try {
await sessionService.revokeAllSessions();
toast.success('All other sessions revoked');
fetchSessions();
const response = await sessionService.revokeAllSessions();
if (response.data?.logout_required) {
toast.warning(response.message || 'All sessions have been revoked. You will be logged out.');
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else {
toast.success(response.message || 'All sessions revoked');
fetchSessions();
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to revoke sessions');
if (error.response?.status === 401) {
toast.warning('All sessions have been revoked. You will be logged out.');
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else {
toast.error(error.response?.data?.message || 'Unable to revoke sessions');
}
}
};
@@ -1051,48 +1189,52 @@ const SessionsTab: React.FC = () => {
return (
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl animate-slide-up space-y-5 sm:space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl sm:text-2xl font-serif font-semibold text-gray-900 mb-2 flex items-center gap-2">
<Monitor className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37]" />
Active Sessions
</h2>
<p className="text-xs sm:text-sm text-gray-600 font-light">
Manage your active sessions across different devices
</p>
</div>
{sessions.length > 1 && (
<button
onClick={handleRevokeAll}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm"
>
Revoke All Others
</button>
)}
<div>
<h2 className="text-xl sm:text-2xl font-serif font-semibold text-gray-900 mb-2 flex items-center gap-2">
<Monitor className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37]" />
Active Sessions
</h2>
<p className="text-xs sm:text-sm text-gray-600 font-light">
Manage your active sessions across different devices
</p>
</div>
{sessions.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-500">No active sessions</p>
<Monitor className="w-12 h-12 mx-auto text-gray-400 mb-3" />
<p className="text-gray-500 text-sm font-light">No active sessions</p>
</div>
) : (
<div className="space-y-4">
{sessions.length > 1 && (
<div className="flex justify-end pb-2">
<button
onClick={handleRevokeAll}
className="px-4 sm:px-6 py-2 sm:py-2.5 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-sm font-semibold hover:from-red-700 hover:to-red-800 shadow-lg hover:shadow-xl transition-all duration-200 text-xs sm:text-sm flex items-center gap-2"
>
<LogOut className="w-4 h-4" />
Revoke All Other Sessions
</button>
</div>
)}
{sessions.map((session) => (
<div
key={session.id}
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
className="bg-gradient-to-r from-slate-50 to-white border border-[#d4af37]/20 rounded-sm p-4 sm:p-5 hover:shadow-lg hover:border-[#d4af37]/40 transition-all duration-300"
>
<div className="flex items-start justify-between">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
<Monitor className="w-5 h-5 text-gray-500 mt-1" />
<div className="flex-1">
<p className="font-semibold mb-1">
{session.user_agent || 'Unknown Device'}
<div className="p-2 sm:p-3 bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/10 rounded-sm flex-shrink-0">
{getDeviceIcon(session.user_agent)}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm sm:text-base text-gray-900 mb-2">
{getDeviceName(session.user_agent)}
</p>
<p className="text-gray-600 text-sm mb-1">
IP: {session.ip_address || 'Unknown'}
<p className="text-gray-600 text-xs sm:text-sm mb-1">
IP Address: <span className="font-mono">{session.ip_address || 'Unknown'}</span>
</p>
<p className="text-gray-500 text-xs">
<p className="text-gray-500 text-xs mb-1">
Last Activity: {formatDate(session.last_activity)}
</p>
<p className="text-gray-500 text-xs">
@@ -1102,7 +1244,7 @@ const SessionsTab: React.FC = () => {
</div>
<button
onClick={() => handleRevoke(session.id)}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 flex items-center gap-2 text-sm"
className="px-4 sm:px-5 py-2 sm:py-2.5 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-sm hover:from-red-700 hover:to-red-800 shadow-md hover:shadow-lg transition-all duration-200 flex items-center gap-2 text-xs sm:text-sm font-semibold flex-shrink-0"
>
<LogOut className="w-4 h-4" />
Revoke
@@ -1116,4 +1258,232 @@ const SessionsTab: React.FC = () => {
);
};
const GDPRTab: React.FC = () => {
const [requests, setRequests] = useState<GDPRRequest[]>([]);
const [loading, setLoading] = useState(true);
const [requesting, setRequesting] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchRequests();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
const fetchRequests = async () => {
try {
setLoading(true);
const response = await gdprService.getMyRequests();
setRequests(response.data?.requests || []);
} catch (error: any) {
// Don't show error if request was aborted
if (error.name === 'AbortError') {
return;
}
toast.error(error.response?.data?.message || 'Unable to load GDPR requests');
} finally {
setLoading(false);
}
};
const handleRequestExport = async () => {
if (!confirm('Request a copy of your personal data? You will receive an email when ready.')) return;
try {
setRequesting(true);
await gdprService.requestDataExport();
toast.success('Data export request created. You will receive an email when ready.');
fetchRequests();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to create export request');
} finally {
setRequesting(false);
}
};
const handleRequestDeletion = async () => {
if (!confirm('WARNING: This will permanently delete your account and all associated data. This action cannot be undone. Are you absolutely sure?')) return;
if (!confirm('This is your last chance. Are you 100% certain you want to delete all your data?')) return;
try {
setRequesting(true);
await gdprService.requestDataDeletion();
toast.success('Deletion request created. Please check your email to confirm.');
fetchRequests();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to create deletion request');
} finally {
setRequesting(false);
}
};
const handleDownload = async (request: GDPRRequest) => {
if (!request.verification_token) {
toast.error('Verification token not available');
return;
}
try {
const blob = await gdprService.downloadExport(request.id, request.verification_token);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `user_data_export_${request.id}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Export downloaded successfully');
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to download export');
}
};
if (loading) {
return (
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl">
<Loading text="Loading privacy data..." />
</div>
);
}
return (
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl animate-slide-up space-y-5 sm:space-y-6">
<div>
<h2 className="text-xl sm:text-2xl font-serif font-semibold text-gray-900 mb-2 flex items-center gap-2">
<Database className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37]" />
Data Privacy & GDPR Rights
</h2>
<p className="text-xs sm:text-sm text-gray-600 font-light">
Manage your personal data and exercise your privacy rights under GDPR
</p>
</div>
{/* Data Export */}
<div className="bg-gradient-to-r from-blue-50/50 to-indigo-50/50 border border-blue-200/60 rounded-sm p-4 sm:p-6 shadow-lg">
<div className="flex flex-col sm:flex-row items-start gap-4 mb-4">
<div className="p-3 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-sm flex-shrink-0">
<Download className="w-6 h-6 sm:w-7 sm:h-7 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-gray-900 mb-2">Export Your Data</h3>
<p className="text-xs sm:text-sm text-gray-600 font-light mb-4 leading-relaxed">
Request a copy of all your personal data stored in our system. You will receive an email with a download link when your data is ready.
</p>
<button
onClick={handleRequestExport}
disabled={requesting}
className="btn-luxury-primary flex items-center gap-2 px-6 py-3 rounded-sm font-semibold text-sm sm:text-base relative disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
<Download className="w-5 h-5 relative z-10" />
<span className="relative z-10">{requesting ? 'Requesting...' : 'Request Data Export'}</span>
</button>
</div>
</div>
</div>
{/* Data Deletion */}
<div className="bg-gradient-to-r from-red-50/50 to-rose-50/50 border-2 border-red-200/60 rounded-sm p-4 sm:p-6 shadow-lg">
<div className="flex flex-col sm:flex-row items-start gap-4 mb-4">
<div className="p-3 bg-gradient-to-br from-red-100 to-rose-100 rounded-sm flex-shrink-0">
<Trash2 className="w-6 h-6 sm:w-7 sm:h-7 text-red-600" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-red-900 mb-2">Delete Your Data</h3>
<div className="bg-white/80 border-2 border-red-200 rounded-sm p-3 sm:p-4 mb-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-xs sm:text-sm text-red-800 font-semibold mb-1">Warning: This action cannot be undone</p>
<p className="text-xs sm:text-sm text-red-700 font-light leading-relaxed">
Requesting data deletion will permanently remove your account and all associated data including bookings, payments, and personal information. This action is irreversible.
</p>
</div>
</div>
</div>
<button
onClick={handleRequestDeletion}
disabled={requesting}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-red-600 to-red-700 text-white rounded-sm font-semibold hover:from-red-700 hover:to-red-800 shadow-lg hover:shadow-xl transition-all duration-200 text-sm sm:text-base disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 className="w-5 h-5" />
{requesting ? 'Requesting...' : 'Request Data Deletion'}
</button>
</div>
</div>
</div>
{/* Request History */}
{requests.length > 0 && (
<div className="border-t border-gray-200 pt-5 sm:pt-6">
<h3 className="text-base sm:text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<FileText className="w-5 h-5 text-[#d4af37]" />
Request History
</h3>
<div className="space-y-3 sm:space-y-4">
{requests.map((request) => (
<div
key={request.id}
className="bg-gradient-to-r from-slate-50 to-white border border-[#d4af37]/20 rounded-sm p-4 sm:p-5 hover:shadow-md hover:border-[#d4af37]/40 transition-all duration-300"
>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-3 mb-2">
{request.request_type === 'data_export' ? (
<Download className="w-5 h-5 text-blue-500 flex-shrink-0" />
) : (
<Trash2 className="w-5 h-5 text-red-500 flex-shrink-0" />
)}
<h4 className="font-semibold text-sm sm:text-base text-gray-900">
{request.request_type === 'data_export' ? 'Data Export' : 'Data Deletion'}
</h4>
<span className={`px-3 py-1 rounded-full text-xs font-semibold border shadow-sm ${
request.status === 'completed'
? 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200'
: request.status === 'pending' || request.status === 'processing'
? 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200'
: 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200'
}`}>
{request.status.charAt(0).toUpperCase() + request.status.slice(1)}
</span>
</div>
<p className="text-xs sm:text-sm text-gray-600 font-light">
Created: {formatDate(request.created_at)}
{request.processed_at && ` • Processed: ${formatDate(request.processed_at)}`}
</p>
</div>
{request.request_type === 'data_export' && request.status === 'completed' && request.verification_token && (
<button
onClick={() => handleDownload(request)}
className="btn-luxury-primary flex items-center gap-2 px-4 sm:px-6 py-2 sm:py-2.5 rounded-sm font-semibold text-xs sm:text-sm relative flex-shrink-0"
>
<span className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-700"></span>
<Download className="w-4 h-4 relative z-10" />
<span className="relative z-10">Download</span>
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default ProfilePage;

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
Users,
@@ -20,6 +20,7 @@ import useAuthStore from '../../store/useAuthStore';
import LuxuryBookingModal from '../../features/bookings/components/LuxuryBookingModal';
import { useAuthModal } from '../../features/auth/contexts/AuthModalContext';
import { toast } from 'react-toastify';
import { logger } from '../../shared/utils/logger';
const RoomDetailPage: React.FC = () => {
const { room_number } = useParams<{ room_number: string }>();
@@ -33,11 +34,29 @@ const RoomDetailPage: React.FC = () => {
const [showBookingModal, setShowBookingModal] = useState(false);
const [nextAvailableDate, setNextAvailableDate] = useState<Date | null>(null);
const [bookedUntilDate, setBookedUntilDate] = useState<Date | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
if (room_number) {
fetchRoomDetail(room_number);
if (!room_number) {
return;
}
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchRoomDetail(room_number);
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [room_number]);
const fetchRoomDetail = async (roomNumber: string) => {
@@ -46,14 +65,16 @@ const RoomDetailPage: React.FC = () => {
setError(null);
const response = await getRoomByNumber(roomNumber);
if ((response as any).success || (response as any).status === 'success') {
if (response.success || response.status === 'success') {
if (response.data && response.data.room) {
const fetchedRoom = response.data.room;
if (fetchedRoom.room_number !== roomNumber) {
console.error(`Room number mismatch: requested ${roomNumber}, got ${fetchedRoom.room_number}`);
// Log room number mismatch for debugging
logger.error('Room number mismatch', new Error('Room data mismatch'), {
requested: roomNumber,
received: fetchedRoom.room_number
});
throw new Error(`Room data mismatch: expected room number ${roomNumber} but got ${fetchedRoom.room_number}`);
}
@@ -70,7 +91,11 @@ const RoomDetailPage: React.FC = () => {
throw new Error('Failed to fetch room details');
}
} catch (err: any) {
console.error('Error fetching room:', err);
// Don't show error if request was aborted
if (err.name === 'AbortError') {
return;
}
logger.error('Error fetching room', err);
const message =
err.response?.data?.message ||
err.message ||
@@ -135,7 +160,11 @@ const RoomDetailPage: React.FC = () => {
}
}
} catch (err: any) {
console.error('Error fetching booked dates:', err);
// Don't show error if request was aborted
if (err.name === 'AbortError') {
return;
}
logger.error('Error fetching booked dates', err);
// Don't show error to user, just don't show availability info
}
};

View File

@@ -7,6 +7,7 @@ import RoomCard from '../../features/rooms/components/RoomCard';
import RoomCardSkeleton from '../../features/rooms/components/RoomCardSkeleton';
import Pagination from '../../shared/components/Pagination';
import { ArrowLeft, Hotel, Filter, ChevronDown, ChevronUp } from 'lucide-react';
import { logger } from '../../shared/utils/logger';
const RoomListPage: React.FC = () => {
const [searchParams] = useSearchParams();
@@ -21,6 +22,7 @@ const RoomListPage: React.FC = () => {
limit: 10,
totalPages: 1,
});
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
@@ -33,6 +35,14 @@ const RoomListPage: React.FC = () => {
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
const fetchRooms = async () => {
setLoading(true);
setError(null);
@@ -57,7 +67,7 @@ const RoomListPage: React.FC = () => {
const response = await getRooms(params);
if (response.status === 'success' && response.data) {
if ((response.status === 'success' || response.success) && response.data) {
setRooms(response.data.rooms || []);
if (response.data.pagination) {
setPagination(response.data.pagination);
@@ -65,8 +75,12 @@ const RoomListPage: React.FC = () => {
} else {
throw new Error('Failed to fetch rooms');
}
} catch (err) {
console.error('Error fetching rooms:', err);
} catch (err: any) {
// Don't show error if request was aborted
if (err.name === 'AbortError') {
return;
}
logger.error('Error fetching rooms', err);
setError('Unable to load room list. Please try again.');
} finally {
setLoading(false);
@@ -74,6 +88,13 @@ const RoomListPage: React.FC = () => {
};
fetchRooms();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [searchParams]);
return (

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
useSearchParams,
useNavigate,
@@ -19,6 +19,7 @@ import { searchAvailableRooms } from '../../features/rooms/services/roomService'
import type { Room } from '../../features/rooms/services/roomService';
import { toast } from 'react-toastify';
import { parseDateLocal } from '../../shared/utils/format';
import { logger } from '../../shared/utils/logger';
const SearchResultsPage: React.FC = () => {
const [searchParams] = useSearchParams();
@@ -33,6 +34,7 @@ const SearchResultsPage: React.FC = () => {
limit: 12,
totalPages: 1,
});
const abortControllerRef = useRef<AbortController | null>(null);
const from = searchParams.get('from') || '';
@@ -54,8 +56,23 @@ const SearchResultsPage: React.FC = () => {
return;
}
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchAvailableRooms();
}, [from, to, type, capacity, page]);
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [from, to, type, capacity, page, navigate]);
const fetchAvailableRooms = async () => {
try {
@@ -95,7 +112,11 @@ const SearchResultsPage: React.FC = () => {
throw new Error('Unable to search rooms');
}
} catch (err: any) {
console.error('Error searching rooms:', err);
// Don't show error if request was aborted
if (err.name === 'AbortError') {
return;
}
logger.error('Error searching rooms', err);
const message =
err.response?.data?.message ||
'Unable to search available rooms';

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { LogOut, Monitor, Smartphone, Tablet } from 'lucide-react';
import sessionService, { UserSession } from '../../features/auth/services/sessionService';
import { toast } from 'react-toastify';
@@ -8,9 +8,25 @@ import { formatDate } from '../../shared/utils/format';
const SessionManagementPage: React.FC = () => {
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loading, setLoading] = useState(true);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchSessions();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
const fetchSessions = async () => {
@@ -19,6 +35,10 @@ const SessionManagementPage: React.FC = () => {
const response = await sessionService.getMySessions();
setSessions(response.data.sessions || []);
} catch (error: any) {
// Don't show error if request was aborted
if (error.name === 'AbortError') {
return;
}
toast.error(error.response?.data?.message || 'Unable to load sessions');
} finally {
setLoading(false);
@@ -29,23 +49,61 @@ const SessionManagementPage: React.FC = () => {
if (!confirm('Are you sure you want to revoke this session?')) return;
try {
await sessionService.revokeSession(sessionId);
toast.success('Session revoked');
fetchSessions();
const response = await sessionService.revokeSession(sessionId);
// Check if logout is required (current session was revoked)
// Response structure: {success: true, data: {logout_required: true}, message: '...'}
// sessionService returns response.data, so response is {success: true, data: {...}, message: '...'}
if (response.data?.logout_required) {
toast.warning('Your current session has been revoked. You will be logged out.');
// Give user a moment to see the message, then logout
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else {
toast.success(response.message || 'Session revoked');
fetchSessions();
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to revoke session');
// Check if it's an unauthorized error (session was revoked)
if (error.response?.status === 401) {
toast.warning('Your session has been revoked. You will be logged out.');
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else {
toast.error(error.response?.data?.message || 'Unable to revoke session');
}
}
};
const handleRevokeAll = async () => {
if (!confirm('Are you sure you want to revoke all other sessions?')) return;
if (!confirm('Are you sure you want to revoke all other sessions? This will also log you out.')) return;
try {
await sessionService.revokeAllSessions();
toast.success('All other sessions revoked');
fetchSessions();
const response = await sessionService.revokeAllSessions();
// Revoking all sessions always logs the user out
// Response structure: {success: true, data: {logout_required: true, revoked_count: X}, message: '...'}
// sessionService returns response.data, so response is {success: true, data: {...}, message: '...'}
if (response.data?.logout_required) {
toast.warning(response.message || 'All sessions have been revoked. You will be logged out.');
// Give user a moment to see the message, then logout
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else {
toast.success(response.message || 'All sessions revoked');
fetchSessions();
}
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to revoke sessions');
// Check if it's an unauthorized error (sessions were revoked)
if (error.response?.status === 401) {
toast.warning('All sessions have been revoked. You will be logged out.');
setTimeout(() => {
window.location.href = '/';
}, 2000);
} else {
toast.error(error.response?.data?.message || 'Unable to revoke sessions');
}
}
};

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import {
Hotel,
Wrench,
@@ -16,6 +16,7 @@ import {
} from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import { logger } from '../../shared/utils/logger';
import advancedRoomService, {
RoomStatusBoardItem,
} from '../../features/rooms/services/advancedRoomService';
@@ -63,7 +64,7 @@ const AdvancedRoomManagementPage: React.FC = () => {
setFloors(uniqueFloors);
}
} catch (error) {
console.error('Failed to fetch floors:', error);
logger.error('Failed to fetch floors', error);
}
};

View File

@@ -252,7 +252,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

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { Search, Eye, XCircle, CheckCircle, Loader2, FileText, Plus } from 'lucide-react';
import bookingService, { Booking } from '../../features/bookings/services/bookingService';
import invoiceService from '../../features/payments/services/invoiceService';
@@ -9,6 +9,8 @@ import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurren
import { parseDateLocal } from '../../shared/utils/format';
import { useNavigate } from 'react-router-dom';
import CreateBookingModal from '../../features/hotel_services/components/CreateBookingModal';
import { logger } from '../../shared/utils/logger';
import { validateBookingId } from '../../shared/utils/routeValidation';
const BookingManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -29,13 +31,29 @@ const BookingManagementPage: React.FC = () => {
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchBookings();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [filters, currentPage]);
const fetchBookings = async () => {
@@ -52,6 +70,11 @@ const BookingManagementPage: React.FC = () => {
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
// Handle AbortError silently
if (error.name === 'AbortError') {
return;
}
logger.error('Error fetching bookings', error);
toast.error(error.response?.data?.message || 'Unable to load bookings list');
} finally {
setLoading(false);
@@ -89,7 +112,8 @@ const BookingManagementPage: React.FC = () => {
const handleCreateInvoice = async (bookingId: number) => {
try {
// Validate bookingId before proceeding
if (!bookingId || isNaN(bookingId) || bookingId <= 0) {
const validatedId = validateBookingId(bookingId);
if (!validatedId) {
toast.error('Invalid booking ID');
return;
}
@@ -120,7 +144,7 @@ const BookingManagementPage: React.FC = () => {
navigate(`/staff/invoices/${invoiceId}`);
}, 100);
} else {
console.error('Invalid invoice ID received from server', {
logger.error('Invalid invoice ID received from server', {
invoiceId,
type: typeof invoiceId,
response: response.data
@@ -128,13 +152,13 @@ const BookingManagementPage: React.FC = () => {
throw new Error(`Invalid invoice ID received from server: ${invoiceId}`);
}
} else {
console.error('Failed to create invoice - invalid response', { response });
logger.error('Failed to create invoice - invalid response', { response });
throw new Error(response.message || 'Failed to create invoice - invalid response from server');
}
} 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

@@ -7,6 +7,7 @@ import EmptyState from '../../shared/components/EmptyState';
import { formatDate } from '../../shared/utils/format';
import useAuthStore from '../../store/useAuthStore';
import { useChatNotifications } from '../../features/notifications/contexts/ChatNotificationContext';
import { logger } from '../../shared/utils/logger';
const ChatManagementPage: React.FC = () => {
const [chats, setChats] = useState<Chat[]>([]);
@@ -73,7 +74,7 @@ const ChatManagementPage: React.FC = () => {
const websocket = new WebSocket(wsUrl);
websocket.onopen = () => {
console.log('Notification WebSocket connected');
logger.debug('Notification WebSocket connected');
};
websocket.onmessage = (event) => {
@@ -109,11 +110,11 @@ const ChatManagementPage: React.FC = () => {
};
websocket.onerror = (error) => {
console.error('Notification WebSocket error:', error);
logger.error('Notification WebSocket error', error);
};
websocket.onclose = () => {
console.log('Notification WebSocket disconnected');
logger.debug('Notification WebSocket disconnected');
setTimeout(() => {
connectNotificationWebSocket();
}, 5000);
@@ -141,7 +142,7 @@ const ChatManagementPage: React.FC = () => {
const websocket = new WebSocket(wsUrl);
websocket.onopen = () => {
console.log('Chat WebSocket connected for chat', chatId);
logger.debug('Chat WebSocket connected for chat', { chatId });
};
websocket.onmessage = (event) => {
@@ -169,19 +170,19 @@ const ChatManagementPage: React.FC = () => {
fetchChats();
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
logger.error('Error parsing WebSocket message', error);
}
};
websocket.onerror = (error) => {
console.error('Chat WebSocket error:', error);
logger.error('Chat WebSocket error', error);
};
websocket.onclose = (event) => {
console.log('Chat WebSocket disconnected', event.code, event.reason);
logger.debug('Chat WebSocket disconnected', { code: event.code, reason: event.reason });
if (event.code !== 1000 && selectedChat && selectedChat.id === chatId) {
console.log('Attempting to reconnect chat WebSocket...');
logger.debug('Attempting to reconnect chat WebSocket...');
setTimeout(() => {
if (selectedChat && selectedChat.id === chatId) {
connectChatWebSocket(chatId);
@@ -204,7 +205,7 @@ const ChatManagementPage: React.FC = () => {
setMessages(response.data);
}
} catch (error) {
console.error('Error polling messages:', error);
logger.error('Error polling messages', error);
}
}, 3000);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import {
Hotel,
Calendar,
@@ -18,6 +18,9 @@ import { formatDate } from '../../shared/utils/format';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { useAsync } from '../../shared/hooks/useAsync';
import { useNavigate } from 'react-router-dom';
import { logger } from '../../shared/utils/logger';
import { getPaymentStatusColor } from '../../shared/utils/paymentUtils';
import { getBookingStatusConfig } from '../../shared/utils/bookingUtils';
const StaffDashboardPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -30,6 +33,8 @@ const StaffDashboardPage: React.FC = () => {
const [recentBookings, setRecentBookings] = useState<Booking[]>([]);
const [loadingPayments, setLoadingPayments] = useState(false);
const [loadingBookings, setLoadingBookings] = useState(false);
const paymentsAbortRef = useRef<AbortController | null>(null);
const bookingsAbortRef = useRef<AbortController | null>(null);
const fetchDashboardData = async () => {
const response = await reportService.getReports({
@@ -54,6 +59,14 @@ const StaffDashboardPage: React.FC = () => {
}, [dateRange]);
useEffect(() => {
// Cancel previous request if exists
if (paymentsAbortRef.current) {
paymentsAbortRef.current.abort();
}
// Create new abort controller
paymentsAbortRef.current = new AbortController();
const fetchPayments = async () => {
try {
setLoadingPayments(true);
@@ -62,15 +75,34 @@ const StaffDashboardPage: React.FC = () => {
setRecentPayments(response.data.payments);
}
} catch (err: any) {
console.error('Error fetching payments:', err);
// Handle AbortError silently
if (err.name === 'AbortError') {
return;
}
logger.error('Error fetching payments', err);
} finally {
setLoadingPayments(false);
}
};
fetchPayments();
// Cleanup: abort request on unmount
return () => {
if (paymentsAbortRef.current) {
paymentsAbortRef.current.abort();
}
};
}, []);
useEffect(() => {
// Cancel previous request if exists
if (bookingsAbortRef.current) {
bookingsAbortRef.current.abort();
}
// Create new abort controller
bookingsAbortRef.current = new AbortController();
const fetchBookings = async () => {
try {
setLoadingBookings(true);
@@ -79,12 +111,23 @@ const StaffDashboardPage: React.FC = () => {
setRecentBookings(response.data.bookings);
}
} catch (err: any) {
console.error('Error fetching bookings:', err);
// Handle AbortError silently
if (err.name === 'AbortError') {
return;
}
logger.error('Error fetching bookings', err);
} finally {
setLoadingBookings(false);
}
};
fetchBookings();
// Cleanup: abort request on unmount
return () => {
if (bookingsAbortRef.current) {
bookingsAbortRef.current.abort();
}
};
}, []);
const handleRefresh = () => {
@@ -92,74 +135,24 @@ const StaffDashboardPage: React.FC = () => {
};
const getPaymentStatusBadge = (status: string) => {
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
completed: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: ' Paid',
border: 'border-emerald-200'
},
pending: {
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
text: 'text-amber-800',
label: '⏳ Pending',
border: 'border-amber-200'
},
failed: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: '❌ Failed',
border: 'border-rose-200'
},
refunded: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: '💰 Refunded',
border: 'border-slate-200'
},
const colorClasses = getPaymentStatusColor(status);
const labels: Record<string, string> = {
completed: '✅ Paid',
paid: '✅ Paid',
pending: ' Pending',
failed: '❌ Failed',
refunded: '💰 Refunded',
};
const config = statusConfig[status] || statusConfig.pending;
const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1);
return (
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.bg} ${config.text} ${config.border}`}>
{config.label}
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${colorClasses}`}>
{label}
</span>
);
};
const getBookingStatusBadge = (status: string) => {
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
pending: {
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
text: 'text-amber-800',
label: 'Pending',
border: 'border-amber-200'
},
confirmed: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Confirmed',
border: 'border-blue-200'
},
checked_in: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Checked In',
border: 'border-emerald-200'
},
checked_out: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: 'Checked Out',
border: 'border-slate-200'
},
cancelled: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: '❌ Canceled',
border: 'border-rose-200'
},
};
const config = statusConfig[status] || statusConfig.pending;
const config = getBookingStatusConfig(status);
return (
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.bg} ${config.text} ${config.border}`}>
{config.label}

View File

@@ -21,6 +21,7 @@ import { toast } from 'react-toastify';
import Loading from '../../shared/components/Loading';
import Pagination from '../../shared/components/Pagination';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { logger } from '../../shared/utils/logger';
type TabType = 'list' | 'profile';
@@ -88,7 +89,7 @@ const GuestProfilePage: React.FC = () => {
const response = await guestProfileService.getAllTags();
setAllTags(response.data.tags);
} catch (error: any) {
console.error('Failed to load tags:', error);
logger.error('Failed to load tags', error);
}
};
@@ -97,7 +98,7 @@ const GuestProfilePage: React.FC = () => {
const response = await guestProfileService.getAllSegments();
setAllSegments(response.data.segments);
} catch (error: any) {
console.error('Failed to load segments:', error);
logger.error('Failed to load segments', error);
}
};

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
Award,
Users,
@@ -19,6 +19,7 @@ import EmptyState from '../../shared/components/EmptyState';
import ConfirmationDialog from '../../shared/components/ConfirmationDialog';
import loyaltyService, { LoyaltyTier, LoyaltyReward } from '../../features/loyalty/services/loyaltyService';
import Pagination from '../../shared/components/Pagination';
import { logger } from '../../shared/utils/logger';
type Tab = 'users' | 'tiers' | 'rewards';
@@ -116,7 +117,7 @@ const LoyaltyManagementPage: React.FC = () => {
const response = await loyaltyService.getProgramStatus();
setProgramEnabled(response.data.enabled);
} catch (error: any) {
console.error('Failed to load program status:', error);
logger.error('Failed to load program status', error);
}
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { Search } from 'lucide-react';
import paymentService from '../../features/payments/services/paymentService';
import type { Payment } from '../../features/payments/services/paymentService';
@@ -8,6 +8,8 @@ import Pagination from '../../shared/components/Pagination';
import ExportButton from '../../shared/components/ExportButton';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { formatDate } from '../../shared/utils/format';
import { logger } from '../../shared/utils/logger';
import { getPaymentStatusColor, getPaymentMethodLabel } from '../../shared/utils/paymentUtils';
const PaymentManagementPage: React.FC = () => {
const { formatCurrency } = useFormatCurrency();
@@ -23,13 +25,29 @@ const PaymentManagementPage: React.FC = () => {
const [totalPages, setTotalPages] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const itemsPerPage = 5;
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
setCurrentPage(1);
}, [filters]);
useEffect(() => {
// Cancel previous request if exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller
abortControllerRef.current = new AbortController();
fetchPayments();
// Cleanup: abort request on unmount
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [filters, currentPage]);
const fetchPayments = async () => {
@@ -46,6 +64,11 @@ const PaymentManagementPage: React.FC = () => {
setTotalItems(response.data.pagination.total);
}
} catch (error: any) {
// Handle AbortError silently
if (error.name === 'AbortError') {
return;
}
logger.error('Error fetching payments', error);
toast.error(error.response?.data?.message || 'Unable to load payments list');
} finally {
setLoading(false);
@@ -53,77 +76,55 @@ const PaymentManagementPage: React.FC = () => {
};
const getMethodBadge = (method: string) => {
const badges: Record<string, { bg: string; text: string; label: string; border: string }> = {
const label = getPaymentMethodLabel(method);
const badges: Record<string, { bg: string; text: string; border: string }> = {
cash: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: 'Cash',
border: 'border-emerald-200'
},
bank_transfer: {
bg: 'bg-gradient-to-r from-blue-50 to-indigo-50',
text: 'text-blue-800',
label: 'Bank transfer',
border: 'border-blue-200'
},
stripe: {
bg: 'bg-gradient-to-r from-indigo-50 to-purple-50',
text: 'text-indigo-800',
label: 'Stripe',
border: 'border-indigo-200'
},
paypal: {
bg: 'bg-gradient-to-r from-blue-50 to-cyan-50',
text: 'text-blue-800',
label: 'PayPal',
border: 'border-blue-200'
},
credit_card: {
bg: 'bg-gradient-to-r from-purple-50 to-pink-50',
text: 'text-purple-800',
label: 'Credit card',
border: 'border-purple-200'
},
};
const badge = badges[method] || badges.cash;
return (
<span className={`px-4 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${badge.bg} ${badge.text} ${badge.border}`}>
{badge.label}
{label}
</span>
);
};
const getPaymentStatusBadge = (status: string) => {
const statusConfig: Record<string, { bg: string; text: string; label: string; border: string }> = {
completed: {
bg: 'bg-gradient-to-r from-emerald-50 to-green-50',
text: 'text-emerald-800',
label: ' Paid',
border: 'border-emerald-200'
},
pending: {
bg: 'bg-gradient-to-r from-amber-50 to-yellow-50',
text: 'text-amber-800',
label: '⏳ Pending',
border: 'border-amber-200'
},
failed: {
bg: 'bg-gradient-to-r from-rose-50 to-red-50',
text: 'text-rose-800',
label: '❌ Failed',
border: 'border-rose-200'
},
refunded: {
bg: 'bg-gradient-to-r from-slate-50 to-gray-50',
text: 'text-slate-700',
label: '💰 Refunded',
border: 'border-slate-200'
},
const colorClasses = getPaymentStatusColor(status);
const labels: Record<string, string> = {
completed: '✅ Paid',
paid: '✅ Paid',
pending: ' Pending',
failed: '❌ Failed',
refunded: '💰 Refunded',
};
const config = statusConfig[status] || statusConfig.pending;
const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1);
return (
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${config.bg} ${config.text} ${config.border}`}>
{config.label}
<span className={`px-3 py-1.5 rounded-full text-xs font-semibold border shadow-sm ${colorClasses}`}>
{label}
</span>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
LogIn,
LogOut,
@@ -35,6 +35,7 @@ import apiClient from '../../shared/services/apiClient';
import { useFormatCurrency } from '../../features/payments/hooks/useFormatCurrency';
import { parseDateLocal } from '../../shared/utils/format';
import CreateBookingModal from '../../features/hotel_services/components/CreateBookingModal';
import { logger } from '../../shared/utils/logger';
type ReceptionTab = 'overview' | 'check-in' | 'check-out' | 'bookings' | 'rooms' | 'services';
@@ -449,7 +450,7 @@ const ReceptionDashboardPage: React.FC = () => {
setAvailableAmenities(response.data.amenities);
}
} catch (error) {
console.error('Failed to fetch amenities:', error);
logger.error('Failed to fetch amenities', error);
}
}, []);
@@ -526,7 +527,7 @@ const ReceptionDashboardPage: React.FC = () => {
}
});
} catch (err) {
console.error(`Failed to fetch page ${page}:`, err);
logger.error(`Failed to fetch page ${page}`, err);
}
}
}
@@ -542,7 +543,7 @@ const ReceptionDashboardPage: React.FC = () => {
});
}
} catch (err) {
console.error('Failed to fetch room types:', err);
logger.error('Failed to fetch room types', err);
}
};
@@ -569,7 +570,7 @@ const ReceptionDashboardPage: 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 = {
@@ -710,7 +711,7 @@ const ReceptionDashboardPage: React.FC = () => {
setEditingRoom(roomData);
} catch (error) {
console.error('Failed to fetch full room details:', error);
logger.error('Failed to fetch full room details', error);
}
};
@@ -851,7 +852,7 @@ const ReceptionDashboardPage: 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,12 +38,51 @@ const AnalyticsLoader: React.FC = () => {
};
}, []);
// Listen for consent updates to disable analytics when consent is withdrawn
useEffect(() => {
const handleConsentUpdate = () => {
if (consent && config) {
const analyticsAllowed =
config.policy.analytics_enabled && consent.categories.analytics;
// SECURITY/COMPLIANCE: Disable Google Analytics when consent is withdrawn
if (gaLoadedRef.current && !analyticsAllowed && window.gtag) {
window.gtag('consent', 'update', {
'analytics_storage': 'denied'
});
}
const marketingAllowed =
config.policy.marketing_enabled && consent.categories.marketing;
// SECURITY/COMPLIANCE: Disable Facebook Pixel when consent is withdrawn
if (fbLoadedRef.current && !marketingAllowed && window.fbq) {
window.fbq('consent', 'revoke');
}
}
};
window.addEventListener('cookie-consent-updated', handleConsentUpdate);
return () => {
window.removeEventListener('cookie-consent-updated', handleConsentUpdate);
};
}, [consent, config]);
// Google Analytics - only load if analytics consent is given
// SECURITY/COMPLIANCE: Respects user consent - only loads if explicitly allowed
useEffect(() => {
if (!config || !consent) return;
const measurementId = config.integrations.ga_measurement_id;
const analyticsAllowed =
config.policy.analytics_enabled && consent.categories.analytics;
// If consent withdrawn, disable analytics
if (gaLoadedRef.current && !analyticsAllowed) {
if (window.gtag) {
window.gtag('consent', 'update', {
'analytics_storage': 'denied'
});
}
return;
}
if (!measurementId || !analyticsAllowed || gaLoadedRef.current) return;
@@ -58,7 +97,15 @@ const AnalyticsLoader: React.FC = () => {
}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', measurementId, { anonymize_ip: true });
// SECURITY/COMPLIANCE: Set consent mode and anonymize IP for privacy
gtag('consent', 'default', {
'analytics_storage': 'granted',
'ad_storage': 'denied', // Don't allow ad storage by default
});
gtag('config', measurementId, {
anonymize_ip: true,
allow_google_signals: false, // Disable Google signals for privacy
});
gaLoadedRef.current = true;
@@ -77,11 +124,22 @@ const AnalyticsLoader: React.FC = () => {
}, [location, config]);
// Facebook Pixel - only load if marketing consent is given
// SECURITY/COMPLIANCE: Respects user consent - only loads if explicitly allowed
useEffect(() => {
if (!config || !consent) return;
const pixelId = config.integrations.fb_pixel_id;
const marketingAllowed =
config.policy.marketing_enabled && consent.categories.marketing;
// If consent withdrawn, disable Facebook Pixel
if (fbLoadedRef.current && !marketingAllowed) {
if (window.fbq) {
window.fbq('consent', 'revoke');
}
return;
}
if (!pixelId || !marketingAllowed || fbLoadedRef.current) return;
@@ -104,6 +162,8 @@ const AnalyticsLoader: React.FC = () => {
}
})(window, document, 'script');
// SECURITY/COMPLIANCE: Set consent before initializing
window.fbq('consent', 'grant');
window.fbq('init', pixelId);
window.fbq('track', 'PageView');
fbLoadedRef.current = true;

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useCookieConsent } from '../contexts/CookieConsentContext';
import { Link } from 'react-router-dom';
const CookieConsentBanner: React.FC = () => {
const { consent, isLoading, hasDecided, updateConsent } = useCookieConsent();
@@ -86,13 +87,21 @@ const CookieConsentBanner: React.FC = () => {
</p>
</div>
<button
type="button"
className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#d4af37] underline underline-offset-4 hover:text-[#f6e7b4]"
onClick={() => setShowDetails((prev) => !prev)}
>
{showDetails ? 'Hide detailed preferences' : 'Fine-tune preferences'}
</button>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#d4af37] underline underline-offset-4 hover:text-[#f6e7b4]"
onClick={() => setShowDetails((prev) => !prev)}
>
{showDetails ? 'Hide detailed preferences' : 'Fine-tune preferences'}
</button>
<Link
to="/gdpr"
className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#d4af37] underline underline-offset-4 hover:text-[#f6e7b4]"
>
Data Privacy (GDPR)
</Link>
</div>
{showDetails && (
<div className="mt-1.5 space-y-3 rounded-xl bg-black/40 p-3 ring-1 ring-zinc-800/80 backdrop-blur-md sm:p-4">

View File

@@ -0,0 +1,48 @@
/**
* Booking Status Constants
*/
export const BOOKING_STATUS = {
PENDING: 'pending',
CONFIRMED: 'confirmed',
CANCELLED: 'cancelled',
CHECKED_IN: 'checked_in',
CHECKED_OUT: 'checked_out',
} as const;
export type BookingStatus = typeof BOOKING_STATUS[keyof typeof BOOKING_STATUS];
/**
* Payment Status Constants
*/
export const PAYMENT_STATUS = {
UNPAID: 'unpaid',
PAID: 'paid',
REFUNDED: 'refunded',
} as const;
export type PaymentStatus = typeof PAYMENT_STATUS[keyof typeof PAYMENT_STATUS];
/**
* Payment Method Constants
*/
export const PAYMENT_METHOD = {
CASH: 'cash',
STRIPE: 'stripe',
PAYPAL: 'paypal',
CREDIT_CARD: 'credit_card',
BANK_TRANSFER: 'bank_transfer',
} as const;
export type PaymentMethod = typeof PAYMENT_METHOD[keyof typeof PAYMENT_METHOD];
/**
* Payment Type Constants
*/
export const PAYMENT_TYPE = {
FULL: 'full',
DEPOSIT: 'deposit',
REMAINING: 'remaining',
} as const;
export type PaymentType = typeof PAYMENT_TYPE[keyof typeof PAYMENT_TYPE];

View File

@@ -0,0 +1,7 @@
/**
* Shared Constants Index
* Central export point for all shared constants
*/
export * from './bookingConstants';

View File

@@ -66,12 +66,19 @@ export const CookieConsentProvider: React.FC<{ children: React.ReactNode }> = ({
const updateConsent = useCallback(
async (payload: UpdateCookieConsentRequest) => {
// SECURITY/COMPLIANCE: Update consent via backend API
const updated = await privacyService.updateCookieConsent(payload);
setConsent(updated);
setHasDecided(true);
// SECURITY: Store decision flag in localStorage (not sensitive data)
// Actual consent preferences are stored securely on backend
if (typeof window !== 'undefined') {
window.localStorage.setItem('cookieConsentDecided', 'true');
}
// Trigger analytics reload to respect new consent
window.dispatchEvent(new CustomEvent('cookie-consent-updated', {
detail: updated
}));
},
[]
);

View File

@@ -1,18 +1,52 @@
import { useState, useEffect, useCallback } from 'react';
import { logDebug } from '../utils/errorReporter';
type SetValue<T> = T | ((val: T) => T);
interface StoredValueWithExpiry<T> {
value: T;
expires?: number; // Timestamp in milliseconds
}
/**
* Hook for localStorage with optional expiration support
* @param key - localStorage key
* @param initialValue - Initial value if key doesn't exist
* @param ttl - Time to live in milliseconds (optional)
*/
export function useLocalStorage<T>(
key: string,
initialValue: T
initialValue: T,
ttl?: number // Time to live in milliseconds
): [T, (value: SetValue<T>) => void, () => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
if (!item) return initialValue;
const parsed: StoredValueWithExpiry<T> | T = JSON.parse(item);
// Check if it has expiration metadata
if (parsed && typeof parsed === 'object' && 'value' in parsed && 'expires' in parsed) {
const withExpiry = parsed as StoredValueWithExpiry<T>;
// Check expiration
if (withExpiry.expires && withExpiry.expires < Date.now()) {
logDebug(`localStorage key "${key}" expired, removing`, {
expiredAt: new Date(withExpiry.expires).toISOString(),
});
localStorage.removeItem(key);
return initialValue;
}
return withExpiry.value ?? initialValue;
}
// Legacy format (no expiration)
return parsed as T;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
logDebug(`Error reading localStorage key "${key}"`, { error: error instanceof Error ? error.message : String(error) });
return initialValue;
}
});
@@ -22,17 +56,24 @@ export function useLocalStorage<T>(
const setValue = useCallback(
(value: SetValue<T>) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
// Store with expiration if ttl is provided
const toStore: StoredValueWithExpiry<T> | T = ttl
? {
value: valueToStore,
expires: Date.now() + ttl,
}
: valueToStore;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
window.localStorage.setItem(key, JSON.stringify(toStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
logDebug(`Error setting localStorage key "${key}"`, { error: error instanceof Error ? error.message : String(error) });
}
},
[key, storedValue]
[key, storedValue, ttl]
);
const removeValue = useCallback(() => {
@@ -40,7 +81,7 @@ export function useLocalStorage<T>(
window.localStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
console.error(`Error removing localStorage key "${key}":`, error);
logDebug(`Error removing localStorage key "${key}"`, { error: error instanceof Error ? error.message : String(error) });
}
}, [key, initialValue]);
@@ -51,7 +92,7 @@ export function useLocalStorage<T>(
try {
setStoredValue(JSON.parse(e.newValue));
} catch (error) {
console.error(`Error parsing localStorage value for key "${key}":`, error);
logDebug(`Error parsing localStorage value for key "${key}"`, { error: error instanceof Error ? error.message : String(error) });
}
}
};

View File

@@ -1,5 +1,6 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import useAuthStore from '../../store/useAuthStore';
import { logSecurityWarning, logDebug, logWarn } from '../utils/errorReporter';
const rawBase = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const normalized = String(rawBase).replace(/\/$/, '');
@@ -9,17 +10,10 @@ const API_BASE_URL = /\/api(\/?$)/i.test(normalized)
// 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.'
logSecurityWarning(
'API URL is not using HTTPS in production! This exposes sensitive data to interception. Please configure VITE_API_URL to use HTTPS.',
{ apiUrl: normalized }
);
// 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;
@@ -80,16 +74,13 @@ const retryRequest = async (
const delay = RETRY_DELAY * Math.pow(2, retryCount);
await new Promise(resolve => setTimeout(resolve, delay));
console.log(`Retrying request (${retryCount + 1}/${MAX_RETRIES}): ${config.url}`);
logDebug(`Retrying request (${retryCount + 1}/${MAX_RETRIES})`, { url: config.url });
return apiClient.request(config);
};
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Get the original URL before normalization for checking
const originalUrl = (config.url || '').toString();
// Note: We no longer check for tokens in localStorage since tokens are now
// stored in httpOnly cookies. The backend will handle authentication via cookies.
// We still need to ensure withCredentials is set (already done in apiClient config).
@@ -125,9 +116,7 @@ apiClient.interceptors.request.use(
} 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.');
}
logDebug('CSRF token not found in cookies. Request may fail and retry after fetching token.');
}
}
}
@@ -177,14 +166,14 @@ apiClient.interceptors.response.use(
if (config.metadata?.startTime) {
const duration = new Date().getTime() - config.metadata.startTime.getTime();
if (duration > 1000) {
console.warn(`Slow request detected: ${config.url} took ${duration}ms`);
logWarn(`Slow request detected`, { url: config.url, duration: `${duration}ms` });
}
}
const requestId = response.headers['x-request-id'];
if (requestId && import.meta.env.DEV) {
console.debug(`Request completed: ${config.url} [${requestId}]`);
if (requestId) {
logDebug(`Request completed`, { url: config.url, requestId });
}
return response;
@@ -221,7 +210,7 @@ apiClient.interceptors.response.use(
return retryRequest(error);
}
console.error('Network error:', error);
logWarn('Network error', { error: error.message });
return Promise.reject({
...error,
message: 'Network error. Please check your internet connection.',
@@ -250,7 +239,8 @@ apiClient.interceptors.response.use(
detail: { message: 'Session expired. Please login again.' }
}));
const errorMessage = (error.response?.data as any)?.message || 'Session expired. Please login again.';
// SECURITY: Sanitize error message to prevent information disclosure
const errorMessage = 'Session expired. Please login again.';
return Promise.reject({
...error,
message: errorMessage,
@@ -259,7 +249,12 @@ apiClient.interceptors.response.use(
if (status === 403) {
const errorMessage = (error.response?.data as any)?.message || 'You do not have permission to access this resource.';
// SECURITY: Sanitize error message - don't expose internal details
const rawMessage = (error.response?.data as any)?.message || '';
// Only show generic message to users, log details in dev mode
const errorMessage = rawMessage.includes('CSRF token')
? 'Security validation failed. Please refresh the page and try again.'
: '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) {
@@ -290,9 +285,7 @@ apiClient.interceptors.response.use(
}
} catch (e) {
// Ignore errors from token fetch attempts
if (import.meta.env.DEV) {
console.warn('Failed to fetch CSRF token:', e);
}
logDebug('Failed to fetch CSRF token', { error: e instanceof Error ? e.message : String(e) });
}
}
}
@@ -341,8 +334,10 @@ apiClient.interceptors.response.use(
return retryRequest(error);
}
console.error(`Server error [${requestId || 'unknown'}]:`, error);
const errorMessage = (error.response?.data as any)?.message || 'Server error. Please try again later.';
// SECURITY: Log detailed error only in development
logDebug(`Server error`, { requestId: requestId || 'unknown', error: error.message });
// SECURITY: Don't expose internal error details to users
const errorMessage = 'Server error. Please try again later.';
return Promise.reject({
...error,
message: errorMessage,
@@ -353,15 +348,23 @@ apiClient.interceptors.response.use(
if (status === 400) {
const errorData = error.response.data as any;
// SECURITY: Sanitize validation errors - only show safe messages
const safeMessage = errorData?.message || errorData?.errors?.[0]?.message || 'Invalid request. Please check your input.';
// Filter out sensitive error details
const safeErrors = (errorData?.errors || []).map((err: any) => ({
field: err.field || err.path,
message: err.message || 'Invalid value',
}));
return Promise.reject({
...error,
message: errorData?.message || errorData?.errors?.[0]?.message || 'Invalid request. Please check your input.',
errors: errorData?.errors || [],
message: safeMessage,
errors: safeErrors,
});
}
const errorMessage = (error.response.data as any)?.message || error.message || 'An error occurred';
// SECURITY: Sanitize error message - don't expose internal details
const errorMessage = 'An error occurred. Please try again.';
return Promise.reject({
...error,
message: errorMessage,

View File

@@ -0,0 +1,74 @@
import { Clock, CheckCircle, XCircle, DoorOpen, DoorClosed, AlertCircle } from 'lucide-react';
import { BookingStatus } from '../constants/bookingConstants';
import type { Booking } from '../../features/bookings/services/bookingService';
/**
* Status configuration for booking status badges
*/
export interface StatusConfig {
icon: React.ComponentType<{ className?: string }>;
color: string;
text: string;
}
/**
* Get status configuration for a booking status
*/
export const getBookingStatusConfig = (status: string): StatusConfig => {
switch (status) {
case 'pending':
return {
icon: Clock,
color: 'bg-yellow-100 text-yellow-800',
text: 'Pending confirmation',
};
case 'confirmed':
return {
icon: CheckCircle,
color: 'bg-green-100 text-green-800',
text: 'Confirmed',
};
case 'cancelled':
return {
icon: XCircle,
color: 'bg-red-100 text-red-800',
text: 'Cancelled',
};
case 'checked_in':
return {
icon: DoorOpen,
color: 'bg-blue-100 text-blue-800',
text: 'Checked in',
};
case 'checked_out':
return {
icon: DoorClosed,
color: 'bg-gray-100 text-gray-800',
text: 'Checked out',
};
default:
return {
icon: AlertCircle,
color: 'bg-gray-100 text-gray-800',
text: status,
};
}
};
/**
* Check if a booking can be cancelled
*/
export const canCancelBooking = (booking: Booking): boolean => {
return booking.status === 'pending';
};
/**
* Calculate number of nights for a booking
*/
export const calculateNights = (checkInDate: string, checkOutDate: string): number => {
const checkIn = new Date(checkInDate);
const checkOut = new Date(checkOutDate);
const nights = Math.ceil((checkOut.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24));
return nights > 0 ? nights : 1;
};

View File

@@ -0,0 +1,99 @@
/**
* Error Reporting Utility
*
* Provides centralized error reporting that respects environment settings.
* In development: logs to console
* In production: sends to error reporting service (when configured)
*/
interface ErrorContext {
[key: string]: any;
}
class ErrorReporter {
private isDevelopment = import.meta.env.DEV;
private isProduction = import.meta.env.PROD;
/**
* Log error with context
*/
error(message: string, error?: any, context?: ErrorContext): void {
if (this.isDevelopment) {
console.error(`[ERROR] ${message}`, error, context);
} else {
// In production, send to error reporting service
// TODO: Integrate with error reporting service (Sentry, LogRocket, etc.)
// Example:
// if (window.Sentry) {
// window.Sentry.captureException(error || new Error(message), {
// extra: context,
// });
// }
// For now, silently log to prevent information disclosure
// In production, errors should be reported to a monitoring service
}
}
/**
* Log warning
*/
warn(message: string, context?: ErrorContext): void {
if (this.isDevelopment) {
console.warn(`[WARN] ${message}`, context);
}
// Warnings typically don't need to be reported in production
}
/**
* Log info message
*/
info(message: string, context?: ErrorContext): void {
if (this.isDevelopment) {
console.info(`[INFO] ${message}`, context);
}
}
/**
* Log debug message (only in development)
*/
debug(message: string, context?: ErrorContext): void {
if (this.isDevelopment) {
console.debug(`[DEBUG] ${message}`, context);
}
}
/**
* Log security warning (always shown, but muted in production)
*/
securityWarning(message: string, context?: ErrorContext): void {
if (this.isDevelopment) {
console.warn(`[SECURITY WARNING] ${message}`, context);
} else {
// Security warnings should be logged to monitoring service in production
// but not shown to users
this.error(`Security Warning: ${message}`, undefined, context);
}
}
}
export const errorReporter = new ErrorReporter();
// Export convenience methods
export const logError = (message: string, error?: any, context?: ErrorContext) =>
errorReporter.error(message, error, context);
export const logWarn = (message: string, context?: ErrorContext) =>
errorReporter.warn(message, context);
export const logInfo = (message: string, context?: ErrorContext) =>
errorReporter.info(message, context);
export const logDebug = (message: string, context?: ErrorContext) =>
errorReporter.debug(message, context);
export const logSecurityWarning = (message: string, context?: ErrorContext) =>
errorReporter.securityWarning(message, context);
export default errorReporter;

View File

@@ -5,15 +5,21 @@ export const normalizeImageUrl = (imageUrl: string | null | undefined): string =
return '';
}
// If already a full URL, return as is
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
const apiBaseUrl = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:8000';
// Ensure path starts with / and handle both /uploads/avatars/... and avatars/... cases
let cleanPath = imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`;
// If path starts with /avatars/ but not /uploads/, add /uploads prefix
if (cleanPath.startsWith('/avatars/') && !cleanPath.startsWith('/uploads/')) {
cleanPath = `/uploads${cleanPath}`;
}
const cleanPath = imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`;
return `${apiBaseUrl}${cleanPath}`;
};

View File

@@ -1,4 +1,9 @@
export * from './format';
export * from './constants';
export * from './validationSchemas';
/**
* Shared Utilities Index
* Central export point for all shared utilities
*/
export * from './routeValidation';
export * from './bookingUtils';
export * from './paymentUtils';
export * from './ownershipValidation';

View File

@@ -0,0 +1,46 @@
import { toast } from 'react-toastify';
import type { Booking } from '../../features/bookings/services/bookingService';
/**
* Validate that a booking belongs to the current user
* @param booking - The booking to validate
* @param currentUserId - The current user's ID
* @returns true if booking belongs to user, false otherwise
*/
export const validateBookingOwnership = (
booking: Booking | null,
currentUserId: number | undefined
): boolean => {
if (!booking || !currentUserId) {
return false;
}
if (booking.user_id !== currentUserId) {
return false;
}
return true;
};
/**
* Validate booking ownership and show error if invalid
* @param booking - The booking to validate
* @param currentUserId - The current user's ID
* @param navigate - Navigation function
* @param redirectPath - Path to redirect to if invalid
* @returns true if valid, false if invalid (and redirects)
*/
export const validateAndHandleBookingOwnership = (
booking: Booking | null,
currentUserId: number | undefined,
navigate: (path: string) => void,
redirectPath: string = '/bookings'
): boolean => {
if (!validateBookingOwnership(booking, currentUserId)) {
toast.error('You do not have permission to view this booking');
navigate(redirectPath);
return false;
}
return true;
};

View File

@@ -0,0 +1,40 @@
import { PaymentStatus, PaymentMethod } from '../constants/bookingConstants';
/**
* Get payment status color classes
*/
export const getPaymentStatusColor = (status: string): string => {
switch (status) {
case 'completed':
case 'paid':
return 'bg-gradient-to-r from-emerald-50 to-green-50 text-emerald-800 border-emerald-200';
case 'pending':
return 'bg-gradient-to-r from-amber-50 to-yellow-50 text-amber-800 border-amber-200';
case 'failed':
return 'bg-gradient-to-r from-rose-50 to-red-50 text-rose-800 border-rose-200';
case 'refunded':
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
default:
return 'bg-gradient-to-r from-slate-50 to-gray-50 text-slate-700 border-slate-200';
}
};
/**
* Get payment method label
*/
export const getPaymentMethodLabel = (method: string): string => {
switch (method) {
case 'stripe':
case 'credit_card':
return 'Card';
case 'paypal':
return 'PayPal';
case 'bank_transfer':
return 'Bank Transfer';
case 'cash':
return 'Cash';
default:
return method;
}
};

View File

@@ -0,0 +1,41 @@
/**
* Route Parameter Validation Utilities
*/
/**
* Validates if a route parameter ID is valid
* @param id - The ID parameter from route
* @returns Validated number ID or null if invalid
*/
export const validateRouteId = (id: string | undefined): number | null => {
if (!id || id === 'undefined' || id === 'null' || id === 'NaN' || id === '') {
return null;
}
const numericId = Number(id);
if (isNaN(numericId) || numericId <= 0 || !isFinite(numericId)) {
return null;
}
return numericId;
};
/**
* Validates booking ID from route parameters
* @param id - The booking ID parameter from route
* @returns Validated booking ID or null if invalid
*/
export const validateBookingId = (id: string | undefined): number | null => {
return validateRouteId(id);
};
/**
* Validates invoice ID from route parameters
* @param id - The invoice ID parameter from route
* @returns Validated invoice ID or null if invalid
*/
export const validateInvoiceId = (id: string | undefined): number | null => {
return validateRouteId(id);
};

View File

@@ -48,13 +48,23 @@ interface AuthState {
const useAuthStore = create<AuthState>((set, get) => ({
// Tokens are now stored in httpOnly cookies, not localStorage
// We only store userInfo in localStorage for UI purposes
// SECURITY: Tokens are stored in httpOnly cookies, not localStorage
// We only store minimal userInfo in localStorage for UI purposes
// Note: localStorage is accessible to XSS attacks, so we minimize sensitive data
token: null, // Token is in httpOnly cookie, not accessible from JS
userInfo: localStorage.getItem('userInfo')
? JSON.parse(localStorage.getItem('userInfo')!)
: null,
isAuthenticated: !!localStorage.getItem('userInfo'), // Check userInfo instead of token
userInfo: (() => {
try {
const stored = localStorage.getItem('userInfo');
return stored ? JSON.parse(stored) : null;
} catch {
// Invalid JSON, clear it
localStorage.removeItem('userInfo');
return null;
}
})(),
// SECURITY: Don't rely solely on localStorage for auth state
// initializeAuth() will validate with backend
isAuthenticated: false, // Will be set after validation
isLoading: false,
error: null,
requiresMFA: false,
@@ -88,8 +98,16 @@ const useAuthStore = create<AuthState>((set, get) => ({
throw new Error(response.message || 'Login failed.');
}
// Only store userInfo in localStorage (token is in httpOnly cookie)
localStorage.setItem('userInfo', JSON.stringify(user));
// SECURITY: Store minimal userInfo in localStorage (token is in httpOnly cookie)
// Only store non-sensitive UI data - sensitive data should come from backend
const minimalUserInfo = {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
};
localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo));
set({
@@ -148,8 +166,15 @@ const useAuthStore = create<AuthState>((set, get) => ({
throw new Error(response.message || 'MFA verification failed.');
}
// Only store userInfo in localStorage (token is in httpOnly cookie)
localStorage.setItem('userInfo', JSON.stringify(user));
// SECURITY: Store minimal userInfo in localStorage (token is in httpOnly cookie)
const minimalUserInfo = {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
};
localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo));
set({
@@ -218,7 +243,10 @@ const useAuthStore = create<AuthState>((set, get) => ({
try {
await authService.logout();
} catch (error) {
console.error('Logout error:', error);
// SECURITY: Don't log sensitive error details
if (import.meta.env.DEV) {
console.error('Logout error:', error);
}
} finally {
// Clear userInfo from localStorage
// Tokens in httpOnly cookies are deleted by backend on logout
@@ -251,8 +279,16 @@ const useAuthStore = create<AuthState>((set, get) => ({
setUser: (user: UserInfo) => {
localStorage.setItem('userInfo', JSON.stringify(user));
set({ userInfo: user });
// SECURITY: Store minimal userInfo
const minimalUserInfo = {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
};
localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo));
set({ userInfo: minimalUserInfo });
},
@@ -266,9 +302,17 @@ const useAuthStore = create<AuthState>((set, get) => ({
// We just need to update userInfo if it's in the response
const user = response.data?.user;
if (user) {
localStorage.setItem('userInfo', JSON.stringify(user));
// SECURITY: Store minimal userInfo
const minimalUserInfo = {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
};
localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo));
set({
userInfo: user,
userInfo: minimalUserInfo,
});
}
}
@@ -343,11 +387,19 @@ const useAuthStore = create<AuthState>((set, get) => ({
// This will fail if cookies are invalid/expired
const profile = await authService.getProfile();
if (profile.data?.user) {
// Update userInfo with latest data from server
localStorage.setItem('userInfo', JSON.stringify(profile.data.user));
// SECURITY: Update userInfo with minimal data from server
const user = profile.data.user;
const minimalUserInfo = {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
avatar: user.avatar,
};
localStorage.setItem('userInfo', JSON.stringify(minimalUserInfo));
set({
token: null, // Token is in httpOnly cookie, not accessible
userInfo: profile.data.user,
userInfo: minimalUserInfo,
isAuthenticated: true,
});
} else {