From 91e028fb183cc0d48c0890e76eef5df61964fe94 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Fri, 28 Nov 2025 19:14:35 +0530 Subject: [PATCH] tat pause resume job logic altered --- .../common/FilePreview/FilePreview.tsx | 22 ++- src/components/sla/SLATracker.tsx | 4 +- .../workNote/WorkNoteChat/WorkNoteChat.tsx | 52 ++++++- .../WorkNoteChat/WorkNoteChatSimple.tsx | 5 +- .../ApprovalWorkflow/ApprovalStepCard.tsx | 142 +++++++++++------- src/components/workflow/PauseModal.tsx | 10 +- .../workflow/RetriggerPauseModal.tsx | 9 +- src/contexts/AuthContext.tsx | 46 +++++- src/hooks/useRequestDetails.ts | 48 +++++- src/pages/Auth/AuthCallback.tsx | 11 +- .../components/sections/UserKPICards.tsx | 13 +- src/pages/Dashboard/hooks/useDashboardData.ts | 56 +++++-- src/pages/RequestDetail/RequestDetail.tsx | 100 ++++++++++-- .../components/QuickActionsSidebar.tsx | 14 +- src/pages/Requests/Requests.tsx | 44 +++--- src/pages/Requests/UserAllRequests.tsx | 50 +++--- .../Requests/components/RequestsHeader.tsx | 8 +- src/redux/slices/authSlice.ts | 48 +++++- src/services/authApi.ts | 37 +++-- src/services/workflowApi.ts | 4 +- src/utils/pushNotifications.ts | 28 +++- 21 files changed, 561 insertions(+), 190 deletions(-) diff --git a/src/components/common/FilePreview/FilePreview.tsx b/src/components/common/FilePreview/FilePreview.tsx index 104eb31..a12a1e3 100644 --- a/src/components/common/FilePreview/FilePreview.tsx +++ b/src/components/common/FilePreview/FilePreview.tsx @@ -54,7 +54,8 @@ export function FilePreview({ setError(null); try { - const token = localStorage.getItem('accessToken'); + const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; + const token = isProduction ? null : localStorage.getItem('accessToken'); // Ensure we have a valid URL - handle relative URLs when served from same origin let urlToFetch = fileUrl; @@ -63,13 +64,20 @@ export function FilePreview({ urlToFetch = `${window.location.origin}${fileUrl}`; } + // Build headers - in production, rely on httpOnly cookies + const headers: HeadersInit = { + 'Accept': isPDF ? 'application/pdf' : '*/*' + }; + + // Only add Authorization header in development mode + if (!isProduction && token) { + headers['Authorization'] = `Bearer ${token}`; + } + const response = await fetch(urlToFetch, { - headers: { - 'Authorization': `Bearer ${token}`, - 'Accept': isPDF ? 'application/pdf' : '*/*' - }, - credentials: 'include', // Include credentials for same-origin requests - mode: 'cors' // Explicitly set CORS mode + headers, + credentials: 'include', // Always include credentials for cookie-based auth + mode: 'cors' }); if (!response.ok) { diff --git a/src/components/sla/SLATracker.tsx b/src/components/sla/SLATracker.tsx index b082aa4..ec657db 100644 --- a/src/components/sla/SLATracker.tsx +++ b/src/components/sla/SLATracker.tsx @@ -22,14 +22,14 @@ export function SLATracker({ startDate, deadline, priority, className = '', show const getProgressColor = () => { if (slaStatus.progress >= 100) return 'bg-red-500'; if (slaStatus.progress >= 75) return 'bg-orange-500'; - if (slaStatus.progress >= 50) return 'bg-yellow-500'; + if (slaStatus.progress >= 50) return 'bg-amber-500'; // Using amber for better visibility return 'bg-green-500'; }; const getStatusBadgeColor = () => { if (slaStatus.progress >= 100) return 'bg-red-100 text-red-800 border-red-200'; if (slaStatus.progress >= 75) return 'bg-orange-100 text-orange-800 border-orange-200'; - if (slaStatus.progress >= 50) return 'bg-yellow-100 text-yellow-800 border-yellow-200'; + if (slaStatus.progress >= 50) return 'bg-amber-100 text-amber-800 border-amber-200'; // Using amber return 'bg-green-100 text-green-800 border-green-200'; }; diff --git a/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx b/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx index b2cf007..972701a 100644 --- a/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx +++ b/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx @@ -11,6 +11,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Textarea } from '@/components/ui/textarea'; import { FilePreview } from '@/components/common/FilePreview'; +import { ActionStatusModal } from '@/components/common/ActionStatusModal'; import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal'; import { AddApproverModal } from '@/components/participant/AddApproverModal'; import { formatDateTime } from '@/utils/dateFormatter'; @@ -154,6 +155,12 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk const [previewFile, setPreviewFile] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; attachmentId?: string } | null>(null); const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false); const [showAddApproverModal, setShowAddApproverModal] = useState(false); + const [showActionStatusModal, setShowActionStatusModal] = useState(false); + const [actionStatus, setActionStatus] = useState<{ success: boolean; title: string; message: string }>({ + success: true, + title: '', + message: '' + }); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); const socketRef = useRef(null); @@ -1005,10 +1012,22 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk } } setShowAddSpectatorModal(false); - alert('Spectator added successfully'); + // Show success modal + setActionStatus({ + success: true, + title: 'Spectator Added', + message: 'Spectator added successfully. They can now view this request.' + }); + setShowActionStatusModal(true); } catch (error: any) { console.error('Failed to add spectator:', error); - alert(error?.response?.data?.error || 'Failed to add spectator'); + // Show error modal + setActionStatus({ + success: false, + title: 'Failed to Add Spectator', + message: error?.response?.data?.error || 'Failed to add spectator. Please try again.' + }); + setShowActionStatusModal(true); throw error; } }; @@ -1052,10 +1071,22 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk } } setShowAddApproverModal(false); - alert(`Approver added successfully at Level ${level} with ${tatHours}h TAT`); + // Show success modal + setActionStatus({ + success: true, + title: 'Approver Added', + message: `Approver added successfully at Level ${level} with ${tatHours}h TAT` + }); + setShowActionStatusModal(true); } catch (error: any) { console.error('Failed to add approver:', error); - alert(error?.response?.data?.error || 'Failed to add approver'); + // Show error modal + setActionStatus({ + success: false, + title: 'Failed to Add Approver', + message: error?.response?.data?.error || 'Failed to add approver. Please try again.' + }); + setShowActionStatusModal(true); throw error; } } @@ -1353,14 +1384,14 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk e.stopPropagation(); if (!attachmentId) { - alert('Cannot download: Attachment ID missing'); + toast.error('Cannot download: Attachment ID missing'); return; } try { await downloadWorkNoteAttachment(attachmentId); } catch (error) { - alert('Failed to download file'); + toast.error('Failed to download file'); } }} title="Download file" @@ -1828,6 +1859,15 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk + + {/* Action Status Modal - Success/Error feedback for adding approver/spectator */} + setShowActionStatusModal(false)} + success={actionStatus.success} + title={actionStatus.title} + message={actionStatus.message} + /> ); } \ No newline at end of file diff --git a/src/components/workNote/WorkNoteChat/WorkNoteChatSimple.tsx b/src/components/workNote/WorkNoteChat/WorkNoteChatSimple.tsx index d465edc..2b21dbf 100644 --- a/src/components/workNote/WorkNoteChat/WorkNoteChatSimple.tsx +++ b/src/components/workNote/WorkNoteChat/WorkNoteChatSimple.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect } from 'react'; import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi'; +import { toast } from 'sonner'; import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket'; import { formatDateTime } from '@/utils/dateFormatter'; import { FilePreview } from '@/components/common/FilePreview'; @@ -499,14 +500,14 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe e.stopPropagation(); if (!attachmentId) { - alert('Cannot download: Attachment ID missing'); + toast.error('Cannot download: Attachment ID missing'); return; } try { await downloadWorkNoteAttachment(attachmentId); } catch (error) { - alert('Failed to download file'); + toast.error('Failed to download file'); } }} title="Download file" diff --git a/src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx b/src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx index 7fce1be..2ee7ceb 100644 --- a/src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx +++ b/src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx @@ -271,16 +271,33 @@ export function ApprovalStepCard({ // If approved in 1 minute out of 24 hours TAT = (1/60) / 24 * 100 = 0.069% const displayPercentage = Math.min(100, progressPercentage); + // Determine progress bar color based on percentage + // Green: 0-50%, Yellow: 50-75%, Orange: 75-100%, Red: 100%+ (breached) + const getIndicatorColor = () => { + if (isBreached) return 'bg-red-600'; + if (progressPercentage >= 75) return 'bg-orange-500'; + if (progressPercentage >= 50) return 'bg-amber-500'; // Using amber instead of yellow for better visibility + return 'bg-green-600'; + }; + + const getProgressTextColor = () => { + if (isBreached) return 'text-red-600'; + if (progressPercentage >= 75) return 'text-orange-600'; + if (progressPercentage >= 50) return 'text-amber-600'; + return 'text-green-600'; + }; + return ( <> div]:bg-red-600' : '[&>div]:bg-green-600'}`} + className="h-2 bg-gray-200" + indicatorClassName={getIndicatorColor()} data-testid={`${testId}-progress-bar`} />
- + {Math.round(displayPercentage)}% of TAT used {isBreached && canEditBreachReason && ( @@ -352,9 +369,10 @@ export function ApprovalStepCard({ {/* Current Approver - Time Tracking */}
= 100 ? 'bg-red-50 border-red-200' : + (approval.sla.percentageUsed || 0) >= 75 ? 'bg-orange-50 border-orange-200' : + (approval.sla.percentageUsed || 0) >= 50 ? 'bg-amber-50 border-amber-200' : + 'bg-green-50 border-green-200' }`}>

@@ -374,51 +392,65 @@ export function ApprovalStepCard({ {/* Progress Bar */}

- div]:bg-red-600' : - approval.sla.status === 'critical' ? '[&>div]:bg-orange-600' : - '[&>div]:bg-yellow-600' - }`} - data-testid={`${testId}-sla-progress`} - /> -
-
- - Progress: {Math.min(100, approval.sla.percentageUsed)}% of TAT used - - {approval.sla.status === 'breached' && canEditBreachReason && ( - - - - - - -

{existingBreachReason ? 'Edit breach reason' : 'Add breach reason'}

-
-
-
- )} -
- - {approval.sla.remainingText} remaining - -
+ {(() => { + // Determine color based on percentage used + // Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached) + const percentUsed = approval.sla.percentageUsed || 0; + const getActiveIndicatorColor = () => { + if (percentUsed >= 100) return 'bg-red-600'; + if (percentUsed >= 75) return 'bg-orange-500'; + if (percentUsed >= 50) return 'bg-amber-500'; + return 'bg-green-600'; + }; + const getActiveTextColor = () => { + if (percentUsed >= 100) return 'text-red-600'; + if (percentUsed >= 75) return 'text-orange-600'; + if (percentUsed >= 50) return 'text-amber-600'; + return 'text-green-600'; + }; + return ( + <> + +
+
+ + Progress: {Math.min(100, approval.sla.percentageUsed)}% of TAT used + + {approval.sla.status === 'breached' && canEditBreachReason && ( + + + + + + +

{existingBreachReason ? 'Edit breach reason' : 'Add breach reason'}

+
+
+
+ )} +
+ + {approval.sla.remainingText} remaining + +
+ + ); + })()} {approval.sla.status === 'breached' && ( <>

@@ -607,8 +639,9 @@ export function ApprovalStepCard({

)} - {/* Skip Approver Button - Only show for initiator on pending/in-review levels */} - {isInitiator && (isActive || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && ( + {/* Skip Approver Button - Only show for initiator on pending/in-review/paused levels */} + {/* When paused, initiator can skip the approver which will negate the pause */} + {isInitiator && (isActive || isPaused || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && (
)} diff --git a/src/components/workflow/PauseModal.tsx b/src/components/workflow/PauseModal.tsx index 50182e2..50421ae 100644 --- a/src/components/workflow/PauseModal.tsx +++ b/src/components/workflow/PauseModal.tsx @@ -14,7 +14,7 @@ interface PauseModalProps { onClose: () => void; requestId: string; levelId: string | null; - onSuccess?: () => void; + onSuccess?: () => void | Promise; } export function PauseModal({ isOpen, onClose, requestId, levelId, onSuccess }: PauseModalProps) { @@ -72,9 +72,15 @@ export function PauseModal({ isOpen, onClose, requestId, levelId, onSuccess }: P setSubmitting(true); await pauseWorkflow(requestId, levelId, reason.trim(), selectedDate.toDate()); toast.success('Workflow paused successfully'); + + // Wait for parent to refresh data before closing modal + // This ensures the UI shows updated pause status + if (onSuccess) { + await onSuccess(); + } + setReason(''); setResumeDate(getDefaultResumeDate()); - onSuccess?.(); onClose(); } catch (error: any) { console.error('Failed to pause workflow:', error); diff --git a/src/components/workflow/RetriggerPauseModal.tsx b/src/components/workflow/RetriggerPauseModal.tsx index cd37e48..4ab6c8e 100644 --- a/src/components/workflow/RetriggerPauseModal.tsx +++ b/src/components/workflow/RetriggerPauseModal.tsx @@ -10,7 +10,7 @@ interface RetriggerPauseModalProps { onClose: () => void; requestId: string; approverName?: string; - onSuccess?: () => void; + onSuccess?: () => void | Promise; } export function RetriggerPauseModal({ isOpen, onClose, requestId, approverName, onSuccess }: RetriggerPauseModalProps) { @@ -21,7 +21,12 @@ export function RetriggerPauseModal({ isOpen, onClose, requestId, approverName, setSubmitting(true); await retriggerPause(requestId); toast.success('Retrigger request sent to approver'); - onSuccess?.(); + + // Wait for parent to refresh data before closing modal + if (onSuccess) { + await onSuccess(); + } + onClose(); } catch (error: any) { console.error('Failed to retrigger pause:', error); diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 51de864..bf1c8dd 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -117,7 +117,16 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { return; } - // PRIORITY 3: Check authentication status + // PRIORITY 3: Skip auth check if on callback page - let callback handler process first + // This is critical for production mode where we need to exchange code for tokens + // before we can verify session with server + if (window.location.pathname === '/login/callback') { + // Don't check auth status here - let the callback handler do its job + // The callback handler will set isAuthenticated after successful token exchange + return; + } + + // PRIORITY 4: Check authentication status const token = TokenManager.getAccessToken(); const refreshToken = TokenManager.getRefreshToken(); const userData = TokenManager.getUserData(); @@ -144,7 +153,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { return; } - // PRIORITY 4: Only check auth status if we have some auth data AND we're not logging out + // PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out if (!isLoggingOut) { checkAuthStatus(); } else { @@ -494,6 +503,27 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { }; const getAccessTokenSilently = async (): Promise => { + const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production'; + + // In production mode, tokens are in httpOnly cookies + // We can't access them directly, but API calls will include them automatically + if (isProductionMode) { + // If user is authenticated, return a placeholder indicating cookies are used + // The actual token is in httpOnly cookie and sent automatically with requests + if (isAuthenticated) { + return 'cookie-based-auth'; // Placeholder - actual auth via cookies + } + + // Try to refresh the session + try { + await refreshTokenSilently(); + return isAuthenticated ? 'cookie-based-auth' : null; + } catch { + return null; + } + } + + // Development mode: tokens in localStorage const token = TokenManager.getAccessToken(); if (token && !isTokenExpired(token)) { return token; @@ -509,10 +539,20 @@ function BackendAuthProvider({ children }: { children: ReactNode }) { }; const refreshTokenSilently = async (): Promise => { + const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production'; + try { const newToken = await refreshAccessToken(); + + // In production, refresh might not return token (it's in httpOnly cookie) + // but if the call succeeded, the session is valid + if (isProductionMode) { + // Session refreshed via cookies + return; + } + if (newToken) { - // Token refreshed successfully + // Token refreshed successfully (development mode) return; } throw new Error('Failed to refresh token'); diff --git a/src/hooks/useRequestDetails.ts b/src/hooks/useRequestDetails.ts index ce5634b..02e85ac 100644 --- a/src/hooks/useRequestDetails.ts +++ b/src/hooks/useRequestDetails.ts @@ -32,6 +32,12 @@ export function useRequestDetails( // State: Indicates if data is currently being fetched const [refreshing, setRefreshing] = useState(false); + // State: Loading state for initial fetch + const [loading, setLoading] = useState(true); + + // State: Access denied information + const [accessDenied, setAccessDenied] = useState<{ denied: boolean; message: string } | null>(null); + // State: Stores the current approval level for the logged-in user const [currentApprovalLevel, setCurrentApprovalLevel] = useState(null); @@ -208,6 +214,19 @@ export function useRequestDetails( }) : []; + /** + * Fetch: Get pause details if request is paused + * This is needed to show resume/retrigger buttons correctly + */ + let pauseInfo = null; + try { + const { getPauseDetails } = await import('@/services/workflowApi'); + pauseInfo = await getPauseDetails(wf.requestId); + } catch (error) { + // Pause info not available or request not paused - ignore + console.debug('Pause details not available:', error); + } + /** * Build: Complete request object with all transformed data * This object is used throughout the UI @@ -244,6 +263,7 @@ export function useRequestDetails( auditTrail: filteredActivities, conclusionRemark: wf.conclusionRemark || null, closureDate: wf.closureDate || null, + pauseInfo: pauseInfo || null, // Include pause info for resume/retrigger buttons }; setApiRequest(updatedRequest); @@ -296,14 +316,22 @@ export function useRequestDetails( * This is the primary data loading mechanism */ useEffect(() => { - if (!requestIdentifier) return; + if (!requestIdentifier) { + setLoading(false); + return; + } let mounted = true; + setLoading(true); + setAccessDenied(null); (async () => { try { const details = await workflowApi.getWorkflowDetails(requestIdentifier); - if (!mounted || !details) return; + if (!mounted || !details) { + if (mounted) setLoading(false); + return; + } // Use the same transformation logic as refreshDetails const wf = details.workflow || {}; @@ -473,11 +501,21 @@ export function useRequestDetails( } else { setIsSpectator(false); } - } catch (error) { + } catch (error: any) { console.error('[useRequestDetails] Error loading request details:', error); if (mounted) { + // Check for 403 Forbidden (Access Denied) + if (error?.response?.status === 403) { + const message = error?.response?.data?.message || + 'You do not have permission to view this request. Access is restricted to the initiator, approvers, and spectators of this request.'; + setAccessDenied({ denied: true, message }); + } setApiRequest(null); } + } finally { + if (mounted) { + setLoading(false); + } } })(); @@ -585,12 +623,14 @@ export function useRequestDetails( return { request, apiRequest, + loading, refreshing, refreshDetails, currentApprovalLevel, isSpectator, isInitiator, - existingParticipants + existingParticipants, + accessDenied }; } diff --git a/src/pages/Auth/AuthCallback.tsx b/src/pages/Auth/AuthCallback.tsx index c58b3e0..a008395 100644 --- a/src/pages/Auth/AuthCallback.tsx +++ b/src/pages/Auth/AuthCallback.tsx @@ -28,12 +28,9 @@ export function AuthCallback() { } } else if (user && isAuthenticated) { setAuthStep('complete'); - // Small delay before redirect for better UX - const timer = setTimeout(() => { - // Use window.location instead of navigate since Router context isn't available yet - window.location.href = '/'; - }, 1500); - return () => clearTimeout(timer); + // AuthContext already handles the URL change via replaceState + // No need to do a full page reload which would lose React state + // The AuthenticatedApp will re-render and show the App component } }, [isAuthenticated, isLoading, error, user]); @@ -165,7 +162,7 @@ export function AuthCallback() { {/* Footer Text */}

- {authStep === 'complete' ? 'Redirecting to dashboard...' : 'Please wait while we secure your session'} + {authStep === 'complete' ? 'Loading dashboard...' : 'Please wait while we secure your session'}

diff --git a/src/pages/Dashboard/components/sections/UserKPICards.tsx b/src/pages/Dashboard/components/sections/UserKPICards.tsx index 3cb56c9..911c281 100644 --- a/src/pages/Dashboard/components/sections/UserKPICards.tsx +++ b/src/pages/Dashboard/components/sections/UserKPICards.tsx @@ -70,7 +70,7 @@ export function UserKPICards({ testId="kpi-my-requests" onClick={() => onKPIClick(getFilterParams())} > -
+
+ { + e.stopPropagation(); + onKPIClick({ ...getFilterParams(), status: 'paused' }); + }} + /> = 8) { - const deptStats = results[4] as DepartmentStats[]; - const priorityDist = results[5] as PriorityDistribution[]; - const aiUtilization = results[6] as AIRemarkUtilization; - const approverResult = results[7] as { performance: ApproverPerformance[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } }; + if (isAdmin && adminResults.length >= 4) { + const deptStats = adminResults[0] as DepartmentStats[]; + const priorityDist = adminResults[1] as PriorityDistribution[]; + const aiUtilization = adminResults[2] as AIRemarkUtilization; + const approverResult = adminResults[3] as { performance: ApproverPerformance[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } }; setDepartmentStats(deptStats); setPriorityDistribution(priorityDist); setAiRemarkUtilization(aiUtilization); @@ -128,7 +158,7 @@ export function useDashboardData({ setLoading(false); setRefreshing(false); } - }, [isAdmin, viewAsUser, dateRange, customStartDate, customEndDate]); + }, [isAdmin, viewAsUser, userId, dateRange, customStartDate, customEndDate]); // Fetch individual data with pagination const fetchRecentActivities = useCallback(async (page: number = 1) => { diff --git a/src/pages/RequestDetail/RequestDetail.tsx b/src/pages/RequestDetail/RequestDetail.tsx index 2dbc1ae..bb8c545 100644 --- a/src/pages/RequestDetail/RequestDetail.tsx +++ b/src/pages/RequestDetail/RequestDetail.tsx @@ -23,6 +23,9 @@ import { MessageSquare, AlertTriangle, FileCheck, + ShieldX, + RefreshCw, + ArrowLeft, } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; @@ -116,12 +119,14 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests const { request, apiRequest, + loading: requestLoading, refreshing, refreshDetails, currentApprovalLevel, isSpectator, isInitiator, existingParticipants, + accessDenied, } = useRequestDetails(requestIdentifier, dynamicRequests, user); const { @@ -193,17 +198,23 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests setShowPauseModal(true); }; + const [resuming, setResuming] = useState(false); + const handleResume = async () => { if (!apiRequest?.requestId) { toast.error('Request ID not found'); return; } try { + setResuming(true); await resumeWorkflow(apiRequest.requestId); toast.success('Workflow resumed successfully'); - refreshDetails(); + // Wait for refresh to complete before clearing loading state + await refreshDetails(); } catch (error: any) { toast.error(error?.response?.data?.error || 'Failed to resume workflow'); + } finally { + setResuming(false); } }; @@ -212,11 +223,13 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests }; const handlePauseSuccess = async () => { - refreshDetails(); + // Wait for refresh to complete to show updated pause status + await refreshDetails(); }; const handleRetriggerSuccess = async () => { - refreshDetails(); + // Wait for refresh to complete + await refreshDetails(); }; const handleShareSummary = async () => { @@ -312,27 +325,89 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests })); // Loading state - if (!request && !apiRequest) { + if (requestLoading && !request && !apiRequest) { return ( -
+
-
+

Loading request details...

); } - // Error state + // Access Denied state + if (accessDenied?.denied) { + return ( +
+
+
+ +
+

Access Denied

+

+ {accessDenied.message} +

+
+

+ Who can access this request? +

+
    +
  • • The person who created this request (Initiator)
  • +
  • • Designated approvers at any level
  • +
  • • Added spectators or participants
  • +
  • • Organization administrators
  • +
+
+
+ + +
+
+
+ ); + } + + // Not Found state if (!request) { return ( -
-
-

Request Not Found

-

The request you're looking for doesn't exist.

- + +
); @@ -518,6 +593,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests refreshTrigger={sharedRecipientsRefreshTrigger} pausedByUserId={request?.pauseInfo?.pausedBy?.userId} currentUserId={(user as any)?.userId} + resuming={resuming} /> )}
diff --git a/src/pages/RequestDetail/components/QuickActionsSidebar.tsx b/src/pages/RequestDetail/components/QuickActionsSidebar.tsx index 883c302..fe38cf9 100644 --- a/src/pages/RequestDetail/components/QuickActionsSidebar.tsx +++ b/src/pages/RequestDetail/components/QuickActionsSidebar.tsx @@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle } from 'lucide-react'; +import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle, Loader2 } from 'lucide-react'; import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi'; interface QuickActionsSidebarProps { @@ -25,6 +25,7 @@ interface QuickActionsSidebarProps { refreshTrigger?: number; // Trigger to refresh shared recipients list pausedByUserId?: string; // User ID of the approver who paused currentUserId?: string; // Current user's ID + resuming?: boolean; // Loading state for resume action } export function QuickActionsSidebar({ @@ -43,6 +44,7 @@ export function QuickActionsSidebar({ refreshTrigger, pausedByUserId, currentUserId, + resuming = false, }: QuickActionsSidebarProps) { const [sharedRecipients, setSharedRecipients] = useState([]); const [loadingRecipients, setLoadingRecipients] = useState(false); @@ -130,10 +132,20 @@ export function QuickActionsSidebar({ variant="outline" className="w-full justify-start gap-2 bg-white text-green-700 border-green-300 hover:bg-green-50 hover:text-green-900 h-9 sm:h-10 text-xs sm:text-sm" onClick={onResume} + disabled={resuming} data-testid="resume-workflow-button" > + {resuming ? ( + <> + + Resuming... + + ) : ( + <> Resume Workflow + + )} )} diff --git a/src/pages/Requests/Requests.tsx b/src/pages/Requests/Requests.tsx index 0022363..5b50d9d 100644 --- a/src/pages/Requests/Requests.tsx +++ b/src/pages/Requests/Requests.tsx @@ -308,29 +308,29 @@ export function Requests({ onViewRequest }: RequestsProps) { // Total changes when other filters are applied, but stays stable when only status changes // Stats are fetched for both org-level AND user-level (Personal mode) views useEffect(() => { - const timeoutId = setTimeout(() => { - const filtersWithoutStatus = { - priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, - department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined, - initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined, - approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined, - approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined, - search: filters.searchTerm || undefined, - slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined - }; - // All Requests (admin/normal user) should always have a date range - // Default to 'month' if no date range is selected - const statsDateRange = filters.dateRange || 'month'; - - fetchBackendStatsRef.current( - statsDateRange, - filters.customStartDate, - filters.customEndDate, - filtersWithoutStatus - ); - }, filters.searchTerm ? 500 : 0); + const timeoutId = setTimeout(() => { + const filtersWithoutStatus = { + priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, + department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined, + initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined, + approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined, + approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined, + search: filters.searchTerm || undefined, + slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined + }; + // All Requests (admin/normal user) should always have a date range + // Default to 'month' if no date range is selected + const statsDateRange = filters.dateRange || 'month'; + + fetchBackendStatsRef.current( + statsDateRange, + filters.customStartDate, + filters.customEndDate, + filtersWithoutStatus + ); + }, filters.searchTerm ? 500 : 0); - return () => clearTimeout(timeoutId); + return () => clearTimeout(timeoutId); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ isOrgLevel, diff --git a/src/pages/Requests/UserAllRequests.tsx b/src/pages/Requests/UserAllRequests.tsx index 1a43d0e..7e6880f 100644 --- a/src/pages/Requests/UserAllRequests.tsx +++ b/src/pages/Requests/UserAllRequests.tsx @@ -284,7 +284,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) { // Transform requests const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]); - + // Calculate stats - Use backend stats API (OPTIMIZED) const stats = useMemo(() => { // Use backend stats if available @@ -301,36 +301,36 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) { } // Fallback: calculate from current page (less accurate, but works during initial load) - const pending = convertedRequests.filter((r: any) => { - const status = (r.status || '').toString().toLowerCase(); - return status === 'pending' || status === 'in-progress'; - }).length; + const pending = convertedRequests.filter((r: any) => { + const status = (r.status || '').toString().toLowerCase(); + return status === 'pending' || status === 'in-progress'; + }).length; const paused = convertedRequests.filter((r: any) => { const status = (r.status || '').toString().toLowerCase(); return status === 'paused'; }).length; - const approved = convertedRequests.filter((r: any) => { - const status = (r.status || '').toString().toLowerCase(); - return status === 'approved'; - }).length; - const rejected = convertedRequests.filter((r: any) => { - const status = (r.status || '').toString().toLowerCase(); - return status === 'rejected'; - }).length; - const closed = convertedRequests.filter((r: any) => { - const status = (r.status || '').toString().toLowerCase(); - return status === 'closed'; - }).length; - - return { + const approved = convertedRequests.filter((r: any) => { + const status = (r.status || '').toString().toLowerCase(); + return status === 'approved'; + }).length; + const rejected = convertedRequests.filter((r: any) => { + const status = (r.status || '').toString().toLowerCase(); + return status === 'rejected'; + }).length; + const closed = convertedRequests.filter((r: any) => { + const status = (r.status || '').toString().toLowerCase(); + return status === 'closed'; + }).length; + + return { total: totalRecords > 0 ? totalRecords : convertedRequests.length, - pending, + pending, paused, - approved, - rejected, - draft: 0, - closed - }; + approved, + rejected, + draft: 0, + closed + }; }, [backendStats, totalRecords, convertedRequests]); return ( diff --git a/src/pages/Requests/components/RequestsHeader.tsx b/src/pages/Requests/components/RequestsHeader.tsx index 614fe54..678cc69 100644 --- a/src/pages/Requests/components/RequestsHeader.tsx +++ b/src/pages/Requests/components/RequestsHeader.tsx @@ -42,12 +42,12 @@ export function RequestsHeader({ return (
- + testId="requests-header" + /> {/* View mode badge */} { + try { + return import.meta.env.PROD || import.meta.env.MODE === 'production'; + } catch { + return false; + } +}; + +// Safe localStorage access - returns null in production (cookies used instead) +const getStoredToken = (): string | null => { + if (isProduction()) { + return null; // In production, auth is via httpOnly cookies + } + try { + return localStorage.getItem('token'); + } catch { + return null; + } +}; + interface AuthState { user: User | null; token: string | null; @@ -11,7 +32,7 @@ interface AuthState { const initialState: AuthState = { user: null, - token: localStorage.getItem('token'), + token: getStoredToken(), isAuthenticated: false, isLoading: false, error: null, @@ -31,7 +52,14 @@ const authSlice = createSlice({ state.user = action.payload.user; state.token = action.payload.token; state.error = null; - localStorage.setItem('token', action.payload.token); + // Only store in localStorage in development mode + if (!isProduction()) { + try { + localStorage.setItem('token', action.payload.token); + } catch { + // Ignore storage errors + } + } }, loginFailure: (state, action: PayloadAction) => { state.isLoading = false; @@ -39,14 +67,26 @@ const authSlice = createSlice({ state.user = null; state.token = null; state.error = action.payload; - localStorage.removeItem('token'); + if (!isProduction()) { + try { + localStorage.removeItem('token'); + } catch { + // Ignore storage errors + } + } }, logout: (state) => { state.isAuthenticated = false; state.user = null; state.token = null; state.error = null; - localStorage.removeItem('token'); + if (!isProduction()) { + try { + localStorage.removeItem('token'); + } catch { + // Ignore storage errors + } + } }, clearError: (state) => { state.error = null; diff --git a/src/services/authApi.ts b/src/services/authApi.ts index 7dd2f4d..85ed810 100644 --- a/src/services/authApi.ts +++ b/src/services/authApi.ts @@ -28,10 +28,10 @@ apiClient.interceptors.request.use( if (!isProduction) { // Development: Get token from localStorage and add to header - const token = TokenManager.getAccessToken(); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } + const token = TokenManager.getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } } // Production: Cookies handle authentication automatically @@ -92,8 +92,8 @@ apiClient.interceptors.response.use( const accessToken = responseData.accessToken; // In production: Backend sets new httpOnly cookie, no token in response - // In development: Token is in response, store it - if (accessToken) { + // In development: Token is in response, store it and add to header + if (!isProduction && accessToken) { TokenManager.setAccessToken(accessToken); originalRequest.headers.Authorization = `Bearer ${accessToken}`; } @@ -205,24 +205,41 @@ export async function exchangeCodeForTokens( /** * Refresh access token using refresh token + * + * PRODUCTION: Refresh token is in httpOnly cookie, sent automatically + * DEVELOPMENT: Refresh token from localStorage */ export async function refreshAccessToken(): Promise { + const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; + + // In development, check for refresh token in localStorage + if (!isProduction) { const refreshToken = TokenManager.getRefreshToken(); if (!refreshToken) { throw new Error('No refresh token available'); } + } + + // In production, httpOnly cookie with refresh token will be sent automatically + // In development, we send the refresh token in the body + const body = isProduction ? {} : { refreshToken: TokenManager.getRefreshToken() }; - const response = await apiClient.post('/auth/refresh', { - refreshToken, - }); + const response = await apiClient.post('/auth/refresh', body); const data = response.data as any; const accessToken = data.data?.accessToken || data.accessToken; - if (accessToken) { + // In development mode, store the token + if (!isProduction && accessToken) { TokenManager.setAccessToken(accessToken); return accessToken; } + + // In production mode, token is set via httpOnly cookie by the backend + // Return a placeholder to indicate success + if (isProduction && (data.success !== false)) { + return 'cookie-based-auth'; + } throw new Error('Failed to refresh token'); } diff --git a/src/services/workflowApi.ts b/src/services/workflowApi.ts index 19fb35c..cfaead2 100644 --- a/src/services/workflowApi.ts +++ b/src/services/workflowApi.ts @@ -380,7 +380,7 @@ export async function downloadDocument(documentId: string): Promise { fetchOptions.headers = { 'Authorization': `Bearer ${token}` }; - } + } const response = await fetch(downloadUrl, fetchOptions); @@ -426,7 +426,7 @@ export async function downloadWorkNoteAttachment(attachmentId: string): Promise< fetchOptions.headers = { 'Authorization': `Bearer ${token}` }; - } + } const response = await fetch(downloadUrl, fetchOptions); diff --git a/src/utils/pushNotifications.ts b/src/utils/pushNotifications.ts index f77506d..4568441 100644 --- a/src/utils/pushNotifications.ts +++ b/src/utils/pushNotifications.ts @@ -123,22 +123,34 @@ export async function subscribeUserToPush(register: ServiceWorkerRegistration) { // Convert subscription to JSON format for backend const subscriptionJson = subscription.toJSON(); - // Attach auth token if available - const token = (window as any)?.localStorage?.getItem?.('accessToken') || - (document?.cookie || '').match(/accessToken=([^;]+)/)?.[1] || ''; + // Check if we're in production mode (cookies used for auth) + const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; - if (!token) { + // In development, get token from localStorage + // In production, httpOnly cookies are sent automatically with credentials: 'include' + const token = isProduction ? null : (window as any)?.localStorage?.getItem?.('accessToken'); + + // In development mode, we need a token + if (!isProduction && !token) { throw new Error('Authentication token not found. Please log in again.'); } // Send subscription to backend try { + // Build headers - in production, auth is via httpOnly cookies + const headers: HeadersInit = { + 'Content-Type': 'application/json', + }; + + // Only add Authorization header in development mode + if (!isProduction && token) { + headers['Authorization'] = `Bearer ${token}`; + } + const response = await fetch(`${VITE_BASE_URL}/api/v1/workflows/notifications/subscribe`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, + headers, + credentials: 'include', // Always include credentials for cookie-based auth body: JSON.stringify(subscriptionJson) });