updates
This commit is contained in:
@@ -24,6 +24,7 @@ import Loading from './shared/components/Loading';
|
||||
import Preloader from './shared/components/Preloader';
|
||||
import ScrollToTop from './shared/components/ScrollToTop';
|
||||
import AuthModalManager from './features/auth/components/AuthModalManager';
|
||||
import StepUpAuthManager from './features/auth/components/StepUpAuthManager';
|
||||
import ResetPasswordRouteHandler from './features/auth/components/ResetPasswordRouteHandler';
|
||||
import ErrorBoundaryRoute from './shared/components/ErrorBoundaryRoute';
|
||||
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
CustomerRoute,
|
||||
HousekeepingRoute
|
||||
} from './features/auth/components';
|
||||
import { StepUpAuthProvider } from './features/auth/contexts/StepUpAuthContext';
|
||||
|
||||
const HomePage = lazy(() => import('./features/content/pages/HomePage'));
|
||||
const DashboardPage = lazy(() => import('./pages/customer/DashboardPage'));
|
||||
@@ -250,7 +252,8 @@ function App() {
|
||||
<CompanySettingsProvider>
|
||||
<AntibotProvider>
|
||||
<AuthModalProvider>
|
||||
<RoomProvider>
|
||||
<StepUpAuthProvider>
|
||||
<RoomProvider>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
@@ -496,6 +499,16 @@ function App() {
|
||||
</ErrorBoundaryRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="guest-requests"
|
||||
element={
|
||||
<ErrorBoundaryRoute>
|
||||
<CustomerRoute>
|
||||
<GuestRequestsPage />
|
||||
</CustomerRoute>
|
||||
</ErrorBoundaryRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gdpr"
|
||||
element={
|
||||
@@ -953,9 +966,11 @@ function App() {
|
||||
<CookiePreferencesModal />
|
||||
<AnalyticsLoader />
|
||||
<AuthModalManager />
|
||||
<StepUpAuthManager />
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</RoomProvider>
|
||||
</RoomProvider>
|
||||
</StepUpAuthProvider>
|
||||
</AuthModalProvider>
|
||||
</AntibotProvider>
|
||||
</CompanySettingsProvider>
|
||||
|
||||
57
Frontend/src/features/auth/components/StepUpAuthManager.tsx
Normal file
57
Frontend/src/features/auth/components/StepUpAuthManager.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useStepUpAuth } from '../contexts/StepUpAuthContext';
|
||||
import StepUpAuthModal from './StepUpAuthModal';
|
||||
|
||||
// Store reference to context functions for event listener
|
||||
let stepUpContextRef: { openStepUp: (action: string, request: () => Promise<any>) => void } | null = null;
|
||||
|
||||
const StepUpAuthManager: React.FC = () => {
|
||||
const { isOpen, actionDescription, closeStepUp, onStepUpSuccess, openStepUp } = useStepUpAuth();
|
||||
const contextRef = useRef({ openStepUp });
|
||||
|
||||
// Update ref when context changes
|
||||
useEffect(() => {
|
||||
contextRef.current = { openStepUp };
|
||||
stepUpContextRef = { openStepUp };
|
||||
}, [openStepUp]);
|
||||
|
||||
// Listen for step-up required events from API client
|
||||
useEffect(() => {
|
||||
const handleStepUpRequired = (event: CustomEvent) => {
|
||||
console.log('Step-up required event received', event.detail);
|
||||
const { action, originalRequest } = event.detail;
|
||||
|
||||
// Store the original request config for retry
|
||||
const retryRequest = async () => {
|
||||
if (originalRequest && typeof originalRequest === 'function') {
|
||||
return await originalRequest();
|
||||
}
|
||||
};
|
||||
|
||||
// Open step-up modal with the pending request
|
||||
if (stepUpContextRef && stepUpContextRef.openStepUp) {
|
||||
console.log('Opening step-up modal', { action: action || 'this action' });
|
||||
stepUpContextRef.openStepUp(action || 'this action', retryRequest);
|
||||
} else {
|
||||
console.warn('StepUpAuthContext ref not available');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('auth:step-up-required', handleStepUpRequired as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('auth:step-up-required', handleStepUpRequired as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StepUpAuthModal
|
||||
isOpen={isOpen}
|
||||
onClose={closeStepUp}
|
||||
onSuccess={onStepUpSuccess}
|
||||
actionDescription={actionDescription}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepUpAuthManager;
|
||||
|
||||
335
Frontend/src/features/auth/components/StepUpAuthModal.tsx
Normal file
335
Frontend/src/features/auth/components/StepUpAuthModal.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Shield, Lock, KeyRound } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { toast } from 'react-toastify';
|
||||
import accountantSecurityService from '../../security/services/accountantSecurityService';
|
||||
import useAuthStore from '../../../store/useAuthStore';
|
||||
|
||||
const mfaTokenSchema = yup.object({
|
||||
mfaToken: yup
|
||||
.string()
|
||||
.required('MFA token is required')
|
||||
.matches(/^\d{6}$/, 'MFA token must be 6 digits'),
|
||||
});
|
||||
|
||||
const passwordSchema = yup.object({
|
||||
password: yup
|
||||
.string()
|
||||
.required('Password is required')
|
||||
.min(6, 'Password must be at least 6 characters'),
|
||||
});
|
||||
|
||||
type MFATokenFormData = yup.InferType<typeof mfaTokenSchema>;
|
||||
type PasswordFormData = yup.InferType<typeof passwordSchema>;
|
||||
|
||||
interface StepUpAuthModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
actionDescription?: string;
|
||||
}
|
||||
|
||||
const StepUpAuthModal: React.FC<StepUpAuthModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
actionDescription = 'this action',
|
||||
}) => {
|
||||
const { userInfo } = useAuthStore();
|
||||
const [verificationMethod, setVerificationMethod] = useState<'mfa' | 'password'>('mfa');
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register: registerMFA,
|
||||
handleSubmit: handleSubmitMFA,
|
||||
formState: { errors: mfaErrors },
|
||||
reset: resetMFA,
|
||||
} = useForm<MFATokenFormData>({
|
||||
resolver: yupResolver(mfaTokenSchema),
|
||||
defaultValues: {
|
||||
mfaToken: '',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
register: registerPassword,
|
||||
handleSubmit: handleSubmitPassword,
|
||||
formState: { errors: passwordErrors },
|
||||
reset: resetPassword,
|
||||
} = useForm<PasswordFormData>({
|
||||
resolver: yupResolver(passwordSchema),
|
||||
defaultValues: {
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setError(null);
|
||||
resetMFA();
|
||||
resetPassword();
|
||||
// Default to MFA if user has it enabled, otherwise password
|
||||
// You can check userInfo.mfa_enabled if available
|
||||
setVerificationMethod('mfa');
|
||||
}
|
||||
}, [isOpen, resetMFA, resetPassword]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen && !isVerifying) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, isVerifying, onClose]);
|
||||
|
||||
const onSubmitMFA = async (data: MFATokenFormData) => {
|
||||
try {
|
||||
setIsVerifying(true);
|
||||
setError(null);
|
||||
|
||||
const response = await accountantSecurityService.verifyStepUp({
|
||||
mfa_token: data.mfaToken,
|
||||
});
|
||||
|
||||
if (response.status === 'success' && response.data.step_up_completed) {
|
||||
toast.success('Identity verified successfully');
|
||||
// Small delay to ensure backend commit is complete before retrying
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
onSuccess();
|
||||
onClose();
|
||||
} else {
|
||||
throw new Error('Step-up verification failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.detail || error.response?.data?.message || 'Failed to verify identity. Please try again.';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmitPassword = async (data: PasswordFormData) => {
|
||||
try {
|
||||
setIsVerifying(true);
|
||||
setError(null);
|
||||
|
||||
const response = await accountantSecurityService.verifyStepUp({
|
||||
password: data.password,
|
||||
});
|
||||
|
||||
if (response.status === 'success' && response.data.step_up_completed) {
|
||||
toast.success('Identity verified successfully');
|
||||
// Small delay to ensure backend commit is complete before retrying
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
onSuccess();
|
||||
onClose();
|
||||
} else {
|
||||
throw new Error('Step-up verification failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage =
|
||||
error.response?.data?.detail || error.response?.data?.message || 'Invalid password. Please try again.';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center p-3 sm:p-4 md:p-6"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget && !isVerifying) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md max-h-[95vh] overflow-y-auto bg-gradient-to-br from-gray-50 via-white to-gray-50 rounded-lg shadow-2xl border border-amber-200">
|
||||
{/* Close button */}
|
||||
{!isVerifying && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 z-10 p-2 rounded-full hover:bg-gray-100 transition-colors text-gray-500 hover:text-gray-900"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-amber-400 to-amber-600 rounded-full flex items-center justify-center mb-4">
|
||||
<Shield className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Verify Your Identity</h2>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Step-up authentication is required for <span className="font-semibold">{actionDescription}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Verification method selector */}
|
||||
<div className="mb-6 flex gap-2 p-1 bg-gray-100 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isVerifying) {
|
||||
setVerificationMethod('mfa');
|
||||
setError(null);
|
||||
resetMFA();
|
||||
}
|
||||
}}
|
||||
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
verificationMethod === 'mfa'
|
||||
? 'bg-white text-amber-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
} ${isVerifying ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
disabled={isVerifying}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<KeyRound className="w-4 h-4" />
|
||||
MFA Token
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isVerifying) {
|
||||
setVerificationMethod('password');
|
||||
setError(null);
|
||||
resetPassword();
|
||||
}
|
||||
}}
|
||||
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
|
||||
verificationMethod === 'password'
|
||||
? 'bg-white text-amber-600 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
} ${isVerifying ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
disabled={isVerifying}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
Password
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MFA Form */}
|
||||
{verificationMethod === 'mfa' && (
|
||||
<form onSubmit={handleSubmitMFA(onSubmitMFA)} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="mfaToken" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Enter 6-digit MFA code
|
||||
</label>
|
||||
<input
|
||||
{...registerMFA('mfaToken')}
|
||||
type="text"
|
||||
id="mfaToken"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500 text-center text-2xl tracking-widest font-mono"
|
||||
disabled={isVerifying}
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
{mfaErrors.mfaToken && (
|
||||
<p className="mt-1 text-sm text-red-600">{mfaErrors.mfaToken.message}</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-gray-500">Enter the 6-digit code from your authenticator app</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isVerifying}
|
||||
className="w-full py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isVerifying ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-5 h-5" />
|
||||
Verify Identity
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Password Form */}
|
||||
{verificationMethod === 'password' && (
|
||||
<form onSubmit={handleSubmitPassword(onSubmitPassword)} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Re-enter your password
|
||||
</label>
|
||||
<input
|
||||
{...registerPassword('password')}
|
||||
type="password"
|
||||
id="password"
|
||||
placeholder="Enter your password"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-amber-500"
|
||||
disabled={isVerifying}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{passwordErrors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{passwordErrors.password.message}</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-gray-500">Enter your password to verify your identity</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isVerifying}
|
||||
className="w-full py-3 bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-lg font-semibold hover:from-amber-600 hover:to-amber-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isVerifying ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-5 h-5" />
|
||||
Verify Identity
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-xs text-blue-800">
|
||||
<strong>Note:</strong> Step-up authentication is valid for 15 minutes. You won't need to verify again for
|
||||
similar actions during this time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepUpAuthModal;
|
||||
|
||||
67
Frontend/src/features/auth/contexts/StepUpAuthContext.tsx
Normal file
67
Frontend/src/features/auth/contexts/StepUpAuthContext.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
interface StepUpAuthContextType {
|
||||
isOpen: boolean;
|
||||
actionDescription: string;
|
||||
pendingRequest: (() => Promise<any>) | null;
|
||||
openStepUp: (actionDescription: string, pendingRequest: () => Promise<any>) => void;
|
||||
closeStepUp: () => void;
|
||||
onStepUpSuccess: () => void;
|
||||
}
|
||||
|
||||
const StepUpAuthContext = createContext<StepUpAuthContextType | undefined>(undefined);
|
||||
|
||||
export const StepUpAuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [actionDescription, setActionDescription] = useState('');
|
||||
const [pendingRequest, setPendingRequest] = useState<(() => Promise<any>) | null>(null);
|
||||
|
||||
const openStepUp = useCallback((action: string, request: () => Promise<any>) => {
|
||||
console.log('openStepUp called', { action, hasRequest: !!request });
|
||||
setActionDescription(action);
|
||||
setPendingRequest(() => request);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeStepUp = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setActionDescription('');
|
||||
setPendingRequest(null);
|
||||
}, []);
|
||||
|
||||
const onStepUpSuccess = useCallback(async () => {
|
||||
if (pendingRequest) {
|
||||
try {
|
||||
await pendingRequest();
|
||||
} catch (error) {
|
||||
// Error will be handled by the original request handler
|
||||
console.error('Error retrying request after step-up:', error);
|
||||
}
|
||||
}
|
||||
closeStepUp();
|
||||
}, [pendingRequest, closeStepUp]);
|
||||
|
||||
return (
|
||||
<StepUpAuthContext.Provider
|
||||
value={{
|
||||
isOpen,
|
||||
actionDescription,
|
||||
pendingRequest,
|
||||
openStepUp,
|
||||
closeStepUp,
|
||||
onStepUpSuccess,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StepUpAuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useStepUpAuth = () => {
|
||||
const context = useContext(StepUpAuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useStepUpAuth must be used within a StepUpAuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useState } from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
import useFavoritesStore from '../../../store/useFavoritesStore';
|
||||
import useAuthStore from '../../../store/useAuthStore';
|
||||
import { useAuthModal } from '../../../features/auth/contexts/AuthModalContext';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface FavoriteButtonProps {
|
||||
roomId: number;
|
||||
@@ -16,7 +18,8 @@ const FavoriteButton: React.FC<FavoriteButtonProps> = ({
|
||||
showTooltip = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const { userInfo } = useAuthStore();
|
||||
const { userInfo, isAuthenticated } = useAuthStore();
|
||||
const { openModal } = useAuthModal();
|
||||
const {
|
||||
isFavorited,
|
||||
addToFavorites,
|
||||
@@ -26,6 +29,7 @@ const FavoriteButton: React.FC<FavoriteButtonProps> = ({
|
||||
const [showTooltipText, setShowTooltipText] =
|
||||
useState(false);
|
||||
|
||||
// Hide button for admin, staff, accountant
|
||||
if (userInfo?.role === 'admin' || userInfo?.role === 'staff' || userInfo?.role === 'accountant') {
|
||||
return null;
|
||||
}
|
||||
@@ -53,6 +57,13 @@ const FavoriteButton: React.FC<FavoriteButtonProps> = ({
|
||||
|
||||
if (isProcessing) return;
|
||||
|
||||
// Require authentication for adding/removing favorites
|
||||
if (!isAuthenticated || userInfo?.role !== 'customer') {
|
||||
toast.info('Please login as a customer to add favorites');
|
||||
openModal('login');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (favorited) {
|
||||
|
||||
@@ -51,6 +51,12 @@ const TeamChatPage: React.FC<TeamChatPageProps> = ({ role }) => {
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
const selectedChannelRef = useRef<TeamChannel | null>(null);
|
||||
|
||||
// Keep ref in sync with state
|
||||
useEffect(() => {
|
||||
selectedChannelRef.current = selectedChannel;
|
||||
}, [selectedChannel]);
|
||||
|
||||
// Fetch channels
|
||||
const fetchChannels = useCallback(async () => {
|
||||
@@ -96,28 +102,43 @@ const TeamChatPage: React.FC<TeamChatPageProps> = ({ role }) => {
|
||||
|
||||
// Initialize WebSocket
|
||||
useEffect(() => {
|
||||
if (!userInfo?.id) return;
|
||||
|
||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/v1/team-chat/ws`;
|
||||
const socket = new WebSocket(wsUrl);
|
||||
|
||||
socket.onopen = () => {
|
||||
// Send authentication
|
||||
socket.send(JSON.stringify({ type: 'auth', user_id: userInfo?.id }));
|
||||
// Send authentication only when socket is open
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
socket.send(JSON.stringify({ type: 'auth', user_id: userInfo.id }));
|
||||
} catch (error) {
|
||||
console.error('Error sending WebSocket auth:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'new_message' && data.data.channel_id === selectedChannel?.id) {
|
||||
setMessages(prev => [...prev, data.data]);
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
} else if (data.type === 'new_message_notification') {
|
||||
// Show notification for messages in other channels
|
||||
toast.info(`New message in ${data.data.channel_name || 'Team Chat'}`);
|
||||
fetchChannels(); // Refresh unread counts
|
||||
} else if (data.type === 'message_edited') {
|
||||
setMessages(prev => prev.map(m => m.id === data.data.id ? data.data : m));
|
||||
} else if (data.type === 'message_deleted') {
|
||||
setMessages(prev => prev.filter(m => m.id !== data.data.id));
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Use ref to get current selectedChannel without causing re-renders
|
||||
const currentChannel = selectedChannelRef.current;
|
||||
|
||||
if (data.type === 'new_message' && data.data.channel_id === currentChannel?.id) {
|
||||
setMessages(prev => [...prev, data.data]);
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
} else if (data.type === 'new_message_notification') {
|
||||
// Show notification for messages in other channels
|
||||
toast.info(`New message in ${data.data.channel_name || 'Team Chat'}`);
|
||||
fetchChannels(); // Refresh unread counts
|
||||
} else if (data.type === 'message_edited') {
|
||||
setMessages(prev => prev.map(m => m.id === data.data.id ? data.data : m));
|
||||
} else if (data.type === 'message_deleted') {
|
||||
setMessages(prev => prev.filter(m => m.id !== data.data.id));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,12 +146,19 @@ const TeamChatPage: React.FC<TeamChatPageProps> = ({ role }) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
console.log('WebSocket closed');
|
||||
};
|
||||
|
||||
setWs(socket);
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
||||
socket.close();
|
||||
}
|
||||
setWs(null);
|
||||
};
|
||||
}, [userInfo?.id, selectedChannel?.id, fetchChannels]);
|
||||
}, [userInfo?.id]); // Removed selectedChannel?.id and fetchChannels from dependencies
|
||||
|
||||
// Initial data load
|
||||
useEffect(() => {
|
||||
@@ -146,8 +174,14 @@ const TeamChatPage: React.FC<TeamChatPageProps> = ({ role }) => {
|
||||
useEffect(() => {
|
||||
if (selectedChannel) {
|
||||
fetchMessages(selectedChannel.id);
|
||||
// Join channel in WebSocket
|
||||
ws?.send(JSON.stringify({ type: 'join_channel', channel_id: selectedChannel.id }));
|
||||
// Join channel in WebSocket - only if socket is open
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'join_channel', channel_id: selectedChannel.id }));
|
||||
} catch (error) {
|
||||
console.error('Error joining channel via WebSocket:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [selectedChannel, fetchMessages, ws]);
|
||||
|
||||
|
||||
@@ -9,15 +9,18 @@ import Pagination from '../../shared/components/Pagination';
|
||||
import useAuthStore from '../../store/useAuthStore';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import { useApiCall } from '../../shared/hooks/useApiCall';
|
||||
import { useStepUpAuth } from '../../features/auth/contexts/StepUpAuthContext';
|
||||
|
||||
const UserManagementPage: React.FC = () => {
|
||||
const { userInfo } = useAuthStore();
|
||||
const { openStepUp } = useStepUpAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [deletingUserId, setDeletingUserId] = useState<number | null>(null);
|
||||
const pendingSubmitDataRef = useRef<{ data: any; isEdit: boolean } | null>(null);
|
||||
|
||||
const { execute: executeSubmit, isLoading: isSubmitting } = useApiCall(
|
||||
async (data: any, isEdit: boolean) => {
|
||||
@@ -113,28 +116,84 @@ const UserManagementPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const submitData: any = {
|
||||
full_name: formData.full_name,
|
||||
email: formData.email,
|
||||
phone_number: formData.phone_number,
|
||||
role: formData.role,
|
||||
status: formData.status,
|
||||
};
|
||||
|
||||
if (editingUser) {
|
||||
if (formData.password && formData.password.trim() !== '') {
|
||||
submitData.password = formData.password;
|
||||
}
|
||||
logger.debug('Updating user', { userId: editingUser.id, updateData: submitData });
|
||||
} else {
|
||||
const submitData: any = {
|
||||
full_name: formData.full_name,
|
||||
email: formData.email,
|
||||
phone_number: formData.phone_number,
|
||||
role: formData.role,
|
||||
status: formData.status,
|
||||
};
|
||||
|
||||
if (editingUser) {
|
||||
if (formData.password && formData.password.trim() !== '') {
|
||||
submitData.password = formData.password;
|
||||
logger.debug('Creating user', { formData: submitData });
|
||||
}
|
||||
|
||||
logger.debug('Updating user', { userId: editingUser.id, updateData: submitData });
|
||||
} else {
|
||||
submitData.password = formData.password;
|
||||
logger.debug('Creating user', { formData: submitData });
|
||||
}
|
||||
|
||||
// Store data for retry after step-up
|
||||
pendingSubmitDataRef.current = { data: submitData, isEdit: !!editingUser };
|
||||
|
||||
try {
|
||||
await executeSubmit(submitData, !!editingUser);
|
||||
} catch (error: any) {
|
||||
logger.error('Error submitting user', error);
|
||||
|
||||
// Check if step-up authentication is required
|
||||
// Check both the original response structure and the modified error from API client
|
||||
const errorData = error.response?.data;
|
||||
const errorDetail = errorData?.detail;
|
||||
|
||||
// Check for step-up required in multiple ways
|
||||
const isStepUpRequired =
|
||||
error.requiresStepUp === true ||
|
||||
error.stepUpAction !== undefined ||
|
||||
(error.response?.status === 403 &&
|
||||
(errorDetail?.error === 'step_up_required' ||
|
||||
errorData?.error === 'step_up_required' ||
|
||||
(typeof errorDetail === 'object' && errorDetail?.error === 'step_up_required') ||
|
||||
(typeof errorDetail === 'string' && errorDetail.includes('Step-up authentication required'))));
|
||||
|
||||
if (isStepUpRequired) {
|
||||
const actionDescription =
|
||||
error.stepUpAction ||
|
||||
(typeof errorDetail === 'object' ? errorDetail?.action : null) ||
|
||||
errorDetail?.action ||
|
||||
(typeof errorDetail === 'string' ? errorDetail : null) ||
|
||||
errorDetail?.message ||
|
||||
(editingUser ? 'user update' : 'user creation');
|
||||
|
||||
logger.debug('Step-up required, opening modal', {
|
||||
actionDescription,
|
||||
error: {
|
||||
requiresStepUp: error.requiresStepUp,
|
||||
stepUpAction: error.stepUpAction,
|
||||
status: error.response?.status,
|
||||
detail: errorDetail
|
||||
}
|
||||
});
|
||||
|
||||
// Open step-up modal and retry after verification
|
||||
try {
|
||||
openStepUp(actionDescription, async () => {
|
||||
if (pendingSubmitDataRef.current) {
|
||||
logger.debug('Retrying request after step-up', { data: pendingSubmitDataRef.current });
|
||||
await executeSubmit(
|
||||
pendingSubmitDataRef.current.data,
|
||||
pendingSubmitDataRef.current.isEdit
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Error opening step-up modal', err);
|
||||
// Fallback: show error message
|
||||
toast.error('Step-up authentication required. Please verify your identity.');
|
||||
}
|
||||
return; // Don't show error toast, step-up modal will handle it
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -258,6 +258,12 @@ apiClient.interceptors.response.use(
|
||||
let errorMessage = 'You do not have permission to access this resource.';
|
||||
let shouldRetry = false;
|
||||
|
||||
// Check for step-up authentication requirement
|
||||
const isStepUpRequired =
|
||||
errorData?.error === 'step_up_required' ||
|
||||
(typeof errorData?.detail === 'object' && errorData?.detail?.error === 'step_up_required') ||
|
||||
(typeof errorData?.detail === 'string' && errorData?.detail?.includes('Step-up authentication required'));
|
||||
|
||||
// Check for MFA requirement error
|
||||
const isMfaRequired =
|
||||
errorData?.error === 'mfa_required' ||
|
||||
@@ -266,7 +272,41 @@ apiClient.interceptors.response.use(
|
||||
(typeof errorData.detail === 'string' && errorData.detail.includes('Multi-factor authentication is required')) ||
|
||||
(typeof errorData.detail === 'object' && errorData.detail?.error === 'mfa_required'));
|
||||
|
||||
if (isMfaRequired) {
|
||||
if (isStepUpRequired) {
|
||||
// Step-up authentication required - dispatch event for UI to handle
|
||||
const actionDescription = (typeof errorData?.detail === 'object' && errorData?.detail?.action) ||
|
||||
(typeof errorData?.detail === 'string' ? errorData?.detail : 'this action');
|
||||
|
||||
errorMessage = typeof errorData?.detail === 'object' && errorData?.detail?.message
|
||||
? errorData.detail.message
|
||||
: `Step-up authentication required for ${actionDescription}. Please verify your identity.`;
|
||||
|
||||
// Create retry function for the original request
|
||||
const retryRequest = async () => {
|
||||
if (originalRequest && !originalRequest._retry) {
|
||||
// Mark as retry to prevent infinite loops
|
||||
originalRequest._retry = true;
|
||||
// Retry the original request
|
||||
return apiClient.request(originalRequest);
|
||||
}
|
||||
};
|
||||
|
||||
// Dispatch custom event for step-up authentication
|
||||
window.dispatchEvent(new CustomEvent('auth:step-up-required', {
|
||||
detail: {
|
||||
action: actionDescription,
|
||||
message: errorMessage,
|
||||
originalRequest: retryRequest,
|
||||
}
|
||||
}));
|
||||
|
||||
return Promise.reject({
|
||||
...error,
|
||||
message: errorMessage,
|
||||
requiresStepUp: true,
|
||||
stepUpAction: actionDescription,
|
||||
});
|
||||
} else if (isMfaRequired) {
|
||||
// Get user info to determine redirect path
|
||||
try {
|
||||
const userInfoStr = localStorage.getItem('userInfo');
|
||||
|
||||
@@ -122,12 +122,10 @@ const useFavoritesStore = create<FavoritesState>(
|
||||
|
||||
|
||||
addToFavorites: async (roomId: number) => {
|
||||
// Don't add favorites if user is not authenticated or not a customer
|
||||
// Require authentication - only logged-in customers can add favorites
|
||||
if (!isAuthenticatedCustomer()) {
|
||||
// Save as guest favorite instead
|
||||
get().saveGuestFavorite(roomId);
|
||||
toast.success('Added to favorites');
|
||||
return;
|
||||
toast.error('Please login as a customer to add favorites');
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -157,27 +155,25 @@ const useFavoritesStore = create<FavoritesState>(
|
||||
} catch (error: any) {
|
||||
console.error('Error adding favorite:', error);
|
||||
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
get().saveGuestFavorite(roomId);
|
||||
toast.success('Added to favorites');
|
||||
// Don't fallback to guest favorites - require authentication
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
toast.error('Please login as a customer to add favorites');
|
||||
} else {
|
||||
const message =
|
||||
error.response?.data?.message ||
|
||||
'Unable to add to favorites';
|
||||
toast.error(message);
|
||||
}
|
||||
throw error; // Re-throw to let caller handle
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
removeFromFavorites: async (roomId: number) => {
|
||||
// Don't remove favorites if user is not authenticated or not a customer
|
||||
// Require authentication - only logged-in customers can remove favorites
|
||||
if (!isAuthenticatedCustomer()) {
|
||||
// Remove from guest favorites instead
|
||||
get().removeGuestFavorite(roomId);
|
||||
toast.success('Removed from favorites');
|
||||
return;
|
||||
toast.error('Please login as a customer to manage favorites');
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -209,16 +205,16 @@ const useFavoritesStore = create<FavoritesState>(
|
||||
} catch (error: any) {
|
||||
console.error('Error removing favorite:', error);
|
||||
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
get().removeGuestFavorite(roomId);
|
||||
toast.success('Removed from favorites');
|
||||
// Don't fallback to guest favorites - require authentication
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
toast.error('Please login as a customer to manage favorites');
|
||||
} else {
|
||||
const message =
|
||||
error.response?.data?.message ||
|
||||
'Unable to remove from favorites';
|
||||
toast.error(message);
|
||||
}
|
||||
throw error; // Re-throw to let caller handle
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user