@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.
- package/app/components/actions/confirm-export.ts +4 -2
- package/app/components/actions/generate-pdf.ts +10 -2
- package/app/components/audit/user-audit-viewer.tsx +121 -940
- package/app/components/audit/user-audit.module.css +20 -0
- package/app/components/audit/viewer/audit-activity-summary.tsx +52 -0
- package/app/components/audit/viewer/audit-entries-list.tsx +200 -0
- package/app/components/audit/viewer/audit-filters-panel.tsx +306 -0
- package/app/components/audit/viewer/audit-user-info-card.tsx +44 -0
- package/app/components/audit/viewer/audit-viewer-header.tsx +55 -0
- package/app/components/audit/viewer/audit-viewer-utils.ts +121 -0
- package/app/components/audit/viewer/types.ts +1 -0
- package/app/components/audit/viewer/use-audit-viewer-data.ts +166 -0
- package/app/components/audit/viewer/use-audit-viewer-export.ts +176 -0
- package/app/components/audit/viewer/use-audit-viewer-filters.ts +141 -0
- package/app/components/auth/mfa-enrollment.module.css +13 -5
- package/app/components/auth/mfa-verification.module.css +13 -5
- package/app/components/canvas/canvas.tsx +3 -0
- package/app/components/canvas/confirmation/confirmation.tsx +13 -37
- package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +1 -0
- package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +8 -37
- package/app/components/sidebar/case-export/case-export.tsx +9 -34
- package/app/components/sidebar/case-import/case-import.module.css +2 -0
- package/app/components/sidebar/case-import/case-import.tsx +10 -34
- package/app/components/sidebar/cases/cases-modal.module.css +44 -9
- package/app/components/sidebar/cases/cases-modal.tsx +16 -14
- package/app/components/sidebar/files/files-modal.module.css +45 -10
- package/app/components/sidebar/files/files-modal.tsx +16 -16
- package/app/components/sidebar/notes/notes-modal.tsx +17 -15
- package/app/components/sidebar/notes/notes.module.css +2 -0
- package/app/components/sidebar/sidebar.module.css +2 -2
- package/app/components/toast/toast.module.css +2 -1
- package/app/components/toast/toast.tsx +16 -11
- package/app/components/user/delete-account.tsx +10 -31
- package/app/components/user/inactivity-warning.module.css +8 -6
- package/app/components/user/manage-profile.module.css +2 -0
- package/app/components/user/manage-profile.tsx +85 -30
- package/app/hooks/useOverlayDismiss.ts +68 -0
- package/app/routes/auth/login.example.tsx +19 -8
- package/app/routes/auth/passwordReset.module.css +23 -13
- package/app/routes/striae/striae.tsx +8 -1
- package/app/routes.ts +7 -0
- package/app/services/audit/audit-export-csv.ts +2 -0
- package/app/services/audit/audit.service.ts +29 -5
- package/app/services/audit/builders/audit-entry-builder.ts +2 -1
- package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
- package/app/services/audit/builders/audit-event-builders-workflow.ts +6 -0
- package/app/types/audit.ts +2 -1
- package/app/types/user.ts +1 -0
- package/app/utils/data/permissions.ts +1 -0
- package/functions/api/pdf/[[path]].ts +32 -1
- package/load-context.ts +9 -0
- package/package.json +5 -1
- package/primershear.emails.example +6 -0
- package/scripts/deploy-pages-secrets.sh +6 -0
- package/scripts/deploy-primershear-emails.sh +166 -0
- package/worker-configuration.d.ts +7493 -7491
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/keys-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/src/pdf-worker.example.ts +3 -0
- package/workers/pdf-worker/src/report-types.ts +3 -0
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/src/user-worker.example.ts +6 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
|
@@ -0,0 +1,121 @@
|
|
|
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-export':
|
|
36
|
+
return '📤';
|
|
37
|
+
case 'case-import':
|
|
38
|
+
return '📥';
|
|
39
|
+
case 'confirmation-create':
|
|
40
|
+
return '✅';
|
|
41
|
+
case 'confirmation-export':
|
|
42
|
+
return '📤';
|
|
43
|
+
case 'confirmation-import':
|
|
44
|
+
return '📥';
|
|
45
|
+
case 'file-upload':
|
|
46
|
+
return '⬆️';
|
|
47
|
+
case 'file-delete':
|
|
48
|
+
return '🗑️';
|
|
49
|
+
case 'file-access':
|
|
50
|
+
return '👁️';
|
|
51
|
+
case 'annotation-create':
|
|
52
|
+
return '✨';
|
|
53
|
+
case 'annotation-edit':
|
|
54
|
+
return '✏️';
|
|
55
|
+
case 'annotation-delete':
|
|
56
|
+
return '❌';
|
|
57
|
+
case 'pdf-generate':
|
|
58
|
+
return '📄';
|
|
59
|
+
case 'security-violation':
|
|
60
|
+
return '🚨';
|
|
61
|
+
case 'export':
|
|
62
|
+
return '📤';
|
|
63
|
+
case 'import':
|
|
64
|
+
return '📥';
|
|
65
|
+
case 'confirm':
|
|
66
|
+
return '✓';
|
|
67
|
+
default:
|
|
68
|
+
return '📄';
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const getAuditStatusIcon = (result: AuditResult): string => {
|
|
73
|
+
switch (result) {
|
|
74
|
+
case 'success':
|
|
75
|
+
return '✅';
|
|
76
|
+
case 'failure':
|
|
77
|
+
return '❌';
|
|
78
|
+
case 'warning':
|
|
79
|
+
return '⚠️';
|
|
80
|
+
case 'blocked':
|
|
81
|
+
return '🛑';
|
|
82
|
+
case 'pending':
|
|
83
|
+
return '⏳';
|
|
84
|
+
default:
|
|
85
|
+
return '❓';
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const formatAuditTimestamp = (timestamp: string): string => {
|
|
90
|
+
return new Date(timestamp).toLocaleString();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const summarizeAuditEntries = (entries: ValidationAuditEntry[]): AuditViewerSummaryStats => {
|
|
94
|
+
return entries.reduce<AuditViewerSummaryStats>((summary, entry) => {
|
|
95
|
+
summary.totalEntries += 1;
|
|
96
|
+
|
|
97
|
+
if (entry.result === 'success') {
|
|
98
|
+
summary.successfulEntries += 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (entry.result === 'failure') {
|
|
102
|
+
summary.failedEntries += 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (entry.action === 'user-login') {
|
|
106
|
+
summary.loginSessions += 1;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (entry.action === 'security-violation') {
|
|
110
|
+
summary.securityIncidents += 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return summary;
|
|
114
|
+
}, {
|
|
115
|
+
totalEntries: 0,
|
|
116
|
+
successfulEntries: 0,
|
|
117
|
+
failedEntries: 0,
|
|
118
|
+
loginSessions: 0,
|
|
119
|
+
securityIncidents: 0
|
|
120
|
+
});
|
|
121
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type DateRangeFilter = '1d' | '7d' | '30d' | '90d' | 'custom';
|
|
@@ -0,0 +1,166 @@
|
|
|
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 { 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
|
+
|
|
86
|
+
const loadUserData = useCallback(async () => {
|
|
87
|
+
if (!user) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const data = await getUserData(user);
|
|
93
|
+
setUserData(data);
|
|
94
|
+
} catch (loadError) {
|
|
95
|
+
console.error('Failed to load user data:', loadError);
|
|
96
|
+
}
|
|
97
|
+
}, [user]);
|
|
98
|
+
|
|
99
|
+
const loadAuditData = useCallback(async () => {
|
|
100
|
+
if (!user?.uid) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setLoading(true);
|
|
105
|
+
setError('');
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const { startDate, endDate } = buildAuditDateQuery(dateRange, customStartDate, customEndDate);
|
|
109
|
+
|
|
110
|
+
const entries = await auditService.getAuditEntriesForUser(user.uid, {
|
|
111
|
+
caseNumber: effectiveCaseNumber,
|
|
112
|
+
startDate,
|
|
113
|
+
endDate,
|
|
114
|
+
limit: effectiveCaseNumber ? 1000 : 500
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
setAuditEntries(entries);
|
|
118
|
+
|
|
119
|
+
if (effectiveCaseNumber && entries.length > 0) {
|
|
120
|
+
const trail: AuditTrail = {
|
|
121
|
+
caseNumber: effectiveCaseNumber,
|
|
122
|
+
workflowId: `workflow-${effectiveCaseNumber}-${user.uid}`,
|
|
123
|
+
entries,
|
|
124
|
+
summary: {
|
|
125
|
+
totalEvents: entries.length,
|
|
126
|
+
successfulEvents: entries.filter(entry => entry.result === 'success').length,
|
|
127
|
+
failedEvents: entries.filter(entry => entry.result === 'failure').length,
|
|
128
|
+
warningEvents: entries.filter(entry => entry.result === 'warning').length,
|
|
129
|
+
workflowPhases: [...new Set(entries
|
|
130
|
+
.map(entry => entry.details.workflowPhase)
|
|
131
|
+
.filter(isWorkflowPhase))],
|
|
132
|
+
participatingUsers: [...new Set(entries.map(entry => entry.userId))],
|
|
133
|
+
startTimestamp: entries[entries.length - 1]?.timestamp || new Date().toISOString(),
|
|
134
|
+
endTimestamp: entries[0]?.timestamp || new Date().toISOString(),
|
|
135
|
+
complianceStatus: entries.some(entry => entry.result === 'failure') ? 'non-compliant' : 'compliant',
|
|
136
|
+
securityIncidents: entries.filter(entry => entry.action === 'security-violation').length
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
setAuditTrail(trail);
|
|
140
|
+
} else {
|
|
141
|
+
setAuditTrail(null);
|
|
142
|
+
}
|
|
143
|
+
} catch (loadError) {
|
|
144
|
+
setError(loadError instanceof Error ? loadError.message : 'Failed to load audit data');
|
|
145
|
+
} finally {
|
|
146
|
+
setLoading(false);
|
|
147
|
+
}
|
|
148
|
+
}, [user, dateRange, customStartDate, customEndDate, effectiveCaseNumber]);
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (isOpen && user) {
|
|
152
|
+
void loadAuditData();
|
|
153
|
+
void loadUserData();
|
|
154
|
+
}
|
|
155
|
+
}, [isOpen, user, loadAuditData, loadUserData]);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
auditEntries,
|
|
159
|
+
userData,
|
|
160
|
+
loading,
|
|
161
|
+
error,
|
|
162
|
+
setError,
|
|
163
|
+
auditTrail,
|
|
164
|
+
loadAuditData
|
|
165
|
+
};
|
|
166
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
justify-content: center;
|
|
11
11
|
z-index: 1000;
|
|
12
12
|
padding: 1rem;
|
|
13
|
+
cursor: default;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
.modal {
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
max-height: 90vh;
|
|
22
23
|
overflow-y: auto;
|
|
23
24
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
25
|
+
cursor: default;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
.header {
|
|
@@ -149,7 +151,8 @@
|
|
|
149
151
|
}
|
|
150
152
|
|
|
151
153
|
.errorMessage {
|
|
152
|
-
background: linear-gradient(
|
|
154
|
+
background: linear-gradient(
|
|
155
|
+
135deg,
|
|
153
156
|
color-mix(in lab, var(--error) 12%, transparent),
|
|
154
157
|
color-mix(in lab, var(--error) 8%, transparent)
|
|
155
158
|
);
|
|
@@ -169,13 +172,17 @@
|
|
|
169
172
|
}
|
|
170
173
|
|
|
171
174
|
.errorMessage::before {
|
|
172
|
-
content:
|
|
175
|
+
content: "";
|
|
173
176
|
position: absolute;
|
|
174
177
|
top: 0;
|
|
175
178
|
left: 0;
|
|
176
179
|
right: 0;
|
|
177
180
|
height: 2px;
|
|
178
|
-
background: linear-gradient(
|
|
181
|
+
background: linear-gradient(
|
|
182
|
+
90deg,
|
|
183
|
+
var(--error),
|
|
184
|
+
color-mix(in lab, var(--error) 60%, transparent)
|
|
185
|
+
);
|
|
179
186
|
animation: shimmer 2s ease-in-out infinite;
|
|
180
187
|
}
|
|
181
188
|
|
|
@@ -246,7 +253,8 @@
|
|
|
246
253
|
}
|
|
247
254
|
|
|
248
255
|
@keyframes shimmer {
|
|
249
|
-
0%,
|
|
256
|
+
0%,
|
|
257
|
+
100% {
|
|
250
258
|
opacity: 0.6;
|
|
251
259
|
transform: translateX(-100%);
|
|
252
260
|
}
|
|
@@ -261,7 +269,7 @@
|
|
|
261
269
|
.errorMessage {
|
|
262
270
|
animation: none;
|
|
263
271
|
}
|
|
264
|
-
|
|
272
|
+
|
|
265
273
|
.errorMessage::before {
|
|
266
274
|
animation: none;
|
|
267
275
|
}
|