@striae-org/striae 4.0.3 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/.env.example +8 -0
  2. package/app/components/actions/case-export/core-export.ts +14 -8
  3. package/app/components/actions/case-export/data-processing.ts +1 -0
  4. package/app/components/actions/case-export/download-handlers.ts +7 -0
  5. package/app/components/actions/case-export/metadata-helpers.ts +2 -1
  6. package/app/components/actions/case-import/confirmation-import.ts +12 -2
  7. package/app/components/actions/case-import/orchestrator.ts +78 -32
  8. package/app/components/actions/case-import/storage-operations.ts +97 -8
  9. package/app/components/actions/case-import/zip-processing.ts +159 -86
  10. package/app/components/actions/case-manage.ts +430 -8
  11. package/app/components/actions/confirm-export.ts +13 -4
  12. package/app/components/actions/generate-pdf.ts +10 -2
  13. package/app/components/actions/image-manage.ts +77 -44
  14. package/app/components/audit/user-audit-viewer.tsx +137 -945
  15. package/app/components/audit/user-audit.module.css +41 -0
  16. package/app/components/audit/viewer/audit-activity-summary.tsx +52 -0
  17. package/app/components/audit/viewer/audit-entries-list.tsx +207 -0
  18. package/app/components/audit/viewer/audit-filters-panel.tsx +307 -0
  19. package/app/components/audit/viewer/audit-user-info-card.tsx +44 -0
  20. package/app/components/audit/viewer/audit-viewer-header.tsx +55 -0
  21. package/app/components/audit/viewer/audit-viewer-utils.ts +123 -0
  22. package/app/components/audit/viewer/types.ts +1 -0
  23. package/app/components/audit/viewer/use-audit-viewer-data.ts +186 -0
  24. package/app/components/audit/viewer/use-audit-viewer-export.ts +176 -0
  25. package/app/components/audit/viewer/use-audit-viewer-filters.ts +141 -0
  26. package/app/components/auth/mfa-enrollment.module.css +13 -5
  27. package/app/components/auth/mfa-verification.module.css +13 -5
  28. package/app/components/canvas/box-annotations/box-annotations.module.css +22 -18
  29. package/app/components/canvas/box-annotations/box-annotations.tsx +15 -0
  30. package/app/components/canvas/canvas.module.css +64 -54
  31. package/app/components/canvas/canvas.tsx +17 -16
  32. package/app/components/canvas/confirmation/confirmation.module.css +1 -0
  33. package/app/components/canvas/confirmation/confirmation.tsx +17 -47
  34. package/app/components/navbar/case-modals/archive-case-modal.module.css +110 -0
  35. package/app/components/navbar/case-modals/archive-case-modal.tsx +129 -0
  36. package/app/components/navbar/case-modals/open-case-modal.module.css +81 -0
  37. package/app/components/navbar/case-modals/open-case-modal.tsx +120 -0
  38. package/app/components/navbar/case-modals/rename-case-modal.module.css +81 -0
  39. package/app/components/navbar/case-modals/rename-case-modal.tsx +107 -0
  40. package/app/components/navbar/navbar.module.css +447 -0
  41. package/app/components/navbar/navbar.tsx +377 -0
  42. package/app/components/public-signing-key-modal/public-signing-key-modal.module.css +2 -0
  43. package/app/components/public-signing-key-modal/public-signing-key-modal.tsx +21 -51
  44. package/app/components/sidebar/case-export/case-export.module.css +1 -0
  45. package/app/components/sidebar/case-export/case-export.tsx +14 -77
  46. package/app/components/sidebar/case-import/case-import.module.css +25 -0
  47. package/app/components/sidebar/case-import/case-import.tsx +64 -40
  48. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +20 -1
  49. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +15 -0
  50. package/app/components/sidebar/cases/case-sidebar.tsx +25 -519
  51. package/app/components/sidebar/cases/cases-modal.module.css +45 -9
  52. package/app/components/sidebar/cases/cases-modal.tsx +16 -16
  53. package/app/components/sidebar/cases/cases.module.css +62 -21
  54. package/app/components/sidebar/files/files-modal.module.css +46 -10
  55. package/app/components/sidebar/files/files-modal.tsx +22 -23
  56. package/app/components/sidebar/notes/notes-editor-modal.module.css +49 -0
  57. package/app/components/sidebar/notes/notes-editor-modal.tsx +66 -0
  58. package/app/components/sidebar/notes/notes-modal.tsx +18 -17
  59. package/app/components/sidebar/notes/notes-sidebar.tsx +199 -113
  60. package/app/components/sidebar/notes/notes.module.css +155 -0
  61. package/app/components/sidebar/sidebar-container.tsx +15 -28
  62. package/app/components/sidebar/sidebar.module.css +7 -71
  63. package/app/components/sidebar/sidebar.tsx +24 -125
  64. package/app/components/sidebar/upload/image-upload-zone.module.css +13 -13
  65. package/app/components/toast/toast.module.css +2 -1
  66. package/app/components/toast/toast.tsx +16 -11
  67. package/app/components/user/delete-account.tsx +10 -31
  68. package/app/components/user/inactivity-warning.module.css +9 -6
  69. package/app/components/user/inactivity-warning.tsx +15 -2
  70. package/app/components/user/manage-profile.module.css +2 -0
  71. package/app/components/user/manage-profile.tsx +108 -40
  72. package/app/hooks/useOverlayDismiss.ts +116 -0
  73. package/app/routes/auth/login.example.tsx +19 -8
  74. package/app/routes/auth/login.tsx +785 -774
  75. package/app/routes/auth/passwordReset.module.css +23 -13
  76. package/app/routes/striae/striae.module.css +10 -3
  77. package/app/routes/striae/striae.tsx +477 -31
  78. package/app/routes.ts +7 -0
  79. package/app/services/audit/audit-export-csv.ts +2 -0
  80. package/app/services/audit/audit.service.ts +202 -32
  81. package/app/services/audit/builders/audit-entry-builder.ts +2 -1
  82. package/app/services/audit/builders/audit-event-builders-case-file.ts +43 -0
  83. package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
  84. package/app/services/audit/builders/audit-event-builders-workflow.ts +8 -0
  85. package/app/services/audit/builders/index.ts +1 -0
  86. package/app/types/audit.ts +5 -2
  87. package/app/types/case.ts +29 -0
  88. package/app/types/import.ts +3 -0
  89. package/app/types/user.ts +1 -0
  90. package/app/utils/data/permissions.ts +17 -1
  91. package/app/utils/forensics/audit-export-signature.ts +5 -1
  92. package/app/utils/forensics/confirmation-signature.ts +3 -0
  93. package/app/utils/forensics/export-verification.ts +497 -22
  94. package/functions/api/pdf/[[path]].ts +32 -1
  95. package/load-context.ts +9 -0
  96. package/package.json +6 -2
  97. package/primershear.emails.example +6 -0
  98. package/scripts/deploy-pages-secrets.sh +6 -0
  99. package/scripts/deploy-primershear-emails.sh +167 -0
  100. package/worker-configuration.d.ts +7493 -7491
  101. package/workers/audit-worker/worker-configuration.d.ts +7448 -11323
  102. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  103. package/workers/data-worker/worker-configuration.d.ts +7448 -11323
  104. package/workers/data-worker/wrangler.jsonc.example +1 -1
  105. package/workers/image-worker/worker-configuration.d.ts +7447 -11322
  106. package/workers/image-worker/wrangler.jsonc.example +1 -1
  107. package/workers/keys-worker/worker-configuration.d.ts +7447 -11322
  108. package/workers/keys-worker/wrangler.jsonc.example +1 -1
  109. package/workers/pdf-worker/src/formats/format-striae.ts +8 -7
  110. package/workers/pdf-worker/src/pdf-worker.example.ts +3 -0
  111. package/workers/pdf-worker/src/report-types.ts +3 -0
  112. package/workers/pdf-worker/worker-configuration.d.ts +7448 -11323
  113. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  114. package/workers/user-worker/src/user-worker.example.ts +6 -1
  115. package/workers/user-worker/worker-configuration.d.ts +7448 -11323
  116. package/workers/user-worker/wrangler.jsonc.example +1 -1
  117. package/wrangler.toml.example +1 -1
  118. package/public/.well-known/keybase.txt +0 -56
@@ -1,6 +1,7 @@
1
1
  import { useState, useEffect, useContext } from 'react';
2
2
  import { type ConfirmationData } from '~/types/annotations';
3
3
  import { AuthContext } from '~/contexts/auth.context';
4
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
4
5
  import { generateConfirmationId } from '~/utils/common';
5
6
  import styles from './confirmation.module.css';
6
7
 
@@ -9,6 +10,7 @@ interface ConfirmationModalProps {
9
10
  onClose: () => void;
10
11
  onConfirm?: (confirmationData: ConfirmationData) => void;
11
12
  company?: string;
13
+ defaultBadgeId?: string;
12
14
  existingConfirmation?: ConfirmationData | null;
13
15
  }
14
16
 
@@ -26,7 +28,7 @@ const formatTimestamp = (): string => {
26
28
  });
27
29
  };
28
30
 
29
- export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, existingConfirmation }: ConfirmationModalProps) => {
31
+ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, defaultBadgeId, existingConfirmation }: ConfirmationModalProps) => {
30
32
  const { user } = useContext(AuthContext);
31
33
  const [badgeId, setBadgeId] = useState('');
32
34
  const [error, setError] = useState('');
@@ -38,38 +40,30 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, existin
38
40
  const timestamp = formatTimestamp();
39
41
  const confirmationId = generateConfirmationId();
40
42
 
43
+ const {
44
+ requestClose,
45
+ overlayProps,
46
+ getCloseButtonProps
47
+ } = useOverlayDismiss({
48
+ isOpen,
49
+ onClose
50
+ });
51
+
41
52
  // Check if this is an existing confirmation
42
53
  const hasExistingConfirmation = !!existingConfirmation;
43
54
 
44
- // Handle Escape key to close modal
45
- useEffect(() => {
46
- const handleEscapeKey = (event: KeyboardEvent) => {
47
- if (event.key === 'Escape' && isOpen) {
48
- onClose();
49
- }
50
- };
51
-
52
- if (isOpen) {
53
- document.addEventListener('keydown', handleEscapeKey);
54
- }
55
-
56
- return () => {
57
- document.removeEventListener('keydown', handleEscapeKey);
58
- };
59
- }, [isOpen, onClose]);
60
-
61
55
  // Reset form when modal opens
62
56
  useEffect(() => {
63
57
  if (isOpen) {
64
58
  if (existingConfirmation) {
65
59
  setBadgeId(existingConfirmation.badgeId);
66
60
  } else {
67
- setBadgeId('');
61
+ setBadgeId(defaultBadgeId || '');
68
62
  }
69
63
  setError('');
70
64
  setIsConfirming(false);
71
65
  }
72
- }, [isOpen, existingConfirmation]);
66
+ }, [isOpen, defaultBadgeId, existingConfirmation]);
73
67
 
74
68
  if (!isOpen) return null;
75
69
 
@@ -104,42 +98,18 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, existin
104
98
  }
105
99
  };
106
100
 
107
- const handleOverlayMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
108
- if (e.target === e.currentTarget) {
109
- onClose();
110
- }
111
- };
112
-
113
- const handleOverlayKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
114
- if (e.target !== e.currentTarget) {
115
- return;
116
- }
117
-
118
- if (e.key === 'Enter' || e.key === ' ') {
119
- e.preventDefault();
120
- onClose();
121
- }
122
- };
123
-
124
101
  return (
125
102
  <div
126
103
  className={styles.overlay}
127
- onMouseDown={handleOverlayMouseDown}
128
- onKeyDown={handleOverlayKeyDown}
129
- role="button"
130
- tabIndex={0}
131
104
  aria-label="Close confirmation dialog"
105
+ {...overlayProps}
132
106
  >
133
107
  <div className={styles.modal}>
134
108
  <div className={styles.header}>
135
109
  <h2 className={styles.title}>
136
110
  {hasExistingConfirmation ? 'Confirmation Details' : 'Confirm Identification'}
137
111
  </h2>
138
- <button
139
- className={styles.closeButton}
140
- onClick={onClose}
141
- aria-label="Close modal"
142
- >
112
+ <button {...getCloseButtonProps({ ariaLabel: 'Close confirmation dialog' })}>
143
113
  ×
144
114
  </button>
145
115
  </div>
@@ -210,7 +180,7 @@ export const ConfirmationModal = ({ isOpen, onClose, onConfirm, company, existin
210
180
  <div className={styles.footer}>
211
181
  <button
212
182
  className={styles.cancelButton}
213
- onClick={onClose}
183
+ onClick={requestClose}
214
184
  disabled={isConfirming}
215
185
  >
216
186
  {hasExistingConfirmation ? 'Close' : 'Cancel'}
@@ -0,0 +1,110 @@
1
+ .overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ background: rgba(0, 0, 0, 0.45);
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ z-index: 120;
9
+ }
10
+
11
+ .modal {
12
+ position: relative;
13
+ width: min(560px, calc(100vw - 2rem));
14
+ background: #ffffff;
15
+ border-radius: 12px;
16
+ border: 1px solid #d9e0e7;
17
+ box-shadow: 0 14px 36px rgba(0, 0, 0, 0.2);
18
+ padding: 1.1rem;
19
+ }
20
+
21
+ .title {
22
+ margin: 0;
23
+ color: #212529;
24
+ font-size: 1.02rem;
25
+ }
26
+
27
+ .subtitle {
28
+ margin: 0.4rem 0 0.9rem;
29
+ color: #6c757d;
30
+ font-size: 0.85rem;
31
+ }
32
+
33
+ .warningPanel {
34
+ border: 1px solid color-mix(in lab, #dc3545 25%, transparent);
35
+ background: color-mix(in lab, #dc3545 7%, #ffffff);
36
+ border-radius: 10px;
37
+ padding: 0.75rem;
38
+ margin-bottom: 0.8rem;
39
+ }
40
+
41
+ .warningPanel p {
42
+ margin: 0;
43
+ color: #3f2a2e;
44
+ font-size: 0.86rem;
45
+ line-height: 1.35;
46
+ }
47
+
48
+ .warningPanel p + p {
49
+ margin-top: 0.45rem;
50
+ }
51
+
52
+ .reasonLabel {
53
+ display: block;
54
+ margin-bottom: 0.35rem;
55
+ color: #495057;
56
+ font-size: 0.8rem;
57
+ font-weight: 600;
58
+ }
59
+
60
+ .reasonInput {
61
+ width: 100%;
62
+ box-sizing: border-box;
63
+ border: 1px solid #cdd5dd;
64
+ border-radius: 8px;
65
+ padding: 0.6rem 0.75rem;
66
+ font-size: 0.9rem;
67
+ font-family: inherit;
68
+ resize: vertical;
69
+ }
70
+
71
+ .reasonInput:focus {
72
+ outline: none;
73
+ border-color: #1f6feb;
74
+ box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.2);
75
+ }
76
+
77
+ .actions {
78
+ display: flex;
79
+ justify-content: flex-end;
80
+ gap: 0.65rem;
81
+ margin-top: 1rem;
82
+ }
83
+
84
+ .cancelButton,
85
+ .confirmButton {
86
+ border: 1px solid transparent;
87
+ border-radius: 8px;
88
+ padding: 0.55rem 0.9rem;
89
+ font-size: 0.86rem;
90
+ font-weight: 500;
91
+ cursor: pointer;
92
+ }
93
+
94
+ .cancelButton {
95
+ background: #f3f4f6;
96
+ color: #3c4651;
97
+ border-color: #d6dce2;
98
+ }
99
+
100
+ .confirmButton {
101
+ background: #dc3545;
102
+ color: #ffffff;
103
+ border-color: #c82333;
104
+ }
105
+
106
+ .cancelButton:disabled,
107
+ .confirmButton:disabled {
108
+ cursor: not-allowed;
109
+ opacity: 0.6;
110
+ }
@@ -0,0 +1,129 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
3
+ import styles from './archive-case-modal.module.css';
4
+
5
+ interface ArchiveCaseModalProps {
6
+ isOpen: boolean;
7
+ currentCase: string;
8
+ isSubmitting?: boolean;
9
+ onClose: () => void;
10
+ onSubmit: (archiveReason: string) => Promise<void>;
11
+ }
12
+
13
+ export const ArchiveCaseModal = ({
14
+ isOpen,
15
+ currentCase,
16
+ isSubmitting = false,
17
+ onClose,
18
+ onSubmit,
19
+ }: ArchiveCaseModalProps) => {
20
+ const [archiveReason, setArchiveReason] = useState('');
21
+ const reasonRef = useRef<HTMLTextAreaElement>(null);
22
+ const isCloseBlocked = isSubmitting;
23
+
24
+ const handleClose = () => {
25
+ if (isSubmitting) {
26
+ return;
27
+ }
28
+
29
+ setArchiveReason('');
30
+ onClose();
31
+ };
32
+
33
+ const {
34
+ requestClose,
35
+ overlayProps,
36
+ getCloseButtonProps,
37
+ } = useOverlayDismiss({
38
+ isOpen,
39
+ onClose: handleClose,
40
+ canDismiss: !isCloseBlocked,
41
+ });
42
+
43
+ useEffect(() => {
44
+ if (!isOpen) {
45
+ return;
46
+ }
47
+
48
+ const focusId = window.requestAnimationFrame(() => {
49
+ reasonRef.current?.focus();
50
+ });
51
+
52
+ return () => {
53
+ window.cancelAnimationFrame(focusId);
54
+ };
55
+ }, [isOpen]);
56
+
57
+ if (!isOpen) {
58
+ return null;
59
+ }
60
+
61
+ const handleSubmit = async () => {
62
+ await onSubmit(archiveReason.trim());
63
+ setArchiveReason('');
64
+ };
65
+
66
+ return (
67
+ <div
68
+ className={styles.overlay}
69
+ aria-label="Close archive case dialog"
70
+ {...overlayProps}
71
+ >
72
+ <div className={styles.modal} role="dialog" aria-modal="true" aria-label="Archive Case">
73
+ <button {...getCloseButtonProps({ ariaLabel: 'Close archive case dialog' })}>
74
+ ×
75
+ </button>
76
+ <h3 className={styles.title}>Archive Case</h3>
77
+ <p className={styles.subtitle}>Case: {currentCase}</p>
78
+
79
+ <div className={styles.warningPanel}>
80
+ <p>
81
+ Archiving a case permanently renders it read-only.
82
+ </p>
83
+ <p>
84
+ The archive will be in JSON format and include all images.
85
+ </p>
86
+ <p>
87
+ The full audit trail is packaged with Striae&apos;s current public key and forensic signatures.
88
+ </p>
89
+ <p>
90
+ You can import the archived package back into Striae for future review.
91
+ </p>
92
+ </div>
93
+
94
+ <label htmlFor="archiveReason" className={styles.reasonLabel}>Archive reason (recommended)</label>
95
+ <textarea
96
+ id="archiveReason"
97
+ ref={reasonRef}
98
+ value={archiveReason}
99
+ onChange={(event) => setArchiveReason(event.target.value)}
100
+ className={styles.reasonInput}
101
+ placeholder="Optional chain-of-custody note"
102
+ disabled={isSubmitting}
103
+ rows={3}
104
+ />
105
+
106
+ <div className={styles.actions}>
107
+ <button
108
+ type="button"
109
+ className={styles.cancelButton}
110
+ onClick={requestClose}
111
+ disabled={isCloseBlocked}
112
+ >
113
+ Cancel
114
+ </button>
115
+ <button
116
+ type="button"
117
+ className={styles.confirmButton}
118
+ onClick={() => {
119
+ void handleSubmit();
120
+ }}
121
+ disabled={isSubmitting}
122
+ >
123
+ {isSubmitting ? 'Archiving...' : 'Confirm Archive'}
124
+ </button>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ );
129
+ };
@@ -0,0 +1,81 @@
1
+ .overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ background: rgba(0, 0, 0, 0.45);
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ z-index: 120;
9
+ }
10
+
11
+ .modal {
12
+ position: relative;
13
+ width: min(460px, calc(100vw - 2rem));
14
+ background: #ffffff;
15
+ border-radius: 12px;
16
+ border: 1px solid #d9e0e7;
17
+ box-shadow: 0 14px 36px rgba(0, 0, 0, 0.2);
18
+ padding: 1.1rem;
19
+ }
20
+
21
+ .title {
22
+ margin: 0;
23
+ color: #212529;
24
+ font-size: 1.02rem;
25
+ }
26
+
27
+ .helperText {
28
+ margin: 0.4rem 0 0.9rem;
29
+ color: #6c757d;
30
+ font-size: 0.85rem;
31
+ }
32
+
33
+ .input {
34
+ width: 100%;
35
+ box-sizing: border-box;
36
+ border: 1px solid #cdd5dd;
37
+ border-radius: 8px;
38
+ padding: 0.6rem 0.75rem;
39
+ font-size: 0.92rem;
40
+ }
41
+
42
+ .input:focus {
43
+ outline: none;
44
+ border-color: #1f6feb;
45
+ box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.2);
46
+ }
47
+
48
+ .actions {
49
+ display: flex;
50
+ justify-content: flex-end;
51
+ gap: 0.65rem;
52
+ margin-top: 1rem;
53
+ }
54
+
55
+ .cancelButton,
56
+ .confirmButton {
57
+ border: 1px solid transparent;
58
+ border-radius: 8px;
59
+ padding: 0.55rem 0.9rem;
60
+ font-size: 0.86rem;
61
+ font-weight: 500;
62
+ cursor: pointer;
63
+ }
64
+
65
+ .cancelButton {
66
+ background: #f3f4f6;
67
+ color: #3c4651;
68
+ border-color: #d6dce2;
69
+ }
70
+
71
+ .confirmButton {
72
+ background: #198754;
73
+ color: #ffffff;
74
+ border-color: #157347;
75
+ }
76
+
77
+ .cancelButton:disabled,
78
+ .confirmButton:disabled {
79
+ cursor: not-allowed;
80
+ opacity: 0.6;
81
+ }
@@ -0,0 +1,120 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
3
+ import styles from './open-case-modal.module.css';
4
+
5
+ interface OpenCaseModalProps {
6
+ isOpen: boolean;
7
+ isSubmitting?: boolean;
8
+ title?: string;
9
+ helperText?: string;
10
+ onClose: () => void;
11
+ onSubmit: (caseNumber: string) => Promise<void>;
12
+ }
13
+
14
+ export const OpenCaseModal = ({
15
+ isOpen,
16
+ isSubmitting = false,
17
+ title = 'Open Case',
18
+ helperText,
19
+ onClose,
20
+ onSubmit,
21
+ }: OpenCaseModalProps) => {
22
+ const [caseNumber, setCaseNumber] = useState('');
23
+ const inputRef = useRef<HTMLInputElement>(null);
24
+ const isCloseBlocked = isSubmitting;
25
+
26
+ useEffect(() => {
27
+ if (!isOpen) {
28
+ return;
29
+ }
30
+
31
+ const focusId = window.requestAnimationFrame(() => {
32
+ inputRef.current?.focus();
33
+ });
34
+
35
+ return () => {
36
+ window.cancelAnimationFrame(focusId);
37
+ };
38
+ }, [isOpen]);
39
+
40
+ const handleClose = () => {
41
+ if (isCloseBlocked) {
42
+ return;
43
+ }
44
+
45
+ setCaseNumber('');
46
+ onClose();
47
+ };
48
+
49
+ const {
50
+ requestClose,
51
+ overlayProps,
52
+ getCloseButtonProps,
53
+ } = useOverlayDismiss({
54
+ isOpen,
55
+ onClose: handleClose,
56
+ canDismiss: !isCloseBlocked,
57
+ });
58
+
59
+ const handleSubmit = async () => {
60
+ const trimmedCaseNumber = caseNumber.trim();
61
+ if (!trimmedCaseNumber || isSubmitting) {
62
+ return;
63
+ }
64
+
65
+ await onSubmit(trimmedCaseNumber);
66
+ setCaseNumber('');
67
+ };
68
+
69
+ if (!isOpen) return null;
70
+
71
+ return (
72
+ <div
73
+ className={styles.overlay}
74
+ aria-label="Close open case dialog"
75
+ {...overlayProps}
76
+ >
77
+ <div className={styles.modal} role="dialog" aria-modal="true" aria-label="Open Case">
78
+ <button {...getCloseButtonProps({ ariaLabel: 'Close open case dialog' })}>
79
+ ×
80
+ </button>
81
+ <h3 className={styles.title}>{title}</h3>
82
+ {helperText ? <p className={styles.helperText}>{helperText}</p> : null}
83
+ <input
84
+ ref={inputRef}
85
+ type="text"
86
+ className={styles.input}
87
+ value={caseNumber}
88
+ onChange={(event) => setCaseNumber(event.target.value)}
89
+ placeholder="Case #"
90
+ disabled={isSubmitting}
91
+ onKeyDown={(event) => {
92
+ if (event.key === 'Enter') {
93
+ void handleSubmit();
94
+ }
95
+ }}
96
+ />
97
+ <div className={styles.actions}>
98
+ <button
99
+ type="button"
100
+ className={styles.cancelButton}
101
+ onClick={requestClose}
102
+ disabled={isCloseBlocked}
103
+ >
104
+ Cancel
105
+ </button>
106
+ <button
107
+ type="button"
108
+ className={styles.confirmButton}
109
+ onClick={() => {
110
+ void handleSubmit();
111
+ }}
112
+ disabled={isSubmitting || !caseNumber.trim()}
113
+ >
114
+ {isSubmitting ? 'Opening...' : 'Load/Create Case'}
115
+ </button>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ );
120
+ };
@@ -0,0 +1,81 @@
1
+ .overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ background: rgba(0, 0, 0, 0.45);
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ z-index: 120;
9
+ }
10
+
11
+ .modal {
12
+ position: relative;
13
+ width: min(460px, calc(100vw - 2rem));
14
+ background: #ffffff;
15
+ border-radius: 12px;
16
+ border: 1px solid #d9e0e7;
17
+ box-shadow: 0 14px 36px rgba(0, 0, 0, 0.2);
18
+ padding: 1.1rem;
19
+ }
20
+
21
+ .title {
22
+ margin: 0;
23
+ color: #212529;
24
+ font-size: 1.02rem;
25
+ }
26
+
27
+ .subtitle {
28
+ margin: 0.4rem 0 0.9rem;
29
+ color: #6c757d;
30
+ font-size: 0.85rem;
31
+ }
32
+
33
+ .input {
34
+ width: 100%;
35
+ box-sizing: border-box;
36
+ border: 1px solid #cdd5dd;
37
+ border-radius: 8px;
38
+ padding: 0.6rem 0.75rem;
39
+ font-size: 0.92rem;
40
+ }
41
+
42
+ .input:focus {
43
+ outline: none;
44
+ border-color: #1f6feb;
45
+ box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.2);
46
+ }
47
+
48
+ .actions {
49
+ display: flex;
50
+ justify-content: flex-end;
51
+ gap: 0.65rem;
52
+ margin-top: 1rem;
53
+ }
54
+
55
+ .cancelButton,
56
+ .confirmButton {
57
+ border: 1px solid transparent;
58
+ border-radius: 8px;
59
+ padding: 0.55rem 0.9rem;
60
+ font-size: 0.86rem;
61
+ font-weight: 500;
62
+ cursor: pointer;
63
+ }
64
+
65
+ .cancelButton {
66
+ background: #f3f4f6;
67
+ color: #3c4651;
68
+ border-color: #d6dce2;
69
+ }
70
+
71
+ .confirmButton {
72
+ background: #ffc107;
73
+ color: #3f2f00;
74
+ border-color: #e8b103;
75
+ }
76
+
77
+ .cancelButton:disabled,
78
+ .confirmButton:disabled {
79
+ cursor: not-allowed;
80
+ opacity: 0.6;
81
+ }