@striae-org/striae 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.env.example +8 -0
  2. package/app/components/actions/case-export/core-export.ts +14 -8
  3. package/app/components/actions/case-export/data-processing.ts +1 -0
  4. package/app/components/actions/case-export/download-handlers.ts +7 -0
  5. package/app/components/actions/case-export/metadata-helpers.ts +2 -1
  6. package/app/components/actions/case-import/confirmation-import.ts +12 -2
  7. package/app/components/actions/case-import/orchestrator.ts +78 -32
  8. package/app/components/actions/case-import/storage-operations.ts +97 -8
  9. package/app/components/actions/case-import/zip-processing.ts +159 -86
  10. package/app/components/actions/case-manage.ts +430 -8
  11. package/app/components/actions/confirm-export.ts +9 -2
  12. package/app/components/actions/image-manage.ts +77 -44
  13. package/app/components/audit/user-audit-viewer.tsx +19 -8
  14. package/app/components/audit/user-audit.module.css +21 -0
  15. package/app/components/audit/viewer/audit-entries-list.tsx +7 -0
  16. package/app/components/audit/viewer/audit-filters-panel.tsx +1 -0
  17. package/app/components/audit/viewer/audit-viewer-utils.ts +2 -0
  18. package/app/components/audit/viewer/use-audit-viewer-data.ts +21 -1
  19. package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
  20. package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
  21. package/app/components/canvas/canvas.module.css +64 -54
  22. package/app/components/canvas/canvas.tsx +14 -16
  23. package/app/components/canvas/confirmation/confirmation.module.css +1 -0
  24. package/app/components/canvas/confirmation/confirmation.tsx +6 -12
  25. package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
  26. package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
  27. package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
  28. package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
  29. package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
  30. package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
  31. package/app/components/navbar/navbar.module.css +447 -0
  32. package/app/components/navbar/navbar.tsx +377 -0
  33. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +1 -0
  34. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +15 -16
  35. package/app/components/sidebar/case-export/case-export.module.css +1 -0
  36. package/app/components/sidebar/case-export/case-export.tsx +8 -46
  37. package/app/components/sidebar/case-import/case-import.module.css +23 -0
  38. package/app/components/sidebar/case-import/case-import.tsx +64 -16
  39. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
  40. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
  41. package/app/components/sidebar/cases/case-sidebar.tsx +25 -519
  42. package/app/components/sidebar/cases/cases-modal.module.css +1 -0
  43. package/app/components/sidebar/cases/cases-modal.tsx +6 -8
  44. package/app/components/sidebar/cases/cases.module.css +62 -21
  45. package/app/components/sidebar/files/files-modal.module.css +1 -0
  46. package/app/components/sidebar/files/files-modal.tsx +12 -13
  47. package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
  48. package/app/components/sidebar/notes/notes-editor-modal.tsx +66 -0
  49. package/app/components/sidebar/notes/notes-modal.tsx +7 -8
  50. package/app/components/sidebar/notes/notes-sidebar.tsx +199 -113
  51. package/app/components/sidebar/notes/notes.module.css +153 -0
  52. package/app/components/sidebar/sidebar-container.tsx +15 -28
  53. package/app/components/sidebar/sidebar.module.css +5 -69
  54. package/app/components/sidebar/sidebar.tsx +24 -125
  55. package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
  56. package/app/components/user/inactivity-warning.module.css +1 -0
  57. package/app/components/user/inactivity-warning.tsx +15 -2
  58. package/app/components/user/manage-profile.tsx +23 -10
  59. package/app/hooks/useOverlayDismiss.ts +52 -4
  60. package/app/routes/auth/login.tsx +785 -774
  61. package/app/routes/striae/striae.module.css +10 -3
  62. package/app/routes/striae/striae.tsx +469 -30
  63. package/app/services/audit/audit.service.ts +173 -27
  64. package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
  65. package/app/services/audit/builders/audit-event-builders-workflow.ts +2 -0
  66. package/app/services/audit/builders/index.ts +1 -0
  67. package/app/types/audit.ts +3 -1
  68. package/app/types/case.ts +29 -0
  69. package/app/types/import.ts +3 -0
  70. package/app/utils/data/permissions.ts +16 -1
  71. package/app/utils/forensics/audit-export-signature.ts +5 -1
  72. package/app/utils/forensics/confirmation-signature.ts +3 -0
  73. package/app/utils/forensics/export-verification.ts +497 -22
  74. package/package.json +3 -3
  75. package/scripts/deploy-primershear-emails.sh +2 -1
  76. package/worker-configuration.d.ts +1 -1
  77. package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
  78. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  79. package/workers/data-worker/worker-configuration.d.ts +7448 -11323
  80. package/workers/data-worker/wrangler.jsonc.example +1 -1
  81. package/workers/image-worker/worker-configuration.d.ts +7447 -11322
  82. package/workers/image-worker/wrangler.jsonc.example +1 -1
  83. package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
  84. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  85. package/workers/pdf-worker/src/formats/format-striae.ts +8 -7
  86. package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
  87. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  88. package/workers/user-worker/worker-configuration.d.ts +7448 -11323
  89. package/workers/user-worker/wrangler.jsonc.example +1 -1
  90. package/wrangler.toml.example +1 -1
  91. package/public/.well-known/keybase.txt +0 -56
@@ -3,54 +3,17 @@
3
3
  position: relative;
4
4
  width: 300px;
5
5
  min-width: 250px;
6
- height: calc(100vh - 60px);
6
+ height: calc(100% - 60px);
7
+ display: flex;
8
+ flex-direction: column;
7
9
  background-color: #f8f9fa;
8
10
  border-right: 1px solid #dee2e6;
9
- padding: 1.5rem;
10
- overflow-y: auto;
11
+ padding: 0.25rem 1rem 0.875rem;
12
+ overflow: hidden;
11
13
  flex-shrink: 0;
12
14
  box-sizing: border-box;
13
15
  }
14
16
 
15
- /* User Section */
16
- .userInfo {
17
- display: flex;
18
- flex-direction: column;
19
- align-items: flex-start;
20
- gap: 1rem;
21
- padding-bottom: 1rem;
22
- border-bottom: 2px solid #e9ecef;
23
- margin-bottom: 1rem;
24
- }
25
-
26
- .userTitle {
27
- font-size: 1.2rem;
28
- font-weight: 600;
29
- color: #212529;
30
- margin: 0;
31
- }
32
-
33
- .userActions {
34
- display: flex;
35
- gap: 0.5rem;
36
- width: 100%;
37
- }
38
-
39
- .profileButton {
40
- padding: 0.5rem 1rem;
41
- background-color: #6c757d;
42
- color: white;
43
- border: none;
44
- border-radius: 6px;
45
- font-size: 0.875rem;
46
- cursor: pointer;
47
- transition: all 0.2s;
48
- }
49
-
50
- .profileButton:hover {
51
- background-color: #5c636a;
52
- }
53
-
54
17
  /* Footer Button */
55
18
  .footerButton {
56
19
  position: fixed;
@@ -239,33 +202,6 @@
239
202
  text-underline-offset: 3px;
240
203
  }
241
204
 
242
- /* Import Section */
243
- .importSection {
244
- margin-top: auto;
245
- padding: 1rem 0;
246
- border-top: 1px solid var(--divider);
247
- }
248
-
249
- .importButton {
250
- width: 100%;
251
- padding: 0.75rem 1rem;
252
- background: var(--success);
253
- color: white;
254
- border: none;
255
- border-radius: var(--spaceXS);
256
- font-size: var(--fontSizeBodyS);
257
- font-weight: var(--fontWeightMedium);
258
- cursor: pointer;
259
- transition: all var(--durationS) var(--bezierFastoutSlowin);
260
- box-shadow: 0 1px 3px color-mix(in lab, var(--success) 30%, transparent);
261
- box-sizing: border-box;
262
- }
263
-
264
- .importButton:hover {
265
- background: color-mix(in lab, var(--success) 85%, var(--black));
266
- box-shadow: 0 2px 6px color-mix(in lab, var(--success) 40%, transparent);
267
- }
268
-
269
205
  /* Hash Button */
270
206
  .hashButton {
271
207
  width: 100%;
@@ -1,31 +1,19 @@
1
1
  import type { User } from 'firebase/auth';
2
2
  import { useState, useCallback } from 'react';
3
3
  import styles from './sidebar.module.css';
4
- import { ManageProfile } from '../user/manage-profile';
5
- import { SignOut } from '../actions/signout';
6
4
  import { CaseSidebar } from './cases/case-sidebar';
7
- import { NotesSidebar } from './notes/notes-sidebar';
8
- import { CaseImport } from './case-import/case-import';
9
5
  import { Toast } from '../toast/toast';
10
- import { type FileData, type ImportResult, type ConfirmationImportResult } from '~/types';
6
+ import { type FileData } from '~/types';
11
7
 
12
8
  interface SidebarProps {
13
9
  user: User;
14
10
  onImageSelect: (file: FileData) => void;
15
11
  imageId?: string;
16
- onCaseChange: (caseNumber: string) => void;
17
12
  currentCase: string;
18
- setCurrentCase: (caseNumber: string) => void;
19
13
  files: FileData[];
20
14
  setFiles: React.Dispatch<React.SetStateAction<FileData[]>>;
21
15
  imageLoaded: boolean;
22
16
  setImageLoaded: (loaded: boolean) => void;
23
- caseNumber: string;
24
- setCaseNumber: (caseNumber: string) => void;
25
- error: string;
26
- setError: (error: string) => void;
27
- successAction: 'loaded' | 'created' | 'deleted' | null;
28
- setSuccessAction: (action: 'loaded' | 'created' | 'deleted' | null) => void;
29
17
  showNotes: boolean;
30
18
  setShowNotes: (show: boolean) => void;
31
19
  onAnnotationRefresh?: () => void;
@@ -33,66 +21,34 @@ interface SidebarProps {
33
21
  isConfirmed?: boolean;
34
22
  confirmationSaveVersion?: number;
35
23
  isUploading?: boolean;
24
+ onUploadStatusChange?: (isUploading: boolean) => void;
36
25
  }
37
26
 
38
27
  export const Sidebar = ({
39
28
  user,
40
29
  onImageSelect,
41
30
  imageId,
42
- onCaseChange,
43
31
  currentCase,
44
- setCurrentCase,
45
32
  imageLoaded,
46
33
  setImageLoaded,
47
34
  files,
48
35
  setFiles,
49
- caseNumber,
50
- setCaseNumber,
51
- error,
52
- setError,
53
- successAction,
54
- setSuccessAction,
55
- showNotes,
56
36
  setShowNotes,
57
- onAnnotationRefresh,
58
37
  isReadOnly = false,
59
38
  isConfirmed = false,
60
39
  confirmationSaveVersion = 0,
61
- isUploading: initialIsUploading = false,
40
+ isUploading: initialIsUploading = false,
41
+ onUploadStatusChange,
62
42
  }: SidebarProps) => {
63
- const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
64
- const [isImportModalOpen, setIsImportModalOpen] = useState(false);
65
43
  const [isUploading, setIsUploading] = useState(initialIsUploading);
66
44
  const [toastMessage, setToastMessage] = useState('');
67
45
  const [toastType, setToastType] = useState<'success' | 'error' | 'warning'>('success');
68
46
  const [isToastVisible, setIsToastVisible] = useState(false);
69
47
 
70
- const handleImportComplete = useCallback((result: ImportResult | ConfirmationImportResult) => {
71
- if (result.success) {
72
- // For case imports, load the imported case automatically
73
- if ('isReadOnly' in result) {
74
- // This is an ImportResult (case import)
75
- if (result.caseNumber && result.isReadOnly) {
76
- // Successful read-only case import - load the case
77
- onCaseChange(result.caseNumber);
78
- setCurrentCase(result.caseNumber);
79
- setCaseNumber(result.caseNumber);
80
- setSuccessAction('loaded');
81
- } else if (!result.caseNumber && !result.isReadOnly) {
82
- // Read-only case cleared - reset all UI state
83
- setCurrentCase('');
84
- setCaseNumber('');
85
- setFiles([]);
86
- onImageSelect({ id: 'clear', originalFilename: '/clear.jpg', uploadedAt: '' });
87
- setImageLoaded(false);
88
- onCaseChange(''); // This will trigger canvas/annotation state reset in main component
89
- setShowNotes(false); // Close notes sidebar
90
- setSuccessAction(null);
91
- }
92
- }
93
- // For confirmation imports, no action needed - the confirmations are already loaded
94
- }
95
- }, [onCaseChange, setCurrentCase, setCaseNumber, setSuccessAction, setFiles, onImageSelect, setImageLoaded, setShowNotes]);
48
+ const handleUploadStatusChange = useCallback((uploading: boolean) => {
49
+ setIsUploading(uploading);
50
+ onUploadStatusChange?.(uploading);
51
+ }, [onUploadStatusChange]);
96
52
 
97
53
  const handleUploadComplete = useCallback((result: { successCount: number; failedFiles: string[] }) => {
98
54
  if (result.successCount === 0 && result.failedFiles.length > 0) {
@@ -115,80 +71,23 @@ export const Sidebar = ({
115
71
 
116
72
  return (
117
73
  <div className={styles.sidebar}>
118
- <div className={styles.userInfo}>
119
- <h3 className={styles.userTitle}>
120
- {`${user.displayName?.split(' ')[0] || 'User'}'s Striae`}
121
- </h3>
122
- <div className={styles.userActions}>
123
- <button
124
- onClick={() => setIsProfileModalOpen(true)}
125
- className={styles.profileButton}
126
- disabled={isUploading}
127
- title={isUploading ? 'Cannot manage profile while uploading files' : undefined}
128
- >
129
- Manage Profile
130
- </button>
131
- <SignOut disabled={isUploading} />
132
- </div>
133
- </div>
134
- <ManageProfile
135
- isOpen={isProfileModalOpen}
136
- onClose={() => setIsProfileModalOpen(false)}
137
- />
138
- <CaseImport
139
- isOpen={isImportModalOpen}
140
- onClose={() => setIsImportModalOpen(false)}
141
- onImportComplete={handleImportComplete}
74
+ <CaseSidebar
75
+ user={user}
76
+ onImageSelect={onImageSelect}
77
+ currentCase={currentCase}
78
+ imageLoaded={imageLoaded}
79
+ setImageLoaded={setImageLoaded}
80
+ files={files}
81
+ setFiles={setFiles}
82
+ onNotesClick={() => setShowNotes(true)}
83
+ isReadOnly={isReadOnly}
84
+ isConfirmed={isConfirmed}
85
+ confirmationSaveVersion={confirmationSaveVersion}
86
+ selectedFileId={imageId}
87
+ isUploading={isUploading}
88
+ onUploadStatusChange={handleUploadStatusChange}
89
+ onUploadComplete={handleUploadComplete}
142
90
  />
143
- {showNotes ? (
144
- <NotesSidebar
145
- currentCase={currentCase}
146
- onReturn={() => setShowNotes(false)}
147
- user={user}
148
- imageId={imageId || ''}
149
- onAnnotationRefresh={onAnnotationRefresh}
150
- originalFileName={files.find(file => file.id === imageId)?.originalFilename}
151
- isUploading={isUploading}
152
- />
153
- ) : (
154
- <>
155
- <CaseSidebar
156
- user={user}
157
- onImageSelect={onImageSelect}
158
- onCaseChange={onCaseChange}
159
- currentCase={currentCase}
160
- setCurrentCase={setCurrentCase}
161
- imageLoaded={imageLoaded}
162
- setImageLoaded={setImageLoaded}
163
- files={files}
164
- setFiles={setFiles}
165
- caseNumber={caseNumber}
166
- setCaseNumber={setCaseNumber}
167
- error={error}
168
- setError={setError}
169
- successAction={successAction}
170
- setSuccessAction={setSuccessAction}
171
- onNotesClick={() => setShowNotes(true)}
172
- isReadOnly={isReadOnly}
173
- isConfirmed={isConfirmed}
174
- confirmationSaveVersion={confirmationSaveVersion}
175
- selectedFileId={imageId}
176
- isUploading={isUploading}
177
- onUploadStatusChange={setIsUploading}
178
- onUploadComplete={handleUploadComplete}
179
- />
180
- <div className={styles.importSection}>
181
- <button
182
- onClick={() => setIsImportModalOpen(true)}
183
- className={styles.importButton}
184
- disabled={isUploading}
185
- title={isUploading ? 'Cannot import while uploading files' : undefined}
186
- >
187
- Import/Clear RO Case
188
- </button>
189
- </div>
190
- </>
191
- )}
192
91
  <Toast
193
92
  message={toastMessage}
194
93
  type={toastType}
@@ -1,24 +1,24 @@
1
1
  /* Image Upload Zone */
2
2
  .imageUploadZone {
3
- margin: 1rem 0;
3
+ margin: 0.25rem 0;
4
4
  display: flex;
5
5
  flex-direction: column;
6
- gap: 0.5rem;
6
+ gap: 0.375rem;
7
7
  }
8
8
 
9
9
  .imageUploadZone label {
10
- font-size: 0.9rem;
10
+ font-size: 0.85rem;
11
11
  font-weight: 500;
12
12
  color: #000000;
13
- margin-bottom: 0.5rem;
13
+ margin-bottom: 0.25rem;
14
14
  }
15
15
 
16
16
  .fileInput {
17
17
  width: 100%;
18
- padding: 0.5rem;
18
+ padding: 0.375rem 0.5rem;
19
19
  border: 1px solid #dee2e6;
20
20
  border-radius: 4px;
21
- font-size: 0.875rem;
21
+ font-size: 0.8125rem;
22
22
  box-sizing: border-box;
23
23
  cursor: pointer;
24
24
  }
@@ -33,18 +33,18 @@
33
33
  display: flex;
34
34
  align-items: center;
35
35
  justify-content: center;
36
- padding: 2rem 1rem;
36
+ padding: 1rem 0.75rem;
37
37
  border: 2px dashed #dee2e6;
38
38
  border-radius: 6px;
39
39
  background-color: #fafafa;
40
40
  transition: all 0.2s ease;
41
41
  pointer-events: none;
42
- min-height: 100px;
42
+ min-height: 72px;
43
43
  }
44
44
 
45
45
  .dragDropText {
46
46
  margin: 0;
47
- font-size: 0.9rem;
47
+ font-size: 0.82rem;
48
48
  color: #6c757d;
49
49
  text-align: center;
50
50
  font-weight: 500;
@@ -68,11 +68,11 @@
68
68
 
69
69
  .progressBar {
70
70
  width: 100%;
71
- height: 6px;
71
+ height: 5px;
72
72
  background-color: #e9ecef;
73
73
  border-radius: 3px;
74
74
  overflow: hidden;
75
- margin-top: 0.5rem;
75
+ margin-top: 0.25rem;
76
76
  }
77
77
 
78
78
  .progressFill {
@@ -82,7 +82,7 @@
82
82
  }
83
83
 
84
84
  .uploadingText {
85
- font-size: 0.875rem;
85
+ font-size: 0.8125rem;
86
86
  color: var(--textBody);
87
87
  font-weight: 500;
88
88
  }
@@ -93,7 +93,7 @@
93
93
  justify-content: center;
94
94
  gap: 0.5rem;
95
95
  width: 100%;
96
- margin-top: 0.25rem;
96
+ margin-top: 0.125rem;
97
97
  }
98
98
 
99
99
  .fileCountText {
@@ -14,6 +14,7 @@
14
14
  }
15
15
 
16
16
  .modal {
17
+ position: relative;
17
18
  background: #ffffff;
18
19
  border-radius: 12px;
19
20
  padding: 2rem;
@@ -1,4 +1,5 @@
1
1
  import { useState, useEffect } from 'react';
2
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
2
3
  import styles from './inactivity-warning.module.css';
3
4
 
4
5
  interface InactivityWarningProps {
@@ -15,6 +16,15 @@ export const InactivityWarning = ({
15
16
  onSignOut
16
17
  }: InactivityWarningProps) => {
17
18
  const [countdown, setCountdown] = useState(remainingSeconds);
19
+ const {
20
+ requestClose,
21
+ overlayProps,
22
+ getCloseButtonProps,
23
+ } = useOverlayDismiss({
24
+ isOpen,
25
+ onClose: onExtendSession,
26
+ closeOnBackdrop: false,
27
+ });
18
28
 
19
29
  useEffect(() => {
20
30
  setCountdown(remainingSeconds);
@@ -46,8 +56,11 @@ export const InactivityWarning = ({
46
56
  const seconds = countdown % 60;
47
57
 
48
58
  return (
49
- <div className={styles.overlay}>
59
+ <div className={styles.overlay} aria-label="Close inactivity warning" {...overlayProps}>
50
60
  <div className={styles.modal}>
61
+ <button className={styles.closeButton} {...getCloseButtonProps({ ariaLabel: 'Close inactivity warning' })}>
62
+ ×
63
+ </button>
51
64
  <div className={styles.header}>
52
65
  <h3>Session Timeout Warning</h3>
53
66
  </div>
@@ -66,7 +79,7 @@ export const InactivityWarning = ({
66
79
 
67
80
  <div className={styles.actions}>
68
81
  <button
69
- onClick={onExtendSession}
82
+ onClick={requestClose}
70
83
  className={styles.extendButton}
71
84
  >
72
85
  Extend Session
@@ -8,7 +8,8 @@ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
8
8
  import { getUserData, updateUserData } from '~/utils/data';
9
9
  import { auditService } from '~/services/audit';
10
10
  import { handleAuthError, ERROR_MESSAGES } from '~/services/firebase/errors';
11
- import { FormField, FormButton, FormMessage } from '../form';
11
+ import { FormField, FormButton } from '../form';
12
+ import { Toast } from '~/components/toast/toast';
12
13
  import { MfaPhoneUpdateSection } from './mfa-phone-update';
13
14
  import styles from './manage-profile.module.css';
14
15
 
@@ -26,8 +27,9 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
26
27
  const [email, setEmail] = useState('');
27
28
  const [isLoading, setIsLoading] = useState(false);
28
29
  const [isMfaBusy, setIsMfaBusy] = useState(false);
29
- const [error, setError] = useState('');
30
- const [success, setSuccess] = useState('');
30
+ const [showToast, setShowToast] = useState(false);
31
+ const [toastMessage, setToastMessage] = useState('');
32
+ const [toastType, setToastType] = useState<'success' | 'error'>('success');
31
33
  const [showResetForm, setShowResetForm] = useState(false);
32
34
  const [showDeleteModal, setShowDeleteModal] = useState(false);
33
35
  const [showAuditViewer, setShowAuditViewer] = useState(false);
@@ -80,8 +82,7 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
80
82
  const handleUpdateProfile = async (e: React.FormEvent) => {
81
83
  e.preventDefault();
82
84
  setIsLoading(true);
83
- setError('');
84
- setSuccess('');
85
+ setShowToast(false);
85
86
 
86
87
  const oldDisplayName = user?.displayName || '';
87
88
  const oldBadgeId = initialBadgeId;
@@ -129,7 +130,12 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
129
130
 
130
131
  setInitialBadgeId(normalizedBadgeId);
131
132
 
132
- setSuccess(ERROR_MESSAGES.PROFILE_UPDATED);
133
+ setToastType('success');
134
+ setToastMessage(ERROR_MESSAGES.PROFILE_UPDATED);
135
+ setShowToast(true);
136
+ setTimeout(() => {
137
+ window.location.reload();
138
+ }, 1500);
133
139
  } catch (err) {
134
140
  const { message } = handleAuthError(err);
135
141
 
@@ -157,7 +163,9 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
157
163
  );
158
164
  }
159
165
 
160
- setError(message);
166
+ setToastType('error');
167
+ setToastMessage(message);
168
+ setShowToast(true);
161
169
  } finally {
162
170
  setIsLoading(false);
163
171
  }
@@ -193,6 +201,13 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
193
201
  }
194
202
 
195
203
  return (
204
+ <>
205
+ <Toast
206
+ message={toastMessage}
207
+ type={toastType}
208
+ isVisible={showToast}
209
+ onClose={() => setShowToast(false)}
210
+ />
196
211
  <div
197
212
  className={styles.modalOverlay}
198
213
  onMouseDown={handleOverlayMouseDown}
@@ -282,9 +297,6 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
282
297
 
283
298
  <MfaPhoneUpdateSection user={user} isOpen={isOpen} onBusyChange={handleMfaBusyChange} />
284
299
 
285
- {error && <FormMessage type="error" message={error} />}
286
- {success && <FormMessage type="success" message={success} />}
287
-
288
300
  <div className={styles.buttonGroup}>
289
301
  <FormButton variant="primary" type="submit" isLoading={isLoading} loadingText="Updating...">
290
302
  Update Profile
@@ -302,5 +314,6 @@ export const ManageProfile = ({ isOpen, onClose }: ManageProfileProps) => {
302
314
  </form>
303
315
  </div>
304
316
  </div>
317
+ </>
305
318
  );
306
319
  };
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, type KeyboardEventHandler, type MouseEventHandler } from 'react';
1
+ import { useCallback, useEffect, type CSSProperties, type KeyboardEventHandler, type MouseEventHandler } from 'react';
2
2
 
3
3
  interface UseOverlayDismissOptions {
4
4
  isOpen: boolean;
@@ -8,6 +8,30 @@ interface UseOverlayDismissOptions {
8
8
  closeOnBackdrop?: boolean;
9
9
  }
10
10
 
11
+ interface CloseButtonOptions {
12
+ ariaLabel?: string;
13
+ title?: string;
14
+ }
15
+
16
+ const sharedCloseButtonStyle: CSSProperties = {
17
+ position: 'absolute',
18
+ top: '0.6rem',
19
+ right: '0.6rem',
20
+ width: '1.9rem',
21
+ height: '1.9rem',
22
+ borderRadius: '999px',
23
+ border: '1px solid #d6dce2',
24
+ background: '#f8f9fa',
25
+ color: '#495057',
26
+ fontSize: '1.2rem',
27
+ lineHeight: 1,
28
+ display: 'inline-flex',
29
+ alignItems: 'center',
30
+ justifyContent: 'center',
31
+ cursor: 'pointer',
32
+ zIndex: 1,
33
+ };
34
+
11
35
  export const useOverlayDismiss = ({
12
36
  isOpen,
13
37
  onClose,
@@ -30,7 +54,8 @@ export const useOverlayDismiss = ({
30
54
 
31
55
  const handleEscape = (event: KeyboardEvent) => {
32
56
  if (event.key === 'Escape') {
33
- onClose();
57
+ event.preventDefault();
58
+ requestClose();
34
59
  }
35
60
  };
36
61
 
@@ -39,7 +64,7 @@ export const useOverlayDismiss = ({
39
64
  return () => {
40
65
  document.removeEventListener('keydown', handleEscape);
41
66
  };
42
- }, [isOpen, closeOnEscape, canDismiss, onClose]);
67
+ }, [isOpen, closeOnEscape, canDismiss, requestClose]);
43
68
 
44
69
  const handleOverlayMouseDown = useCallback<MouseEventHandler<HTMLDivElement>>((event) => {
45
70
  if (!closeOnBackdrop || event.target !== event.currentTarget) {
@@ -60,9 +85,32 @@ export const useOverlayDismiss = ({
60
85
  }
61
86
  }, [closeOnBackdrop, requestClose]);
62
87
 
88
+ const overlayProps = {
89
+ role: 'button' as const,
90
+ tabIndex: 0,
91
+ onMouseDown: handleOverlayMouseDown,
92
+ onKeyDown: handleOverlayKeyDown,
93
+ style: { cursor: 'default' as const },
94
+ };
95
+
96
+ const getCloseButtonProps = useCallback((options?: CloseButtonOptions) => {
97
+ const ariaLabel = options?.ariaLabel || 'Close modal';
98
+
99
+ return {
100
+ type: 'button' as const,
101
+ onClick: requestClose,
102
+ disabled: !canDismiss,
103
+ 'aria-label': ariaLabel,
104
+ title: options?.title || ariaLabel,
105
+ style: sharedCloseButtonStyle,
106
+ };
107
+ }, [requestClose, canDismiss]);
108
+
63
109
  return {
64
110
  requestClose,
65
111
  handleOverlayMouseDown,
66
- handleOverlayKeyDown
112
+ handleOverlayKeyDown,
113
+ overlayProps,
114
+ getCloseButtonProps,
67
115
  };
68
116
  };