@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,6 +1,7 @@
1
1
  import type React from 'react';
2
2
  import { useState, useContext, useEffect } from 'react';
3
3
  import { AuthContext } from '~/contexts/auth.context';
4
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
4
5
  import { deleteFile } from '~/components/actions/image-manage';
5
6
  import { getFileAnnotations } from '~/utils/data';
6
7
  import { type FileData } from '~/types';
@@ -33,6 +34,13 @@ export const FilesModal = ({ isOpen, onClose, onFileSelect, currentCase, files,
33
34
  const [currentPage, setCurrentPage] = useState(0);
34
35
  const [deletingFileId, setDeletingFileId] = useState<string | null>(null);
35
36
  const [fileConfirmationStatus, setFileConfirmationStatus] = useState<FileConfirmationStatus>({});
37
+ const {
38
+ handleOverlayMouseDown,
39
+ handleOverlayKeyDown
40
+ } = useOverlayDismiss({
41
+ isOpen,
42
+ onClose
43
+ });
36
44
 
37
45
  const totalPages = Math.ceil(files.length / FILES_PER_PAGE);
38
46
  const startIndex = currentPage * FILES_PER_PAGE;
@@ -88,21 +96,6 @@ export const FilesModal = ({ isOpen, onClose, onFileSelect, currentCase, files,
88
96
  fetchConfirmationStatuses();
89
97
  }, [isOpen, currentCase, currentPage, files, user]);
90
98
 
91
- useEffect(() => {
92
- const handleEscape = (event: KeyboardEvent) => {
93
- if (event.key === 'Escape' && isOpen) {
94
- onClose();
95
- }
96
- };
97
-
98
- if (isOpen) {
99
- document.addEventListener('keydown', handleEscape);
100
- return () => {
101
- document.removeEventListener('keydown', handleEscape);
102
- };
103
- }
104
- }, [isOpen, onClose]);
105
-
106
99
  const handleFileSelect = (file: FileData) => {
107
100
  onFileSelect?.(file);
108
101
  onClose();
@@ -166,7 +159,14 @@ export const FilesModal = ({ isOpen, onClose, onFileSelect, currentCase, files,
166
159
  if (!isOpen) return null;
167
160
 
168
161
  return (
169
- <div className={styles.modalOverlay}>
162
+ <div
163
+ className={styles.modalOverlay}
164
+ onMouseDown={handleOverlayMouseDown}
165
+ onKeyDown={handleOverlayKeyDown}
166
+ role="button"
167
+ tabIndex={0}
168
+ aria-label="Close files dialog"
169
+ >
170
170
  <div className={styles.modal}>
171
171
  <div className={styles.modalHeader}>
172
172
  <h2>Files in Case {currentCase}</h2>
@@ -1,4 +1,5 @@
1
- import { useState, useEffect } from 'react';
1
+ import { useState } from 'react';
2
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
2
3
  import styles from './notes.module.css';
3
4
 
4
5
  interface NotesModalProps {
@@ -10,19 +11,13 @@ interface NotesModalProps {
10
11
 
11
12
  export const NotesModal = ({ isOpen, onClose, notes, onSave }: NotesModalProps) => {
12
13
  const [tempNotes, setTempNotes] = useState(notes);
13
-
14
- useEffect(() => {
15
- const handleEscape = (e: KeyboardEvent) => {
16
- if (e.key === 'Escape') {
17
- onClose();
18
- }
19
- };
20
-
21
- if (isOpen) {
22
- document.addEventListener('keydown', handleEscape);
23
- return () => document.removeEventListener('keydown', handleEscape);
24
- }
25
- }, [isOpen, onClose]);
14
+ const {
15
+ handleOverlayMouseDown,
16
+ handleOverlayKeyDown
17
+ } = useOverlayDismiss({
18
+ isOpen,
19
+ onClose
20
+ });
26
21
 
27
22
  if (!isOpen) return null;
28
23
 
@@ -32,7 +27,14 @@ export const NotesModal = ({ isOpen, onClose, notes, onSave }: NotesModalProps)
32
27
  };
33
28
 
34
29
  return (
35
- <div className={styles.modalOverlay}>
30
+ <div
31
+ className={styles.modalOverlay}
32
+ onMouseDown={handleOverlayMouseDown}
33
+ onKeyDown={handleOverlayKeyDown}
34
+ role="button"
35
+ tabIndex={0}
36
+ aria-label="Close notes dialog"
37
+ >
36
38
  <div className={styles.modal}>
37
39
  <h5 className={styles.modalTitle}>Additional Notes</h5>
38
40
  <textarea
@@ -262,6 +262,7 @@ textarea:focus {
262
262
  justify-content: center;
263
263
  align-items: center;
264
264
  z-index: 1000;
265
+ cursor: default;
265
266
  }
266
267
 
267
268
  .modal {
@@ -271,6 +272,7 @@ textarea:focus {
271
272
  width: 90%;
272
273
  max-width: 500px;
273
274
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
275
+ cursor: default;
274
276
  }
275
277
 
276
278
  .modalTitle {
@@ -120,6 +120,7 @@
120
120
  justify-content: center;
121
121
  align-items: center;
122
122
  z-index: 1000;
123
+ cursor: default;
123
124
  }
124
125
 
125
126
  .footerModal {
@@ -131,6 +132,7 @@
131
132
  max-height: 80vh;
132
133
  overflow-y: auto;
133
134
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
135
+ cursor: default;
134
136
  }
135
137
 
136
138
  .footerModalHeader {
@@ -237,8 +239,6 @@
237
239
  text-underline-offset: 3px;
238
240
  }
239
241
 
240
-
241
-
242
242
  /* Import Section */
243
243
  .importSection {
244
244
  margin-top: auto;
@@ -6,7 +6,7 @@
6
6
  bottom: 0;
7
7
  background: color-mix(in lab, var(--black) 40%, transparent);
8
8
  z-index: 999;
9
- cursor: pointer;
9
+ cursor: default;
10
10
  }
11
11
 
12
12
  .toast {
@@ -28,6 +28,7 @@
28
28
  max-width: 400px;
29
29
  min-width: 300px;
30
30
  backdrop-filter: blur(10px);
31
+ cursor: default;
31
32
  }
32
33
 
33
34
  .toast.show {
@@ -1,4 +1,5 @@
1
1
  import { useEffect, type ReactNode } from 'react';
2
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
2
3
  import styles from './toast.module.css';
3
4
 
4
5
  interface ToastProps {
@@ -10,30 +11,34 @@ interface ToastProps {
10
11
  }
11
12
 
12
13
  export const Toast = ({ message, type, isVisible, onClose, duration = 4000 }: ToastProps) => {
14
+ const {
15
+ requestClose,
16
+ handleOverlayMouseDown,
17
+ handleOverlayKeyDown
18
+ } = useOverlayDismiss({
19
+ isOpen: isVisible,
20
+ onClose,
21
+ closeOnEscape: false
22
+ });
23
+
13
24
  useEffect(() => {
14
25
  if (isVisible && duration > 0) {
15
26
  const timer = setTimeout(() => {
16
- onClose();
27
+ requestClose();
17
28
  }, duration);
18
29
 
19
30
  return () => clearTimeout(timer);
20
31
  }
21
- }, [isVisible, onClose, duration]);
32
+ }, [isVisible, requestClose, duration]);
22
33
 
23
34
  if (!isVisible) return null;
24
35
 
25
- const handleBackdropKeyDown = (e: React.KeyboardEvent) => {
26
- if (e.key === 'Enter' || e.key === ' ') {
27
- onClose();
28
- }
29
- };
30
-
31
36
  return (
32
37
  <>
33
38
  <div
34
39
  className={styles.backdrop}
35
- onClick={onClose}
36
- onKeyDown={handleBackdropKeyDown}
40
+ onMouseDown={handleOverlayMouseDown}
41
+ onKeyDown={handleOverlayKeyDown}
37
42
  role="button"
38
43
  tabIndex={0}
39
44
  aria-label="Close notification"
@@ -45,7 +50,7 @@ export const Toast = ({ message, type, isVisible, onClose, duration = 4000 }: To
45
50
  <span className={styles.message}>{message}</span>
46
51
  <button
47
52
  className={styles.closeButton}
48
- onClick={onClose}
53
+ onClick={requestClose}
49
54
  aria-label="Close notification"
50
55
  >
51
56
  ×
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
2
2
  import { signOut } from 'firebase/auth';
3
3
  import { auth } from '~/services/firebase';
4
4
  import { fetchUserApi } from '~/utils/api';
5
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
5
6
  import { auditService } from '~/services/audit';
6
7
  import styles from './delete-account.module.css';
7
8
 
@@ -37,6 +38,13 @@ export const DeleteAccount = ({ isOpen, onClose, user, company }: DeleteAccountP
37
38
  const [error, setError] = useState('');
38
39
  const [success, setSuccess] = useState(false);
39
40
  const [deletionProgress, setDeletionProgress] = useState<DeletionProgress>(initialDeletionProgress);
41
+ const {
42
+ handleOverlayMouseDown,
43
+ handleOverlayKeyDown
44
+ } = useOverlayDismiss({
45
+ isOpen,
46
+ onClose
47
+ });
40
48
 
41
49
  // Extract first and last name from display name
42
50
  const [firstName, lastName] = (user.displayName || '').split(' ');
@@ -170,26 +178,14 @@ export const DeleteAccount = ({ isOpen, onClose, user, company }: DeleteAccountP
170
178
  };
171
179
 
172
180
  useEffect(() => {
173
- const handleEscape = (e: KeyboardEvent) => {
174
- if (e.key === 'Escape' && isOpen) {
175
- onClose();
176
- }
177
- };
178
-
179
181
  if (isOpen) {
180
- document.addEventListener('keydown', handleEscape);
181
- // Reset form when modal opens
182
182
  setUidConfirmation('');
183
183
  setEmailConfirmation('');
184
184
  setError('');
185
185
  setSuccess(false);
186
186
  setDeletionProgress(initialDeletionProgress);
187
187
  }
188
-
189
- return () => {
190
- document.removeEventListener('keydown', handleEscape);
191
- };
192
- }, [isOpen, onClose]);
188
+ }, [isOpen]);
193
189
 
194
190
  const handleDeleteAccount = async () => {
195
191
  if (!isConfirmationValid) return;
@@ -317,27 +313,10 @@ export const DeleteAccount = ({ isOpen, onClose, user, company }: DeleteAccountP
317
313
  ? `Deleting case ${deletionProgress.currentCaseNumber}...`
318
314
  : 'Preparing account deletion...');
319
315
 
320
- const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
321
- if (event.target === event.currentTarget) {
322
- onClose();
323
- }
324
- };
325
-
326
- const handleOverlayKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
327
- if (event.target !== event.currentTarget) {
328
- return;
329
- }
330
-
331
- if (event.key === 'Enter' || event.key === ' ') {
332
- event.preventDefault();
333
- onClose();
334
- }
335
- };
336
-
337
316
  return (
338
317
  <div
339
318
  className={styles.modalOverlay}
340
- onClick={handleOverlayClick}
319
+ onMouseDown={handleOverlayMouseDown}
341
320
  onKeyDown={handleOverlayKeyDown}
342
321
  role="button"
343
322
  tabIndex={0}
@@ -10,6 +10,7 @@
10
10
  align-items: center;
11
11
  z-index: 9999;
12
12
  backdrop-filter: blur(2px);
13
+ cursor: default;
13
14
  }
14
15
 
15
16
  .modal {
@@ -21,6 +22,7 @@
21
22
  box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
22
23
  border: 1px solid #e0e0e0;
23
24
  animation: slideIn 0.3s ease-out;
25
+ cursor: default;
24
26
  }
25
27
 
26
28
  @keyframes slideIn {
@@ -62,7 +64,7 @@
62
64
  font-size: 2rem;
63
65
  font-weight: bold;
64
66
  color: #dc3545;
65
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
67
+ font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
66
68
  background: rgba(220, 53, 69, 0.1);
67
69
  padding: 1rem;
68
70
  border-radius: 8px;
@@ -118,25 +120,25 @@
118
120
  .overlay {
119
121
  background-color: rgba(0, 0, 0, 0.9);
120
122
  }
121
-
123
+
122
124
  .modal {
123
125
  background: #2d2d2d;
124
126
  border-color: #404040;
125
127
  }
126
-
128
+
127
129
  .header h3 {
128
130
  color: #ffffff;
129
131
  }
130
-
132
+
131
133
  .content p {
132
134
  color: #cccccc;
133
135
  }
134
-
136
+
135
137
  .signOutButton {
136
138
  color: #adb5bd;
137
139
  border-color: #6c757d;
138
140
  }
139
-
141
+
140
142
  .signOutButton:hover {
141
143
  background: #404040;
142
144
  color: #ffffff;
@@ -7,6 +7,7 @@
7
7
  justify-content: center;
8
8
  align-items: center;
9
9
  z-index: var(--zIndex5);
10
+ cursor: default;
10
11
  transition: background-color var(--durationM) var(--bezierFastoutSlowin);
11
12
  }
12
13
 
@@ -22,6 +23,7 @@
22
23
  flex-direction: column;
23
24
  transition: background-color var(--durationM) var(--bezierFastoutSlowin);
24
25
  overflow: hidden;
26
+ cursor: default;
25
27
  }
26
28
 
27
29
  /* Modal Header */
@@ -4,6 +4,7 @@ import { PasswordReset } from '~/routes/auth/passwordReset';
4
4
  import { DeleteAccount } from './delete-account';
5
5
  import { UserAuditViewer } from '../audit/user-audit-viewer';
6
6
  import { AuthContext } from '~/contexts/auth.context';
7
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
7
8
  import { getUserData, updateUserData } from '~/utils/data';
8
9
  import { auditService } from '~/services/audit';
9
10
  import { handleAuthError, ERROR_MESSAGES } from '~/services/firebase/errors';
@@ -19,6 +20,8 @@ interface ManageProfileProps {
19
20
  export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
20
21
  const { user } = useContext(AuthContext);
21
22
  const [displayName, setDisplayName] = useState(user?.displayName || '');
23
+ const [badgeId, setBadgeId] = useState('');
24
+ const [initialBadgeId, setInitialBadgeId] = useState('');
22
25
  const [company, setCompany] = useState('');
23
26
  const [email, setEmail] = useState('');
24
27
  const [isLoading, setIsLoading] = useState(false);
@@ -29,19 +32,20 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
29
32
  const [showDeleteModal, setShowDeleteModal] = useState(false);
30
33
  const [showAuditViewer, setShowAuditViewer] = useState(false);
31
34
  const isCloseBlocked = isMfaBusy || isLoading;
35
+ const {
36
+ requestClose,
37
+ handleOverlayMouseDown,
38
+ handleOverlayKeyDown
39
+ } = useOverlayDismiss({
40
+ isOpen,
41
+ onClose,
42
+ canDismiss: !isCloseBlocked
43
+ });
32
44
 
33
45
  const handleMfaBusyChange = useCallback((isBusy: boolean) => {
34
46
  setIsMfaBusy(isBusy);
35
47
  }, []);
36
48
 
37
- const handleCloseRequest = () => {
38
- if (isCloseBlocked) {
39
- return;
40
- }
41
-
42
- onClose();
43
- };
44
-
45
49
  useEffect(() => {
46
50
  if (isOpen && user) {
47
51
  const loadUserData = async () => {
@@ -51,6 +55,18 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
51
55
  if (userData) {
52
56
  setCompany(userData.company || '');
53
57
  setEmail(userData.email || '');
58
+ const storedBadgeId = userData.badgeId || '';
59
+ setBadgeId(storedBadgeId);
60
+ setInitialBadgeId(storedBadgeId);
61
+
62
+ if (userData.badgeId === undefined) {
63
+ try {
64
+ await updateUserData(user, { badgeId: '' });
65
+ setInitialBadgeId('');
66
+ } catch (badgeInitError) {
67
+ console.error('Failed to initialize badge ID field:', badgeInitError);
68
+ }
69
+ }
54
70
  }
55
71
  } catch (err) {
56
72
  console.error('Failed to load user data:', err);
@@ -61,22 +77,6 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
61
77
  }
62
78
  }, [isOpen, user]);
63
79
 
64
- useEffect(() => {
65
- const handleEscape = (e: KeyboardEvent) => {
66
- if (e.key === 'Escape' && isOpen && !isCloseBlocked) {
67
- onClose();
68
- }
69
- };
70
-
71
- if (isOpen) {
72
- document.addEventListener('keydown', handleEscape);
73
- }
74
-
75
- return () => {
76
- document.removeEventListener('keydown', handleEscape);
77
- };
78
- }, [isOpen, isCloseBlocked, onClose]);
79
-
80
80
  const handleUpdateProfile = async (e: React.FormEvent) => {
81
81
  e.preventDefault();
82
82
  setIsLoading(true);
@@ -84,6 +84,8 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
84
84
  setSuccess('');
85
85
 
86
86
  const oldDisplayName = user?.displayName || '';
87
+ const oldBadgeId = initialBadgeId;
88
+ const normalizedBadgeId = badgeId.trim();
87
89
 
88
90
  try {
89
91
  if (!user) throw new Error(ERROR_MESSAGES.NO_USER);
@@ -98,6 +100,7 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
98
100
  email: user.email,
99
101
  firstName: firstName || '',
100
102
  lastName: lastName || '',
103
+ badgeId: normalizedBadgeId,
101
104
  });
102
105
 
103
106
  await auditService.logUserProfileUpdate(
@@ -105,9 +108,27 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
105
108
  'displayName',
106
109
  oldDisplayName,
107
110
  displayName,
108
- 'success'
111
+ 'success',
112
+ undefined,
113
+ [],
114
+ normalizedBadgeId
109
115
  );
110
116
 
117
+ if (oldBadgeId !== normalizedBadgeId) {
118
+ await auditService.logUserProfileUpdate(
119
+ user,
120
+ 'badgeId',
121
+ oldBadgeId,
122
+ normalizedBadgeId,
123
+ 'success',
124
+ undefined,
125
+ [],
126
+ normalizedBadgeId
127
+ );
128
+ }
129
+
130
+ setInitialBadgeId(normalizedBadgeId);
131
+
111
132
  setSuccess(ERROR_MESSAGES.PROFILE_UPDATED);
112
133
  } catch (err) {
113
134
  const { message } = handleAuthError(err);
@@ -119,9 +140,23 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
119
140
  displayName,
120
141
  'failure',
121
142
  undefined,
122
- [message]
143
+ [message],
144
+ normalizedBadgeId
123
145
  );
124
146
 
147
+ if (oldBadgeId !== normalizedBadgeId) {
148
+ await auditService.logUserProfileUpdate(
149
+ user!,
150
+ 'badgeId',
151
+ oldBadgeId,
152
+ normalizedBadgeId,
153
+ 'failure',
154
+ undefined,
155
+ [message],
156
+ normalizedBadgeId
157
+ );
158
+ }
159
+
125
160
  setError(message);
126
161
  } finally {
127
162
  setIsLoading(false);
@@ -158,19 +193,24 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
158
193
  }
159
194
 
160
195
  return (
161
- <div className={styles.modalOverlay} onClick={handleCloseRequest} role="presentation">
162
- {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */}
196
+ <div
197
+ className={styles.modalOverlay}
198
+ onMouseDown={handleOverlayMouseDown}
199
+ onKeyDown={handleOverlayKeyDown}
200
+ role="button"
201
+ tabIndex={0}
202
+ aria-label="Close manage profile dialog"
203
+ >
163
204
  <div
164
205
  className={styles.modal}
165
206
  role="dialog"
166
207
  aria-modal="true"
167
208
  aria-labelledby="modal-title"
168
- onClick={(e) => e.stopPropagation()}
169
209
  >
170
210
  <header className={styles.modalHeader}>
171
211
  <h1 id="modal-title">Manage Profile</h1>
172
212
  <button
173
- onClick={handleCloseRequest}
213
+ onClick={requestClose}
174
214
  className={styles.closeButton}
175
215
  aria-label="Close modal"
176
216
  disabled={isCloseBlocked}
@@ -192,6 +232,21 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
192
232
  required
193
233
  />
194
234
 
235
+ <div className={styles.formGroup}>
236
+ <label htmlFor="badgeId">Badge/ID #</label>
237
+ <input
238
+ id="badgeId"
239
+ type="text"
240
+ value={badgeId}
241
+ onChange={(e) => setBadgeId(e.target.value)}
242
+ className={styles.input}
243
+ autoComplete="off"
244
+ />
245
+ <p className={styles.helpText}>
246
+ Enter your Badge/ID number for confirmations and reports. This can be updated as needed.
247
+ </p>
248
+ </div>
249
+
195
250
  <div className={styles.formGroup}>
196
251
  <label htmlFor="company">Lab/Company Name</label>
197
252
  <input
@@ -0,0 +1,68 @@
1
+ import { useCallback, useEffect, type KeyboardEventHandler, type MouseEventHandler } from 'react';
2
+
3
+ interface UseOverlayDismissOptions {
4
+ isOpen: boolean;
5
+ onClose: () => void;
6
+ canDismiss?: boolean;
7
+ closeOnEscape?: boolean;
8
+ closeOnBackdrop?: boolean;
9
+ }
10
+
11
+ export const useOverlayDismiss = ({
12
+ isOpen,
13
+ onClose,
14
+ canDismiss = true,
15
+ closeOnEscape = true,
16
+ closeOnBackdrop = true
17
+ }: UseOverlayDismissOptions) => {
18
+ const requestClose = useCallback(() => {
19
+ if (!canDismiss) {
20
+ return;
21
+ }
22
+
23
+ onClose();
24
+ }, [canDismiss, onClose]);
25
+
26
+ useEffect(() => {
27
+ if (!isOpen || !closeOnEscape || !canDismiss) {
28
+ return;
29
+ }
30
+
31
+ const handleEscape = (event: KeyboardEvent) => {
32
+ if (event.key === 'Escape') {
33
+ onClose();
34
+ }
35
+ };
36
+
37
+ document.addEventListener('keydown', handleEscape);
38
+
39
+ return () => {
40
+ document.removeEventListener('keydown', handleEscape);
41
+ };
42
+ }, [isOpen, closeOnEscape, canDismiss, onClose]);
43
+
44
+ const handleOverlayMouseDown = useCallback<MouseEventHandler<HTMLDivElement>>((event) => {
45
+ if (!closeOnBackdrop || event.target !== event.currentTarget) {
46
+ return;
47
+ }
48
+
49
+ requestClose();
50
+ }, [closeOnBackdrop, requestClose]);
51
+
52
+ const handleOverlayKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>((event) => {
53
+ if (!closeOnBackdrop || event.target !== event.currentTarget) {
54
+ return;
55
+ }
56
+
57
+ if (event.key === 'Enter' || event.key === ' ') {
58
+ event.preventDefault();
59
+ requestClose();
60
+ }
61
+ }, [closeOnBackdrop, requestClose]);
62
+
63
+ return {
64
+ requestClose,
65
+ handleOverlayMouseDown,
66
+ handleOverlayKeyDown
67
+ };
68
+ };