diff --git a/Backend/src/shared/utils/__pycache__/sanitization.cpython-312.pyc b/Backend/src/shared/utils/__pycache__/sanitization.cpython-312.pyc new file mode 100644 index 00000000..422ecde6 Binary files /dev/null and b/Backend/src/shared/utils/__pycache__/sanitization.cpython-312.pyc differ diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index 2608c20d..5a41cb5d 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -122,6 +122,9 @@ const AccountantPaymentManagementPage = lazy(() => import('./pages/accountant/Pa const AccountantInvoiceManagementPage = lazy(() => import('./pages/accountant/InvoiceManagementPage')); const AccountantAnalyticsDashboardPage = lazy(() => import('./pages/accountant/AnalyticsDashboardPage')); const AccountantLayout = lazy(() => import('./pages/AccountantLayout')); +const AdminProfilePage = lazy(() => import('./pages/admin/ProfilePage')); +const StaffProfilePage = lazy(() => import('./pages/staff/ProfilePage')); +const AccountantProfilePage = lazy(() => import('./pages/accountant/ProfilePage')); const NotFoundPage = lazy(() => import('./shared/pages/NotFoundPage')); @@ -604,6 +607,10 @@ function App() { path="backups" element={} /> + } + /> {} @@ -654,6 +661,10 @@ function App() { path="advanced-rooms" element={} /> + } + /> {/* Accountant Routes */} @@ -692,6 +703,10 @@ function App() { path="reports" element={} /> + } + /> {} diff --git a/Frontend/src/features/auth/components/LoginModal.tsx b/Frontend/src/features/auth/components/LoginModal.tsx index b8e7eabe..0a5b31c0 100644 --- a/Frontend/src/features/auth/components/LoginModal.tsx +++ b/Frontend/src/features/auth/components/LoginModal.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import { X, Eye, EyeOff, LogIn, Loader2, Mail, Lock, Shield, ArrowLeft } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; import useAuthStore from '../../../store/useAuthStore'; import { loginSchema, LoginFormData } from '../../../shared/utils/validationSchemas'; import { useCompanySettings } from '../../../shared/contexts/CompanySettingsContext'; @@ -26,8 +27,9 @@ type MFATokenFormData = yup.InferType; const LoginModal: React.FC = () => { const { closeModal, openModal } = useAuthModal(); - const { login, verifyMFA, isLoading, error, clearError, requiresMFA, clearMFA, isAuthenticated } = useAuthStore(); + const { login, verifyMFA, isLoading, error, clearError, requiresMFA, clearMFA, isAuthenticated, userInfo } = useAuthStore(); const { settings } = useCompanySettings(); + const navigate = useNavigate(); const [showPassword, setShowPassword] = useState(false); @@ -61,12 +63,26 @@ const LoginModal: React.FC = () => { }, }); - // Close modal on successful authentication + // Close modal and redirect to appropriate dashboard on successful authentication useEffect(() => { - if (!isLoading && isAuthenticated && !requiresMFA) { + if (!isLoading && isAuthenticated && !requiresMFA && userInfo) { closeModal(); + + // Redirect to role-specific dashboard + const role = userInfo.role?.toLowerCase() || (userInfo as any).role_name?.toLowerCase(); + + if (role === 'admin') { + navigate('/admin/dashboard', { replace: true }); + } else if (role === 'staff') { + navigate('/staff/dashboard', { replace: true }); + } else if (role === 'accountant') { + navigate('/accountant/dashboard', { replace: true }); + } else { + // Customer or default - go to customer dashboard + navigate('/dashboard', { replace: true }); + } } - }, [isLoading, isAuthenticated, requiresMFA, closeModal]); + }, [isLoading, isAuthenticated, requiresMFA, userInfo, closeModal, navigate]); const { register, diff --git a/Frontend/src/pages/accountant/ProfilePage.tsx b/Frontend/src/pages/accountant/ProfilePage.tsx new file mode 100644 index 00000000..7f78618c --- /dev/null +++ b/Frontend/src/pages/accountant/ProfilePage.tsx @@ -0,0 +1,1489 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { + User, + Mail, + Phone, + Save, + CheckCircle, + AlertCircle, + Lock, + Camera, + Shield, + ShieldCheck, + ShieldOff, + Copy, + Eye, + EyeOff, + RefreshCw, + KeyRound, + LogOut, + 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'; +import { useAsync } from '../../shared/hooks/useAsync'; +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 + .string() + .required('Full name is required') + .min(2, 'Full name must be at least 2 characters') + .max(100, 'Full name cannot exceed 100 characters'), + email: yup + .string() + .required('Email is required') + .email('Invalid email address'), + phone: yup + .string() + .required('Phone number is required') + .matches( + /^[\d\s\-\+\(\)]{5,}$/, + 'Please enter a valid phone number' + ), +}); + +const passwordValidationSchema = yup.object().shape({ + currentPassword: yup + .string() + .required('Current password is required'), + newPassword: yup + .string() + .required('New password is required') + .min(8, 'Password must be at least 8 characters') + .matches(/[A-Z]/, 'Password must contain at least one uppercase letter') + .matches(/[a-z]/, 'Password must contain at least one lowercase letter') + .matches(/[0-9]/, 'Password must contain at least one number') + .matches(/[!@#$%^&*(),.?":{}|<>]/, 'Password must contain at least one special character (!@#$%^&*(),.?":{}|<>)'), + confirmPassword: yup + .string() + .required('Please confirm your password') + .oneOf([yup.ref('newPassword')], 'Passwords must match'), +}); + +type ProfileFormData = yup.InferType; +type PasswordFormData = yup.InferType; + +const ProfilePage: React.FC = () => { + const { userInfo, setUser } = useAuthStore(); + const { setLoading } = useGlobalLoading(); + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'mfa' | 'sessions' | 'gdpr'>('profile'); + const [avatarPreview, setAvatarPreview] = useState(null); + const [avatarError, setAvatarError] = useState(false); + const [showPassword, setShowPassword] = useState<{ + current: boolean; + new: boolean; + confirm: boolean; + }>({ + current: false, + new: false, + confirm: false, + }); + + + const [mfaStatus, setMfaStatus] = useState<{mfa_enabled: boolean; backup_codes_count: number} | null>(null); + const [mfaSecret, setMfaSecret] = useState(null); + const [mfaQrCode, setMfaQrCode] = useState(null); + const [mfaVerificationToken, setMfaVerificationToken] = useState(''); + const [showBackupCodes, setShowBackupCodes] = useState(null); + const [showMfaSecret, setShowMfaSecret] = useState(false); + const mfaAbortControllerRef = useRef(null); + const sessionsAbortControllerRef = useRef(null); + const gdprAbortControllerRef = useRef(null); + + + const fetchProfile = async () => { + const response = await authService.getProfile(); + if (response.status === 'success' || response.success) { + const user = response.data?.user || response.data; + if (user) { + setUser(user as any); + return user; + } + } + throw new Error('Failed to load profile'); + }; + + const { + data: profileData, + loading: loadingProfile, + error: profileError, + execute: refetchProfile + } = useAsync(fetchProfile, { + immediate: true, + onError: (error: any) => { + toast.error(error.message || 'Unable to load profile'); + } + }); + + + const { + register: registerProfile, + handleSubmit: handleSubmitProfile, + formState: { errors: profileErrors }, + reset: resetProfile, + } = useForm({ + resolver: yupResolver(profileValidationSchema), + defaultValues: { + name: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.name || '', + email: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.email || '', + phone: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.phone || '', + }, + }); + + + const { + register: registerPassword, + handleSubmit: handleSubmitPassword, + formState: { errors: passwordErrors }, + reset: resetPassword, + } = useForm({ + resolver: yupResolver(passwordValidationSchema), + }); + + + const fetchMFAStatus = async () => { + try { + const response = await authService.getMFAStatus(); + + if (response) { + setMfaStatus({ + mfa_enabled: response.mfa_enabled || false, + backup_codes_count: response.backup_codes_count || 0, + }); + } + } catch (error: any) { + // Don't show error if request was aborted + if (error.name === 'AbortError') { + return; + } + // Error fetching MFA status - handled silently + } + }; + + + useEffect(() => { + if (profileData || userInfo) { + const data = profileData || (userInfo && 'user' in userInfo ? userInfo.user : userInfo); + resetProfile({ + name: (data as any)?.name || '', + email: (data as any)?.email || '', + phone: (data as any)?.phone || '', + }); + 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]); + + + 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]); + + + const onSubmitProfile = async (data: ProfileFormData) => { + try { + setLoading(true, 'Updating profile...'); + + if ('updateProfile' in authService) { + const response = await (authService as any).updateProfile({ + full_name: data.name, + email: data.email, + phone_number: data.phone, + }); + + if (response.status === 'success' || response.success) { + const updatedUser = response.data?.user || response.data; + if (updatedUser) { + setUser(updatedUser); + toast.success('Profile updated successfully!'); + refetchProfile(); + } + } + } else { + const { updateUser } = await import('../../features/auth/services/userService'); + const user = userInfo && 'user' in userInfo ? userInfo.user : userInfo; + const response = await updateUser((user as any)?.id || (userInfo as any)?.id, { + full_name: data.name, + email: data.email, + phone_number: data.phone, + }); + + if ((response as any).success || (response as any).status === 'success') { + const updatedUser = response.data?.user || response.data; + if (updatedUser) { + setUser({ + id: updatedUser.id, + name: (updatedUser as any).full_name || (updatedUser as any).name, + email: updatedUser.email, + phone: (updatedUser as any).phone_number || (updatedUser as any).phone, + avatar: (updatedUser as any).avatar, + role: updatedUser.role, + }); + toast.success('Profile updated successfully!'); + refetchProfile(); + } + } + } + } catch (error: any) { + const errorMessage = + error.response?.data?.message || + error.message || + 'Failed to update profile'; + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + + const onSubmitPassword = async (data: PasswordFormData) => { + try { + setLoading(true, 'Changing password...'); + + if ('updateProfile' in authService) { + const response = await (authService as any).updateProfile({ + currentPassword: data.currentPassword, + password: data.newPassword, + }); + + if (response.status === 'success' || response.success) { + toast.success('Password changed successfully!'); + resetPassword(); + } + } else { + const { updateUser } = await import('../../features/auth/services/userService'); + const user = userInfo && 'user' in userInfo ? userInfo.user : userInfo; + const response = await updateUser((user as any)?.id || (userInfo as any)?.id, { + password: data.newPassword, + }); + + if ((response as any).success || (response as any).status === 'success') { + toast.success('Password changed successfully!'); + resetPassword(); + } + } + } catch (error: any) { + const errorMessage = + error.response?.data?.message || + error.message || + 'Failed to change password'; + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + + const handleAvatarChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + + if (!file.type.startsWith('image/')) { + toast.error('Please select an image file'); + return; + } + + + if (file.size > 2 * 1024 * 1024) { + toast.error('Image size must be less than 2MB'); + return; + } + + + const reader = new FileReader(); + reader.onloadend = () => { + setAvatarPreview(reader.result as string); + }; + reader.readAsDataURL(file); + + + try { + setLoading(true, 'Uploading avatar...'); + const response = await authService.uploadAvatar(file); + + // 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); + + 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. Please try again.'; + toast.error(errorMessage); + + // 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 = ''; + } + }; + + if (loadingProfile) { + return ; + } + + if (profileError && !userInfo) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {} +
+
+
+

+ Accountant Profile & Settings +

+
+

+ Manage your accountant account information, security, and privacy preferences +

+
+ + {} +
+
+ + + + + +
+
+ + {} + {activeTab === 'profile' && ( +
+
+ {} +
+
+ {(avatarPreview || userInfo?.avatar) && !avatarError ? ( + Profile { + // If image fails to load, show default avatar + setAvatarError(true); + }} + /> + ) : ( +
+ +
+ )} + +
+
+ +
+ + + {profileErrors.name && ( +

+ + {profileErrors.name.message} +

+ )} +
+ + {} +
+ + + {profileErrors.email && ( +

+ + {profileErrors.email.message} +

+ )} +
+ + {} +
+ + + {profileErrors.phone && ( +

+ + {profileErrors.phone.message} +

+ )} +
+ + {} +
+ +
+
+
+ )} + + {} + {activeTab === 'password' && ( +
+
+ {} +
+
+
+ +
+
+

+ Password Requirements +

+
    +
  • + + At least 6 characters long +
  • +
  • + + Use a combination of letters and numbers for better security +
  • +
+
+
+
+ + {} +
+ +
+ + +
+ {passwordErrors.currentPassword && ( +

+ + {passwordErrors.currentPassword.message} +

+ )} +
+ + {} +
+ +
+ + +
+ {passwordErrors.newPassword && ( +

+ + {passwordErrors.newPassword.message} +

+ )} +
+ + {} +
+ +
+ + +
+ {passwordErrors.confirmPassword && ( +

+ + {passwordErrors.confirmPassword.message} +

+ )} +
+ + {} +
+ +
+
+
+ )} + + {} + {activeTab === 'mfa' && ( +
+ {} +
+

+ + Two-Factor Authentication +

+

+ Add an extra layer of security to your account by requiring a verification code in addition to your password. +

+
+ + {mfaStatus?.mfa_enabled ? ( + +
+ {} +
+
+
+ +
+
+

MFA is Enabled

+

+ Your account is protected with two-factor authentication. +

+

+ Remaining backup codes: {mfaStatus.backup_codes_count} +

+
+
+
+ + {} + {showBackupCodes && ( +
+
+

+ + Save Your Backup Codes +

+

+ Store these codes in a safe place. Each code can only be used once. +

+
+
+ {showBackupCodes.map((code, index) => ( +
+ {code} + +
+ ))} +
+ +
+ )} + + {} +
+

+ + Backup Codes +

+

+ If you lose access to your authenticator app, you can use backup codes to access your account. +

+ +
+ + {} +
+

+ + Disable MFA +

+

+ Disabling MFA will reduce the security of your account. We recommend keeping it enabled. +

+ +
+
+ ) : ( + +
+ {!mfaSecret ? ( + +
+ +
+ ) : ( + +
+ {} +
+

+ + Step 1: Scan QR Code +

+

+ Use your authenticator app (Google Authenticator, Authy, etc.) to scan this QR code. +

+ {mfaQrCode && ( +
+ MFA QR Code +
+ )} +
+
+ Manual Entry Key: + +
+
+ + {showMfaSecret ? mfaSecret : '••••••••••••••••'} + + +
+
+
+ + {} +
+

+ + Step 2: Verify Setup +

+

+ Enter the 6-digit code from your authenticator app to complete the setup. +

+
+
+ + setMfaVerificationToken(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" + maxLength={6} + className="luxury-input text-center text-lg sm:text-xl tracking-widest font-mono" + /> +
+
+ + +
+
+
+
+ )} +
+ )} +
+ )} + + {/* Sessions Tab */} + {activeTab === 'sessions' && ( + + )} + + {/* GDPR Tab */} + {activeTab === 'gdpr' && ( + + )} +
+
+ ); +}; + +const SessionsTab: React.FC = () => { + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const abortControllerRef = useRef(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 || []); + } 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 ; + if (userAgent.includes('Mobile')) return ; + if (userAgent.includes('Tablet')) return ; + return ; + }; + + 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 { + 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) { + 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? This will also log you out.')) return; + + try { + 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) { + 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'); + } + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ + Active Sessions +

+

+ Manage your active sessions across different devices +

+
+ + {sessions.length === 0 ? ( +
+ +

No active sessions

+
+ ) : ( +
+ {sessions.length > 1 && ( +
+ +
+ )} + {sessions.map((session) => ( +
+
+
+
+ {getDeviceIcon(session.user_agent)} +
+
+

+ {getDeviceName(session.user_agent)} +

+

+ IP Address: {session.ip_address || 'Unknown'} +

+

+ Last Activity: {formatDate(session.last_activity)} +

+

+ Expires: {formatDate(session.expires_at)} +

+
+
+ +
+
+ ))} +
+ )} +
+ ); +}; + +const GDPRTab: React.FC = () => { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [requesting, setRequesting] = useState(false); + const abortControllerRef = useRef(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 ( +
+ +
+ ); + } + + return ( +
+
+

+ + Data Privacy & GDPR Rights +

+

+ Manage your personal data and exercise your privacy rights under GDPR +

+
+ + {/* Data Export */} +
+
+
+ +
+
+

Export Your Data

+

+ 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. +

+ +
+
+
+ + {/* Data Deletion */} +
+
+
+ +
+
+

Delete Your Data

+
+
+ +
+

Warning: This action cannot be undone

+

+ Requesting data deletion will permanently remove your account and all associated data including bookings, payments, and personal information. This action is irreversible. +

+
+
+
+ +
+
+
+ + {/* Request History */} + {requests.length > 0 && ( +
+

+ + Request History +

+
+ {requests.map((request) => ( +
+
+
+
+ {request.request_type === 'data_export' ? ( + + ) : ( + + )} +

+ {request.request_type === 'data_export' ? 'Data Export' : 'Data Deletion'} +

+ + {request.status.charAt(0).toUpperCase() + request.status.slice(1)} + +
+

+ Created: {formatDate(request.created_at)} + {request.processed_at && ` • Processed: ${formatDate(request.processed_at)}`} +

+
+ {request.request_type === 'data_export' && request.status === 'completed' && request.verification_token && ( + + )} +
+
+ ))} +
+
+ )} +
+ ); +}; + +export default ProfilePage; diff --git a/Frontend/src/pages/admin/ProfilePage.tsx b/Frontend/src/pages/admin/ProfilePage.tsx new file mode 100644 index 00000000..fafea992 --- /dev/null +++ b/Frontend/src/pages/admin/ProfilePage.tsx @@ -0,0 +1,1489 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { + User, + Mail, + Phone, + Save, + CheckCircle, + AlertCircle, + Lock, + Camera, + Shield, + ShieldCheck, + ShieldOff, + Copy, + Eye, + EyeOff, + RefreshCw, + KeyRound, + LogOut, + 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'; +import { useAsync } from '../../shared/hooks/useAsync'; +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 + .string() + .required('Full name is required') + .min(2, 'Full name must be at least 2 characters') + .max(100, 'Full name cannot exceed 100 characters'), + email: yup + .string() + .required('Email is required') + .email('Invalid email address'), + phone: yup + .string() + .required('Phone number is required') + .matches( + /^[\d\s\-\+\(\)]{5,}$/, + 'Please enter a valid phone number' + ), +}); + +const passwordValidationSchema = yup.object().shape({ + currentPassword: yup + .string() + .required('Current password is required'), + newPassword: yup + .string() + .required('New password is required') + .min(8, 'Password must be at least 8 characters') + .matches(/[A-Z]/, 'Password must contain at least one uppercase letter') + .matches(/[a-z]/, 'Password must contain at least one lowercase letter') + .matches(/[0-9]/, 'Password must contain at least one number') + .matches(/[!@#$%^&*(),.?":{}|<>]/, 'Password must contain at least one special character (!@#$%^&*(),.?":{}|<>)'), + confirmPassword: yup + .string() + .required('Please confirm your password') + .oneOf([yup.ref('newPassword')], 'Passwords must match'), +}); + +type ProfileFormData = yup.InferType; +type PasswordFormData = yup.InferType; + +const ProfilePage: React.FC = () => { + const { userInfo, setUser } = useAuthStore(); + const { setLoading } = useGlobalLoading(); + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'mfa' | 'sessions' | 'gdpr'>('profile'); + const [avatarPreview, setAvatarPreview] = useState(null); + const [avatarError, setAvatarError] = useState(false); + const [showPassword, setShowPassword] = useState<{ + current: boolean; + new: boolean; + confirm: boolean; + }>({ + current: false, + new: false, + confirm: false, + }); + + + const [mfaStatus, setMfaStatus] = useState<{mfa_enabled: boolean; backup_codes_count: number} | null>(null); + const [mfaSecret, setMfaSecret] = useState(null); + const [mfaQrCode, setMfaQrCode] = useState(null); + const [mfaVerificationToken, setMfaVerificationToken] = useState(''); + const [showBackupCodes, setShowBackupCodes] = useState(null); + const [showMfaSecret, setShowMfaSecret] = useState(false); + const mfaAbortControllerRef = useRef(null); + const sessionsAbortControllerRef = useRef(null); + const gdprAbortControllerRef = useRef(null); + + + const fetchProfile = async () => { + const response = await authService.getProfile(); + if (response.status === 'success' || response.success) { + const user = response.data?.user || response.data; + if (user) { + setUser(user as any); + return user; + } + } + throw new Error('Failed to load profile'); + }; + + const { + data: profileData, + loading: loadingProfile, + error: profileError, + execute: refetchProfile + } = useAsync(fetchProfile, { + immediate: true, + onError: (error: any) => { + toast.error(error.message || 'Unable to load profile'); + } + }); + + + const { + register: registerProfile, + handleSubmit: handleSubmitProfile, + formState: { errors: profileErrors }, + reset: resetProfile, + } = useForm({ + resolver: yupResolver(profileValidationSchema), + defaultValues: { + name: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.name || '', + email: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.email || '', + phone: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.phone || '', + }, + }); + + + const { + register: registerPassword, + handleSubmit: handleSubmitPassword, + formState: { errors: passwordErrors }, + reset: resetPassword, + } = useForm({ + resolver: yupResolver(passwordValidationSchema), + }); + + + const fetchMFAStatus = async () => { + try { + const response = await authService.getMFAStatus(); + + if (response) { + setMfaStatus({ + mfa_enabled: response.mfa_enabled || false, + backup_codes_count: response.backup_codes_count || 0, + }); + } + } catch (error: any) { + // Don't show error if request was aborted + if (error.name === 'AbortError') { + return; + } + // Error fetching MFA status - handled silently + } + }; + + + useEffect(() => { + if (profileData || userInfo) { + const data = profileData || (userInfo && 'user' in userInfo ? userInfo.user : userInfo); + resetProfile({ + name: (data as any)?.name || '', + email: (data as any)?.email || '', + phone: (data as any)?.phone || '', + }); + 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]); + + + 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]); + + + const onSubmitProfile = async (data: ProfileFormData) => { + try { + setLoading(true, 'Updating profile...'); + + if ('updateProfile' in authService) { + const response = await (authService as any).updateProfile({ + full_name: data.name, + email: data.email, + phone_number: data.phone, + }); + + if (response.status === 'success' || response.success) { + const updatedUser = response.data?.user || response.data; + if (updatedUser) { + setUser(updatedUser); + toast.success('Profile updated successfully!'); + refetchProfile(); + } + } + } else { + const { updateUser } = await import('../../features/auth/services/userService'); + const user = userInfo && 'user' in userInfo ? userInfo.user : userInfo; + const response = await updateUser((user as any)?.id || (userInfo as any)?.id, { + full_name: data.name, + email: data.email, + phone_number: data.phone, + }); + + if ((response as any).success || (response as any).status === 'success') { + const updatedUser = response.data?.user || response.data; + if (updatedUser) { + setUser({ + id: updatedUser.id, + name: (updatedUser as any).full_name || (updatedUser as any).name, + email: updatedUser.email, + phone: (updatedUser as any).phone_number || (updatedUser as any).phone, + avatar: (updatedUser as any).avatar, + role: updatedUser.role, + }); + toast.success('Profile updated successfully!'); + refetchProfile(); + } + } + } + } catch (error: any) { + const errorMessage = + error.response?.data?.message || + error.message || + 'Failed to update profile'; + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + + const onSubmitPassword = async (data: PasswordFormData) => { + try { + setLoading(true, 'Changing password...'); + + if ('updateProfile' in authService) { + const response = await (authService as any).updateProfile({ + currentPassword: data.currentPassword, + password: data.newPassword, + }); + + if (response.status === 'success' || response.success) { + toast.success('Password changed successfully!'); + resetPassword(); + } + } else { + const { updateUser } = await import('../../features/auth/services/userService'); + const user = userInfo && 'user' in userInfo ? userInfo.user : userInfo; + const response = await updateUser((user as any)?.id || (userInfo as any)?.id, { + password: data.newPassword, + }); + + if ((response as any).success || (response as any).status === 'success') { + toast.success('Password changed successfully!'); + resetPassword(); + } + } + } catch (error: any) { + const errorMessage = + error.response?.data?.message || + error.message || + 'Failed to change password'; + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + + const handleAvatarChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + + if (!file.type.startsWith('image/')) { + toast.error('Please select an image file'); + return; + } + + + if (file.size > 2 * 1024 * 1024) { + toast.error('Image size must be less than 2MB'); + return; + } + + + const reader = new FileReader(); + reader.onloadend = () => { + setAvatarPreview(reader.result as string); + }; + reader.readAsDataURL(file); + + + try { + setLoading(true, 'Uploading avatar...'); + const response = await authService.uploadAvatar(file); + + // 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); + + 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. Please try again.'; + toast.error(errorMessage); + + // 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 = ''; + } + }; + + if (loadingProfile) { + return ; + } + + if (profileError && !userInfo) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {} +
+
+
+

+ Admin Profile & Settings +

+
+

+ Manage your admin account information, security, and privacy preferences +

+
+ + {} +
+
+ + + + + +
+
+ + {} + {activeTab === 'profile' && ( +
+
+ {} +
+
+ {(avatarPreview || userInfo?.avatar) && !avatarError ? ( + Profile { + // If image fails to load, show default avatar + setAvatarError(true); + }} + /> + ) : ( +
+ +
+ )} + +
+
+ +
+ + + {profileErrors.name && ( +

+ + {profileErrors.name.message} +

+ )} +
+ + {} +
+ + + {profileErrors.email && ( +

+ + {profileErrors.email.message} +

+ )} +
+ + {} +
+ + + {profileErrors.phone && ( +

+ + {profileErrors.phone.message} +

+ )} +
+ + {} +
+ +
+
+
+ )} + + {} + {activeTab === 'password' && ( +
+
+ {} +
+
+
+ +
+
+

+ Password Requirements +

+
    +
  • + + At least 6 characters long +
  • +
  • + + Use a combination of letters and numbers for better security +
  • +
+
+
+
+ + {} +
+ +
+ + +
+ {passwordErrors.currentPassword && ( +

+ + {passwordErrors.currentPassword.message} +

+ )} +
+ + {} +
+ +
+ + +
+ {passwordErrors.newPassword && ( +

+ + {passwordErrors.newPassword.message} +

+ )} +
+ + {} +
+ +
+ + +
+ {passwordErrors.confirmPassword && ( +

+ + {passwordErrors.confirmPassword.message} +

+ )} +
+ + {} +
+ +
+
+
+ )} + + {} + {activeTab === 'mfa' && ( +
+ {} +
+

+ + Two-Factor Authentication +

+

+ Add an extra layer of security to your account by requiring a verification code in addition to your password. +

+
+ + {mfaStatus?.mfa_enabled ? ( + +
+ {} +
+
+
+ +
+
+

MFA is Enabled

+

+ Your account is protected with two-factor authentication. +

+

+ Remaining backup codes: {mfaStatus.backup_codes_count} +

+
+
+
+ + {} + {showBackupCodes && ( +
+
+

+ + Save Your Backup Codes +

+

+ Store these codes in a safe place. Each code can only be used once. +

+
+
+ {showBackupCodes.map((code, index) => ( +
+ {code} + +
+ ))} +
+ +
+ )} + + {} +
+

+ + Backup Codes +

+

+ If you lose access to your authenticator app, you can use backup codes to access your account. +

+ +
+ + {} +
+

+ + Disable MFA +

+

+ Disabling MFA will reduce the security of your account. We recommend keeping it enabled. +

+ +
+
+ ) : ( + +
+ {!mfaSecret ? ( + +
+ +
+ ) : ( + +
+ {} +
+

+ + Step 1: Scan QR Code +

+

+ Use your authenticator app (Google Authenticator, Authy, etc.) to scan this QR code. +

+ {mfaQrCode && ( +
+ MFA QR Code +
+ )} +
+
+ Manual Entry Key: + +
+
+ + {showMfaSecret ? mfaSecret : '••••••••••••••••'} + + +
+
+
+ + {} +
+

+ + Step 2: Verify Setup +

+

+ Enter the 6-digit code from your authenticator app to complete the setup. +

+
+
+ + setMfaVerificationToken(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" + maxLength={6} + className="luxury-input text-center text-lg sm:text-xl tracking-widest font-mono" + /> +
+
+ + +
+
+
+
+ )} +
+ )} +
+ )} + + {/* Sessions Tab */} + {activeTab === 'sessions' && ( + + )} + + {/* GDPR Tab */} + {activeTab === 'gdpr' && ( + + )} +
+
+ ); +}; + +const SessionsTab: React.FC = () => { + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const abortControllerRef = useRef(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 || []); + } 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 ; + if (userAgent.includes('Mobile')) return ; + if (userAgent.includes('Tablet')) return ; + return ; + }; + + 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 { + 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) { + 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? This will also log you out.')) return; + + try { + 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) { + 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'); + } + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ + Active Sessions +

+

+ Manage your active sessions across different devices +

+
+ + {sessions.length === 0 ? ( +
+ +

No active sessions

+
+ ) : ( +
+ {sessions.length > 1 && ( +
+ +
+ )} + {sessions.map((session) => ( +
+
+
+
+ {getDeviceIcon(session.user_agent)} +
+
+

+ {getDeviceName(session.user_agent)} +

+

+ IP Address: {session.ip_address || 'Unknown'} +

+

+ Last Activity: {formatDate(session.last_activity)} +

+

+ Expires: {formatDate(session.expires_at)} +

+
+
+ +
+
+ ))} +
+ )} +
+ ); +}; + +const GDPRTab: React.FC = () => { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [requesting, setRequesting] = useState(false); + const abortControllerRef = useRef(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 ( +
+ +
+ ); + } + + return ( +
+
+

+ + Data Privacy & GDPR Rights +

+

+ Manage your personal data and exercise your privacy rights under GDPR +

+
+ + {/* Data Export */} +
+
+
+ +
+
+

Export Your Data

+

+ 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. +

+ +
+
+
+ + {/* Data Deletion */} +
+
+
+ +
+
+

Delete Your Data

+
+
+ +
+

Warning: This action cannot be undone

+

+ Requesting data deletion will permanently remove your account and all associated data including bookings, payments, and personal information. This action is irreversible. +

+
+
+
+ +
+
+
+ + {/* Request History */} + {requests.length > 0 && ( +
+

+ + Request History +

+
+ {requests.map((request) => ( +
+
+
+
+ {request.request_type === 'data_export' ? ( + + ) : ( + + )} +

+ {request.request_type === 'data_export' ? 'Data Export' : 'Data Deletion'} +

+ + {request.status.charAt(0).toUpperCase() + request.status.slice(1)} + +
+

+ Created: {formatDate(request.created_at)} + {request.processed_at && ` • Processed: ${formatDate(request.processed_at)}`} +

+
+ {request.request_type === 'data_export' && request.status === 'completed' && request.verification_token && ( + + )} +
+
+ ))} +
+
+ )} +
+ ); +}; + +export default ProfilePage; diff --git a/Frontend/src/pages/staff/ProfilePage.tsx b/Frontend/src/pages/staff/ProfilePage.tsx new file mode 100644 index 00000000..25bf75ff --- /dev/null +++ b/Frontend/src/pages/staff/ProfilePage.tsx @@ -0,0 +1,1489 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { + User, + Mail, + Phone, + Save, + CheckCircle, + AlertCircle, + Lock, + Camera, + Shield, + ShieldCheck, + ShieldOff, + Copy, + Eye, + EyeOff, + RefreshCw, + KeyRound, + LogOut, + 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'; +import { useAsync } from '../../shared/hooks/useAsync'; +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 + .string() + .required('Full name is required') + .min(2, 'Full name must be at least 2 characters') + .max(100, 'Full name cannot exceed 100 characters'), + email: yup + .string() + .required('Email is required') + .email('Invalid email address'), + phone: yup + .string() + .required('Phone number is required') + .matches( + /^[\d\s\-\+\(\)]{5,}$/, + 'Please enter a valid phone number' + ), +}); + +const passwordValidationSchema = yup.object().shape({ + currentPassword: yup + .string() + .required('Current password is required'), + newPassword: yup + .string() + .required('New password is required') + .min(8, 'Password must be at least 8 characters') + .matches(/[A-Z]/, 'Password must contain at least one uppercase letter') + .matches(/[a-z]/, 'Password must contain at least one lowercase letter') + .matches(/[0-9]/, 'Password must contain at least one number') + .matches(/[!@#$%^&*(),.?":{}|<>]/, 'Password must contain at least one special character (!@#$%^&*(),.?":{}|<>)'), + confirmPassword: yup + .string() + .required('Please confirm your password') + .oneOf([yup.ref('newPassword')], 'Passwords must match'), +}); + +type ProfileFormData = yup.InferType; +type PasswordFormData = yup.InferType; + +const ProfilePage: React.FC = () => { + const { userInfo, setUser } = useAuthStore(); + const { setLoading } = useGlobalLoading(); + const navigate = useNavigate(); + const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'mfa' | 'sessions' | 'gdpr'>('profile'); + const [avatarPreview, setAvatarPreview] = useState(null); + const [avatarError, setAvatarError] = useState(false); + const [showPassword, setShowPassword] = useState<{ + current: boolean; + new: boolean; + confirm: boolean; + }>({ + current: false, + new: false, + confirm: false, + }); + + + const [mfaStatus, setMfaStatus] = useState<{mfa_enabled: boolean; backup_codes_count: number} | null>(null); + const [mfaSecret, setMfaSecret] = useState(null); + const [mfaQrCode, setMfaQrCode] = useState(null); + const [mfaVerificationToken, setMfaVerificationToken] = useState(''); + const [showBackupCodes, setShowBackupCodes] = useState(null); + const [showMfaSecret, setShowMfaSecret] = useState(false); + const mfaAbortControllerRef = useRef(null); + const sessionsAbortControllerRef = useRef(null); + const gdprAbortControllerRef = useRef(null); + + + const fetchProfile = async () => { + const response = await authService.getProfile(); + if (response.status === 'success' || response.success) { + const user = response.data?.user || response.data; + if (user) { + setUser(user as any); + return user; + } + } + throw new Error('Failed to load profile'); + }; + + const { + data: profileData, + loading: loadingProfile, + error: profileError, + execute: refetchProfile + } = useAsync(fetchProfile, { + immediate: true, + onError: (error: any) => { + toast.error(error.message || 'Unable to load profile'); + } + }); + + + const { + register: registerProfile, + handleSubmit: handleSubmitProfile, + formState: { errors: profileErrors }, + reset: resetProfile, + } = useForm({ + resolver: yupResolver(profileValidationSchema), + defaultValues: { + name: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.name || '', + email: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.email || '', + phone: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.phone || '', + }, + }); + + + const { + register: registerPassword, + handleSubmit: handleSubmitPassword, + formState: { errors: passwordErrors }, + reset: resetPassword, + } = useForm({ + resolver: yupResolver(passwordValidationSchema), + }); + + + const fetchMFAStatus = async () => { + try { + const response = await authService.getMFAStatus(); + + if (response) { + setMfaStatus({ + mfa_enabled: response.mfa_enabled || false, + backup_codes_count: response.backup_codes_count || 0, + }); + } + } catch (error: any) { + // Don't show error if request was aborted + if (error.name === 'AbortError') { + return; + } + // Error fetching MFA status - handled silently + } + }; + + + useEffect(() => { + if (profileData || userInfo) { + const data = profileData || (userInfo && 'user' in userInfo ? userInfo.user : userInfo); + resetProfile({ + name: (data as any)?.name || '', + email: (data as any)?.email || '', + phone: (data as any)?.phone || '', + }); + 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]); + + + 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]); + + + const onSubmitProfile = async (data: ProfileFormData) => { + try { + setLoading(true, 'Updating profile...'); + + if ('updateProfile' in authService) { + const response = await (authService as any).updateProfile({ + full_name: data.name, + email: data.email, + phone_number: data.phone, + }); + + if (response.status === 'success' || response.success) { + const updatedUser = response.data?.user || response.data; + if (updatedUser) { + setUser(updatedUser); + toast.success('Profile updated successfully!'); + refetchProfile(); + } + } + } else { + const { updateUser } = await import('../../features/auth/services/userService'); + const user = userInfo && 'user' in userInfo ? userInfo.user : userInfo; + const response = await updateUser((user as any)?.id || (userInfo as any)?.id, { + full_name: data.name, + email: data.email, + phone_number: data.phone, + }); + + if ((response as any).success || (response as any).status === 'success') { + const updatedUser = response.data?.user || response.data; + if (updatedUser) { + setUser({ + id: updatedUser.id, + name: (updatedUser as any).full_name || (updatedUser as any).name, + email: updatedUser.email, + phone: (updatedUser as any).phone_number || (updatedUser as any).phone, + avatar: (updatedUser as any).avatar, + role: updatedUser.role, + }); + toast.success('Profile updated successfully!'); + refetchProfile(); + } + } + } + } catch (error: any) { + const errorMessage = + error.response?.data?.message || + error.message || + 'Failed to update profile'; + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + + const onSubmitPassword = async (data: PasswordFormData) => { + try { + setLoading(true, 'Changing password...'); + + if ('updateProfile' in authService) { + const response = await (authService as any).updateProfile({ + currentPassword: data.currentPassword, + password: data.newPassword, + }); + + if (response.status === 'success' || response.success) { + toast.success('Password changed successfully!'); + resetPassword(); + } + } else { + const { updateUser } = await import('../../features/auth/services/userService'); + const user = userInfo && 'user' in userInfo ? userInfo.user : userInfo; + const response = await updateUser((user as any)?.id || (userInfo as any)?.id, { + password: data.newPassword, + }); + + if ((response as any).success || (response as any).status === 'success') { + toast.success('Password changed successfully!'); + resetPassword(); + } + } + } catch (error: any) { + const errorMessage = + error.response?.data?.message || + error.message || + 'Failed to change password'; + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + + const handleAvatarChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + + if (!file.type.startsWith('image/')) { + toast.error('Please select an image file'); + return; + } + + + if (file.size > 2 * 1024 * 1024) { + toast.error('Image size must be less than 2MB'); + return; + } + + + const reader = new FileReader(); + reader.onloadend = () => { + setAvatarPreview(reader.result as string); + }; + reader.readAsDataURL(file); + + + try { + setLoading(true, 'Uploading avatar...'); + const response = await authService.uploadAvatar(file); + + // 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); + + 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. Please try again.'; + toast.error(errorMessage); + + // 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 = ''; + } + }; + + if (loadingProfile) { + return ; + } + + if (profileError && !userInfo) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {} +
+
+
+

+ Staff Profile & Settings +

+
+

+ Manage your staff account information, security, and privacy preferences +

+
+ + {} +
+
+ + + + + +
+
+ + {} + {activeTab === 'profile' && ( +
+
+ {} +
+
+ {(avatarPreview || userInfo?.avatar) && !avatarError ? ( + Profile { + // If image fails to load, show default avatar + setAvatarError(true); + }} + /> + ) : ( +
+ +
+ )} + +
+
+ +
+ + + {profileErrors.name && ( +

+ + {profileErrors.name.message} +

+ )} +
+ + {} +
+ + + {profileErrors.email && ( +

+ + {profileErrors.email.message} +

+ )} +
+ + {} +
+ + + {profileErrors.phone && ( +

+ + {profileErrors.phone.message} +

+ )} +
+ + {} +
+ +
+
+
+ )} + + {} + {activeTab === 'password' && ( +
+
+ {} +
+
+
+ +
+
+

+ Password Requirements +

+
    +
  • + + At least 6 characters long +
  • +
  • + + Use a combination of letters and numbers for better security +
  • +
+
+
+
+ + {} +
+ +
+ + +
+ {passwordErrors.currentPassword && ( +

+ + {passwordErrors.currentPassword.message} +

+ )} +
+ + {} +
+ +
+ + +
+ {passwordErrors.newPassword && ( +

+ + {passwordErrors.newPassword.message} +

+ )} +
+ + {} +
+ +
+ + +
+ {passwordErrors.confirmPassword && ( +

+ + {passwordErrors.confirmPassword.message} +

+ )} +
+ + {} +
+ +
+
+
+ )} + + {} + {activeTab === 'mfa' && ( +
+ {} +
+

+ + Two-Factor Authentication +

+

+ Add an extra layer of security to your account by requiring a verification code in addition to your password. +

+
+ + {mfaStatus?.mfa_enabled ? ( + +
+ {} +
+
+
+ +
+
+

MFA is Enabled

+

+ Your account is protected with two-factor authentication. +

+

+ Remaining backup codes: {mfaStatus.backup_codes_count} +

+
+
+
+ + {} + {showBackupCodes && ( +
+
+

+ + Save Your Backup Codes +

+

+ Store these codes in a safe place. Each code can only be used once. +

+
+
+ {showBackupCodes.map((code, index) => ( +
+ {code} + +
+ ))} +
+ +
+ )} + + {} +
+

+ + Backup Codes +

+

+ If you lose access to your authenticator app, you can use backup codes to access your account. +

+ +
+ + {} +
+

+ + Disable MFA +

+

+ Disabling MFA will reduce the security of your account. We recommend keeping it enabled. +

+ +
+
+ ) : ( + +
+ {!mfaSecret ? ( + +
+ +
+ ) : ( + +
+ {} +
+

+ + Step 1: Scan QR Code +

+

+ Use your authenticator app (Google Authenticator, Authy, etc.) to scan this QR code. +

+ {mfaQrCode && ( +
+ MFA QR Code +
+ )} +
+
+ Manual Entry Key: + +
+
+ + {showMfaSecret ? mfaSecret : '••••••••••••••••'} + + +
+
+
+ + {} +
+

+ + Step 2: Verify Setup +

+

+ Enter the 6-digit code from your authenticator app to complete the setup. +

+
+
+ + setMfaVerificationToken(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" + maxLength={6} + className="luxury-input text-center text-lg sm:text-xl tracking-widest font-mono" + /> +
+
+ + +
+
+
+
+ )} +
+ )} +
+ )} + + {/* Sessions Tab */} + {activeTab === 'sessions' && ( + + )} + + {/* GDPR Tab */} + {activeTab === 'gdpr' && ( + + )} +
+
+ ); +}; + +const SessionsTab: React.FC = () => { + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const abortControllerRef = useRef(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 || []); + } 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 ; + if (userAgent.includes('Mobile')) return ; + if (userAgent.includes('Tablet')) return ; + return ; + }; + + 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 { + 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) { + 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? This will also log you out.')) return; + + try { + 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) { + 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'); + } + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ + Active Sessions +

+

+ Manage your active sessions across different devices +

+
+ + {sessions.length === 0 ? ( +
+ +

No active sessions

+
+ ) : ( +
+ {sessions.length > 1 && ( +
+ +
+ )} + {sessions.map((session) => ( +
+
+
+
+ {getDeviceIcon(session.user_agent)} +
+
+

+ {getDeviceName(session.user_agent)} +

+

+ IP Address: {session.ip_address || 'Unknown'} +

+

+ Last Activity: {formatDate(session.last_activity)} +

+

+ Expires: {formatDate(session.expires_at)} +

+
+
+ +
+
+ ))} +
+ )} +
+ ); +}; + +const GDPRTab: React.FC = () => { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [requesting, setRequesting] = useState(false); + const abortControllerRef = useRef(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 ( +
+ +
+ ); + } + + return ( +
+
+

+ + Data Privacy & GDPR Rights +

+

+ Manage your personal data and exercise your privacy rights under GDPR +

+
+ + {/* Data Export */} +
+
+
+ +
+
+

Export Your Data

+

+ 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. +

+ +
+
+
+ + {/* Data Deletion */} +
+
+
+ +
+
+

Delete Your Data

+
+
+ +
+

Warning: This action cannot be undone

+

+ Requesting data deletion will permanently remove your account and all associated data including bookings, payments, and personal information. This action is irreversible. +

+
+
+
+ +
+
+
+ + {/* Request History */} + {requests.length > 0 && ( +
+

+ + Request History +

+
+ {requests.map((request) => ( +
+
+
+
+ {request.request_type === 'data_export' ? ( + + ) : ( + + )} +

+ {request.request_type === 'data_export' ? 'Data Export' : 'Data Deletion'} +

+ + {request.status.charAt(0).toUpperCase() + request.status.slice(1)} + +
+

+ Created: {formatDate(request.created_at)} + {request.processed_at && ` • Processed: ${formatDate(request.processed_at)}`} +

+
+ {request.request_type === 'data_export' && request.status === 'completed' && request.verification_token && ( + + )} +
+
+ ))} +
+
+ )} +
+ ); +}; + +export default ProfilePage; diff --git a/Frontend/src/shared/components/SidebarAccountant.tsx b/Frontend/src/shared/components/SidebarAccountant.tsx index ed92f8b9..eb536754 100644 --- a/Frontend/src/shared/components/SidebarAccountant.tsx +++ b/Frontend/src/shared/components/SidebarAccountant.tsx @@ -9,7 +9,8 @@ import { Menu, X, CreditCard, - Receipt + Receipt, + User } from 'lucide-react'; import useAuthStore from '../../store/useAuthStore'; import { useResponsive } from '../../hooks'; @@ -95,6 +96,11 @@ const SidebarAccountant: React.FC = ({ icon: BarChart3, label: 'Financial Reports' }, + { + path: '/accountant/profile', + icon: User, + label: 'My Profile' + }, ]; const isActive = (path: string) => { diff --git a/Frontend/src/shared/components/SidebarAdmin.tsx b/Frontend/src/shared/components/SidebarAdmin.tsx index c4623ce7..1c881d3c 100644 --- a/Frontend/src/shared/components/SidebarAdmin.tsx +++ b/Frontend/src/shared/components/SidebarAdmin.tsx @@ -303,6 +303,11 @@ const SidebarAdmin: React.FC = ({ icon: HardDrive, label: 'Backups' }, + { + path: '/admin/profile', + icon: User, + label: 'My Profile' + }, ] }, ]; diff --git a/Frontend/src/shared/components/SidebarStaff.tsx b/Frontend/src/shared/components/SidebarStaff.tsx index 247e1a01..9b3eac7b 100644 --- a/Frontend/src/shared/components/SidebarStaff.tsx +++ b/Frontend/src/shared/components/SidebarStaff.tsx @@ -127,6 +127,11 @@ const SidebarStaff: React.FC = ({ icon: BarChart3, label: 'Reports' }, + { + path: '/staff/profile', + icon: Users, + label: 'My Profile' + }, ]; const isActive = (path: string) => {