@striae-org/striae 4.0.3 → 4.1.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 (66) hide show
  1. package/app/components/actions/confirm-export.ts +4 -2
  2. package/app/components/actions/generate-pdf.ts +10 -2
  3. package/app/components/audit/user-audit-viewer.tsx +121 -940
  4. package/app/components/audit/user-audit.module.css +20 -0
  5. package/app/components/audit/viewer/audit-activity-summary.tsx +52 -0
  6. package/app/components/audit/viewer/audit-entries-list.tsx +200 -0
  7. package/app/components/audit/viewer/audit-filters-panel.tsx +306 -0
  8. package/app/components/audit/viewer/audit-user-info-card.tsx +44 -0
  9. package/app/components/audit/viewer/audit-viewer-header.tsx +55 -0
  10. package/app/components/audit/viewer/audit-viewer-utils.ts +121 -0
  11. package/app/components/audit/viewer/types.ts +1 -0
  12. package/app/components/audit/viewer/use-audit-viewer-data.ts +166 -0
  13. package/app/components/audit/viewer/use-audit-viewer-export.ts +176 -0
  14. package/app/components/audit/viewer/use-audit-viewer-filters.ts +141 -0
  15. package/app/components/auth/mfa-enrollment.module.css +13 -5
  16. package/app/components/auth/mfa-verification.module.css +13 -5
  17. package/app/components/canvas/canvas.tsx +3 -0
  18. package/app/components/canvas/confirmation/confirmation.tsx +13 -37
  19. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +1 -0
  20. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +8 -37
  21. package/app/components/sidebar/case-export/case-export.tsx +9 -34
  22. package/app/components/sidebar/case-import/case-import.module.css +2 -0
  23. package/app/components/sidebar/case-import/case-import.tsx +10 -34
  24. package/app/components/sidebar/cases/cases-modal.module.css +44 -9
  25. package/app/components/sidebar/cases/cases-modal.tsx +16 -14
  26. package/app/components/sidebar/files/files-modal.module.css +45 -10
  27. package/app/components/sidebar/files/files-modal.tsx +16 -16
  28. package/app/components/sidebar/notes/notes-modal.tsx +17 -15
  29. package/app/components/sidebar/notes/notes.module.css +2 -0
  30. package/app/components/sidebar/sidebar.module.css +2 -2
  31. package/app/components/toast/toast.module.css +2 -1
  32. package/app/components/toast/toast.tsx +16 -11
  33. package/app/components/user/delete-account.tsx +10 -31
  34. package/app/components/user/inactivity-warning.module.css +8 -6
  35. package/app/components/user/manage-profile.module.css +2 -0
  36. package/app/components/user/manage-profile.tsx +85 -30
  37. package/app/hooks/useOverlayDismiss.ts +68 -0
  38. package/app/routes/auth/login.example.tsx +19 -8
  39. package/app/routes/auth/passwordReset.module.css +23 -13
  40. package/app/routes/striae/striae.tsx +8 -1
  41. package/app/routes.ts +7 -0
  42. package/app/services/audit/audit-export-csv.ts +2 -0
  43. package/app/services/audit/audit.service.ts +29 -5
  44. package/app/services/audit/builders/audit-entry-builder.ts +2 -1
  45. package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
  46. package/app/services/audit/builders/audit-event-builders-workflow.ts +6 -0
  47. package/app/types/audit.ts +2 -1
  48. package/app/types/user.ts +1 -0
  49. package/app/utils/data/permissions.ts +1 -0
  50. package/functions/api/pdf/[[path]].ts +32 -1
  51. package/load-context.ts +9 -0
  52. package/package.json +5 -1
  53. package/primershear.emails.example +6 -0
  54. package/scripts/deploy-pages-secrets.sh +6 -0
  55. package/scripts/deploy-primershear-emails.sh +166 -0
  56. package/worker-configuration.d.ts +7493 -7491
  57. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  58. package/workers/data-worker/wrangler.jsonc.example +1 -1
  59. package/workers/image-worker/wrangler.jsonc.example +1 -1
  60. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  61. package/workers/pdf-worker/src/pdf-worker.example.ts +3 -0
  62. package/workers/pdf-worker/src/report-types.ts +3 -0
  63. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  64. package/workers/user-worker/src/user-worker.example.ts +6 -1
  65. package/workers/user-worker/wrangler.jsonc.example +1 -1
  66. package/wrangler.toml.example +1 -1
@@ -1,17 +1,17 @@
1
- import { useState, useEffect, useContext, useCallback } from 'react';
1
+ import { useContext, useMemo } from 'react';
2
2
  import { AuthContext } from '~/contexts/auth.context';
3
- import { auditService, auditExportService } from '~/services/audit';
4
- import { type ValidationAuditEntry, type AuditAction, type AuditResult, type AuditTrail, type UserData, type WorkflowPhase } from '~/types';
5
- import { getUserData } from '~/utils/data';
3
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
4
+ import { AuditViewerHeader } from './viewer/audit-viewer-header';
5
+ import { AuditUserInfoCard } from './viewer/audit-user-info-card';
6
+ import { AuditActivitySummary } from './viewer/audit-activity-summary';
7
+ import { AuditFiltersPanel } from './viewer/audit-filters-panel';
8
+ import { AuditEntriesList } from './viewer/audit-entries-list';
9
+ import { summarizeAuditEntries } from './viewer/audit-viewer-utils';
10
+ import { useAuditViewerData } from './viewer/use-audit-viewer-data';
11
+ import { useAuditViewerFilters } from './viewer/use-audit-viewer-filters';
12
+ import { useAuditViewerExport } from './viewer/use-audit-viewer-export';
6
13
  import styles from './user-audit.module.css';
7
14
 
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
15
  interface UserAuditViewerProps {
16
16
  isOpen: boolean;
17
17
  onClose: () => void;
@@ -21,463 +21,77 @@ interface UserAuditViewerProps {
21
21
 
22
22
  export const UserAuditViewer = ({ isOpen, onClose, caseNumber, title }: UserAuditViewerProps) => {
23
23
  const { user } = useContext(AuthContext);
24
- const [auditEntries, setAuditEntries] = useState<ValidationAuditEntry[]>([]);
25
- const [userData, setUserData] = useState<UserData | null>(null);
26
- const [loading, setLoading] = useState(false);
27
- const [error, setError] = useState<string>('');
28
- const [filterAction, setFilterAction] = useState<AuditAction | 'all'>('all');
29
- const [filterResult, setFilterResult] = useState<AuditResult | 'all'>('all');
30
- const [filterCaseNumber, setFilterCaseNumber] = useState<string>('');
31
- const [caseNumberInput, setCaseNumberInput] = useState<string>('');
32
- const [dateRange, setDateRange] = useState<'1d' | '7d' | '30d' | '90d' | 'custom'>('1d');
33
- const [customStartDate, setCustomStartDate] = useState<string>('');
34
- const [customEndDate, setCustomEndDate] = useState<string>('');
35
- const [customStartDateInput, setCustomStartDateInput] = useState<string>('');
36
- const [customEndDateInput, setCustomEndDateInput] = useState<string>('');
37
- const [auditTrail, setAuditTrail] = useState<AuditTrail | null>(null);
38
-
39
- useEffect(() => {
40
- const handleEscape = (event: KeyboardEvent) => {
41
- if (event.key === 'Escape' && isOpen) {
42
- onClose();
43
- }
44
- };
45
-
46
- if (isOpen) {
47
- document.addEventListener('keydown', handleEscape);
48
- return () => {
49
- document.removeEventListener('keydown', handleEscape);
50
- };
51
- }
52
- }, [isOpen, onClose]);
53
-
54
- const loadUserData = useCallback(async () => {
55
- if (!user) return;
56
-
57
- try {
58
- const data = await getUserData(user);
59
- setUserData(data);
60
- } catch (error) {
61
- console.error('Failed to load user data:', error);
62
- // Don't set error state for user data failure, just log it
63
- }
64
- }, [user]);
65
-
66
- const loadAuditData = useCallback(async () => {
67
- if (!user?.uid) return;
68
-
69
- setLoading(true);
70
- setError('');
71
-
72
- try {
73
- // Calculate date range
74
- let startDate: string | undefined;
75
- let endDate: string | undefined;
76
-
77
- if (dateRange === 'custom') {
78
- if (customStartDate) {
79
- startDate = new Date(customStartDate + 'T00:00:00').toISOString();
80
- }
81
- if (customEndDate) {
82
- endDate = new Date(customEndDate + 'T23:59:59').toISOString();
83
- }
84
- // If only one custom date is provided, handle it appropriately
85
- if (customStartDate && !customEndDate) {
86
- // If only start date, set end date to now
87
- const endDateObj = new Date();
88
- endDate = endDateObj.toISOString();
89
- } else if (!customStartDate && customEndDate) {
90
- // If only end date, set start date to 30 days before end date
91
- const startDateObj = new Date(customEndDate + 'T23:59:59');
92
- startDateObj.setDate(startDateObj.getDate() - 30);
93
- startDate = startDateObj.toISOString();
94
- }
95
- } else if (dateRange === '90d') {
96
- // For '90d' entries, get last 90 days to avoid loading too much data
97
- const startDateObj = new Date();
98
- startDateObj.setDate(startDateObj.getDate() - 90);
99
- startDate = startDateObj.toISOString();
100
-
101
- const endDateObj = new Date();
102
- endDate = endDateObj.toISOString();
103
- } else {
104
- // Handle predefined ranges like '1d', '7d', '30d'
105
- const days = parseInt(dateRange.replace('d', ''));
106
- const startDateObj = new Date();
107
- startDateObj.setDate(startDateObj.getDate() - days);
108
- startDate = startDateObj.toISOString();
109
-
110
- // Always set end date to now for proper range querying
111
- const endDateObj = new Date();
112
- endDate = endDateObj.toISOString();
113
- }
114
-
115
- // Get audit entries (filtered by case if specified)
116
- const effectiveCaseNumber = caseNumber || (filterCaseNumber.trim() || undefined);
117
- const entries = await auditService.getAuditEntriesForUser(user.uid, {
118
- caseNumber: effectiveCaseNumber,
119
- startDate,
120
- endDate,
121
- limit: effectiveCaseNumber ? 1000 : 500 // More entries for case-specific view
122
- });
123
-
124
- setAuditEntries(entries);
125
-
126
- // If case-specific, create audit trail for enhanced export functionality
127
- if (effectiveCaseNumber && entries.length > 0) {
128
- const trail: AuditTrail = {
129
- caseNumber: effectiveCaseNumber,
130
- workflowId: `workflow-${effectiveCaseNumber}-${user.uid}`,
131
- entries,
132
- summary: {
133
- totalEvents: entries.length,
134
- successfulEvents: entries.filter(e => e.result === 'success').length,
135
- failedEvents: entries.filter(e => e.result === 'failure').length,
136
- warningEvents: entries.filter(e => e.result === 'warning').length,
137
- workflowPhases: [...new Set(entries
138
- .map(e => e.details.workflowPhase)
139
- .filter(isWorkflowPhase))],
140
- participatingUsers: [...new Set(entries.map(e => e.userId))],
141
- startTimestamp: entries[entries.length - 1]?.timestamp || new Date().toISOString(),
142
- endTimestamp: entries[0]?.timestamp || new Date().toISOString(),
143
- complianceStatus: entries.some(e => e.result === 'failure') ? 'non-compliant' : 'compliant',
144
- securityIncidents: entries.filter(e => e.action === 'security-violation').length
145
- }
146
- };
147
- setAuditTrail(trail);
148
- } else {
149
- setAuditTrail(null);
150
- }
151
- } catch (err) {
152
- setError(err instanceof Error ? err.message : 'Failed to load audit data');
153
- } finally {
154
- setLoading(false);
155
- }
156
- }, [
157
- user,
24
+ const {
25
+ filterAction,
26
+ setFilterAction,
27
+ filterResult,
28
+ setFilterResult,
29
+ filterCaseNumber,
30
+ caseNumberInput,
31
+ setCaseNumberInput,
32
+ filterBadgeId,
33
+ badgeIdInput,
34
+ setBadgeIdInput,
158
35
  dateRange,
159
36
  customStartDate,
160
37
  customEndDate,
161
- caseNumber,
162
- filterCaseNumber
163
- ]);
164
-
165
- useEffect(() => {
166
- if (isOpen && user) {
167
- loadAuditData();
168
- loadUserData();
169
- }
170
- }, [isOpen, user, loadAuditData, loadUserData]);
171
-
172
- const handleApplyCaseFilter = () => {
173
- setFilterCaseNumber(caseNumberInput.trim());
174
- };
175
-
176
- const handleClearCaseFilter = () => {
177
- setCaseNumberInput('');
178
- setFilterCaseNumber('');
179
- };
180
-
181
- const handleApplyCustomDateRange = () => {
182
- setCustomStartDate(customStartDateInput);
183
- setCustomEndDate(customEndDateInput);
184
- };
185
-
186
- const handleClearCustomDateRange = () => {
187
- setCustomStartDateInput('');
188
- setCustomEndDateInput('');
189
- setCustomStartDate('');
190
- setCustomEndDate('');
191
- };
192
-
193
- const getFilteredEntries = (): ValidationAuditEntry[] => {
194
- return auditEntries.filter(entry => {
195
- // Handle consolidation and mapping of actions
196
- let actionMatch: boolean;
197
- if (filterAction === 'all') {
198
- actionMatch = true;
199
- } else if (filterAction === 'confirmation-create') {
200
- // Accept both 'confirm' and 'confirmation-create' for this filter
201
- actionMatch = entry.action === 'confirm' || entry.action === 'confirmation-create';
202
- } else if (filterAction === 'case-export') {
203
- // Case exports use legacy 'export' action with 'case-export' workflowPhase
204
- actionMatch = entry.action === 'export' && entry.details.workflowPhase === 'case-export';
205
- } else if (filterAction === 'case-import') {
206
- // Case imports use legacy 'import' action with 'case-import' workflowPhase
207
- actionMatch = entry.action === 'import' && entry.details.workflowPhase === 'case-import';
208
- } else if (filterAction === 'confirmation-export') {
209
- // Confirmation exports use legacy 'export' action with 'confirmation' workflowPhase
210
- actionMatch = entry.action === 'export' && entry.details.workflowPhase === 'confirmation';
211
- } else if (filterAction === 'confirmation-import') {
212
- // Confirmation imports use legacy 'import' action with 'confirmation' workflowPhase
213
- actionMatch = entry.action === 'import' && entry.details.workflowPhase === 'confirmation';
214
- } else {
215
- // Direct action match for all other cases
216
- actionMatch = entry.action === filterAction;
217
- }
218
-
219
- const resultMatch = filterResult === 'all' || entry.result === filterResult;
220
- return actionMatch && resultMatch;
221
- });
222
- };
223
-
224
- // Export functions
225
- const handleExportCSV = async () => {
226
- if (!user) return;
227
-
228
- const filteredEntries = getFilteredEntries();
229
- const effectiveCaseNumber = caseNumber || filterCaseNumber.trim();
230
- const identifier = effectiveCaseNumber || user.uid;
231
- const type = effectiveCaseNumber ? 'case' : 'user';
232
- const filename = auditExportService.generateFilename(type, identifier, 'csv');
233
- const exportContext = {
234
- user,
235
- scopeType: type,
236
- scopeIdentifier: identifier,
237
- caseNumber: effectiveCaseNumber || undefined
238
- } as const;
239
-
240
- try {
241
- if (auditTrail && effectiveCaseNumber) {
242
- // Use full audit trail export for case-specific data
243
- await auditExportService.exportAuditTrailToCSV(auditTrail, filename, exportContext);
244
- } else {
245
- // Use regular entry export for user data
246
- await auditExportService.exportToCSV(filteredEntries, filename, exportContext);
247
- }
248
- } catch (error) {
249
- console.error('Export failed:', error);
250
- setError('Failed to export audit trail to CSV');
251
- }
252
- };
253
-
254
- const handleExportJSON = async () => {
255
- if (!user) return;
256
-
257
- const filteredEntries = getFilteredEntries();
258
- const effectiveCaseNumber = caseNumber || filterCaseNumber.trim();
259
- const identifier = effectiveCaseNumber || user.uid;
260
- const type = effectiveCaseNumber ? 'case' : 'user';
261
- const filename = auditExportService.generateFilename(type, identifier, 'csv'); // Will be converted to .json
262
- const exportContext = {
263
- user,
264
- scopeType: type,
265
- scopeIdentifier: identifier,
266
- caseNumber: effectiveCaseNumber || undefined
267
- } as const;
268
-
269
- try {
270
- if (auditTrail && effectiveCaseNumber) {
271
- // Use full audit trail export for case-specific data
272
- await auditExportService.exportAuditTrailToJSON(auditTrail, filename, exportContext);
273
- } else {
274
- // Use regular entry export for user data
275
- await auditExportService.exportToJSON(filteredEntries, filename, exportContext);
276
- }
277
- } catch (error) {
278
- console.error('Export failed:', error);
279
- setError('Failed to export audit trail to JSON');
280
- }
281
- };
282
-
283
- const handleGenerateReport = async () => {
284
- if (!user) return;
285
-
286
- const filteredEntries = getFilteredEntries();
287
- const effectiveCaseNumber = caseNumber || filterCaseNumber.trim();
288
- const identifier = effectiveCaseNumber || user.uid;
289
- const type = effectiveCaseNumber ? 'case' : 'user';
290
- const filename = `${type}-audit-report-${identifier}-${new Date().toISOString().split('T')[0]}.txt`;
291
- const exportContext = {
292
- user,
293
- scopeType: type,
294
- scopeIdentifier: identifier,
295
- caseNumber: effectiveCaseNumber || undefined
296
- } as const;
297
-
298
- try {
299
- let reportContent: string;
300
-
301
- if (auditTrail && effectiveCaseNumber) {
302
- // Use audit trail report for case-specific data
303
- reportContent = await auditExportService.generateReportSummary(auditTrail, exportContext);
304
- } else {
305
- // Generate user-specific report
306
- const totalEntries = filteredEntries.length;
307
- const successfulActions = filteredEntries.filter(e => e.result === 'success').length;
308
- const failedActions = filteredEntries.filter(e => e.result === 'failure').length;
309
-
310
- const actionCounts = filteredEntries.reduce((acc, entry) => {
311
- acc[entry.action] = (acc[entry.action] || 0) + 1;
312
- return acc;
313
- }, {} as Record<string, number>);
314
-
315
- const dateRange = filteredEntries.length > 0 ? {
316
- earliest: new Date(Math.min(...filteredEntries.map(e => new Date(e.timestamp).getTime()))),
317
- latest: new Date(Math.max(...filteredEntries.map(e => new Date(e.timestamp).getTime())))
318
- } : null;
319
-
320
- reportContent = `${caseNumber ? 'CASE' : 'USER'} AUDIT REPORT
321
- Generated: ${new Date().toISOString()}
322
- ${caseNumber ? `Case: ${caseNumber}` : `User: ${user.email}`}
323
- ${caseNumber ? '' : `User ID: ${user.uid}`}
324
-
325
- === SUMMARY ===
326
- Total Actions: ${totalEntries}
327
- Successful: ${successfulActions}
328
- Failed: ${failedActions}
329
- Success Rate: ${totalEntries > 0 ? ((successfulActions / totalEntries) * 100).toFixed(1) : 0}%
330
-
331
- ${dateRange ? `Date Range: ${dateRange.earliest.toLocaleDateString()} - ${dateRange.latest.toLocaleDateString()}` : 'No entries found'}
332
-
333
- === ACTION BREAKDOWN ===
334
- ${Object.entries(actionCounts)
335
- .sort(([,a], [,b]) => b - a)
336
- .map(([action, count]) => `${action}: ${count}`)
337
- .join('\n')}
338
-
339
- === RECENT ACTIVITIES ===
340
- ${filteredEntries.slice(0, 10).map(entry =>
341
- `${new Date(entry.timestamp).toLocaleString()} | ${entry.action} | ${entry.result}${entry.details.caseNumber ? ` | Case: ${entry.details.caseNumber}` : ''}`
342
- ).join('\n')}
343
-
344
- Generated by Striae
345
- `;
346
-
347
- reportContent = await auditExportService.appendSignedReportIntegrity(
348
- reportContent,
349
- exportContext,
350
- totalEntries
351
- );
352
- }
353
-
354
- // Create and download the report file
355
- const blob = new Blob([reportContent], { type: 'text/plain' });
356
- const url = URL.createObjectURL(blob);
357
- const a = document.createElement('a');
358
- a.href = url;
359
- a.download = filename;
360
- document.body.appendChild(a);
361
- a.click();
362
- document.body.removeChild(a);
363
- URL.revokeObjectURL(url);
364
- } catch (error) {
365
- console.error('Report generation failed:', error);
366
- setError('Failed to generate audit report');
367
- }
368
- };
369
-
370
- const getActionIcon = (action: AuditAction): string => {
371
- switch (action) {
372
- // User & Session Management
373
- case 'user-login': return '🔑';
374
- case 'user-logout': return '🚪';
375
- case 'user-profile-update': return '👤';
376
- case 'user-password-reset': return '🔒';
377
- // NEW: User Registration & Authentication
378
- case 'user-registration': return '📝';
379
- case 'email-verification': return '📧';
380
- case 'mfa-enrollment': return '🔐';
381
- case 'mfa-authentication': return '📱';
382
-
383
- // Case Management
384
- case 'case-create': return '📂';
385
- case 'case-rename': return '✏️';
386
- case 'case-delete': return '🗑️';
387
-
388
- // Confirmation Workflow
389
- case 'case-export': return '📤';
390
- case 'case-import': return '📥';
391
- case 'confirmation-create': return '✅';
392
- case 'confirmation-export': return '📤';
393
- case 'confirmation-import': return '📥';
394
-
395
- // File Operations
396
- case 'file-upload': return '⬆️';
397
- case 'file-delete': return '🗑️';
398
- case 'file-access': return '👁️';
399
-
400
- // Annotation Operations
401
- case 'annotation-create': return '✨';
402
- case 'annotation-edit': return '✏️';
403
- case 'annotation-delete': return '❌';
404
-
405
- // Document Generation
406
- case 'pdf-generate': return '📄';
407
-
408
- // Security & Monitoring
409
- case 'security-violation': return '🚨';
410
-
411
- // Legacy Actions
412
- case 'export': return '📤';
413
- case 'import': return '📥';
414
- case 'confirm': return '✓';
415
-
416
- default: return '📄';
417
- }
418
- };
419
-
420
- const getStatusIcon = (result: AuditResult): string => {
421
- switch (result) {
422
- case 'success': return '✅';
423
- case 'failure': return '❌';
424
- case 'warning': return '⚠️';
425
- case 'blocked': return '🛑';
426
- case 'pending': return '⏳';
427
- default: return '❓';
428
- }
429
- };
430
-
431
- const formatTimestamp = (timestamp: string): string => {
432
- return new Date(timestamp).toLocaleString();
433
- };
434
-
435
- const getDateRangeDisplay = (): string => {
436
- switch (dateRange) {
437
- case '90d':
438
- return 'Last 90 Days';
439
- case 'custom':
440
- if (customStartDate && customEndDate) {
441
- const startFormatted = new Date(customStartDate).toLocaleDateString();
442
- const endFormatted = new Date(customEndDate).toLocaleDateString();
443
- return `${startFormatted} - ${endFormatted}`;
444
- } else if (customStartDate) {
445
- return `From ${new Date(customStartDate).toLocaleDateString()}`;
446
- } else if (customEndDate) {
447
- return `Until ${new Date(customEndDate).toLocaleDateString()}`;
448
- } else {
449
- return 'Custom Range';
450
- }
451
- default:
452
- return `Last ${dateRange}`;
453
- }
454
- };
455
-
456
- // Get summary statistics
457
- const totalEntries = auditEntries.length;
458
- const successfulEntries = auditEntries.filter(e => e.result === 'success').length;
459
- const failedEntries = auditEntries.filter(e => e.result === 'failure').length;
460
- const securityIncidents = auditEntries.filter(e =>
461
- e.action === 'security-violation'
462
- ).length;
463
- const loginSessions = auditEntries.filter(e => e.action === 'user-login').length;
464
-
465
- const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
466
- if (event.target === event.currentTarget) {
467
- onClose();
468
- }
469
- };
38
+ customStartDateInput,
39
+ customEndDateInput,
40
+ setCustomStartDateInput,
41
+ setCustomEndDateInput,
42
+ handleApplyCaseFilter,
43
+ handleClearCaseFilter,
44
+ handleApplyBadgeFilter,
45
+ handleClearBadgeFilter,
46
+ handleApplyCustomDateRange,
47
+ handleClearCustomDateRange,
48
+ handleDateRangeChange,
49
+ getFilteredEntries,
50
+ dateRangeDisplay,
51
+ effectiveCaseNumber
52
+ } = useAuditViewerFilters(caseNumber);
53
+
54
+ const {
55
+ auditEntries,
56
+ userData,
57
+ loading,
58
+ error,
59
+ setError,
60
+ auditTrail,
61
+ loadAuditData
62
+ } = useAuditViewerData({
63
+ isOpen,
64
+ user,
65
+ effectiveCaseNumber,
66
+ dateRange,
67
+ customStartDate,
68
+ customEndDate
69
+ });
470
70
 
471
- const handleOverlayKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
472
- if (event.target !== event.currentTarget) {
473
- return;
474
- }
71
+ const filteredEntries = useMemo(() => getFilteredEntries(auditEntries), [auditEntries, getFilteredEntries]);
72
+ const auditSummary = useMemo(() => summarizeAuditEntries(auditEntries), [auditEntries]);
475
73
 
476
- if (event.key === 'Enter' || event.key === ' ') {
477
- event.preventDefault();
478
- onClose();
479
- }
480
- };
74
+ const {
75
+ handleExportCSV,
76
+ handleExportJSON,
77
+ handleGenerateReport
78
+ } = useAuditViewerExport({
79
+ user,
80
+ effectiveCaseNumber,
81
+ filteredEntries,
82
+ auditTrail,
83
+ setError
84
+ });
85
+
86
+ const {
87
+ handleOverlayMouseDown,
88
+ handleOverlayKeyDown
89
+ } = useOverlayDismiss({
90
+ isOpen,
91
+ onClose
92
+ });
93
+
94
+ const userBadgeId = userData?.badgeId?.trim() || '';
481
95
 
482
96
  if (!isOpen) return null;
483
97
 
@@ -491,39 +105,14 @@ Generated by Striae
491
105
  aria-label="Close audit trail dialog"
492
106
  >
493
107
  <div className={styles.modal}>
494
- <div className={styles.header}>
495
- <h2 className={styles.title}>
496
- {title || (caseNumber ? `Audit Trail - Case ${caseNumber}` : 'My Audit Trail')}
497
- </h2>
498
- <div className={styles.headerActions}>
499
- {auditEntries.length > 0 && (
500
- <div className={styles.exportButtons}>
501
- <button
502
- onClick={handleExportCSV}
503
- className={styles.exportButton}
504
- title="CSV - Individual entry log with summary data"
505
- >
506
- 📊 CSV
507
- </button>
508
- <button
509
- onClick={handleExportJSON}
510
- className={styles.exportButton}
511
- title="JSON - Complete log data for version capture and auditing"
512
- >
513
- 📄 JSON
514
- </button>
515
- <button
516
- onClick={handleGenerateReport}
517
- className={styles.exportButton}
518
- title="Summary report only"
519
- >
520
- 📋 Report
521
- </button>
522
- </div>
523
- )}
524
- <button className={styles.closeButton} onClick={onClose}>×</button>
525
- </div>
526
- </div>
108
+ <AuditViewerHeader
109
+ title={title || (effectiveCaseNumber ? `Audit Trail - Case ${effectiveCaseNumber}` : 'My Audit Trail')}
110
+ hasEntries={auditEntries.length > 0}
111
+ onExportCSV={handleExportCSV}
112
+ onExportJSON={handleExportJSON}
113
+ onGenerateReport={handleGenerateReport}
114
+ onClose={onClose}
115
+ />
527
116
 
528
117
  <div className={styles.content}>
529
118
  {loading && (
@@ -546,457 +135,49 @@ Generated by Striae
546
135
  <>
547
136
  {/* User Information Section */}
548
137
  {user && (
549
- <div className={styles.summary}>
550
- <h3>User Information</h3>
551
- <div className={styles.userInfoContent}>
552
- <div className={styles.userInfoItem}>
553
- Name: <strong>
554
- {userData ? `${userData.firstName} ${userData.lastName}` : user.displayName || 'Not provided'}
555
- </strong>
556
- </div>
557
- <div className={styles.userInfoItem}>
558
- Email: <strong>{user.email || 'Not provided'}</strong>
559
- </div>
560
- <div className={styles.userInfoItem}>
561
- Lab/Company: <strong>{userData?.company || 'Not provided'}</strong>
562
- </div>
563
- <div className={styles.userInfoItem}>
564
- User ID: <strong>{user.uid}</strong>
565
- </div>
566
- </div>
567
- </div>
138
+ <AuditUserInfoCard user={user} userData={userData} userBadgeId={userBadgeId} />
568
139
  )}
569
140
 
570
141
  {/* Summary Section */}
571
- <div className={styles.summary}>
572
- <h3>
573
- {(caseNumber || filterCaseNumber.trim())
574
- ? `Case Activity Summary - ${caseNumber || filterCaseNumber.trim()} (${getDateRangeDisplay()})`
575
- : `Activity Summary (${getDateRangeDisplay()})`
576
- }
577
- </h3>
578
- <div className={styles.summaryGrid}>
579
- <div className={styles.summaryItem}>
580
- <span className={styles.label}>Total Activities:</span>
581
- <span className={styles.value}>{totalEntries}</span>
582
- </div>
583
- <div className={styles.summaryItem}>
584
- <span className={styles.label}>Successful:</span>
585
- <span className={styles.value}>{successfulEntries}</span>
586
- </div>
587
- <div className={styles.summaryItem}>
588
- <span className={styles.label}>Failed:</span>
589
- <span className={styles.value}>{failedEntries}</span>
590
- </div>
591
- <div className={styles.summaryItem}>
592
- <span className={styles.label}>Login Sessions:</span>
593
- <span className={styles.value}>{loginSessions}</span>
594
- </div>
595
- <div className={styles.summaryItem}>
596
- <span className={styles.label}>Security Incidents:</span>
597
- <span className={`${styles.value} ${securityIncidents > 0 ? styles.warning : ''}`}>
598
- {securityIncidents}
599
- </span>
600
- </div>
601
- </div>
602
- </div>
142
+ <AuditActivitySummary
143
+ caseNumber={caseNumber}
144
+ filterCaseNumber={filterCaseNumber}
145
+ dateRangeDisplay={dateRangeDisplay}
146
+ summary={auditSummary}
147
+ />
603
148
 
604
149
  {/* Filters */}
605
- <div className={styles.filters}>
606
- <div className={styles.filterGroup}>
607
- <label htmlFor="dateRange">Time Period:</label>
608
- <select
609
- id="dateRange"
610
- value={dateRange}
611
- onChange={(e) => {
612
- const newRange = e.target.value as '1d' | '7d' | '30d' | '90d' | 'custom';
613
- setDateRange(newRange);
614
- // When switching to custom, populate inputs with current applied values
615
- if (newRange === 'custom') {
616
- setCustomStartDateInput(customStartDate);
617
- setCustomEndDateInput(customEndDate);
618
- }
619
- }}
620
- className={styles.filterSelect}
621
- >
622
- <option value="1d">Last 24 Hours</option>
623
- <option value="7d">Last 7 Days</option>
624
- <option value="30d">Last 30 Days</option>
625
- <option value="90d">Last 90 Days</option>
626
- <option value="custom">Custom Range</option>
627
- </select>
628
- </div>
629
-
630
- {/* Custom Date Range Inputs */}
631
- {dateRange === 'custom' && (
632
- <div className={styles.customDateRange}>
633
- <div className={styles.customDateInputs}>
634
- <div className={styles.filterGroup}>
635
- <label htmlFor="startDate">Start Date:</label>
636
- <input
637
- type="date"
638
- id="startDate"
639
- value={customStartDateInput}
640
- onChange={(e) => setCustomStartDateInput(e.target.value)}
641
- className={styles.filterInput}
642
- max={customEndDateInput || new Date().toISOString().split('T')[0]}
643
- />
644
- </div>
645
- <div className={styles.filterGroup}>
646
- <label htmlFor="endDate">End Date:</label>
647
- <input
648
- type="date"
649
- id="endDate"
650
- value={customEndDateInput}
651
- onChange={(e) => setCustomEndDateInput(e.target.value)}
652
- className={styles.filterInput}
653
- min={customStartDateInput}
654
- max={new Date().toISOString().split('T')[0]}
655
- />
656
- </div>
657
- <div className={styles.dateRangeButtons}>
658
- {(customStartDateInput || customEndDateInput) && (
659
- <button
660
- type="button"
661
- onClick={handleApplyCustomDateRange}
662
- className={styles.filterButton}
663
- title="Apply custom date range"
664
- >
665
- Apply Dates
666
- </button>
667
- )}
668
- {(customStartDate || customEndDate) && (
669
- <button
670
- type="button"
671
- onClick={handleClearCustomDateRange}
672
- className={styles.clearButton}
673
- title="Clear custom date range"
674
- >
675
- Clear Dates
676
- </button>
677
- )}
678
- </div>
679
- </div>
680
- {(customStartDate || customEndDate) && (
681
- <div className={styles.activeFilter}>
682
- <small>
683
- Custom range:
684
- {customStartDate && <strong> from {new Date(customStartDate).toLocaleDateString()}</strong>}
685
- {customEndDate && <strong> to {new Date(customEndDate).toLocaleDateString()}</strong>}
686
- </small>
687
- </div>
688
- )}
689
- </div>
690
- )}
691
-
692
- <div className={styles.filterGroup}>
693
- <label htmlFor="caseFilter">Case Number:</label>
694
- <div className={styles.inputWithButton}>
695
- <input
696
- type="text"
697
- id="caseFilter"
698
- value={caseNumberInput}
699
- onChange={(e) => setCaseNumberInput(e.target.value)}
700
- className={styles.filterInput}
701
- placeholder="Enter case number..."
702
- disabled={!!caseNumber} // Disable if already viewing a specific case
703
- title={caseNumber ? "Case filter disabled - viewing specific case" : "Enter complete case number and click Filter"}
704
- onKeyDown={(e) => {
705
- if (e.key === 'Enter' && caseNumberInput.trim() && !caseNumber) {
706
- handleApplyCaseFilter();
707
- }
708
- }}
709
- />
710
- {!caseNumber && (
711
- <div className={styles.caseFilterButtons}>
712
- {caseNumberInput.trim() && (
713
- <button
714
- type="button"
715
- onClick={handleApplyCaseFilter}
716
- className={styles.filterButton}
717
- title="Apply case filter"
718
- >
719
- Filter
720
- </button>
721
- )}
722
- {filterCaseNumber && (
723
- <button
724
- type="button"
725
- onClick={handleClearCaseFilter}
726
- className={styles.clearButton}
727
- title="Clear case filter"
728
- >
729
- Clear
730
- </button>
731
- )}
732
- </div>
733
- )}
734
- </div>
735
- {filterCaseNumber && !caseNumber && (
736
- <div className={styles.activeFilter}>
737
- <small>Filtering by case: <strong>{filterCaseNumber}</strong></small>
738
- </div>
739
- )}
740
- </div>
741
-
742
- <div className={styles.filterGroup}>
743
- <label htmlFor="actionFilter">Activity Type:</label>
744
- <select
745
- id="actionFilter"
746
- value={filterAction}
747
- onChange={(e) => setFilterAction(e.target.value as AuditAction | 'all')}
748
- className={styles.filterSelect}
749
- >
750
- <option value="all">All Activities</option>
751
- <optgroup label="User Sessions">
752
- <option value="user-login">Login</option>
753
- <option value="user-logout">Logout</option>
754
- </optgroup>
755
- <optgroup label="Case Management">
756
- <option value="case-create">Case Create</option>
757
- <option value="case-rename">Case Rename</option>
758
- <option value="case-delete">Case Delete</option>
759
- <option value="case-export">Case Export</option>
760
- <option value="case-import">Case Import</option>
761
- </optgroup>
762
- <optgroup label="File Operations">
763
- <option value="file-upload">File Upload</option>
764
- <option value="file-access">File Access</option>
765
- <option value="file-delete">File Delete</option>
766
- </optgroup>
767
- <optgroup label="Annotations">
768
- <option value="annotation-create">Annotation Create</option>
769
- <option value="annotation-edit">Annotation Edit</option>
770
- <option value="annotation-delete">Annotation Delete</option>
771
- </optgroup>
772
- <optgroup label="Confirmation Activity">
773
- <option value="confirmation-create">Confirmation Create</option>
774
- <option value="confirmation-export">Confirmation Export</option>
775
- <option value="confirmation-import">Confirmation Import</option>
776
- </optgroup>
777
- <optgroup label="Documents">
778
- <option value="pdf-generate">PDF Generate</option>
779
- </optgroup>
780
- <optgroup label="Security">
781
- <option value="security-violation">Security Violation</option>
782
- </optgroup>
783
- </select>
784
- </div>
785
-
786
- <div className={styles.filterGroup}>
787
- <label htmlFor="resultFilter">Result:</label>
788
- <select
789
- id="resultFilter"
790
- value={filterResult}
791
- onChange={(e) => setFilterResult(e.target.value as AuditResult | 'all')}
792
- className={styles.filterSelect}
793
- >
794
- <option value="all">All Results</option>
795
- <option value="success">Success</option>
796
- <option value="failure">Failure</option>
797
- <option value="warning">Warning</option>
798
- <option value="blocked">Blocked</option>
799
- </select>
800
- </div>
801
- </div>
150
+ <AuditFiltersPanel
151
+ dateRange={dateRange}
152
+ customStartDate={customStartDate}
153
+ customEndDate={customEndDate}
154
+ customStartDateInput={customStartDateInput}
155
+ customEndDateInput={customEndDateInput}
156
+ caseNumber={caseNumber}
157
+ filterCaseNumber={filterCaseNumber}
158
+ caseNumberInput={caseNumberInput}
159
+ filterBadgeId={filterBadgeId}
160
+ badgeIdInput={badgeIdInput}
161
+ filterAction={filterAction}
162
+ filterResult={filterResult}
163
+ onDateRangeChange={handleDateRangeChange}
164
+ onCustomStartDateInputChange={setCustomStartDateInput}
165
+ onCustomEndDateInputChange={setCustomEndDateInput}
166
+ onApplyCustomDateRange={handleApplyCustomDateRange}
167
+ onClearCustomDateRange={handleClearCustomDateRange}
168
+ onCaseNumberInputChange={setCaseNumberInput}
169
+ onApplyCaseFilter={handleApplyCaseFilter}
170
+ onClearCaseFilter={handleClearCaseFilter}
171
+ onBadgeIdInputChange={setBadgeIdInput}
172
+ onApplyBadgeFilter={handleApplyBadgeFilter}
173
+ onClearBadgeFilter={handleClearBadgeFilter}
174
+ onFilterActionChange={setFilterAction}
175
+ onFilterResultChange={setFilterResult}
176
+ />
802
177
 
803
178
  {/* Entries List */}
804
- <div className={styles.entriesList}>
805
- <h3>Activity Log ({getFilteredEntries().length} entries)</h3>
806
- {getFilteredEntries().length === 0 ? (
807
- <div className={styles.noEntries}>
808
- <p>No activities match the current filters.</p>
809
- </div>
810
- ) : (
811
- getFilteredEntries().map((entry, index) => (
812
- <div key={index} className={`${styles.entry} ${styles[entry.result]}`}>
813
- <div className={styles.entryHeader}>
814
- <div className={styles.entryIcons}>
815
- <span className={styles.actionIcon}>{getActionIcon(entry.action)}</span>
816
- <span className={styles.statusIcon}>{getStatusIcon(entry.result)}</span>
817
- </div>
818
- <div className={styles.entryTitle}>
819
- <span className={styles.action}>{entry.action.toUpperCase().replace(/-/g, ' ')}</span>
820
- <span className={styles.fileName}>{entry.details.fileName}</span>
821
- </div>
822
- <div className={styles.entryTimestamp}>
823
- {formatTimestamp(entry.timestamp)}
824
- </div>
825
- </div>
826
-
827
- {/* Basic Details */}
828
- <div className={styles.entryDetails}>
829
- {entry.details.caseNumber && (
830
- <div className={styles.detailRow}>
831
- <span className={styles.detailLabel}>Case:</span>
832
- <span className={styles.detailValue}>{entry.details.caseNumber}</span>
833
- </div>
834
- )}
835
-
836
- {entry.result === 'failure' && entry.details.validationErrors.length > 0 && (
837
- <div className={styles.detailRow}>
838
- <span className={styles.detailLabel}>Error:</span>
839
- <span className={styles.detailValue}>{entry.details.validationErrors[0]}</span>
840
- </div>
841
- )}
842
-
843
- {/* Session Details for Login/Logout */}
844
- {(entry.action === 'user-login' || entry.action === 'user-logout') && entry.details.sessionDetails && (
845
- <>
846
- {entry.details.sessionDetails.userAgent && (
847
- <div className={styles.detailRow}>
848
- <span className={styles.detailLabel}>User Agent:</span>
849
- <span className={styles.detailValue}>{entry.details.sessionDetails.userAgent}</span>
850
- </div>
851
- )}
852
- </>
853
- )}
854
-
855
- {/* Security Details */}
856
- {entry.action === 'security-violation' && entry.details.securityDetails && (
857
- <>
858
- <div className={styles.detailRow}>
859
- <span className={styles.detailLabel}>Severity:</span>
860
- <span className={`${styles.detailValue} ${styles.severity} ${styles[entry.details.securityDetails.severity || 'low']}`}>
861
- {(entry.details.securityDetails.severity || 'low').toUpperCase()}
862
- </span>
863
- </div>
864
- {entry.details.securityDetails.incidentType && (
865
- <div className={styles.detailRow}>
866
- <span className={styles.detailLabel}>Type:</span>
867
- <span className={styles.detailValue}>{entry.details.securityDetails.incidentType}</span>
868
- </div>
869
- )}
870
- </>
871
- )}
872
-
873
- {/* File Operation Details */}
874
- {(entry.action === 'file-upload' || entry.action === 'file-delete' || entry.action === 'file-access') && entry.details.fileDetails && (
875
- <>
876
- {/* File ID */}
877
- {entry.details.fileDetails.fileId && (
878
- <div className={styles.detailRow}>
879
- <span className={styles.detailLabel}>File ID:</span>
880
- <span className={styles.detailValue}>{entry.details.fileDetails.fileId}</span>
881
- </div>
882
- )}
883
-
884
- {/* Original Filename */}
885
- {entry.details.fileDetails.originalFileName && (
886
- <div className={styles.detailRow}>
887
- <span className={styles.detailLabel}>Original Filename:</span>
888
- <span className={styles.detailValue}>{entry.details.fileDetails.originalFileName}</span>
889
- </div>
890
- )}
891
-
892
- {/* File Size */}
893
- {entry.details.fileDetails.fileSize > 0 && (
894
- <div className={styles.detailRow}>
895
- <span className={styles.detailLabel}>File Size:</span>
896
- <span className={styles.detailValue}>
897
- {(entry.details.fileDetails.fileSize / 1024 / 1024).toFixed(2)} MB
898
- </span>
899
- </div>
900
- )}
901
-
902
- {/* Access Method/Upload Method */}
903
- {entry.details.fileDetails.uploadMethod && (
904
- <div className={styles.detailRow}>
905
- <span className={styles.detailLabel}>
906
- {entry.action === 'file-access' ? 'Access Method' : 'Upload Method'}:
907
- </span>
908
- <span className={styles.detailValue}>{entry.details.fileDetails.uploadMethod}</span>
909
- </div>
910
- )}
911
-
912
- {/* Delete Reason */}
913
- {entry.details.fileDetails.deleteReason && (
914
- <div className={styles.detailRow}>
915
- <span className={styles.detailLabel}>Reason:</span>
916
- <span className={styles.detailValue}>{entry.details.fileDetails.deleteReason}</span>
917
- </div>
918
- )}
919
-
920
- {/* Access Source */}
921
- {entry.details.fileDetails.sourceLocation && entry.action === 'file-access' && (
922
- <div className={styles.detailRow}>
923
- <span className={styles.detailLabel}>Access Source:</span>
924
- <span className={styles.detailValue}>{entry.details.fileDetails.sourceLocation}</span>
925
- </div>
926
- )}
927
- </>
928
- )}
929
-
930
- {/* Annotation Details */}
931
- {(entry.action === 'annotation-create' || entry.action === 'annotation-edit' || entry.action === 'annotation-delete') && entry.details.fileDetails && (
932
- <>
933
- {/* File ID */}
934
- {entry.details.fileDetails.fileId && (
935
- <div className={styles.detailRow}>
936
- <span className={styles.detailLabel}>File ID:</span>
937
- <span className={styles.detailValue}>{entry.details.fileDetails.fileId}</span>
938
- </div>
939
- )}
940
-
941
- {/* Original Filename */}
942
- {entry.details.fileDetails.originalFileName && (
943
- <div className={styles.detailRow}>
944
- <span className={styles.detailLabel}>Original Filename:</span>
945
- <span className={styles.detailValue}>{entry.details.fileDetails.originalFileName}</span>
946
- </div>
947
- )}
948
-
949
- {/* Annotation Type */}
950
- {entry.details.annotationDetails?.annotationType && (
951
- <div className={styles.detailRow}>
952
- <span className={styles.detailLabel}>Annotation Type:</span>
953
- <span className={styles.detailValue}>{entry.details.annotationDetails.annotationType}</span>
954
- </div>
955
- )}
956
-
957
- {/* Tool Used */}
958
- {entry.details.annotationDetails?.tool && (
959
- <div className={styles.detailRow}>
960
- <span className={styles.detailLabel}>Tool:</span>
961
- <span className={styles.detailValue}>{entry.details.annotationDetails.tool}</span>
962
- </div>
963
- )}
964
- </>
965
- )}
966
-
967
- {/* PDF Generation and Confirmation Details */}
968
- {(entry.action === 'pdf-generate' || entry.action === 'confirm') && entry.details.fileDetails && (
969
- <>
970
- {/* Source File ID */}
971
- {entry.details.fileDetails.fileId && (
972
- <div className={styles.detailRow}>
973
- <span className={styles.detailLabel}>{entry.action === 'pdf-generate' ? 'Source File ID:' : 'Original Image ID:'}</span>
974
- <span className={styles.detailValue}>{entry.details.fileDetails.fileId}</span>
975
- </div>
976
- )}
977
-
978
- {/* Source Original Filename */}
979
- {entry.details.fileDetails.originalFileName && (
980
- <div className={styles.detailRow}>
981
- <span className={styles.detailLabel}>{entry.action === 'pdf-generate' ? 'Source Filename:' : 'Original Filename:'}</span>
982
- <span className={styles.detailValue}>{entry.details.fileDetails.originalFileName}</span>
983
- </div>
984
- )}
179
+ <AuditEntriesList entries={filteredEntries} />
985
180
 
986
- {/* Confirmation ID (for confirm actions) */}
987
- {entry.action === 'confirm' && entry.details.confirmationId && (
988
- <div className={styles.detailRow}>
989
- <span className={styles.detailLabel}>Confirmation ID:</span>
990
- <span className={styles.detailValue}>{entry.details.confirmationId}</span>
991
- </div>
992
- )}
993
- </>
994
- )}
995
- </div>
996
- </div>
997
- ))
998
- )}
999
- </div>
1000
181
  </>
1001
182
  )}
1002
183
 
@@ -1009,4 +190,4 @@ Generated by Striae
1009
190
  </div>
1010
191
  </div>
1011
192
  );
1012
- };
193
+ };