@striae-org/striae 4.0.3 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/.env.example +8 -0
  2. package/app/components/actions/case-export/core-export.ts +14 -8
  3. package/app/components/actions/case-export/data-processing.ts +1 -0
  4. package/app/components/actions/case-export/download-handlers.ts +7 -0
  5. package/app/components/actions/case-export/metadata-helpers.ts +2 -1
  6. package/app/components/actions/case-import/confirmation-import.ts +12 -2
  7. package/app/components/actions/case-import/orchestrator.ts +78 -32
  8. package/app/components/actions/case-import/storage-operations.ts +97 -8
  9. package/app/components/actions/case-import/zip-processing.ts +159 -86
  10. package/app/components/actions/case-manage.ts +430 -8
  11. package/app/components/actions/confirm-export.ts +13 -4
  12. package/app/components/actions/generate-pdf.ts +10 -2
  13. package/app/components/actions/image-manage.ts +77 -44
  14. package/app/components/audit/user-audit-viewer.tsx +137 -945
  15. package/app/components/audit/user-audit.module.css +41 -0
  16. package/app/components/audit/viewer/audit-activity-summary.tsx +52 -0
  17. package/app/components/audit/viewer/audit-entries-list.tsx +207 -0
  18. package/app/components/audit/viewer/audit-filters-panel.tsx +307 -0
  19. package/app/components/audit/viewer/audit-user-info-card.tsx +44 -0
  20. package/app/components/audit/viewer/audit-viewer-header.tsx +55 -0
  21. package/app/components/audit/viewer/audit-viewer-utils.ts +123 -0
  22. package/app/components/audit/viewer/types.ts +1 -0
  23. package/app/components/audit/viewer/use-audit-viewer-data.ts +186 -0
  24. package/app/components/audit/viewer/use-audit-viewer-export.ts +176 -0
  25. package/app/components/audit/viewer/use-audit-viewer-filters.ts +141 -0
  26. package/app/components/auth/mfa-enrollment.module.css +13 -5
  27. package/app/components/auth/mfa-verification.module.css +13 -5
  28. package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
  29. package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
  30. package/app/components/canvas/canvas.module.css +64 -54
  31. package/app/components/canvas/canvas.tsx +17 -16
  32. package/app/components/canvas/confirmation/confirmation.module.css +1 -0
  33. package/app/components/canvas/confirmation/confirmation.tsx +17 -47
  34. package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
  35. package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
  36. package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
  37. package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
  38. package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
  39. package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
  40. package/app/components/navbar/navbar.module.css +447 -0
  41. package/app/components/navbar/navbar.tsx +377 -0
  42. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +2 -0
  43. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +21 -51
  44. package/app/components/sidebar/case-export/case-export.module.css +1 -0
  45. package/app/components/sidebar/case-export/case-export.tsx +14 -77
  46. package/app/components/sidebar/case-import/case-import.module.css +25 -0
  47. package/app/components/sidebar/case-import/case-import.tsx +64 -40
  48. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
  49. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
  50. package/app/components/sidebar/cases/case-sidebar.tsx +25 -519
  51. package/app/components/sidebar/cases/cases-modal.module.css +45 -9
  52. package/app/components/sidebar/cases/cases-modal.tsx +16 -16
  53. package/app/components/sidebar/cases/cases.module.css +62 -21
  54. package/app/components/sidebar/files/files-modal.module.css +46 -10
  55. package/app/components/sidebar/files/files-modal.tsx +22 -23
  56. package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
  57. package/app/components/sidebar/notes/notes-editor-modal.tsx +66 -0
  58. package/app/components/sidebar/notes/notes-modal.tsx +18 -17
  59. package/app/components/sidebar/notes/notes-sidebar.tsx +199 -113
  60. package/app/components/sidebar/notes/notes.module.css +155 -0
  61. package/app/components/sidebar/sidebar-container.tsx +15 -28
  62. package/app/components/sidebar/sidebar.module.css +7 -71
  63. package/app/components/sidebar/sidebar.tsx +24 -125
  64. package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
  65. package/app/components/toast/toast.module.css +2 -1
  66. package/app/components/toast/toast.tsx +16 -11
  67. package/app/components/user/delete-account.tsx +10 -31
  68. package/app/components/user/inactivity-warning.module.css +9 -6
  69. package/app/components/user/inactivity-warning.tsx +15 -2
  70. package/app/components/user/manage-profile.module.css +2 -0
  71. package/app/components/user/manage-profile.tsx +108 -40
  72. package/app/hooks/useOverlayDismiss.ts +116 -0
  73. package/app/routes/auth/login.example.tsx +19 -8
  74. package/app/routes/auth/login.tsx +785 -774
  75. package/app/routes/auth/passwordReset.module.css +23 -13
  76. package/app/routes/striae/striae.module.css +10 -3
  77. package/app/routes/striae/striae.tsx +477 -31
  78. package/app/routes.ts +7 -0
  79. package/app/services/audit/audit-export-csv.ts +2 -0
  80. package/app/services/audit/audit.service.ts +202 -32
  81. package/app/services/audit/builders/audit-entry-builder.ts +2 -1
  82. package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
  83. package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
  84. package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -0
  85. package/app/services/audit/builders/index.ts +1 -0
  86. package/app/types/audit.ts +5 -2
  87. package/app/types/case.ts +29 -0
  88. package/app/types/import.ts +3 -0
  89. package/app/types/user.ts +1 -0
  90. package/app/utils/data/permissions.ts +17 -1
  91. package/app/utils/forensics/audit-export-signature.ts +5 -1
  92. package/app/utils/forensics/confirmation-signature.ts +3 -0
  93. package/app/utils/forensics/export-verification.ts +497 -22
  94. package/functions/api/pdf/[[path]].ts +32 -1
  95. package/load-context.ts +9 -0
  96. package/package.json +6 -2
  97. package/primershear.emails.example +6 -0
  98. package/scripts/deploy-pages-secrets.sh +6 -0
  99. package/scripts/deploy-primershear-emails.sh +167 -0
  100. package/worker-configuration.d.ts +7493 -7491
  101. package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
  102. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  103. package/workers/data-worker/worker-configuration.d.ts +7448 -11323
  104. package/workers/data-worker/wrangler.jsonc.example +1 -1
  105. package/workers/image-worker/worker-configuration.d.ts +7447 -11322
  106. package/workers/image-worker/wrangler.jsonc.example +1 -1
  107. package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
  108. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  109. package/workers/pdf-worker/src/formats/format-striae.ts +8 -7
  110. package/workers/pdf-worker/src/pdf-worker.example.ts +3 -0
  111. package/workers/pdf-worker/src/report-types.ts +3 -0
  112. package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
  113. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  114. package/workers/user-worker/src/user-worker.example.ts +6 -1
  115. package/workers/user-worker/worker-configuration.d.ts +7448 -11323
  116. package/workers/user-worker/wrangler.jsonc.example +1 -1
  117. package/wrangler.toml.example +1 -1
  118. package/public/.well-known/keybase.txt +0 -56
@@ -0,0 +1,55 @@
1
+ import styles from '../user-audit.module.css';
2
+
3
+ interface AuditViewerHeaderProps {
4
+ title: string;
5
+ hasEntries: boolean;
6
+ onExportCSV: () => void;
7
+ onExportJSON: () => void;
8
+ onGenerateReport: () => void;
9
+ onClose: () => void;
10
+ }
11
+
12
+ export const AuditViewerHeader = ({
13
+ title,
14
+ hasEntries,
15
+ onExportCSV,
16
+ onExportJSON,
17
+ onGenerateReport,
18
+ onClose,
19
+ }: AuditViewerHeaderProps) => {
20
+ return (
21
+ <div className={styles.header}>
22
+ <h2 className={styles.title}>{title}</h2>
23
+ <div className={styles.headerActions}>
24
+ {hasEntries && (
25
+ <div className={styles.exportButtons}>
26
+ <button
27
+ onClick={onExportCSV}
28
+ className={styles.exportButton}
29
+ title="CSV - Individual entry log with summary data"
30
+ >
31
+ 📊 CSV
32
+ </button>
33
+ <button
34
+ onClick={onExportJSON}
35
+ className={styles.exportButton}
36
+ title="JSON - Complete log data for version capture and auditing"
37
+ >
38
+ 📄 JSON
39
+ </button>
40
+ <button
41
+ onClick={onGenerateReport}
42
+ className={styles.exportButton}
43
+ title="Summary report only"
44
+ >
45
+ 📋 Report
46
+ </button>
47
+ </div>
48
+ )}
49
+ <button className={styles.closeButton} onClick={onClose}>
50
+ ×
51
+ </button>
52
+ </div>
53
+ </div>
54
+ );
55
+ };
@@ -0,0 +1,123 @@
1
+ import { type AuditAction, type AuditResult, type ValidationAuditEntry } from '~/types';
2
+
3
+ export interface AuditViewerSummaryStats {
4
+ totalEntries: number;
5
+ successfulEntries: number;
6
+ failedEntries: number;
7
+ loginSessions: number;
8
+ securityIncidents: number;
9
+ }
10
+
11
+ export const getAuditActionIcon = (action: AuditAction): string => {
12
+ switch (action) {
13
+ case 'user-login':
14
+ return '🔑';
15
+ case 'user-logout':
16
+ return '🚪';
17
+ case 'user-profile-update':
18
+ return '👤';
19
+ case 'user-password-reset':
20
+ return '🔒';
21
+ case 'user-registration':
22
+ return '📝';
23
+ case 'email-verification':
24
+ return '📧';
25
+ case 'mfa-enrollment':
26
+ return '🔐';
27
+ case 'mfa-authentication':
28
+ return '📱';
29
+ case 'case-create':
30
+ return '📂';
31
+ case 'case-rename':
32
+ return '✏️';
33
+ case 'case-delete':
34
+ return '🗑️';
35
+ case 'case-archive':
36
+ return '📦';
37
+ case 'case-export':
38
+ return '📤';
39
+ case 'case-import':
40
+ return '📥';
41
+ case 'confirmation-create':
42
+ return '✅';
43
+ case 'confirmation-export':
44
+ return '📤';
45
+ case 'confirmation-import':
46
+ return '📥';
47
+ case 'file-upload':
48
+ return '⬆️';
49
+ case 'file-delete':
50
+ return '🗑️';
51
+ case 'file-access':
52
+ return '👁️';
53
+ case 'annotation-create':
54
+ return '✨';
55
+ case 'annotation-edit':
56
+ return '✏️';
57
+ case 'annotation-delete':
58
+ return '❌';
59
+ case 'pdf-generate':
60
+ return '📄';
61
+ case 'security-violation':
62
+ return '🚨';
63
+ case 'export':
64
+ return '📤';
65
+ case 'import':
66
+ return '📥';
67
+ case 'confirm':
68
+ return '✓';
69
+ default:
70
+ return '📄';
71
+ }
72
+ };
73
+
74
+ export const getAuditStatusIcon = (result: AuditResult): string => {
75
+ switch (result) {
76
+ case 'success':
77
+ return '✅';
78
+ case 'failure':
79
+ return '❌';
80
+ case 'warning':
81
+ return '⚠️';
82
+ case 'blocked':
83
+ return '🛑';
84
+ case 'pending':
85
+ return '⏳';
86
+ default:
87
+ return '❓';
88
+ }
89
+ };
90
+
91
+ export const formatAuditTimestamp = (timestamp: string): string => {
92
+ return new Date(timestamp).toLocaleString();
93
+ };
94
+
95
+ export const summarizeAuditEntries = (entries: ValidationAuditEntry[]): AuditViewerSummaryStats => {
96
+ return entries.reduce<AuditViewerSummaryStats>((summary, entry) => {
97
+ summary.totalEntries += 1;
98
+
99
+ if (entry.result === 'success') {
100
+ summary.successfulEntries += 1;
101
+ }
102
+
103
+ if (entry.result === 'failure') {
104
+ summary.failedEntries += 1;
105
+ }
106
+
107
+ if (entry.action === 'user-login') {
108
+ summary.loginSessions += 1;
109
+ }
110
+
111
+ if (entry.action === 'security-violation') {
112
+ summary.securityIncidents += 1;
113
+ }
114
+
115
+ return summary;
116
+ }, {
117
+ totalEntries: 0,
118
+ successfulEntries: 0,
119
+ failedEntries: 0,
120
+ loginSessions: 0,
121
+ securityIncidents: 0
122
+ });
123
+ };
@@ -0,0 +1 @@
1
+ export type DateRangeFilter = '1d' | '7d' | '30d' | '90d' | 'custom';
@@ -0,0 +1,186 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import type { User } from 'firebase/auth';
3
+ import { auditService } from '~/services/audit';
4
+ import { type AuditTrail, type UserData, type ValidationAuditEntry, type WorkflowPhase } from '~/types';
5
+ import { getCaseData, getUserData } from '~/utils/data';
6
+ import type { DateRangeFilter } from './types';
7
+
8
+ const isWorkflowPhase = (phase: unknown): phase is WorkflowPhase =>
9
+ phase === 'casework' ||
10
+ phase === 'case-export' ||
11
+ phase === 'case-import' ||
12
+ phase === 'confirmation' ||
13
+ phase === 'user-management';
14
+
15
+ interface UseAuditViewerDataParams {
16
+ isOpen: boolean;
17
+ user: User | null;
18
+ effectiveCaseNumber?: string;
19
+ dateRange: DateRangeFilter;
20
+ customStartDate: string;
21
+ customEndDate: string;
22
+ }
23
+
24
+ interface AuditDateQuery {
25
+ startDate?: string;
26
+ endDate?: string;
27
+ }
28
+
29
+ const buildAuditDateQuery = (
30
+ dateRange: DateRangeFilter,
31
+ customStartDate: string,
32
+ customEndDate: string
33
+ ): AuditDateQuery => {
34
+ let startDate: string | undefined;
35
+ let endDate: string | undefined;
36
+
37
+ if (dateRange === 'custom') {
38
+ if (customStartDate) {
39
+ startDate = new Date(customStartDate + 'T00:00:00').toISOString();
40
+ }
41
+
42
+ if (customEndDate) {
43
+ endDate = new Date(customEndDate + 'T23:59:59').toISOString();
44
+ }
45
+
46
+ if (customStartDate && !customEndDate) {
47
+ endDate = new Date().toISOString();
48
+ } else if (!customStartDate && customEndDate) {
49
+ const startDateObj = new Date(customEndDate + 'T23:59:59');
50
+ startDateObj.setDate(startDateObj.getDate() - 30);
51
+ startDate = startDateObj.toISOString();
52
+ }
53
+ } else if (dateRange === '90d') {
54
+ const startDateObj = new Date();
55
+ startDateObj.setDate(startDateObj.getDate() - 90);
56
+ startDate = startDateObj.toISOString();
57
+ endDate = new Date().toISOString();
58
+ } else {
59
+ const days = parseInt(dateRange.replace('d', ''), 10);
60
+ const startDateObj = new Date();
61
+ startDateObj.setDate(startDateObj.getDate() - days);
62
+ startDate = startDateObj.toISOString();
63
+ endDate = new Date().toISOString();
64
+ }
65
+
66
+ return {
67
+ startDate,
68
+ endDate
69
+ };
70
+ };
71
+
72
+ export const useAuditViewerData = ({
73
+ isOpen,
74
+ user,
75
+ effectiveCaseNumber,
76
+ dateRange,
77
+ customStartDate,
78
+ customEndDate
79
+ }: UseAuditViewerDataParams) => {
80
+ const [auditEntries, setAuditEntries] = useState<ValidationAuditEntry[]>([]);
81
+ const [userData, setUserData] = useState<UserData | null>(null);
82
+ const [loading, setLoading] = useState(false);
83
+ const [error, setError] = useState<string>('');
84
+ const [auditTrail, setAuditTrail] = useState<AuditTrail | null>(null);
85
+ const [isArchivedReadOnlyCase, setIsArchivedReadOnlyCase] = useState(false);
86
+ const [bundledAuditWarning, setBundledAuditWarning] = useState<string>('');
87
+
88
+ const loadUserData = useCallback(async () => {
89
+ if (!user) {
90
+ return;
91
+ }
92
+
93
+ try {
94
+ const data = await getUserData(user);
95
+ setUserData(data);
96
+ } catch (loadError) {
97
+ console.error('Failed to load user data:', loadError);
98
+ }
99
+ }, [user]);
100
+
101
+ const loadAuditData = useCallback(async () => {
102
+ if (!user?.uid) {
103
+ return;
104
+ }
105
+
106
+ setLoading(true);
107
+ setError('');
108
+ setBundledAuditWarning('');
109
+
110
+ try {
111
+ const { startDate, endDate } = buildAuditDateQuery(dateRange, customStartDate, customEndDate);
112
+
113
+ if (effectiveCaseNumber) {
114
+ const caseData = await getCaseData(user, effectiveCaseNumber);
115
+ const archivedReadOnlyCase = Boolean(caseData?.isReadOnly && caseData.archived === true);
116
+ setIsArchivedReadOnlyCase(archivedReadOnlyCase);
117
+
118
+ if (archivedReadOnlyCase && !caseData?.bundledAuditTrail?.entries?.length) {
119
+ setBundledAuditWarning(
120
+ 'This imported archived case does not include bundled audit trail data. No audit entries are available for this case.'
121
+ );
122
+ }
123
+ } else {
124
+ setIsArchivedReadOnlyCase(false);
125
+ }
126
+
127
+ const entries = await auditService.getAuditEntriesForUser(user.uid, {
128
+ requestingUser: user,
129
+ caseNumber: effectiveCaseNumber,
130
+ startDate,
131
+ endDate,
132
+ limit: effectiveCaseNumber ? 1000 : 500
133
+ });
134
+
135
+ setAuditEntries(entries);
136
+
137
+ if (effectiveCaseNumber && entries.length > 0) {
138
+ const trail: AuditTrail = {
139
+ caseNumber: effectiveCaseNumber,
140
+ workflowId: `workflow-${effectiveCaseNumber}-${user.uid}`,
141
+ entries,
142
+ summary: {
143
+ totalEvents: entries.length,
144
+ successfulEvents: entries.filter(entry => entry.result === 'success').length,
145
+ failedEvents: entries.filter(entry => entry.result === 'failure').length,
146
+ warningEvents: entries.filter(entry => entry.result === 'warning').length,
147
+ workflowPhases: [...new Set(entries
148
+ .map(entry => entry.details.workflowPhase)
149
+ .filter(isWorkflowPhase))],
150
+ participatingUsers: [...new Set(entries.map(entry => entry.userId))],
151
+ startTimestamp: entries[entries.length - 1]?.timestamp || new Date().toISOString(),
152
+ endTimestamp: entries[0]?.timestamp || new Date().toISOString(),
153
+ complianceStatus: entries.some(entry => entry.result === 'failure') ? 'non-compliant' : 'compliant',
154
+ securityIncidents: entries.filter(entry => entry.action === 'security-violation').length
155
+ }
156
+ };
157
+ setAuditTrail(trail);
158
+ } else {
159
+ setAuditTrail(null);
160
+ }
161
+ } catch (loadError) {
162
+ setError(loadError instanceof Error ? loadError.message : 'Failed to load audit data');
163
+ } finally {
164
+ setLoading(false);
165
+ }
166
+ }, [user, dateRange, customStartDate, customEndDate, effectiveCaseNumber]);
167
+
168
+ useEffect(() => {
169
+ if (isOpen && user) {
170
+ void loadAuditData();
171
+ void loadUserData();
172
+ }
173
+ }, [isOpen, user, loadAuditData, loadUserData]);
174
+
175
+ return {
176
+ auditEntries,
177
+ userData,
178
+ loading,
179
+ error,
180
+ setError,
181
+ auditTrail,
182
+ isArchivedReadOnlyCase,
183
+ bundledAuditWarning,
184
+ loadAuditData
185
+ };
186
+ };
@@ -0,0 +1,176 @@
1
+ import { useCallback } from 'react';
2
+ import type { Dispatch, SetStateAction } from 'react';
3
+ import type { User } from 'firebase/auth';
4
+ import { auditExportService } from '~/services/audit';
5
+ import type { AuditTrail, ValidationAuditEntry } from '~/types';
6
+
7
+ interface UseAuditViewerExportParams {
8
+ user: User | null;
9
+ effectiveCaseNumber?: string;
10
+ filteredEntries: ValidationAuditEntry[];
11
+ auditTrail: AuditTrail | null;
12
+ setError: Dispatch<SetStateAction<string>>;
13
+ }
14
+
15
+ export const useAuditViewerExport = ({
16
+ user,
17
+ effectiveCaseNumber,
18
+ filteredEntries,
19
+ auditTrail,
20
+ setError
21
+ }: UseAuditViewerExportParams) => {
22
+ const resolveExportContext = useCallback(() => {
23
+ if (!user) {
24
+ return null;
25
+ }
26
+
27
+ const identifier = effectiveCaseNumber || user.uid;
28
+ const scopeType: 'case' | 'user' = effectiveCaseNumber ? 'case' : 'user';
29
+
30
+ return {
31
+ identifier,
32
+ scopeType,
33
+ context: {
34
+ user,
35
+ scopeType,
36
+ scopeIdentifier: identifier,
37
+ caseNumber: effectiveCaseNumber || undefined
38
+ } as const
39
+ };
40
+ }, [user, effectiveCaseNumber]);
41
+
42
+ const handleExportCSV = useCallback(async () => {
43
+ const exportContextData = resolveExportContext();
44
+ if (!exportContextData) {
45
+ return;
46
+ }
47
+
48
+ const filename = auditExportService.generateFilename(
49
+ exportContextData.scopeType,
50
+ exportContextData.identifier,
51
+ 'csv'
52
+ );
53
+
54
+ try {
55
+ if (auditTrail && effectiveCaseNumber) {
56
+ await auditExportService.exportAuditTrailToCSV(auditTrail, filename, exportContextData.context);
57
+ } else {
58
+ await auditExportService.exportToCSV(filteredEntries, filename, exportContextData.context);
59
+ }
60
+ } catch (exportError) {
61
+ console.error('Export failed:', exportError);
62
+ setError('Failed to export audit trail to CSV');
63
+ }
64
+ }, [resolveExportContext, auditTrail, effectiveCaseNumber, filteredEntries, setError]);
65
+
66
+ const handleExportJSON = useCallback(async () => {
67
+ const exportContextData = resolveExportContext();
68
+ if (!exportContextData) {
69
+ return;
70
+ }
71
+
72
+ const filename = auditExportService.generateFilename(
73
+ exportContextData.scopeType,
74
+ exportContextData.identifier,
75
+ 'csv'
76
+ );
77
+
78
+ try {
79
+ if (auditTrail && effectiveCaseNumber) {
80
+ await auditExportService.exportAuditTrailToJSON(auditTrail, filename, exportContextData.context);
81
+ } else {
82
+ await auditExportService.exportToJSON(filteredEntries, filename, exportContextData.context);
83
+ }
84
+ } catch (exportError) {
85
+ console.error('Export failed:', exportError);
86
+ setError('Failed to export audit trail to JSON');
87
+ }
88
+ }, [resolveExportContext, auditTrail, effectiveCaseNumber, filteredEntries, setError]);
89
+
90
+ const handleGenerateReport = useCallback(async () => {
91
+ const exportContextData = resolveExportContext();
92
+ if (!exportContextData) {
93
+ return;
94
+ }
95
+
96
+ const resolvedUser = exportContextData.context.user;
97
+
98
+ const filename = `${exportContextData.scopeType}-audit-report-${exportContextData.identifier}-${new Date().toISOString().split('T')[0]}.txt`;
99
+
100
+ try {
101
+ let reportContent: string;
102
+
103
+ if (auditTrail && effectiveCaseNumber) {
104
+ reportContent = await auditExportService.generateReportSummary(auditTrail, exportContextData.context);
105
+ } else {
106
+ const totalEntries = filteredEntries.length;
107
+ const successfulActions = filteredEntries.filter(entry => entry.result === 'success').length;
108
+ const failedActions = filteredEntries.filter(entry => entry.result === 'failure').length;
109
+
110
+ const actionCounts = filteredEntries.reduce((accumulator, entry) => {
111
+ accumulator[entry.action] = (accumulator[entry.action] || 0) + 1;
112
+ return accumulator;
113
+ }, {} as Record<string, number>);
114
+
115
+ const detectedDateRange = filteredEntries.length > 0
116
+ ? {
117
+ earliest: new Date(Math.min(...filteredEntries.map(entry => new Date(entry.timestamp).getTime()))),
118
+ latest: new Date(Math.max(...filteredEntries.map(entry => new Date(entry.timestamp).getTime())))
119
+ }
120
+ : null;
121
+
122
+ reportContent = `${effectiveCaseNumber ? 'CASE' : 'USER'} AUDIT REPORT
123
+ Generated: ${new Date().toISOString()}
124
+ ${effectiveCaseNumber ? `Case: ${effectiveCaseNumber}` : `User: ${resolvedUser.email}`}
125
+ ${effectiveCaseNumber ? '' : `User ID: ${resolvedUser.uid}`}
126
+
127
+ === SUMMARY ===
128
+ Total Actions: ${totalEntries}
129
+ Successful: ${successfulActions}
130
+ Failed: ${failedActions}
131
+ Success Rate: ${totalEntries > 0 ? ((successfulActions / totalEntries) * 100).toFixed(1) : 0}%
132
+
133
+ ${detectedDateRange ? `Date Range: ${detectedDateRange.earliest.toLocaleDateString()} - ${detectedDateRange.latest.toLocaleDateString()}` : 'No entries found'}
134
+
135
+ === ACTION BREAKDOWN ===
136
+ ${Object.entries(actionCounts)
137
+ .sort(([, actionCountA], [, actionCountB]) => actionCountB - actionCountA)
138
+ .map(([action, count]) => `${action}: ${count}`)
139
+ .join('\n')}
140
+
141
+ === RECENT ACTIVITIES ===
142
+ ${filteredEntries.slice(0, 10).map(entry =>
143
+ `${new Date(entry.timestamp).toLocaleString()} | ${entry.action} | ${entry.result}${entry.details.caseNumber ? ` | Case: ${entry.details.caseNumber}` : ''}`
144
+ ).join('\n')}
145
+
146
+ Generated by Striae
147
+ `;
148
+
149
+ reportContent = await auditExportService.appendSignedReportIntegrity(
150
+ reportContent,
151
+ exportContextData.context,
152
+ totalEntries
153
+ );
154
+ }
155
+
156
+ const blob = new Blob([reportContent], { type: 'text/plain' });
157
+ const url = URL.createObjectURL(blob);
158
+ const anchor = document.createElement('a');
159
+ anchor.href = url;
160
+ anchor.download = filename;
161
+ document.body.appendChild(anchor);
162
+ anchor.click();
163
+ document.body.removeChild(anchor);
164
+ URL.revokeObjectURL(url);
165
+ } catch (reportError) {
166
+ console.error('Report generation failed:', reportError);
167
+ setError('Failed to generate audit report');
168
+ }
169
+ }, [resolveExportContext, auditTrail, effectiveCaseNumber, filteredEntries, setError]);
170
+
171
+ return {
172
+ handleExportCSV,
173
+ handleExportJSON,
174
+ handleGenerateReport
175
+ };
176
+ };
@@ -0,0 +1,141 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+ import type { AuditAction, AuditResult, ValidationAuditEntry } from '~/types';
3
+ import type { DateRangeFilter } from './types';
4
+
5
+ export const useAuditViewerFilters = (caseNumber?: string) => {
6
+ const [filterAction, setFilterAction] = useState<AuditAction | 'all'>('all');
7
+ const [filterResult, setFilterResult] = useState<AuditResult | 'all'>('all');
8
+ const [filterCaseNumber, setFilterCaseNumber] = useState<string>('');
9
+ const [caseNumberInput, setCaseNumberInput] = useState<string>('');
10
+ const [filterBadgeId, setFilterBadgeId] = useState<string>('');
11
+ const [badgeIdInput, setBadgeIdInput] = useState<string>('');
12
+ const [dateRange, setDateRange] = useState<DateRangeFilter>('1d');
13
+ const [customStartDate, setCustomStartDate] = useState<string>('');
14
+ const [customEndDate, setCustomEndDate] = useState<string>('');
15
+ const [customStartDateInput, setCustomStartDateInput] = useState<string>('');
16
+ const [customEndDateInput, setCustomEndDateInput] = useState<string>('');
17
+
18
+ const handleApplyCaseFilter = useCallback(() => {
19
+ setFilterCaseNumber(caseNumberInput.trim());
20
+ }, [caseNumberInput]);
21
+
22
+ const handleClearCaseFilter = useCallback(() => {
23
+ setCaseNumberInput('');
24
+ setFilterCaseNumber('');
25
+ }, []);
26
+
27
+ const handleApplyBadgeFilter = useCallback(() => {
28
+ setFilterBadgeId(badgeIdInput.trim());
29
+ }, [badgeIdInput]);
30
+
31
+ const handleClearBadgeFilter = useCallback(() => {
32
+ setBadgeIdInput('');
33
+ setFilterBadgeId('');
34
+ }, []);
35
+
36
+ const handleApplyCustomDateRange = useCallback(() => {
37
+ setCustomStartDate(customStartDateInput);
38
+ setCustomEndDate(customEndDateInput);
39
+ }, [customStartDateInput, customEndDateInput]);
40
+
41
+ const handleClearCustomDateRange = useCallback(() => {
42
+ setCustomStartDateInput('');
43
+ setCustomEndDateInput('');
44
+ setCustomStartDate('');
45
+ setCustomEndDate('');
46
+ }, []);
47
+
48
+ const handleDateRangeChange = useCallback((value: DateRangeFilter) => {
49
+ setDateRange(value);
50
+ if (value === 'custom') {
51
+ setCustomStartDateInput(customStartDate);
52
+ setCustomEndDateInput(customEndDate);
53
+ }
54
+ }, [customStartDate, customEndDate]);
55
+
56
+ const getFilteredEntries = useCallback((entries: ValidationAuditEntry[]): ValidationAuditEntry[] => {
57
+ const normalizedBadgeFilter = filterBadgeId.trim().toLowerCase();
58
+
59
+ return entries.filter(entry => {
60
+ let actionMatch: boolean;
61
+ if (filterAction === 'all') {
62
+ actionMatch = true;
63
+ } else if (filterAction === 'confirmation-create') {
64
+ actionMatch = entry.action === 'confirm' || entry.action === 'confirmation-create';
65
+ } else if (filterAction === 'case-export') {
66
+ actionMatch = entry.action === 'export' && entry.details.workflowPhase === 'case-export';
67
+ } else if (filterAction === 'case-import') {
68
+ actionMatch = entry.action === 'import' && entry.details.workflowPhase === 'case-import';
69
+ } else if (filterAction === 'confirmation-export') {
70
+ actionMatch = entry.action === 'export' && entry.details.workflowPhase === 'confirmation';
71
+ } else if (filterAction === 'confirmation-import') {
72
+ actionMatch = entry.action === 'import' && entry.details.workflowPhase === 'confirmation';
73
+ } else {
74
+ actionMatch = entry.action === filterAction;
75
+ }
76
+
77
+ const resultMatch = filterResult === 'all' || entry.result === filterResult;
78
+ const entryBadgeId = entry.details.userProfileDetails?.badgeId?.trim().toLowerCase() || '';
79
+ const badgeMatch = normalizedBadgeFilter === '' || entryBadgeId.includes(normalizedBadgeFilter);
80
+
81
+ return actionMatch && resultMatch && badgeMatch;
82
+ });
83
+ }, [filterAction, filterResult, filterBadgeId]);
84
+
85
+ const dateRangeDisplay = useMemo(() => {
86
+ switch (dateRange) {
87
+ case '90d':
88
+ return 'Last 90 Days';
89
+ case 'custom':
90
+ if (customStartDate && customEndDate) {
91
+ const startFormatted = new Date(customStartDate).toLocaleDateString();
92
+ const endFormatted = new Date(customEndDate).toLocaleDateString();
93
+ return `${startFormatted} - ${endFormatted}`;
94
+ }
95
+ if (customStartDate) {
96
+ return `From ${new Date(customStartDate).toLocaleDateString()}`;
97
+ }
98
+ if (customEndDate) {
99
+ return `Until ${new Date(customEndDate).toLocaleDateString()}`;
100
+ }
101
+ return 'Custom Range';
102
+ default:
103
+ return `Last ${dateRange}`;
104
+ }
105
+ }, [dateRange, customStartDate, customEndDate]);
106
+
107
+ const effectiveCaseNumber = useMemo(() => {
108
+ const trimmedCaseNumber = filterCaseNumber.trim();
109
+ return caseNumber || trimmedCaseNumber || undefined;
110
+ }, [caseNumber, filterCaseNumber]);
111
+
112
+ return {
113
+ filterAction,
114
+ setFilterAction,
115
+ filterResult,
116
+ setFilterResult,
117
+ filterCaseNumber,
118
+ caseNumberInput,
119
+ setCaseNumberInput,
120
+ filterBadgeId,
121
+ badgeIdInput,
122
+ setBadgeIdInput,
123
+ dateRange,
124
+ customStartDate,
125
+ customEndDate,
126
+ customStartDateInput,
127
+ customEndDateInput,
128
+ setCustomStartDateInput,
129
+ setCustomEndDateInput,
130
+ handleApplyCaseFilter,
131
+ handleClearCaseFilter,
132
+ handleApplyBadgeFilter,
133
+ handleClearBadgeFilter,
134
+ handleApplyCustomDateRange,
135
+ handleClearCustomDateRange,
136
+ handleDateRangeChange,
137
+ getFilteredEntries,
138
+ dateRangeDisplay,
139
+ effectiveCaseNumber
140
+ };
141
+ };