diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index ed447da..5e7be4b 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -17,6 +17,7 @@ import { Bell, Paperclip, Bot, + ShieldCheck, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -235,6 +236,11 @@ const tenantAdminSystemMenu: MenuItem[] = [ label: "Audit Logs", path: "/tenant/audit-logs", }, + { + icon: ShieldCheck, + label: "E-Signatures", + path: "/tenant/electronic-signatures", + }, { icon: Settings, label: "Settings", diff --git a/src/pages/tenant/ElectronicSignatures.tsx b/src/pages/tenant/ElectronicSignatures.tsx new file mode 100644 index 0000000..082bbce --- /dev/null +++ b/src/pages/tenant/ElectronicSignatures.tsx @@ -0,0 +1,707 @@ +import { useState, useEffect } from "react"; +import type { ReactElement } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Layout } from "@/components/layout/Layout"; +import { + DataTable, + Pagination, + FilterDropdown, + StatusBadge, + SearchBox, + type Column, + PrimaryButton, + Modal, + FormField, + GradientStatCard, +} from "@/components/shared"; +import { ShieldCheck, History, Key, BarChart3, Info, CheckCircle2, AlertTriangle, Calendar } from "lucide-react"; +import { electronicSignatureService } from "@/services/electronic-signature-service"; +import { useAppTheme } from "@/hooks/useAppTheme"; +import { showToast } from "@/utils/toast"; +import { usePermissions } from "@/hooks/usePermissions"; + +export interface SignatureLog { + id: string; + signer: { + id: string; + email: string; + name: string; + }; + resource: { + type: string; + id: string; + name: string | null; + }; + action: string; + meaning: string; + auth_method: string; + signed_at: string; + is_valid: boolean; + workflow?: { + id: string; + order: number; + required: number; + } | null; + verification: { + signature_hash: string; + ip_address: string | null; + }; +} + +const formatDate = (dateString?: string | null): string => { + if (!dateString) return "N/A"; + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +const getActionVariant = (action: string): "success" | "failure" | "info" | "process" => { + const upper = action.toUpperCase(); + if (upper === "APPROVE" || upper === "RELEASE") return "success"; + if (upper === "REJECT" || upper === "WITHDRAW") return "failure"; + if (upper === "REVIEW" || upper === "ACKNOWLEDGE") return "process"; + return "info"; // AUTHOR, VERIFY +}; + +const pinUpdateSchema = z.object({ + password: z.string().min(1, "Password is required"), + pin: z.string().regex(/^\d{4,6}$/, "PIN must be between 4 and 6 digits"), + confirmPin: z.string().min(1, "Confirm PIN is required"), +}).refine((data) => data.pin === data.confirmPin, { + message: "PINs do not match", + path: ["confirmPin"], +}); + +type PinUpdateFormData = z.infer; + +const ElectronicSignatures = (): ReactElement => { + const { primaryColor } = useAppTheme(); + const { hasPermission } = usePermissions(); + const canViewStats = hasPermission("audit_logs", "read"); + const [activeTab, setActiveTab] = useState<"logs" | "pin" | "stats">("logs"); + + // History / Logs State + const [logs, setLogs] = useState([]); + const [isLoadingLogs, setIsLoadingLogs] = useState(true); + const [logsError, setLogsError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [limit, setLimit] = useState(10); + const [totalLogs, setTotalLogs] = useState(0); + const [totalPages, setTotalPages] = useState(1); + const [search, setSearch] = useState(""); + const [actionFilter, setActionFilter] = useState(null); + const [selectedLog, setSelectedLog] = useState(null); + + // Manage Credentials Form State + const [isUpdatingCredentials, setIsUpdatingCredentials] = useState(false); + + // Statistics State + const [stats, setStats] = useState(null); + const [isLoadingStats, setIsLoadingStats] = useState(false); + const [statsError, setStatsError] = useState(null); + const [startDate, setStartDate] = useState( + new Date(new Date().getFullYear(), 0, 1).toISOString().split("T")[0] + ); + const [endDate, setEndDate] = useState( + new Date(new Date().getFullYear(), 11, 31).toISOString().split("T")[0] + ); + + // react-hook-form setup + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: zodResolver(pinUpdateSchema), + defaultValues: { + password: "", + pin: "", + confirmPin: "", + } + }); + + // Fetch signature history logs + const fetchLogs = async (): Promise => { + try { + setIsLoadingLogs(true); + setLogsError(null); + const res = await electronicSignatureService.getHistory({ + limit, + offset: (currentPage - 1) * limit, + }); + + if (res.success) { + // Handle pagination structure + const signatureRecords = res.data?.signatures || res.data || []; + setLogs(signatureRecords); + const count = res.data?.total || signatureRecords.length; + setTotalLogs(count); + setTotalPages(Math.ceil(count / limit)); + } else { + setLogsError("Failed to fetch signature logs"); + } + } catch (err: any) { + setLogsError( + err?.response?.data?.error?.message || "Failed to load signature logs" + ); + } finally { + setIsLoadingLogs(false); + } + }; + + // Fetch statistics + const fetchStats = async (): Promise => { + try { + setIsLoadingStats(true); + setStatsError(null); + const res = await electronicSignatureService.getStatistics({ + start_date: startDate, + end_date: endDate, + }); + if (res.success) { + setStats(res.data); + } else { + setStatsError("Failed to load signature statistics"); + } + } catch (err: any) { + setStatsError( + err?.response?.data?.error?.message || "Failed to load signature statistics" + ); + } finally { + setIsLoadingStats(false); + } + }; + + useEffect(() => { + if (activeTab === "logs") { + void fetchLogs(); + } else if (activeTab === "stats" && canViewStats) { + void fetchStats(); + } + }, [activeTab, currentPage, limit, canViewStats]); + + // Handle PIN update + const handleUpdatePin = async (data: PinUpdateFormData): Promise => { + try { + setIsUpdatingCredentials(true); + await electronicSignatureService.setPin({ + password: data.password, + pin: data.pin, + }); + showToast.success("Electronic signature PIN updated successfully"); + reset(); + } catch (err: any) { + showToast.error( + err?.response?.data?.error?.message || "Failed to update PIN" + ); + } finally { + setIsUpdatingCredentials(false); + } + }; + + // Filter logs locally based on search & action + const filteredLogs = logs.filter((log) => { + const matchesSearch = + !search || + log.resource?.name?.toLowerCase().includes(search.toLowerCase()) || + log.meaning.toLowerCase().includes(search.toLowerCase()) || + log.id.toLowerCase().includes(search.toLowerCase()) || + log.signer?.name?.toLowerCase().includes(search.toLowerCase()) || + log.signer?.email?.toLowerCase().includes(search.toLowerCase()); + + const matchesAction = !actionFilter || log.action === actionFilter; + + return matchesSearch && matchesAction; + }); + + const columns: Column[] = [ + { + key: "signed_at", + label: "Timestamp", + render: (log) => ( + + {formatDate(log.signed_at)} + + ), + }, + { + key: "action", + label: "Action", + render: (log) => ( + + {log.action} + + ), + }, + { + key: "meaning", + label: "Meaning", + render: (log) => ( + + {log.meaning} + + ), + }, + { + key: "resource_name", + label: "Resource Name", + render: (log) => ( + + {log.resource?.name || log.resource?.id} + + ), + }, + { + key: "is_valid", + label: "Status", + render: (log) => ( + + {log.is_valid ? "Active / Compliant" : "Invalidated"} + + ), + }, + { + key: "actions", + label: "Actions", + align: "right", + render: (log) => ( + + ), + }, + ]; + + // Calculate actual total signatures from action stats to avoid API group count overrides + const totalSignatureCount: number = stats?.by_action + ? Object.keys(stats.by_action).reduce((sum: number, key: string) => sum + (parseInt(stats.by_action[key], 10) || 0), 0) + : 0; + + return ( + +
+ {/* Navigation Tabs */} +
+ + + {canViewStats && ( + + )} +
+ + {/* Tab Content: Logs */} + {activeTab === "logs" && ( +
+
+
+ + setActionFilter(val as string | null)} + placeholder="All Actions" + /> + {(search || actionFilter) && ( + + )} +
+
+ + log.id} + isLoading={isLoadingLogs} + error={logsError} + emptyMessage={canViewStats ? "No electronic signatures recorded yet." : "You have not recorded any electronic signatures yet."} + onRowClick={(log) => setSelectedLog(log)} + /> + + {totalLogs > limit && ( + { + setLimit(lim); + setCurrentPage(1); + }} + /> + )} +
+ )} + + {/* Tab Content: Manage PIN */} + {activeTab === "pin" && ( +
+
+ +

+ Configure Electronic Signature PIN +

+
+

+ Set or update your 4-6 digit compliance PIN. The PIN is combined with your account password during signature verification steps to ensure regulatory compliance with 21 CFR Part 11. +

+ +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ + {isUpdatingCredentials ? "Updating PIN..." : "Update Signature PIN"} + +
+
+
+ )} + + {/* Tab Content: Statistics */} + {activeTab === "stats" && canViewStats && ( +
+ {/* Filter Row */} +
+
+ + Date Range: +
+
+ setStartDate(e.target.value)} + className="px-3 py-1.5 border border-gray-200 rounded-md text-xs focus:ring-1 focus:ring-amber-500 focus:outline-none bg-white" + /> + to + setEndDate(e.target.value)} + className="px-3 py-1.5 border border-gray-200 rounded-md text-xs focus:ring-1 focus:ring-amber-500 focus:outline-none bg-white" + /> +
+ void fetchStats()} + disabled={isLoadingStats} + className="w-full sm:w-auto px-4 py-2 text-xs font-semibold" + > + {isLoadingStats ? "Analyzing..." : "Apply Range"} + +
+ + {statsError ? ( +
+ + {statsError} +
+ ) : isLoadingStats ? ( +
+ Generating compliance statistics... +
+ ) : stats ? ( +
+ {/* Statistics Cards using GradientStatCard */} +
+ + + +
+ + {/* Distribution Grid */} +
+

+ + Signature Action Distribution +

+ {stats.by_action && Object.keys(stats.by_action).length > 0 ? ( +
+ {Object.entries(stats.by_action).map(([action, count]: [string, any]) => { + const total = totalSignatureCount || 1; + const percentage = total > 0 ? Math.round((Number(count) / Number(total)) * 100) : 0; + return ( +
+
+ + {action} + + + {count} ({percentage}%) + +
+
+
+
+
+ ); + })} +
+ ) : ( + + No distribution data found for this period. + + )} +
+
+ ) : ( + + Please apply a date range to generate statistics. + + )} +
+ )} +
+ + {/* Log Details Modal */} + {selectedLog && ( + setSelectedLog(null)} + title="Electronic Signature Audit Record" + description="Detailed metadata for compliance audit verification." + maxWidth="2xl" + > +
+ {/* Header info */} +
+
+ Signature ID + + {selectedLog.id} + +
+
+ Verification Status + + {selectedLog.is_valid ? "Compliant & Verified" : "Invalidated"} + +
+
+ + {/* Core details */} +
+
+ Signer + + {selectedLog.signer + ? `${selectedLog.signer.name} (${selectedLog.signer.email})` + : "N/A"} + +
+
+ Signed At + + {formatDate(selectedLog.signed_at)} + +
+
+ Action Code + + {selectedLog.action} + +
+
+ Auth Method + + {selectedLog.auth_method} + +
+
+ + {/* Document/Resource details */} +
+
+ + Target Resource Context +
+
+
+ Resource Type + {selectedLog.resource?.type} +
+
+ Resource Name + + {selectedLog.resource?.name || "N/A"} + +
+
+
+ Resource ID + {selectedLog.resource?.id} +
+
+ + {/* Audit details */} +
+
+ Signature Meaning +

+ "{selectedLog.meaning}" +

+
+ +
+ Audit Trail Context +
+
+ IP Address + {selectedLog.verification?.ip_address || "N/A"} +
+
+
+ +
+ SHA-256 Digital Fingerprint + + {selectedLog.verification?.signature_hash} + +
+
+
+
+ )} + + ); +}; + +export default ElectronicSignatures; diff --git a/src/pages/tenant/ViewDocument.tsx b/src/pages/tenant/ViewDocument.tsx index 58ccb58..85a5836 100644 --- a/src/pages/tenant/ViewDocument.tsx +++ b/src/pages/tenant/ViewDocument.tsx @@ -16,6 +16,7 @@ import { type FileAttachmentItem, } from "@/services/document-service"; import { workflowService } from "@/services/workflow-service"; +import { electronicSignatureService } from "@/services/electronic-signature-service"; import type { DocumentDetail, DocumentVersion, DocumentSection, DocumentComment } from "@/types/document"; import type { WorkflowInstance } from "@/types/workflow"; import { cn } from "@/lib/utils"; @@ -111,6 +112,11 @@ const ViewDocument = (): ReactElement => { const [versionSelectedFileId, setVersionSelectedFileId] = useState(""); const [versionFileName, setVersionFileName] = useState(""); const [transitionComment, setTransitionComment] = useState(""); + const [transitionPassword, setTransitionPassword] = useState(""); + const [transitionPin, setTransitionPin] = useState(""); + const [transitionSignatureAction, setTransitionSignatureAction] = useState< + 'APPROVE' | 'REJECT' | 'REVIEW' | 'AUTHOR' | 'VERIFY' | 'RELEASE' | 'ACKNOWLEDGE' | 'WITHDRAW' + >("APPROVE"); const [selectedWorkflowAction, setSelectedWorkflowAction] = useState(null); const [isTransitioning, setIsTransitioning] = useState(false); @@ -469,13 +475,58 @@ const ViewDocument = (): ReactElement => { return; } + if (selectedWorkflowAction.requires_signature) { + if (!transitionPassword.trim()) { + showToast.error("Password is required for electronic signature"); + return; + } + } + + let signatureId: string | undefined = undefined; + try { setIsTransitioning(true); + + if (selectedWorkflowAction.requires_signature) { + // 1. Check if signature is already available for this resource + try { + const sigsRes = await electronicSignatureService.getResourceSignatures("workflow_task", pendingTask.id); + const validSigs = sigsRes.data?.signatures || sigsRes.data || []; + const activeSig = validSigs.find((s: any) => s.is_valid); + if (activeSig) { + signatureId = activeSig.id; + } + } catch (e) { + console.warn("Failed to check existing signatures", e); + } + + // 2. If no valid signature exists, create one + if (!signatureId) { + const signRes = await electronicSignatureService.sign({ + resource_type: "workflow_task", + resource_id: pendingTask.id, + resource_name: `${document?.title || "Document"} - ${selectedWorkflowAction.name || selectedWorkflowAction.action}`, + action: transitionSignatureAction, + meaning: selectedWorkflowAction.signature_meaning || `I ${selectedWorkflowAction.action} this workflow step`, + password: transitionPassword, + pin: transitionPin || null, + auth_method: transitionPin ? "password_pin" : "password", + workflow_id: workflowInstance.id + }); + signatureId = signRes.data?.id || signRes.data?.signature?.id; + } + } + await workflowService.transition(workflowInstance.id, { task_id: pendingTask.id, action: selectedWorkflowAction.action, comments: transitionComment.trim() || undefined, + signature_id: signatureId, + signature_action: selectedWorkflowAction.requires_signature ? transitionSignatureAction : undefined, + password: selectedWorkflowAction.requires_signature ? transitionPassword : undefined, + pin: selectedWorkflowAction.requires_signature ? transitionPin : undefined, }); + showToast.success(`Action "${selectedWorkflowAction.name}" completed`); // Refresh workflow instance data @@ -485,6 +536,9 @@ const ViewDocument = (): ReactElement => { // Reset transition state setSelectedWorkflowAction(null); setTransitionComment(""); + setTransitionPassword(""); + setTransitionPin(""); + setTransitionSignatureAction("APPROVE"); // Also refresh document data as it might have changed status await refreshData(); @@ -1781,6 +1835,9 @@ const ViewDocument = (): ReactElement => { setShowWorkflowTracker(false); setSelectedWorkflowAction(null); setTransitionComment(""); + setTransitionPassword(""); + setTransitionPin(""); + setTransitionSignatureAction("APPROVE"); }} title="Workflow Status Tracker" description="Track the progress of this document's approval workflow." @@ -1875,6 +1932,9 @@ const ViewDocument = (): ReactElement => { onClick={() => { setSelectedWorkflowAction(null); setTransitionComment(""); + setTransitionPassword(""); + setTransitionPin(""); + setTransitionSignatureAction("APPROVE"); }} className="text-amber-500 hover:text-amber-700" > @@ -1903,6 +1963,56 @@ const ViewDocument = (): ReactElement => { />
+ {selectedWorkflowAction.requires_signature && ( +
+
+ + +
+
+
+ + setTransitionPassword(e.target.value)} + placeholder="Enter password to sign" + className="w-full px-3 py-2 border border-amber-200 rounded-md text-xs focus:ring-1 focus:ring-amber-500 focus:outline-none bg-white" + /> +
+
+ + setTransitionPin(e.target.value)} + placeholder="Enter 4-6 digit PIN" + className="w-full px-3 py-2 border border-amber-200 rounded-md text-xs focus:ring-1 focus:ring-amber-500 focus:outline-none bg-white" + /> +
+
+
+ )} +
void handleWorkflowTransition()} diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx index 249ce8d..b92a4c4 100644 --- a/src/routes/tenant-admin-routes.tsx +++ b/src/routes/tenant-admin-routes.tsx @@ -61,6 +61,7 @@ const TenantAIProviderCreate = lazy( ); const TenantAIDashboard = lazy(() => import("@/pages/tenant/TenantAIDashboard")); const SecurityPolicy = lazy(() => import("@/pages/tenant/SecurityPolicy")); +const ElectronicSignatures = lazy(() => import("@/pages/tenant/ElectronicSignatures")); // Loading fallback component const RouteLoader = (): ReactElement => ( @@ -111,6 +112,10 @@ export const tenantAdminRoutes: RouteConfig[] = [ path: "/tenant/audit-logs", element: , }, + { + path: "/tenant/electronic-signatures", + element: , + }, { path: "/tenant/settings", element: , diff --git a/src/services/electronic-signature-service.ts b/src/services/electronic-signature-service.ts new file mode 100644 index 0000000..6a6689e --- /dev/null +++ b/src/services/electronic-signature-service.ts @@ -0,0 +1,66 @@ +import apiClient from './api-client'; + +export interface CreateSignatureData { + resource_type: string; + resource_id: string; + resource_name?: string | null; + action: 'APPROVE' | 'REJECT' | 'REVIEW' | 'AUTHOR' | 'VERIFY' | 'RELEASE' | 'ACKNOWLEDGE' | 'WITHDRAW'; + meaning?: string | null; + password?: string; + pin?: string | null; + auth_method?: 'password' | 'password_pin'; + signature_order?: number | null; + required_signatures?: number | null; + workflow_id?: string | null; +} + +export interface SetPinData { + password?: string; + pin: string; +} + +class ElectronicSignatureService { + private readonly baseUrl = '/electronic-signatures'; + + async sign(data: CreateSignatureData): Promise<{ success: boolean; data: any }> { + const response = await apiClient.post(`${this.baseUrl}/sign`, data); + return response.data; + } + + async verify(signatureId: string): Promise<{ success: boolean; data: any }> { + const response = await apiClient.post(`${this.baseUrl}/verify`, { signature_id: signatureId }); + return response.data; + } + + async setPin(data: SetPinData): Promise<{ success: boolean; data: any }> { + const response = await apiClient.post(`${this.baseUrl}/pin`, data); + return response.data; + } + + async getResourceSignatures(resourceType: string, resourceId: string): Promise<{ success: boolean; data: any }> { + const response = await apiClient.get(`${this.baseUrl}/resource/${resourceType}/${resourceId}`); + return response.data; + } + + async getWorkflowStatus(workflowId: string): Promise<{ success: boolean; data: any }> { + const response = await apiClient.get(`${this.baseUrl}/workflow/${workflowId}/status`); + return response.data; + } + + async getSignature(id: string): Promise<{ success: boolean; data: any }> { + const response = await apiClient.get(`${this.baseUrl}/${id}`); + return response.data; + } + + async getHistory(params?: { limit?: number; offset?: number }): Promise<{ success: boolean; data: any }> { + const response = await apiClient.get(`${this.baseUrl}/history`, { params }); + return response.data; + } + + async getStatistics(params?: { start_date?: string; end_date?: string }): Promise<{ success: boolean; data: any }> { + const response = await apiClient.get(`${this.baseUrl}/statistics`, { params }); + return response.data; + } +} + +export const electronicSignatureService = new ElectronicSignatureService(); diff --git a/src/services/workflow-service.ts b/src/services/workflow-service.ts index 1f56a1b..a01e3ba 100644 --- a/src/services/workflow-service.ts +++ b/src/services/workflow-service.ts @@ -108,7 +108,7 @@ class WorkflowService { return response.data; } - async transition(instanceId: string, data: { task_id: string; action: string; comments?: string; signature_id?: string }): Promise { + async transition(instanceId: string, data: { task_id: string; action: string; comments?: string; signature_id?: string; signature_action?: string; password?: string; pin?: string }): Promise { const response = await apiClient.post(`${this.baseUrl}/instances/${instanceId}/transition`, data); return response.data; }