tat pause resume job logic altered
This commit is contained in:
parent
fe441c8b76
commit
91e028fb18
@ -54,7 +54,8 @@ export function FilePreview({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
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
|
// Ensure we have a valid URL - handle relative URLs when served from same origin
|
||||||
let urlToFetch = fileUrl;
|
let urlToFetch = fileUrl;
|
||||||
@ -63,13 +64,20 @@ export function FilePreview({
|
|||||||
urlToFetch = `${window.location.origin}${fileUrl}`;
|
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, {
|
const response = await fetch(urlToFetch, {
|
||||||
headers: {
|
headers,
|
||||||
'Authorization': `Bearer ${token}`,
|
credentials: 'include', // Always include credentials for cookie-based auth
|
||||||
'Accept': isPDF ? 'application/pdf' : '*/*'
|
mode: 'cors'
|
||||||
},
|
|
||||||
credentials: 'include', // Include credentials for same-origin requests
|
|
||||||
mode: 'cors' // Explicitly set CORS mode
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@ -22,14 +22,14 @@ export function SLATracker({ startDate, deadline, priority, className = '', show
|
|||||||
const getProgressColor = () => {
|
const getProgressColor = () => {
|
||||||
if (slaStatus.progress >= 100) return 'bg-red-500';
|
if (slaStatus.progress >= 100) return 'bg-red-500';
|
||||||
if (slaStatus.progress >= 75) return 'bg-orange-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';
|
return 'bg-green-500';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadgeColor = () => {
|
const getStatusBadgeColor = () => {
|
||||||
if (slaStatus.progress >= 100) return 'bg-red-100 text-red-800 border-red-200';
|
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 >= 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';
|
return 'bg-green-100 text-green-800 border-green-200';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { FilePreview } from '@/components/common/FilePreview';
|
import { FilePreview } from '@/components/common/FilePreview';
|
||||||
|
import { ActionStatusModal } from '@/components/common/ActionStatusModal';
|
||||||
import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal';
|
import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal';
|
||||||
import { AddApproverModal } from '@/components/participant/AddApproverModal';
|
import { AddApproverModal } from '@/components/participant/AddApproverModal';
|
||||||
import { formatDateTime } from '@/utils/dateFormatter';
|
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 [previewFile, setPreviewFile] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; attachmentId?: string } | null>(null);
|
||||||
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
|
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
|
||||||
const [showAddApproverModal, setShowAddApproverModal] = 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<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const socketRef = useRef<any>(null);
|
const socketRef = useRef<any>(null);
|
||||||
@ -1005,10 +1012,22 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setShowAddSpectatorModal(false);
|
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) {
|
} catch (error: any) {
|
||||||
console.error('Failed to add spectator:', error);
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1052,10 +1071,22 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setShowAddApproverModal(false);
|
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) {
|
} catch (error: any) {
|
||||||
console.error('Failed to add approver:', error);
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1353,14 +1384,14 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (!attachmentId) {
|
if (!attachmentId) {
|
||||||
alert('Cannot download: Attachment ID missing');
|
toast.error('Cannot download: Attachment ID missing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadWorkNoteAttachment(attachmentId);
|
await downloadWorkNoteAttachment(attachmentId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Failed to download file');
|
toast.error('Failed to download file');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Download file"
|
title="Download file"
|
||||||
@ -1828,6 +1859,15 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Action Status Modal - Success/Error feedback for adding approver/spectator */}
|
||||||
|
<ActionStatusModal
|
||||||
|
open={showActionStatusModal}
|
||||||
|
onClose={() => setShowActionStatusModal(false)}
|
||||||
|
success={actionStatus.success}
|
||||||
|
title={actionStatus.title}
|
||||||
|
message={actionStatus.message}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
|
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
||||||
import { formatDateTime } from '@/utils/dateFormatter';
|
import { formatDateTime } from '@/utils/dateFormatter';
|
||||||
import { FilePreview } from '@/components/common/FilePreview';
|
import { FilePreview } from '@/components/common/FilePreview';
|
||||||
@ -499,14 +500,14 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
if (!attachmentId) {
|
if (!attachmentId) {
|
||||||
alert('Cannot download: Attachment ID missing');
|
toast.error('Cannot download: Attachment ID missing');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadWorkNoteAttachment(attachmentId);
|
await downloadWorkNoteAttachment(attachmentId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Failed to download file');
|
toast.error('Failed to download file');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Download file"
|
title="Download file"
|
||||||
|
|||||||
@ -271,16 +271,33 @@ export function ApprovalStepCard({
|
|||||||
// If approved in 1 minute out of 24 hours TAT = (1/60) / 24 * 100 = 0.069%
|
// If approved in 1 minute out of 24 hours TAT = (1/60) / 24 * 100 = 0.069%
|
||||||
const displayPercentage = Math.min(100, progressPercentage);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Progress
|
<Progress
|
||||||
value={displayPercentage}
|
value={displayPercentage}
|
||||||
className={`h-2 bg-gray-200 ${isBreached ? '[&>div]:bg-red-600' : '[&>div]:bg-green-600'}`}
|
className="h-2 bg-gray-200"
|
||||||
|
indicatorClassName={getIndicatorColor()}
|
||||||
data-testid={`${testId}-progress-bar`}
|
data-testid={`${testId}-progress-bar`}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`font-semibold ${isBreached ? 'text-red-600' : 'text-green-600'}`}>
|
<span className={`font-semibold ${getProgressTextColor()}`}>
|
||||||
{Math.round(displayPercentage)}% of TAT used
|
{Math.round(displayPercentage)}% of TAT used
|
||||||
</span>
|
</span>
|
||||||
{isBreached && canEditBreachReason && (
|
{isBreached && canEditBreachReason && (
|
||||||
@ -352,9 +369,10 @@ export function ApprovalStepCard({
|
|||||||
|
|
||||||
{/* Current Approver - Time Tracking */}
|
{/* Current Approver - Time Tracking */}
|
||||||
<div className={`border rounded-lg p-3 ${
|
<div className={`border rounded-lg p-3 ${
|
||||||
approval.sla.status === 'critical' ? 'bg-orange-50 border-orange-200' :
|
(approval.sla.percentageUsed || 0) >= 100 ? 'bg-red-50 border-red-200' :
|
||||||
approval.sla.status === 'breached' ? 'bg-red-50 border-red-200' :
|
(approval.sla.percentageUsed || 0) >= 75 ? 'bg-orange-50 border-orange-200' :
|
||||||
'bg-yellow-50 border-yellow-200'
|
(approval.sla.percentageUsed || 0) >= 50 ? 'bg-amber-50 border-amber-200' :
|
||||||
|
'bg-green-50 border-green-200'
|
||||||
}`}>
|
}`}>
|
||||||
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
@ -374,51 +392,65 @@ export function ApprovalStepCard({
|
|||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Progress
|
{(() => {
|
||||||
value={approval.sla.percentageUsed}
|
// Determine color based on percentage used
|
||||||
className={`h-3 ${
|
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
|
||||||
approval.sla.status === 'breached' ? '[&>div]:bg-red-600' :
|
const percentUsed = approval.sla.percentageUsed || 0;
|
||||||
approval.sla.status === 'critical' ? '[&>div]:bg-orange-600' :
|
const getActiveIndicatorColor = () => {
|
||||||
'[&>div]:bg-yellow-600'
|
if (percentUsed >= 100) return 'bg-red-600';
|
||||||
}`}
|
if (percentUsed >= 75) return 'bg-orange-500';
|
||||||
data-testid={`${testId}-sla-progress`}
|
if (percentUsed >= 50) return 'bg-amber-500';
|
||||||
/>
|
return 'bg-green-600';
|
||||||
<div className="flex items-center justify-between">
|
};
|
||||||
<div className="flex items-center gap-2">
|
const getActiveTextColor = () => {
|
||||||
<span className={`text-xs font-semibold ${
|
if (percentUsed >= 100) return 'text-red-600';
|
||||||
approval.sla.status === 'breached' ? 'text-red-600' :
|
if (percentUsed >= 75) return 'text-orange-600';
|
||||||
approval.sla.status === 'critical' ? 'text-orange-600' :
|
if (percentUsed >= 50) return 'text-amber-600';
|
||||||
'text-yellow-700'
|
return 'text-green-600';
|
||||||
}`}>
|
};
|
||||||
Progress: {Math.min(100, approval.sla.percentageUsed)}% of TAT used
|
return (
|
||||||
</span>
|
<>
|
||||||
{approval.sla.status === 'breached' && canEditBreachReason && (
|
<Progress
|
||||||
<TooltipProvider>
|
value={approval.sla.percentageUsed}
|
||||||
<Tooltip>
|
className="h-3"
|
||||||
<TooltipTrigger asChild>
|
indicatorClassName={getActiveIndicatorColor()}
|
||||||
<Button
|
data-testid={`${testId}-sla-progress`}
|
||||||
variant="ghost"
|
/>
|
||||||
size="sm"
|
<div className="flex items-center justify-between">
|
||||||
className="h-5 w-5 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => {
|
<span className={`text-xs font-semibold ${getActiveTextColor()}`}>
|
||||||
setBreachReason(existingBreachReason);
|
Progress: {Math.min(100, approval.sla.percentageUsed)}% of TAT used
|
||||||
setShowBreachReasonModal(true);
|
</span>
|
||||||
}}
|
{approval.sla.status === 'breached' && canEditBreachReason && (
|
||||||
>
|
<TooltipProvider>
|
||||||
<FileEdit className="w-3 h-3" />
|
<Tooltip>
|
||||||
</Button>
|
<TooltipTrigger asChild>
|
||||||
</TooltipTrigger>
|
<Button
|
||||||
<TooltipContent>
|
variant="ghost"
|
||||||
<p>{existingBreachReason ? 'Edit breach reason' : 'Add breach reason'}</p>
|
size="sm"
|
||||||
</TooltipContent>
|
className="h-5 w-5 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
</Tooltip>
|
onClick={() => {
|
||||||
</TooltipProvider>
|
setBreachReason(existingBreachReason);
|
||||||
)}
|
setShowBreachReasonModal(true);
|
||||||
</div>
|
}}
|
||||||
<span className="text-xs font-medium text-gray-700">
|
>
|
||||||
{approval.sla.remainingText} remaining
|
<FileEdit className="w-3 h-3" />
|
||||||
</span>
|
</Button>
|
||||||
</div>
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{existingBreachReason ? 'Edit breach reason' : 'Add breach reason'}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-gray-700">
|
||||||
|
{approval.sla.remainingText} remaining
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{approval.sla.status === 'breached' && (
|
{approval.sla.status === 'breached' && (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs font-semibold text-center text-red-600 flex items-center justify-center gap-1.5">
|
<p className="text-xs font-semibold text-center text-red-600 flex items-center justify-center gap-1.5">
|
||||||
@ -607,8 +639,9 @@ export function ApprovalStepCard({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Skip Approver Button - Only show for initiator on pending/in-review levels */}
|
{/* Skip Approver Button - Only show for initiator on pending/in-review/paused levels */}
|
||||||
{isInitiator && (isActive || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && (
|
{/* When paused, initiator can skip the approver which will negate the pause */}
|
||||||
|
{isInitiator && (isActive || isPaused || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && (
|
||||||
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-200">
|
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-200">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -625,7 +658,10 @@ export function ApprovalStepCard({
|
|||||||
Skip This Approver
|
Skip This Approver
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-[10px] sm:text-xs text-gray-500 mt-1 text-center">
|
<p className="text-[10px] sm:text-xs text-gray-500 mt-1 text-center">
|
||||||
Skip if approver is unavailable and move to next level
|
{isPaused
|
||||||
|
? 'Skip this approver to resume the workflow and move to next level'
|
||||||
|
: 'Skip if approver is unavailable and move to next level'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ interface PauseModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
levelId: string | null;
|
levelId: string | null;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PauseModal({ isOpen, onClose, requestId, levelId, onSuccess }: PauseModalProps) {
|
export function PauseModal({ isOpen, onClose, requestId, levelId, onSuccess }: PauseModalProps) {
|
||||||
@ -72,9 +72,15 @@ export function PauseModal({ isOpen, onClose, requestId, levelId, onSuccess }: P
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
await pauseWorkflow(requestId, levelId, reason.trim(), selectedDate.toDate());
|
await pauseWorkflow(requestId, levelId, reason.trim(), selectedDate.toDate());
|
||||||
toast.success('Workflow paused successfully');
|
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('');
|
setReason('');
|
||||||
setResumeDate(getDefaultResumeDate());
|
setResumeDate(getDefaultResumeDate());
|
||||||
onSuccess?.();
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to pause workflow:', error);
|
console.error('Failed to pause workflow:', error);
|
||||||
|
|||||||
@ -10,7 +10,7 @@ interface RetriggerPauseModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
approverName?: string;
|
approverName?: string;
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RetriggerPauseModal({ isOpen, onClose, requestId, approverName, onSuccess }: RetriggerPauseModalProps) {
|
export function RetriggerPauseModal({ isOpen, onClose, requestId, approverName, onSuccess }: RetriggerPauseModalProps) {
|
||||||
@ -21,7 +21,12 @@ export function RetriggerPauseModal({ isOpen, onClose, requestId, approverName,
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
await retriggerPause(requestId);
|
await retriggerPause(requestId);
|
||||||
toast.success('Retrigger request sent to approver');
|
toast.success('Retrigger request sent to approver');
|
||||||
onSuccess?.();
|
|
||||||
|
// Wait for parent to refresh data before closing modal
|
||||||
|
if (onSuccess) {
|
||||||
|
await onSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to retrigger pause:', error);
|
console.error('Failed to retrigger pause:', error);
|
||||||
|
|||||||
@ -117,7 +117,16 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
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 token = TokenManager.getAccessToken();
|
||||||
const refreshToken = TokenManager.getRefreshToken();
|
const refreshToken = TokenManager.getRefreshToken();
|
||||||
const userData = TokenManager.getUserData();
|
const userData = TokenManager.getUserData();
|
||||||
@ -144,7 +153,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
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) {
|
if (!isLoggingOut) {
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
} else {
|
} else {
|
||||||
@ -494,6 +503,27 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getAccessTokenSilently = async (): Promise<string | null> => {
|
const getAccessTokenSilently = async (): Promise<string | null> => {
|
||||||
|
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();
|
const token = TokenManager.getAccessToken();
|
||||||
if (token && !isTokenExpired(token)) {
|
if (token && !isTokenExpired(token)) {
|
||||||
return token;
|
return token;
|
||||||
@ -509,10 +539,20 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const refreshTokenSilently = async (): Promise<void> => {
|
const refreshTokenSilently = async (): Promise<void> => {
|
||||||
|
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newToken = await refreshAccessToken();
|
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) {
|
if (newToken) {
|
||||||
// Token refreshed successfully
|
// Token refreshed successfully (development mode)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error('Failed to refresh token');
|
throw new Error('Failed to refresh token');
|
||||||
|
|||||||
@ -32,6 +32,12 @@ export function useRequestDetails(
|
|||||||
// State: Indicates if data is currently being fetched
|
// State: Indicates if data is currently being fetched
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
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
|
// State: Stores the current approval level for the logged-in user
|
||||||
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null);
|
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(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
|
* Build: Complete request object with all transformed data
|
||||||
* This object is used throughout the UI
|
* This object is used throughout the UI
|
||||||
@ -244,6 +263,7 @@ export function useRequestDetails(
|
|||||||
auditTrail: filteredActivities,
|
auditTrail: filteredActivities,
|
||||||
conclusionRemark: wf.conclusionRemark || null,
|
conclusionRemark: wf.conclusionRemark || null,
|
||||||
closureDate: wf.closureDate || null,
|
closureDate: wf.closureDate || null,
|
||||||
|
pauseInfo: pauseInfo || null, // Include pause info for resume/retrigger buttons
|
||||||
};
|
};
|
||||||
|
|
||||||
setApiRequest(updatedRequest);
|
setApiRequest(updatedRequest);
|
||||||
@ -296,14 +316,22 @@ export function useRequestDetails(
|
|||||||
* This is the primary data loading mechanism
|
* This is the primary data loading mechanism
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!requestIdentifier) return;
|
if (!requestIdentifier) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
setLoading(true);
|
||||||
|
setAccessDenied(null);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
|
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
|
// Use the same transformation logic as refreshDetails
|
||||||
const wf = details.workflow || {};
|
const wf = details.workflow || {};
|
||||||
@ -473,11 +501,21 @@ export function useRequestDetails(
|
|||||||
} else {
|
} else {
|
||||||
setIsSpectator(false);
|
setIsSpectator(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('[useRequestDetails] Error loading request details:', error);
|
console.error('[useRequestDetails] Error loading request details:', error);
|
||||||
if (mounted) {
|
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);
|
setApiRequest(null);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@ -585,12 +623,14 @@ export function useRequestDetails(
|
|||||||
return {
|
return {
|
||||||
request,
|
request,
|
||||||
apiRequest,
|
apiRequest,
|
||||||
|
loading,
|
||||||
refreshing,
|
refreshing,
|
||||||
refreshDetails,
|
refreshDetails,
|
||||||
currentApprovalLevel,
|
currentApprovalLevel,
|
||||||
isSpectator,
|
isSpectator,
|
||||||
isInitiator,
|
isInitiator,
|
||||||
existingParticipants
|
existingParticipants,
|
||||||
|
accessDenied
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,12 +28,9 @@ export function AuthCallback() {
|
|||||||
}
|
}
|
||||||
} else if (user && isAuthenticated) {
|
} else if (user && isAuthenticated) {
|
||||||
setAuthStep('complete');
|
setAuthStep('complete');
|
||||||
// Small delay before redirect for better UX
|
// AuthContext already handles the URL change via replaceState
|
||||||
const timer = setTimeout(() => {
|
// No need to do a full page reload which would lose React state
|
||||||
// Use window.location instead of navigate since Router context isn't available yet
|
// The AuthenticatedApp will re-render and show the App component
|
||||||
window.location.href = '/';
|
|
||||||
}, 1500);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, isLoading, error, user]);
|
}, [isAuthenticated, isLoading, error, user]);
|
||||||
|
|
||||||
@ -165,7 +162,7 @@ export function AuthCallback() {
|
|||||||
|
|
||||||
{/* Footer Text */}
|
{/* Footer Text */}
|
||||||
<p className="mt-6 text-slate-500 text-xs">
|
<p className="mt-6 text-slate-500 text-xs">
|
||||||
{authStep === 'complete' ? 'Redirecting to dashboard...' : 'Please wait while we secure your session'}
|
{authStep === 'complete' ? 'Loading dashboard...' : 'Please wait while we secure your session'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -70,7 +70,7 @@ export function UserKPICards({
|
|||||||
testId="kpi-my-requests"
|
testId="kpi-my-requests"
|
||||||
onClick={() => onKPIClick(getFilterParams())}
|
onClick={() => onKPIClick(getFilterParams())}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-1.5 sm:gap-2">
|
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Approved"
|
label="Approved"
|
||||||
value={kpis?.requestVolume.approvedRequests || 0}
|
value={kpis?.requestVolume.approvedRequests || 0}
|
||||||
@ -93,6 +93,17 @@ export function UserKPICards({
|
|||||||
onKPIClick({ ...getFilterParams(), status: 'pending' });
|
onKPIClick({ ...getFilterParams(), status: 'pending' });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Paused"
|
||||||
|
value={kpis?.requestVolume.pausedRequests || 0}
|
||||||
|
bgColor="bg-amber-50"
|
||||||
|
textColor="text-amber-600"
|
||||||
|
testId="stat-user-paused"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onKPIClick({ ...getFilterParams(), status: 'paused' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Rejected"
|
label="Rejected"
|
||||||
value={kpis?.requestVolume.rejectedRequests || 0}
|
value={kpis?.requestVolume.rejectedRequests || 0}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import type { CriticalAlertData } from '@/components/dashboard/CriticalAlertCard
|
|||||||
interface UseDashboardDataOptions {
|
interface UseDashboardDataOptions {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
viewAsUser?: boolean; // For admin to view as normal user
|
viewAsUser?: boolean; // For admin to view as normal user
|
||||||
userId?: string; // User ID for filtering when viewAsUser is true (not used directly, backend handles it)
|
userId?: string; // User ID for filtering - needed to fetch user-initiated requests
|
||||||
dateRange: DateRange;
|
dateRange: DateRange;
|
||||||
customStartDate?: Date;
|
customStartDate?: Date;
|
||||||
customEndDate?: Date;
|
customEndDate?: Date;
|
||||||
@ -25,7 +25,7 @@ interface UseDashboardDataOptions {
|
|||||||
export function useDashboardData({
|
export function useDashboardData({
|
||||||
isAdmin,
|
isAdmin,
|
||||||
viewAsUser = false,
|
viewAsUser = false,
|
||||||
userId: _userId, // Prefixed with _ to indicate intentionally unused (backend handles userId from auth)
|
userId, // User ID for filtering user-initiated requests
|
||||||
dateRange,
|
dateRange,
|
||||||
customStartDate,
|
customStartDate,
|
||||||
customEndDate,
|
customEndDate,
|
||||||
@ -62,6 +62,25 @@ export function useDashboardData({
|
|||||||
dashboardService.getUpcomingDeadlines(1, 10, viewAsUser)
|
dashboardService.getUpcomingDeadlines(1, 10, viewAsUser)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// For normal users (or admin viewing as user):
|
||||||
|
// Fetch user-INITIATED requests stats separately for "My Requests" card
|
||||||
|
// This ensures "My Requests" only shows requests they created, not all requests they're involved in
|
||||||
|
const userInitiatedPromise = (!isAdmin && userId)
|
||||||
|
? dashboardService.getRequestStats(
|
||||||
|
dateRange,
|
||||||
|
customStartDate?.toISOString(),
|
||||||
|
customEndDate?.toISOString(),
|
||||||
|
undefined, // status
|
||||||
|
undefined, // priority
|
||||||
|
undefined, // department
|
||||||
|
userId, // initiator - filter by user's ID to get ONLY their initiated requests
|
||||||
|
undefined, // approver
|
||||||
|
undefined, // approverType
|
||||||
|
undefined, // search
|
||||||
|
undefined // slaCompliance
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Fetch admin-only data if user is admin
|
// Fetch admin-only data if user is admin
|
||||||
const adminPromises = isAdmin ? [
|
const adminPromises = isAdmin ? [
|
||||||
dashboardService.getDepartmentStats(dateRange, customStartDate, customEndDate),
|
dashboardService.getDepartmentStats(dateRange, customStartDate, customEndDate),
|
||||||
@ -71,12 +90,23 @@ export function useDashboardData({
|
|||||||
] : [];
|
] : [];
|
||||||
|
|
||||||
// Fetch all data in parallel
|
// Fetch all data in parallel
|
||||||
const results = await Promise.all([...commonPromises, ...adminPromises]);
|
const [commonResults, userInitiatedStats, adminResults] = await Promise.all([
|
||||||
|
Promise.all(commonPromises),
|
||||||
|
userInitiatedPromise,
|
||||||
|
Promise.all(adminPromises)
|
||||||
|
]);
|
||||||
|
|
||||||
const kpisData = results[0] as DashboardKPIs;
|
const kpisData = commonResults[0] as DashboardKPIs;
|
||||||
const activityResult = results[1] as { activities: RecentActivity[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
|
const activityResult = commonResults[1] as { activities: RecentActivity[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
|
||||||
const criticalResult = results[2] as { criticalRequests: CriticalRequest[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
|
const criticalResult = commonResults[2] as { criticalRequests: CriticalRequest[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
|
||||||
const deadlinesResult = results[3] as { deadlines: UpcomingDeadline[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
|
const deadlinesResult = commonResults[3] as { deadlines: UpcomingDeadline[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
|
||||||
|
|
||||||
|
// For normal users: Override requestVolume with user-initiated requests only
|
||||||
|
// This makes "My Requests" show only requests they created
|
||||||
|
// Other KPIs (approverLoad, etc.) remain as-is since they need all involved requests
|
||||||
|
if (!isAdmin && userInitiatedStats) {
|
||||||
|
kpisData.requestVolume = userInitiatedStats;
|
||||||
|
}
|
||||||
|
|
||||||
setKpis(kpisData);
|
setKpis(kpisData);
|
||||||
setRecentActivity(activityResult.activities);
|
setRecentActivity(activityResult.activities);
|
||||||
@ -101,11 +131,11 @@ export function useDashboardData({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Only set admin-specific data if user is admin
|
// Only set admin-specific data if user is admin
|
||||||
if (isAdmin && results.length >= 8) {
|
if (isAdmin && adminResults.length >= 4) {
|
||||||
const deptStats = results[4] as DepartmentStats[];
|
const deptStats = adminResults[0] as DepartmentStats[];
|
||||||
const priorityDist = results[5] as PriorityDistribution[];
|
const priorityDist = adminResults[1] as PriorityDistribution[];
|
||||||
const aiUtilization = results[6] as AIRemarkUtilization;
|
const aiUtilization = adminResults[2] as AIRemarkUtilization;
|
||||||
const approverResult = results[7] as { performance: ApproverPerformance[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
|
const approverResult = adminResults[3] as { performance: ApproverPerformance[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
|
||||||
setDepartmentStats(deptStats);
|
setDepartmentStats(deptStats);
|
||||||
setPriorityDistribution(priorityDist);
|
setPriorityDistribution(priorityDist);
|
||||||
setAiRemarkUtilization(aiUtilization);
|
setAiRemarkUtilization(aiUtilization);
|
||||||
@ -128,7 +158,7 @@ export function useDashboardData({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [isAdmin, viewAsUser, dateRange, customStartDate, customEndDate]);
|
}, [isAdmin, viewAsUser, userId, dateRange, customStartDate, customEndDate]);
|
||||||
|
|
||||||
// Fetch individual data with pagination
|
// Fetch individual data with pagination
|
||||||
const fetchRecentActivities = useCallback(async (page: number = 1) => {
|
const fetchRecentActivities = useCallback(async (page: number = 1) => {
|
||||||
|
|||||||
@ -23,6 +23,9 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
|
ShieldX,
|
||||||
|
RefreshCw,
|
||||||
|
ArrowLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
@ -116,12 +119,14 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
const {
|
const {
|
||||||
request,
|
request,
|
||||||
apiRequest,
|
apiRequest,
|
||||||
|
loading: requestLoading,
|
||||||
refreshing,
|
refreshing,
|
||||||
refreshDetails,
|
refreshDetails,
|
||||||
currentApprovalLevel,
|
currentApprovalLevel,
|
||||||
isSpectator,
|
isSpectator,
|
||||||
isInitiator,
|
isInitiator,
|
||||||
existingParticipants,
|
existingParticipants,
|
||||||
|
accessDenied,
|
||||||
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
|
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -193,17 +198,23 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
setShowPauseModal(true);
|
setShowPauseModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [resuming, setResuming] = useState(false);
|
||||||
|
|
||||||
const handleResume = async () => {
|
const handleResume = async () => {
|
||||||
if (!apiRequest?.requestId) {
|
if (!apiRequest?.requestId) {
|
||||||
toast.error('Request ID not found');
|
toast.error('Request ID not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
setResuming(true);
|
||||||
await resumeWorkflow(apiRequest.requestId);
|
await resumeWorkflow(apiRequest.requestId);
|
||||||
toast.success('Workflow resumed successfully');
|
toast.success('Workflow resumed successfully');
|
||||||
refreshDetails();
|
// Wait for refresh to complete before clearing loading state
|
||||||
|
await refreshDetails();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error?.response?.data?.error || 'Failed to resume workflow');
|
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 () => {
|
const handlePauseSuccess = async () => {
|
||||||
refreshDetails();
|
// Wait for refresh to complete to show updated pause status
|
||||||
|
await refreshDetails();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRetriggerSuccess = async () => {
|
const handleRetriggerSuccess = async () => {
|
||||||
refreshDetails();
|
// Wait for refresh to complete
|
||||||
|
await refreshDetails();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShareSummary = async () => {
|
const handleShareSummary = async () => {
|
||||||
@ -312,27 +325,89 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (!request && !apiRequest) {
|
if (requestLoading && !request && !apiRequest) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen" data-testid="loading-state">
|
<div className="flex items-center justify-center h-screen bg-gray-50" data-testid="loading-state">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
<RefreshCw className="w-12 h-12 text-blue-600 animate-spin mx-auto mb-4" />
|
||||||
<p className="text-gray-600">Loading request details...</p>
|
<p className="text-gray-600">Loading request details...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state
|
// Access Denied state
|
||||||
|
if (accessDenied?.denied) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6" data-testid="access-denied-state">
|
||||||
|
<div className="max-w-lg w-full bg-white rounded-2xl shadow-xl p-8 text-center">
|
||||||
|
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<ShieldX className="w-10 h-10 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-3">Access Denied</h2>
|
||||||
|
<p className="text-gray-600 mb-6 leading-relaxed">
|
||||||
|
{accessDenied.message}
|
||||||
|
</p>
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6 text-left">
|
||||||
|
<p className="text-sm text-amber-800">
|
||||||
|
<strong>Who can access this request?</strong>
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-amber-700 mt-2 space-y-1">
|
||||||
|
<li>• The person who created this request (Initiator)</li>
|
||||||
|
<li>• Designated approvers at any level</li>
|
||||||
|
<li>• Added spectators or participants</li>
|
||||||
|
<li>• Organization administrators</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onBack || (() => window.history.back())}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/dashboard'}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Go to Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not Found state
|
||||||
if (!request) {
|
if (!request) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen" data-testid="not-found-state">
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6" data-testid="not-found-state">
|
||||||
<div className="text-center">
|
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 text-center">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Request Not Found</h2>
|
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
<p className="text-gray-600 mb-4">The request you're looking for doesn't exist.</p>
|
<FileText className="w-10 h-10 text-gray-400" />
|
||||||
<Button onClick={onBack}>
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-3">Request Not Found</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
The request you're looking for doesn't exist or may have been deleted.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onBack || (() => window.history.back())}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Go Back
|
Go Back
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/dashboard'}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Go to Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -518,6 +593,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
refreshTrigger={sharedRecipientsRefreshTrigger}
|
refreshTrigger={sharedRecipientsRefreshTrigger}
|
||||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
||||||
currentUserId={(user as any)?.userId}
|
currentUserId={(user as any)?.userId}
|
||||||
|
resuming={resuming}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
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';
|
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
|
||||||
|
|
||||||
interface QuickActionsSidebarProps {
|
interface QuickActionsSidebarProps {
|
||||||
@ -25,6 +25,7 @@ interface QuickActionsSidebarProps {
|
|||||||
refreshTrigger?: number; // Trigger to refresh shared recipients list
|
refreshTrigger?: number; // Trigger to refresh shared recipients list
|
||||||
pausedByUserId?: string; // User ID of the approver who paused
|
pausedByUserId?: string; // User ID of the approver who paused
|
||||||
currentUserId?: string; // Current user's ID
|
currentUserId?: string; // Current user's ID
|
||||||
|
resuming?: boolean; // Loading state for resume action
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuickActionsSidebar({
|
export function QuickActionsSidebar({
|
||||||
@ -43,6 +44,7 @@ export function QuickActionsSidebar({
|
|||||||
refreshTrigger,
|
refreshTrigger,
|
||||||
pausedByUserId,
|
pausedByUserId,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
resuming = false,
|
||||||
}: QuickActionsSidebarProps) {
|
}: QuickActionsSidebarProps) {
|
||||||
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
||||||
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
||||||
@ -130,10 +132,20 @@ export function QuickActionsSidebar({
|
|||||||
variant="outline"
|
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"
|
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}
|
onClick={onResume}
|
||||||
|
disabled={resuming}
|
||||||
data-testid="resume-workflow-button"
|
data-testid="resume-workflow-button"
|
||||||
>
|
>
|
||||||
|
{resuming ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 sm:w-4 sm:h-4 animate-spin" />
|
||||||
|
Resuming...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Play className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
<Play className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||||
Resume Workflow
|
Resume Workflow
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -308,29 +308,29 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
// Total changes when other filters are applied, but stays stable when only status changes
|
// 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
|
// Stats are fetched for both org-level AND user-level (Personal mode) views
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
const filtersWithoutStatus = {
|
const filtersWithoutStatus = {
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined,
|
department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined,
|
||||||
initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined,
|
initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined,
|
||||||
approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined,
|
approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined,
|
||||||
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
|
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
|
||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined
|
slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined
|
||||||
};
|
};
|
||||||
// All Requests (admin/normal user) should always have a date range
|
// All Requests (admin/normal user) should always have a date range
|
||||||
// Default to 'month' if no date range is selected
|
// Default to 'month' if no date range is selected
|
||||||
const statsDateRange = filters.dateRange || 'month';
|
const statsDateRange = filters.dateRange || 'month';
|
||||||
|
|
||||||
fetchBackendStatsRef.current(
|
fetchBackendStatsRef.current(
|
||||||
statsDateRange,
|
statsDateRange,
|
||||||
filters.customStartDate,
|
filters.customStartDate,
|
||||||
filters.customEndDate,
|
filters.customEndDate,
|
||||||
filtersWithoutStatus
|
filtersWithoutStatus
|
||||||
);
|
);
|
||||||
}, filters.searchTerm ? 500 : 0);
|
}, filters.searchTerm ? 500 : 0);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
isOrgLevel,
|
isOrgLevel,
|
||||||
|
|||||||
@ -301,36 +301,36 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: calculate from current page (less accurate, but works during initial load)
|
// Fallback: calculate from current page (less accurate, but works during initial load)
|
||||||
const pending = convertedRequests.filter((r: any) => {
|
const pending = convertedRequests.filter((r: any) => {
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
return status === 'pending' || status === 'in-progress';
|
return status === 'pending' || status === 'in-progress';
|
||||||
}).length;
|
}).length;
|
||||||
const paused = convertedRequests.filter((r: any) => {
|
const paused = convertedRequests.filter((r: any) => {
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
return status === 'paused';
|
return status === 'paused';
|
||||||
}).length;
|
}).length;
|
||||||
const approved = convertedRequests.filter((r: any) => {
|
const approved = convertedRequests.filter((r: any) => {
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
return status === 'approved';
|
return status === 'approved';
|
||||||
}).length;
|
}).length;
|
||||||
const rejected = convertedRequests.filter((r: any) => {
|
const rejected = convertedRequests.filter((r: any) => {
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
return status === 'rejected';
|
return status === 'rejected';
|
||||||
}).length;
|
}).length;
|
||||||
const closed = convertedRequests.filter((r: any) => {
|
const closed = convertedRequests.filter((r: any) => {
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
return status === 'closed';
|
return status === 'closed';
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: totalRecords > 0 ? totalRecords : convertedRequests.length,
|
total: totalRecords > 0 ? totalRecords : convertedRequests.length,
|
||||||
pending,
|
pending,
|
||||||
paused,
|
paused,
|
||||||
approved,
|
approved,
|
||||||
rejected,
|
rejected,
|
||||||
draft: 0,
|
draft: 0,
|
||||||
closed
|
closed
|
||||||
};
|
};
|
||||||
}, [backendStats, totalRecords, convertedRequests]);
|
}, [backendStats, totalRecords, convertedRequests]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -42,12 +42,12 @@ export function RequestsHeader({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-4" data-testid="requests-header-container">
|
<div className="flex items-start justify-between gap-4" data-testid="requests-header-container">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={FileText}
|
icon={FileText}
|
||||||
title={getTitle()}
|
title={getTitle()}
|
||||||
description={getDescription()}
|
description={getDescription()}
|
||||||
testId="requests-header"
|
testId="requests-header"
|
||||||
/>
|
/>
|
||||||
{/* View mode badge */}
|
{/* View mode badge */}
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -1,6 +1,27 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { User } from '@/types/user.types';
|
import { User } from '@/types/user.types';
|
||||||
|
|
||||||
|
// Check if running in production mode
|
||||||
|
const isProduction = (): boolean => {
|
||||||
|
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 {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
@ -11,7 +32,7 @@ interface AuthState {
|
|||||||
|
|
||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
user: null,
|
user: null,
|
||||||
token: localStorage.getItem('token'),
|
token: getStoredToken(),
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@ -31,7 +52,14 @@ const authSlice = createSlice({
|
|||||||
state.user = action.payload.user;
|
state.user = action.payload.user;
|
||||||
state.token = action.payload.token;
|
state.token = action.payload.token;
|
||||||
state.error = null;
|
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<string>) => {
|
loginFailure: (state, action: PayloadAction<string>) => {
|
||||||
state.isLoading = false;
|
state.isLoading = false;
|
||||||
@ -39,14 +67,26 @@ const authSlice = createSlice({
|
|||||||
state.user = null;
|
state.user = null;
|
||||||
state.token = null;
|
state.token = null;
|
||||||
state.error = action.payload;
|
state.error = action.payload;
|
||||||
localStorage.removeItem('token');
|
if (!isProduction()) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
logout: (state) => {
|
logout: (state) => {
|
||||||
state.isAuthenticated = false;
|
state.isAuthenticated = false;
|
||||||
state.user = null;
|
state.user = null;
|
||||||
state.token = null;
|
state.token = null;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
localStorage.removeItem('token');
|
if (!isProduction()) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
clearError: (state) => {
|
clearError: (state) => {
|
||||||
state.error = null;
|
state.error = null;
|
||||||
|
|||||||
@ -28,10 +28,10 @@ apiClient.interceptors.request.use(
|
|||||||
|
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
// Development: Get token from localStorage and add to header
|
// Development: Get token from localStorage and add to header
|
||||||
const token = TokenManager.getAccessToken();
|
const token = TokenManager.getAccessToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Production: Cookies handle authentication automatically
|
// Production: Cookies handle authentication automatically
|
||||||
|
|
||||||
@ -92,8 +92,8 @@ apiClient.interceptors.response.use(
|
|||||||
const accessToken = responseData.accessToken;
|
const accessToken = responseData.accessToken;
|
||||||
|
|
||||||
// In production: Backend sets new httpOnly cookie, no token in response
|
// In production: Backend sets new httpOnly cookie, no token in response
|
||||||
// In development: Token is in response, store it
|
// In development: Token is in response, store it and add to header
|
||||||
if (accessToken) {
|
if (!isProduction && accessToken) {
|
||||||
TokenManager.setAccessToken(accessToken);
|
TokenManager.setAccessToken(accessToken);
|
||||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
@ -205,25 +205,42 @@ export async function exchangeCodeForTokens(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh access token using refresh token
|
* 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<string> {
|
export async function refreshAccessToken(): Promise<string> {
|
||||||
|
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();
|
const refreshToken = TokenManager.getRefreshToken();
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw new Error('No refresh token available');
|
throw new Error('No refresh token available');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response = await apiClient.post<RefreshTokenResponse>('/auth/refresh', {
|
// In production, httpOnly cookie with refresh token will be sent automatically
|
||||||
refreshToken,
|
// In development, we send the refresh token in the body
|
||||||
});
|
const body = isProduction ? {} : { refreshToken: TokenManager.getRefreshToken() };
|
||||||
|
|
||||||
|
const response = await apiClient.post<RefreshTokenResponse>('/auth/refresh', body);
|
||||||
|
|
||||||
const data = response.data as any;
|
const data = response.data as any;
|
||||||
const accessToken = data.data?.accessToken || data.accessToken;
|
const accessToken = data.data?.accessToken || data.accessToken;
|
||||||
|
|
||||||
if (accessToken) {
|
// In development mode, store the token
|
||||||
|
if (!isProduction && accessToken) {
|
||||||
TokenManager.setAccessToken(accessToken);
|
TokenManager.setAccessToken(accessToken);
|
||||||
return 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');
|
throw new Error('Failed to refresh token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -380,7 +380,7 @@ export async function downloadDocument(documentId: string): Promise<void> {
|
|||||||
fetchOptions.headers = {
|
fetchOptions.headers = {
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(downloadUrl, fetchOptions);
|
const response = await fetch(downloadUrl, fetchOptions);
|
||||||
|
|
||||||
@ -426,7 +426,7 @@ export async function downloadWorkNoteAttachment(attachmentId: string): Promise<
|
|||||||
fetchOptions.headers = {
|
fetchOptions.headers = {
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(downloadUrl, fetchOptions);
|
const response = await fetch(downloadUrl, fetchOptions);
|
||||||
|
|
||||||
|
|||||||
@ -123,22 +123,34 @@ export async function subscribeUserToPush(register: ServiceWorkerRegistration) {
|
|||||||
// Convert subscription to JSON format for backend
|
// Convert subscription to JSON format for backend
|
||||||
const subscriptionJson = subscription.toJSON();
|
const subscriptionJson = subscription.toJSON();
|
||||||
|
|
||||||
// Attach auth token if available
|
// Check if we're in production mode (cookies used for auth)
|
||||||
const token = (window as any)?.localStorage?.getItem?.('accessToken') ||
|
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
(document?.cookie || '').match(/accessToken=([^;]+)/)?.[1] || '';
|
|
||||||
|
|
||||||
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.');
|
throw new Error('Authentication token not found. Please log in again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send subscription to backend
|
// Send subscription to backend
|
||||||
try {
|
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`, {
|
const response = await fetch(`${VITE_BASE_URL}/api/v1/workflows/notifications/subscribe`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
credentials: 'include', // Always include credentials for cookie-based auth
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(subscriptionJson)
|
body: JSON.stringify(subscriptionJson)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user