This commit is contained in:
Iliyan Angelov
2025-12-07 01:28:03 +02:00
parent 5a8ca3c475
commit 876af48145
31 changed files with 914 additions and 110 deletions

View File

@@ -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>

View 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;

View 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;

View 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;
};

View File

@@ -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) {

View File

@@ -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]);

View File

@@ -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
}
}
};

View File

@@ -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');

View File

@@ -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
}
},