tat pause resume job logic altered
This commit is contained in:
parent
fe441c8b76
commit
91e028fb18
@ -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}`;
|
||||
}
|
||||
|
||||
const response = await fetch(urlToFetch, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
// Build headers - in production, rely on httpOnly cookies
|
||||
const headers: HeadersInit = {
|
||||
'Accept': isPDF ? 'application/pdf' : '*/*'
|
||||
},
|
||||
credentials: 'include', // Include credentials for same-origin requests
|
||||
mode: 'cors' // Explicitly set CORS mode
|
||||
};
|
||||
|
||||
// Only add Authorization header in development mode
|
||||
if (!isProduction && token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(urlToFetch, {
|
||||
headers,
|
||||
credentials: 'include', // Always include credentials for cookie-based auth
|
||||
mode: 'cors'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@ -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';
|
||||
};
|
||||
|
||||
|
||||
@ -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<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const socketRef = useRef<any>(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
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<Progress
|
||||
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`}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<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
|
||||
</span>
|
||||
{isBreached && canEditBreachReason && (
|
||||
@ -352,9 +369,10 @@ export function ApprovalStepCard({
|
||||
|
||||
{/* Current Approver - Time Tracking */}
|
||||
<div className={`border rounded-lg p-3 ${
|
||||
approval.sla.status === 'critical' ? 'bg-orange-50 border-orange-200' :
|
||||
approval.sla.status === 'breached' ? 'bg-red-50 border-red-200' :
|
||||
'bg-yellow-50 border-yellow-200'
|
||||
(approval.sla.percentageUsed || 0) >= 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'
|
||||
}`}>
|
||||
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
@ -374,22 +392,33 @@ export function ApprovalStepCard({
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
// 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
|
||||
value={approval.sla.percentageUsed}
|
||||
className={`h-3 ${
|
||||
approval.sla.status === 'breached' ? '[&>div]:bg-red-600' :
|
||||
approval.sla.status === 'critical' ? '[&>div]:bg-orange-600' :
|
||||
'[&>div]:bg-yellow-600'
|
||||
}`}
|
||||
className="h-3"
|
||||
indicatorClassName={getActiveIndicatorColor()}
|
||||
data-testid={`${testId}-sla-progress`}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-semibold ${
|
||||
approval.sla.status === 'breached' ? 'text-red-600' :
|
||||
approval.sla.status === 'critical' ? 'text-orange-600' :
|
||||
'text-yellow-700'
|
||||
}`}>
|
||||
<span className={`text-xs font-semibold ${getActiveTextColor()}`}>
|
||||
Progress: {Math.min(100, approval.sla.percentageUsed)}% of TAT used
|
||||
</span>
|
||||
{approval.sla.status === 'breached' && canEditBreachReason && (
|
||||
@ -419,6 +448,9 @@ export function ApprovalStepCard({
|
||||
{approval.sla.remainingText} remaining
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{approval.sla.status === 'breached' && (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -625,7 +658,10 @@ export function ApprovalStepCard({
|
||||
Skip This Approver
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -14,7 +14,7 @@ interface PauseModalProps {
|
||||
onClose: () => void;
|
||||
requestId: string;
|
||||
levelId: string | null;
|
||||
onSuccess?: () => void;
|
||||
onSuccess?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@ -10,7 +10,7 @@ interface RetriggerPauseModalProps {
|
||||
onClose: () => void;
|
||||
requestId: string;
|
||||
approverName?: string;
|
||||
onSuccess?: () => void;
|
||||
onSuccess?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@ -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<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();
|
||||
if (token && !isTokenExpired(token)) {
|
||||
return token;
|
||||
@ -509,10 +539,20 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
|
||||
const refreshTokenSilently = async (): Promise<void> => {
|
||||
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');
|
||||
|
||||
@ -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<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
|
||||
* 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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 */}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@ -70,7 +70,7 @@ export function UserKPICards({
|
||||
testId="kpi-my-requests"
|
||||
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
|
||||
label="Approved"
|
||||
value={kpis?.requestVolume.approvedRequests || 0}
|
||||
@ -93,6 +93,17 @@ export function UserKPICards({
|
||||
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
|
||||
label="Rejected"
|
||||
value={kpis?.requestVolume.rejectedRequests || 0}
|
||||
|
||||
@ -10,7 +10,7 @@ import type { CriticalAlertData } from '@/components/dashboard/CriticalAlertCard
|
||||
interface UseDashboardDataOptions {
|
||||
isAdmin: boolean;
|
||||
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;
|
||||
customStartDate?: Date;
|
||||
customEndDate?: Date;
|
||||
@ -25,7 +25,7 @@ interface UseDashboardDataOptions {
|
||||
export function useDashboardData({
|
||||
isAdmin,
|
||||
viewAsUser = false,
|
||||
userId: _userId, // Prefixed with _ to indicate intentionally unused (backend handles userId from auth)
|
||||
userId, // User ID for filtering user-initiated requests
|
||||
dateRange,
|
||||
customStartDate,
|
||||
customEndDate,
|
||||
@ -62,6 +62,25 @@ export function useDashboardData({
|
||||
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
|
||||
const adminPromises = isAdmin ? [
|
||||
dashboardService.getDepartmentStats(dateRange, customStartDate, customEndDate),
|
||||
@ -71,12 +90,23 @@ export function useDashboardData({
|
||||
] : [];
|
||||
|
||||
// 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 activityResult = results[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 deadlinesResult = results[3] as { deadlines: UpcomingDeadline[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
|
||||
const kpisData = commonResults[0] as DashboardKPIs;
|
||||
const activityResult = commonResults[1] as { activities: RecentActivity[]; 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 = 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);
|
||||
setRecentActivity(activityResult.activities);
|
||||
@ -101,11 +131,11 @@ export function useDashboardData({
|
||||
);
|
||||
|
||||
// Only set admin-specific data if user is admin
|
||||
if (isAdmin && results.length >= 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) => {
|
||||
|
||||
@ -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 (
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (!request) {
|
||||
// Access Denied state
|
||||
if (accessDenied?.denied) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen" data-testid="not-found-state">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Request Not Found</h2>
|
||||
<p className="text-gray-600 mb-4">The request you're looking for doesn't exist.</p>
|
||||
<Button onClick={onBack}>
|
||||
<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) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6" data-testid="not-found-state">
|
||||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 text-center">
|
||||
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<FileText className="w-10 h-10 text-gray-400" />
|
||||
</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
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.location.href = '/dashboard'}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -518,6 +593,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
refreshTrigger={sharedRecipientsRefreshTrigger}
|
||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
||||
currentUserId={(user as any)?.userId}
|
||||
resuming={resuming}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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<SharedRecipient[]>([]);
|
||||
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 ? (
|
||||
<>
|
||||
<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" />
|
||||
Resume Workflow
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,6 +1,27 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
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 {
|
||||
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;
|
||||
// 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>) => {
|
||||
state.isLoading = false;
|
||||
@ -39,14 +67,26 @@ const authSlice = createSlice({
|
||||
state.user = null;
|
||||
state.token = null;
|
||||
state.error = action.payload;
|
||||
if (!isProduction()) {
|
||||
try {
|
||||
localStorage.removeItem('token');
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
},
|
||||
logout: (state) => {
|
||||
state.isAuthenticated = false;
|
||||
state.user = null;
|
||||
state.token = null;
|
||||
state.error = null;
|
||||
if (!isProduction()) {
|
||||
try {
|
||||
localStorage.removeItem('token');
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
},
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
|
||||
@ -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,25 +205,42 @@ 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<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();
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.post<RefreshTokenResponse>('/auth/refresh', {
|
||||
refreshToken,
|
||||
});
|
||||
// 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<RefreshTokenResponse>('/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');
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user