@striae-org/striae 5.3.1 → 5.4.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 (100) hide show
  1. package/.env.example +3 -0
  2. package/app/components/actions/generate-pdf.ts +22 -0
  3. package/app/components/auth/auth.module.css +531 -0
  4. package/app/components/auth/mfa-enrollment.tsx +132 -79
  5. package/app/components/auth/mfa-totp-enrollment.tsx +231 -0
  6. package/app/components/auth/mfa-verification.tsx +155 -33
  7. package/app/components/{sidebar/cases/cases-modal.tsx → navbar/case-modals/all-cases-modal.tsx} +4 -4
  8. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -10
  9. package/app/components/navbar/case-modals/case-modal-shared.module.css +88 -0
  10. package/app/components/navbar/case-modals/delete-case-modal.tsx +9 -10
  11. package/app/components/navbar/case-modals/export-case-modal.tsx +9 -10
  12. package/app/components/navbar/case-modals/export-confirmations-modal.tsx +9 -10
  13. package/app/components/navbar/case-modals/open-case-modal.tsx +4 -4
  14. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -10
  15. package/app/components/navbar/navbar.tsx +1 -1
  16. package/app/components/sidebar/files/delete-files-modal.tsx +3 -3
  17. package/app/components/sidebar/files/files-modal.module.css +29 -0
  18. package/app/components/sidebar/notes/{class-details-fields.tsx → class-details/class-details-fields.tsx} +1 -1
  19. package/app/components/sidebar/notes/{class-details-modal.tsx → class-details/class-details-modal.tsx} +1 -1
  20. package/app/components/sidebar/notes/{class-details-sections.tsx → class-details/class-details-sections.tsx} +1 -1
  21. package/app/components/sidebar/notes/notes-editor-form.tsx +2 -2
  22. package/app/components/sidebar/notes/notes-editor-modal.tsx +6 -6
  23. package/app/components/sidebar/notes/notes.module.css +52 -0
  24. package/app/components/toolbar/toolbar-color-selector.tsx +8 -8
  25. package/app/components/toolbar/toolbar.module.css +181 -2
  26. package/app/components/user/delete-account.tsx +7 -7
  27. package/app/components/user/inactivity-warning.tsx +6 -6
  28. package/app/components/user/manage-profile.tsx +18 -1
  29. package/app/components/user/mfa-enrolled-factors.tsx +117 -0
  30. package/app/components/user/mfa-phone-update.tsx +8 -4
  31. package/app/components/user/mfa-totp-section.tsx +446 -0
  32. package/app/components/user/user.module.css +665 -0
  33. package/app/routes/striae/striae.tsx +1 -1
  34. package/app/services/audit/audit.service.ts +1 -1
  35. package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
  36. package/app/services/firebase/errors.ts +2 -0
  37. package/app/utils/auth/mfa.ts +35 -1
  38. package/functions/api/image/[[path]].ts +19 -3
  39. package/package.json +16 -21
  40. package/scripts/deploy-all.sh +166 -0
  41. package/scripts/deploy-config/modules/env-utils.sh +322 -0
  42. package/scripts/deploy-config/modules/keys.sh +404 -0
  43. package/scripts/deploy-config/modules/prompt.sh +375 -0
  44. package/scripts/deploy-config/modules/scaffolding.sh +310 -0
  45. package/scripts/deploy-config/modules/validation.sh +354 -0
  46. package/scripts/deploy-config.sh +236 -0
  47. package/scripts/deploy-pages-secrets.sh +231 -0
  48. package/scripts/deploy-pages.sh +34 -0
  49. package/scripts/deploy-primershear-emails.sh +167 -0
  50. package/scripts/deploy-worker-secrets.sh +385 -0
  51. package/scripts/dev.cjs +23 -0
  52. package/scripts/enable-totp-mfa.mjs +57 -0
  53. package/scripts/install-workers.sh +87 -0
  54. package/scripts/run-eslint.cjs +43 -0
  55. package/scripts/update-compatibility-dates.cjs +124 -0
  56. package/scripts/update-markdown-versions.cjs +43 -0
  57. package/workers/audit-worker/package.json +1 -1
  58. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  59. package/workers/data-worker/package.json +1 -1
  60. package/workers/data-worker/wrangler.jsonc.example +1 -1
  61. package/workers/image-worker/package.json +1 -1
  62. package/workers/image-worker/src/image-worker.example.ts +36 -2
  63. package/workers/image-worker/wrangler.jsonc.example +1 -1
  64. package/workers/pdf-worker/package.json +1 -1
  65. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  66. package/workers/user-worker/package.json +1 -1
  67. package/workers/user-worker/wrangler.jsonc.example +1 -1
  68. package/wrangler.toml.example +1 -1
  69. package/app/components/auth/mfa-enrollment.module.css +0 -276
  70. package/app/components/auth/mfa-verification.module.css +0 -259
  71. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -34
  72. package/app/components/navbar/case-modals/delete-case-modal.module.css +0 -9
  73. package/app/components/navbar/case-modals/export-case-modal.module.css +0 -27
  74. package/app/components/navbar/case-modals/export-confirmations-modal.module.css +0 -24
  75. package/app/components/navbar/case-modals/open-case-modal.module.css +0 -82
  76. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -9
  77. package/app/components/sidebar/files/delete-files-modal.module.css +0 -26
  78. package/app/components/sidebar/notes/notes-editor-modal.module.css +0 -49
  79. package/app/components/toolbar/toolbar-color-selector.module.css +0 -171
  80. package/app/components/user/delete-account.module.css +0 -277
  81. package/app/components/user/inactivity-warning.module.css +0 -148
  82. package/app/components/user/manage-profile.module.css +0 -192
  83. package/app/routes/auth/login.module.css +0 -523
  84. package/app/routes/auth/login.tsx +0 -705
  85. /package/app/components/{sidebar → navbar}/case-import/case-import.module.css +0 -0
  86. /package/app/components/{sidebar → navbar}/case-import/case-import.tsx +0 -0
  87. /package/app/components/{sidebar → navbar}/case-import/components/CasePreviewSection.tsx +0 -0
  88. /package/app/components/{sidebar → navbar}/case-import/components/ConfirmationDialog.tsx +0 -0
  89. /package/app/components/{sidebar → navbar}/case-import/components/ConfirmationPreviewSection.tsx +0 -0
  90. /package/app/components/{sidebar → navbar}/case-import/components/ExistingCaseSection.tsx +0 -0
  91. /package/app/components/{sidebar → navbar}/case-import/components/FileSelector.tsx +0 -0
  92. /package/app/components/{sidebar → navbar}/case-import/components/ProgressSection.tsx +0 -0
  93. /package/app/components/{sidebar → navbar}/case-import/hooks/useFilePreview.ts +0 -0
  94. /package/app/components/{sidebar → navbar}/case-import/hooks/useImportExecution.ts +0 -0
  95. /package/app/components/{sidebar → navbar}/case-import/hooks/useImportState.ts +0 -0
  96. /package/app/components/{sidebar → navbar}/case-import/index.ts +0 -0
  97. /package/app/components/{sidebar → navbar}/case-import/utils/file-validation.ts +0 -0
  98. /package/app/components/{sidebar/cases/cases-modal.module.css → navbar/case-modals/all-cases-modal.module.css} +0 -0
  99. /package/app/components/sidebar/notes/{class-details-shared.ts → class-details/class-details-shared.ts} +0 -0
  100. /package/app/components/sidebar/notes/{use-class-details-state.ts → class-details/use-class-details-state.ts} +0 -0
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
2
2
  import {
3
3
  PhoneAuthProvider,
4
4
  PhoneMultiFactorGenerator,
5
+ TotpMultiFactorGenerator,
5
6
  RecaptchaVerifier,
6
7
  type MultiFactorResolver,
7
8
  type UserCredential
@@ -11,7 +12,7 @@ import { handleAuthError, getValidationError } from '~/services/firebase/errors'
11
12
  import { SignOut } from '~/components/actions/signout';
12
13
  import { auditService } from '~/services/audit';
13
14
  import { generateUniqueId } from '~/utils/common';
14
- import styles from './mfa-verification.module.css';
15
+ import styles from './auth.module.css';
15
16
 
16
17
  interface MFAVerificationProps {
17
18
  resolver: MultiFactorResolver;
@@ -45,6 +46,12 @@ export const MFAVerification = ({ resolver, onSuccess, onError, onCancel }: MFAV
45
46
 
46
47
  useEffect(() => {
47
48
  if (!isClient) return;
49
+
50
+ // Only initialize reCAPTCHA if there is at least one phone hint
51
+ const hasPhoneHint = resolver.hints.some(
52
+ (h) => h.factorId === PhoneMultiFactorGenerator.FACTOR_ID
53
+ );
54
+ if (!hasPhoneHint) return;
48
55
 
49
56
  // Initialize reCAPTCHA verifier
50
57
  const verifier = new RecaptchaVerifier(auth, 'recaptcha-container', {
@@ -63,7 +70,7 @@ export const MFAVerification = ({ resolver, onSuccess, onError, onCancel }: MFAV
63
70
  return () => {
64
71
  verifier.clear();
65
72
  };
66
- }, [isClient, onError]);
73
+ }, [isClient, onError, resolver.hints]);
67
74
 
68
75
  const sendVerificationCode = async () => {
69
76
  if (!recaptchaVerifier) {
@@ -188,8 +195,82 @@ export const MFAVerification = ({ resolver, onSuccess, onError, onCancel }: MFAV
188
195
  };
189
196
 
190
197
  const selectedHint = resolver.hints[selectedHintIndex];
198
+ const isTotpHint = selectedHint?.factorId === TotpMultiFactorGenerator.FACTOR_ID;
191
199
  const maskedPhoneNumber = selectedHint?.displayName || 'your phone';
192
200
 
201
+ const getHintLabel = (hint: (typeof resolver.hints)[number], index: number): string => {
202
+ if (hint.factorId === TotpMultiFactorGenerator.FACTOR_ID) {
203
+ return hint.displayName || 'Authenticator App';
204
+ }
205
+ if (hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID) {
206
+ return hint.displayName || `Phone (SMS) ${index + 1}`;
207
+ }
208
+ return hint.displayName || `Verification method ${index + 1}`;
209
+ };
210
+
211
+ const verifyTotpCode = async () => {
212
+ if (!verificationCode.trim() || verificationCode.length !== 6) {
213
+ const error = getValidationError('MFA_CODE_REQUIRED');
214
+ setErrorMessage(error);
215
+ onError(error);
216
+ return;
217
+ }
218
+
219
+ setLoading(true);
220
+ setErrorMessage('');
221
+ try {
222
+ const assertion = TotpMultiFactorGenerator.assertionForSignIn(
223
+ selectedHint.uid,
224
+ verificationCode
225
+ );
226
+ const result = await resolver.resolveSignIn(assertion);
227
+
228
+ try {
229
+ const sessionId = `session_${result.user.uid}_${Date.now()}_${generateUniqueId(8)}`;
230
+ await auditService.logMfaAuthentication(
231
+ result.user,
232
+ 'totp',
233
+ 'success',
234
+ 1,
235
+ sessionId,
236
+ navigator.userAgent
237
+ );
238
+ } catch (auditError) {
239
+ console.error('Failed to log TOTP authentication success audit:', auditError);
240
+ }
241
+
242
+ onSuccess(result);
243
+ } catch (error: unknown) {
244
+ const authError = error as { code?: string; message?: string };
245
+ let errorMsg = '';
246
+
247
+ if (authError.code === 'auth/invalid-verification-code') {
248
+ errorMsg = getValidationError('MFA_INVALID_CODE');
249
+ } else if (authError.code === 'auth/code-expired') {
250
+ errorMsg = getValidationError('MFA_CODE_EXPIRED');
251
+ } else {
252
+ errorMsg = handleAuthError(authError).message;
253
+ }
254
+ setErrorMessage(errorMsg);
255
+ onError(errorMsg);
256
+
257
+ try {
258
+ await auditService.logSecurityViolation(
259
+ null,
260
+ authError.code === 'auth/invalid-verification-code' ? 'brute-force' : 'unauthorized-access',
261
+ authError.code === 'auth/invalid-verification-code' ? 'high' : 'medium',
262
+ `Failed TOTP verification: ${authError.code} - ${errorMsg}`,
263
+ 'mfa-verification-endpoint',
264
+ true
265
+ );
266
+ } catch (auditError) {
267
+ console.error('Failed to log TOTP security violation audit:', auditError);
268
+ }
269
+ } finally {
270
+ setLoading(false);
271
+ }
272
+ };
273
+
193
274
  if (!isClient) {
194
275
  return null;
195
276
  }
@@ -214,69 +295,110 @@ export const MFAVerification = ({ resolver, onSuccess, onError, onCancel }: MFAV
214
295
  value={selectedHintIndex}
215
296
  onChange={(e) => {
216
297
  setSelectedHintIndex(Number(e.target.value));
298
+ setCodeSent(false);
299
+ setVerificationCode('');
300
+ setVerificationId('');
217
301
  if (errorMessage) setErrorMessage(''); // Clear error when changing method
218
302
  }}
219
303
  className={styles.select}
220
304
  >
221
305
  {resolver.hints.map((hint, index) => (
222
306
  <option key={index} value={index}>
223
- {hint.displayName || `Phone verification ${index + 1}`}
307
+ {getHintLabel(hint, index)}
224
308
  </option>
225
309
  ))}
226
310
  </select>
227
311
  </div>
228
312
  )}
229
313
 
230
- {!codeSent ? (
231
- <div className={styles.sendCode}>
232
- <p className={styles.description}>
233
- We&apos;ll send a verification code to {maskedPhoneNumber}
234
- </p>
235
- <button
236
- onClick={sendVerificationCode}
237
- disabled={loading}
238
- className={styles.button}
239
- >
240
- {loading ? 'Sending...' : 'Send Verification Code'}
241
- </button>
242
- </div>
243
- ) : (
314
+ {isTotpHint ? (
244
315
  <div className={styles.verifyCode}>
245
316
  <p className={styles.description}>
246
- Enter the verification code sent to {maskedPhoneNumber}
317
+ Enter the 6-digit code from your authenticator app.
247
318
  </p>
248
319
  <input
249
320
  type="text"
321
+ inputMode="numeric"
250
322
  placeholder="Enter 6-digit code"
251
323
  value={verificationCode}
252
324
  onChange={(e) => {
253
- setVerificationCode(e.target.value);
254
- if (errorMessage) setErrorMessage(''); // Clear error on input
325
+ setVerificationCode(e.target.value.replace(/\D/g, ''));
326
+ if (errorMessage) setErrorMessage('');
327
+ }}
328
+ onKeyDown={(e) => {
329
+ if (e.key === 'Enter' && verificationCode.length === 6) {
330
+ e.preventDefault();
331
+ void verifyTotpCode();
332
+ }
255
333
  }}
256
334
  className={styles.input}
257
335
  maxLength={6}
336
+ autoComplete="one-time-code"
258
337
  />
259
338
  <div className={styles.buttons}>
260
- <button
261
- onClick={verifyCode}
339
+ <button
340
+ onClick={verifyTotpCode}
262
341
  disabled={loading || verificationCode.length !== 6}
263
342
  className={styles.button}
264
343
  >
265
344
  {loading ? 'Verifying...' : 'Verify Code'}
266
345
  </button>
267
- <button
268
- onClick={() => {
269
- setCodeSent(false);
270
- setVerificationCode('');
271
- setVerificationId('');
272
- setErrorMessage(''); // Clear errors when requesting new code
273
- }}
274
- className={styles.secondaryButton}
275
- >
276
- Send New Code
277
- </button>
278
346
  </div>
279
347
  </div>
348
+ ) : (
349
+ <>
350
+ {!codeSent ? (
351
+ <div className={styles.sendCode}>
352
+ <p className={styles.description}>
353
+ We&apos;ll send a verification code to {maskedPhoneNumber}
354
+ </p>
355
+ <button
356
+ onClick={sendVerificationCode}
357
+ disabled={loading}
358
+ className={styles.button}
359
+ >
360
+ {loading ? 'Sending...' : 'Send Verification Code'}
361
+ </button>
362
+ </div>
363
+ ) : (
364
+ <div className={styles.verifyCode}>
365
+ <p className={styles.description}>
366
+ Enter the verification code sent to {maskedPhoneNumber}
367
+ </p>
368
+ <input
369
+ type="text"
370
+ placeholder="Enter 6-digit code"
371
+ value={verificationCode}
372
+ onChange={(e) => {
373
+ setVerificationCode(e.target.value);
374
+ if (errorMessage) setErrorMessage(''); // Clear error on input
375
+ }}
376
+ className={styles.input}
377
+ maxLength={6}
378
+ />
379
+ <div className={styles.buttons}>
380
+ <button
381
+ onClick={verifyCode}
382
+ disabled={loading || verificationCode.length !== 6}
383
+ className={styles.button}
384
+ >
385
+ {loading ? 'Verifying...' : 'Verify Code'}
386
+ </button>
387
+ <button
388
+ onClick={() => {
389
+ setCodeSent(false);
390
+ setVerificationCode('');
391
+ setVerificationId('');
392
+ setErrorMessage(''); // Clear errors when requesting new code
393
+ }}
394
+ className={styles.secondaryButton}
395
+ >
396
+ Send New Code
397
+ </button>
398
+ </div>
399
+ </div>
400
+ )}
401
+ </>
280
402
  )}
281
403
 
282
404
  <div className={styles.actions}>
@@ -15,9 +15,9 @@ import {
15
15
  renameCase,
16
16
  validateCaseNumber,
17
17
  } from '~/components/actions/case-manage';
18
- import { RenameCaseModal } from '../../navbar/case-modals/rename-case-modal';
19
- import { ArchiveCaseModal } from '../../navbar/case-modals/archive-case-modal';
20
- import { DeleteCaseModal } from '../../navbar/case-modals/delete-case-modal';
18
+ import { RenameCaseModal } from './rename-case-modal';
19
+ import { ArchiveCaseModal } from './archive-case-modal';
20
+ import { DeleteCaseModal } from './delete-case-modal';
21
21
  import {
22
22
  ensureCaseConfirmationSummary,
23
23
  getCaseData,
@@ -26,7 +26,7 @@ import {
26
26
  getUserReadOnlyCases,
27
27
  } from '~/utils/data';
28
28
  import { fetchFiles } from '~/components/actions/image-manage';
29
- import styles from './cases-modal.module.css';
29
+ import styles from './all-cases-modal.module.css';
30
30
 
31
31
  interface CasesModalProps {
32
32
  isOpen: boolean;
@@ -1,7 +1,6 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
2
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
3
- import sharedStyles from './case-modal-shared.module.css';
4
- import styles from './archive-case-modal.module.css';
3
+ import styles from './case-modal-shared.module.css';
5
4
 
6
5
  interface ArchiveCaseModalProps {
7
6
  isOpen: boolean;
@@ -66,18 +65,18 @@ export const ArchiveCaseModal = ({
66
65
 
67
66
  return (
68
67
  <div
69
- className={sharedStyles.overlay}
68
+ className={styles.overlay}
70
69
  aria-label="Close archive case dialog"
71
70
  {...overlayProps}
72
71
  >
73
- <div className={`${sharedStyles.modal} ${styles.modal}`} role="dialog" aria-modal="true" aria-label="Archive Case">
72
+ <div className={`${styles.modal} ${styles.modalLarge}`} role="dialog" aria-modal="true" aria-label="Archive Case">
74
73
  <button {...getCloseButtonProps({ ariaLabel: 'Close archive case dialog' })}>
75
74
  ×
76
75
  </button>
77
- <h3 className={sharedStyles.title}>Archive Case</h3>
78
- <p className={sharedStyles.subtitle}>Case: {currentCase}</p>
76
+ <h3 className={styles.title}>Archive Case</h3>
77
+ <p className={styles.subtitle}>Case: {currentCase}</p>
79
78
 
80
- <div className={sharedStyles.warningPanel}>
79
+ <div className={styles.warningPanel}>
81
80
  <p>
82
81
  Archiving a case permanently renders it read-only.
83
82
  </p>
@@ -104,10 +103,10 @@ export const ArchiveCaseModal = ({
104
103
  rows={3}
105
104
  />
106
105
 
107
- <div className={sharedStyles.actions}>
106
+ <div className={styles.actions}>
108
107
  <button
109
108
  type="button"
110
- className={sharedStyles.cancelButton}
109
+ className={styles.cancelButton}
111
110
  onClick={requestClose}
112
111
  disabled={isCloseBlocked}
113
112
  >
@@ -115,7 +114,7 @@ export const ArchiveCaseModal = ({
115
114
  </button>
116
115
  <button
117
116
  type="button"
118
- className={`${sharedStyles.confirmButton} ${styles.confirmButton}`}
117
+ className={`${styles.confirmButton} ${styles.confirmButtonDanger}`}
119
118
  onClick={() => {
120
119
  void handleSubmit();
121
120
  }}
@@ -92,3 +92,91 @@
92
92
  cursor: not-allowed;
93
93
  opacity: 0.6;
94
94
  }
95
+
96
+ /* Modal width variants */
97
+ .modalCompact {
98
+ width: min(400px, calc(100vw - 2rem));
99
+ }
100
+
101
+ .modalStandard {
102
+ width: min(460px, calc(100vw - 2rem));
103
+ }
104
+
105
+ .modalWide {
106
+ width: min(480px, calc(100vw - 2rem));
107
+ }
108
+
109
+ .modalLarge {
110
+ width: min(560px, calc(100vw - 2rem));
111
+ }
112
+
113
+ /* Confirm button color variants */
114
+ .confirmButtonDanger {
115
+ background: #dc3545;
116
+ color: #ffffff;
117
+ border-color: #c82333;
118
+ }
119
+
120
+ .confirmButtonPrimary {
121
+ background: #1f6feb;
122
+ color: #ffffff;
123
+ border-color: #1560d4;
124
+ }
125
+
126
+ .confirmButtonPrimary:not(:disabled):hover {
127
+ background: #1560d4;
128
+ }
129
+
130
+ .confirmButtonSuccess {
131
+ background: #198754;
132
+ color: #ffffff;
133
+ border-color: #157347;
134
+ }
135
+
136
+ .confirmButtonWarning {
137
+ background: #ffc107;
138
+ color: #3f2f00;
139
+ border-color: #e8b103;
140
+ }
141
+
142
+ /* Archive reason input */
143
+ .reasonLabel {
144
+ display: block;
145
+ margin-bottom: 0.35rem;
146
+ color: #495057;
147
+ font-size: 0.8rem;
148
+ font-weight: 600;
149
+ }
150
+
151
+ .reasonInput {
152
+ width: 100%;
153
+ box-sizing: border-box;
154
+ border: 1px solid #cdd5dd;
155
+ border-radius: 8px;
156
+ padding: 0.6rem 0.75rem;
157
+ font-size: 0.9rem;
158
+ font-family: inherit;
159
+ resize: vertical;
160
+ }
161
+
162
+ .reasonInput:focus {
163
+ outline: none;
164
+ border-color: #1f6feb;
165
+ box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.2);
166
+ }
167
+
168
+ /* Export description */
169
+ .description {
170
+ margin: 0 0 0.9rem;
171
+ color: #4b5563;
172
+ font-size: 0.86rem;
173
+ line-height: 1.5;
174
+ }
175
+
176
+ /* Export email error */
177
+ .emailError {
178
+ margin: 0.45rem 0 0;
179
+ color: #b91c1c;
180
+ font-size: 0.83rem;
181
+ line-height: 1.4;
182
+ }
@@ -1,6 +1,5 @@
1
1
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
2
- import sharedStyles from './case-modal-shared.module.css';
3
- import styles from './delete-case-modal.module.css';
2
+ import styles from './case-modal-shared.module.css';
4
3
 
5
4
  interface DeleteCaseModalProps {
6
5
  isOpen: boolean;
@@ -35,28 +34,28 @@ export const DeleteCaseModal = ({
35
34
 
36
35
  return (
37
36
  <div
38
- className={sharedStyles.overlay}
37
+ className={styles.overlay}
39
38
  aria-label="Close delete case dialog"
40
39
  {...overlayProps}
41
40
  >
42
- <div className={`${sharedStyles.modal} ${styles.modal}`} role="dialog" aria-modal="true" aria-label="Delete Case">
41
+ <div className={`${styles.modal} ${styles.modalLarge}`} role="dialog" aria-modal="true" aria-label="Delete Case">
43
42
  <button {...getCloseButtonProps({ ariaLabel: 'Close delete case dialog' })}>
44
43
  ×
45
44
  </button>
46
45
 
47
- <h3 className={sharedStyles.title}>Delete Case</h3>
48
- <p className={sharedStyles.subtitle}>Case: {currentCase}</p>
46
+ <h3 className={styles.title}>Delete Case</h3>
47
+ <p className={styles.subtitle}>Case: {currentCase}</p>
49
48
 
50
- <div className={sharedStyles.warningPanel}>
49
+ <div className={styles.warningPanel}>
51
50
  <p>This action permanently deletes the case and all associated files.</p>
52
51
  <p>This operation cannot be undone.</p>
53
52
  <p>Any image assets that are already missing will be skipped automatically.</p>
54
53
  </div>
55
54
 
56
- <div className={sharedStyles.actions}>
55
+ <div className={styles.actions}>
57
56
  <button
58
57
  type="button"
59
- className={sharedStyles.cancelButton}
58
+ className={styles.cancelButton}
60
59
  onClick={requestClose}
61
60
  disabled={isCloseBlocked}
62
61
  >
@@ -64,7 +63,7 @@ export const DeleteCaseModal = ({
64
63
  </button>
65
64
  <button
66
65
  type="button"
67
- className={`${sharedStyles.confirmButton} ${styles.confirmButton}`}
66
+ className={`${styles.confirmButton} ${styles.confirmButtonDanger}`}
68
67
  onClick={() => {
69
68
  void onSubmit();
70
69
  }}
@@ -1,7 +1,6 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
2
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
3
- import sharedStyles from './case-modal-shared.module.css';
4
- import styles from './export-case-modal.module.css';
3
+ import styles from './case-modal-shared.module.css';
5
4
 
6
5
  interface ExportCaseModalProps {
7
6
  isOpen: boolean;
@@ -69,12 +68,12 @@ export const ExportCaseModal = ({
69
68
 
70
69
  return (
71
70
  <div
72
- className={sharedStyles.overlay}
71
+ className={styles.overlay}
73
72
  aria-label="Close export case dialog"
74
73
  {...overlayProps}
75
74
  >
76
75
  <div
77
- className={`${sharedStyles.modal} ${styles.modal}`}
76
+ className={`${styles.modal} ${styles.modalWide}`}
78
77
  role="dialog"
79
78
  aria-modal="true"
80
79
  aria-label="Export Case"
@@ -82,8 +81,8 @@ export const ExportCaseModal = ({
82
81
  <button {...getCloseButtonProps({ ariaLabel: 'Close export case dialog' })}>
83
82
  ×
84
83
  </button>
85
- <h3 className={sharedStyles.title}>Export Case</h3>
86
- <p className={sharedStyles.subtitle}>Case: {caseNumber}</p>
84
+ <h3 className={styles.title}>Export Case</h3>
85
+ <p className={styles.subtitle}>Case: {caseNumber}</p>
87
86
  <p className={styles.description}>
88
87
  You may designate a specific email address for review approval. Only the user
89
88
  with the supplied email address will be able to open your case for review in
@@ -94,7 +93,7 @@ export const ExportCaseModal = ({
94
93
  type="email"
95
94
  value={email}
96
95
  onChange={(event) => setEmail(event.target.value)}
97
- className={sharedStyles.input}
96
+ className={styles.input}
98
97
  placeholder="Reviewer email address (optional)"
99
98
  disabled={isSubmitting}
100
99
  onKeyDown={(event) => {
@@ -108,10 +107,10 @@ export const ExportCaseModal = ({
108
107
  You cannot designate yourself as the reviewer. The recipient must be a different Striae user.
109
108
  </p>
110
109
  )}
111
- <div className={sharedStyles.actions}>
110
+ <div className={styles.actions}>
112
111
  <button
113
112
  type="button"
114
- className={sharedStyles.cancelButton}
113
+ className={styles.cancelButton}
115
114
  onClick={requestClose}
116
115
  disabled={isSubmitting}
117
116
  >
@@ -119,7 +118,7 @@ export const ExportCaseModal = ({
119
118
  </button>
120
119
  <button
121
120
  type="button"
122
- className={`${sharedStyles.confirmButton} ${styles.confirmButton}`}
121
+ className={`${styles.confirmButton} ${styles.confirmButtonPrimary}`}
123
122
  onClick={() => void handleSubmit()}
124
123
  disabled={isSubmitDisabled}
125
124
  >
@@ -1,7 +1,6 @@
1
1
  import { useEffect, useRef } from 'react';
2
2
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
3
- import sharedStyles from './case-modal-shared.module.css';
4
- import styles from './export-confirmations-modal.module.css';
3
+ import styles from './case-modal-shared.module.css';
5
4
 
6
5
  interface ExportConfirmationsModalProps {
7
6
  isOpen: boolean;
@@ -52,12 +51,12 @@ export const ExportConfirmationsModal = ({
52
51
 
53
52
  return (
54
53
  <div
55
- className={sharedStyles.overlay}
54
+ className={styles.overlay}
56
55
  aria-label="Close export confirmations dialog"
57
56
  {...overlayProps}
58
57
  >
59
58
  <div
60
- className={`${sharedStyles.modal} ${styles.modal}`}
59
+ className={`${styles.modal} ${styles.modalCompact}`}
61
60
  role="dialog"
62
61
  aria-modal="true"
63
62
  aria-label="Export Confirmations"
@@ -65,10 +64,10 @@ export const ExportConfirmationsModal = ({
65
64
  <button {...getCloseButtonProps({ ariaLabel: 'Close export confirmations dialog' })}>
66
65
  ×
67
66
  </button>
68
- <h3 className={sharedStyles.title}>Export Confirmations</h3>
69
- <p className={sharedStyles.subtitle}>Case: {caseNumber}</p>
67
+ <h3 className={styles.title}>Export Confirmations</h3>
68
+ <p className={styles.subtitle}>Case: {caseNumber}</p>
70
69
  {unconfirmedCount > 0 && (
71
- <div className={`${sharedStyles.warningPanel} ${styles.warningPanel}`}>
70
+ <div className={styles.warningPanel}>
72
71
  <p>
73
72
  <strong>
74
73
  {unconfirmedCount} image{unconfirmedCount !== 1 ? 's' : ''}{' '}
@@ -83,10 +82,10 @@ export const ExportConfirmationsModal = ({
83
82
  ? 'No confirmed images found for this case.'
84
83
  : `${confirmationLabel} will be exported.`}
85
84
  </p>
86
- <div className={sharedStyles.actions}>
85
+ <div className={styles.actions}>
87
86
  <button
88
87
  type="button"
89
- className={sharedStyles.cancelButton}
88
+ className={styles.cancelButton}
90
89
  onClick={requestClose}
91
90
  disabled={isSubmitting}
92
91
  >
@@ -95,7 +94,7 @@ export const ExportConfirmationsModal = ({
95
94
  <button
96
95
  ref={confirmButtonRef}
97
96
  type="button"
98
- className={`${sharedStyles.confirmButton} ${styles.confirmButton}`}
97
+ className={`${styles.confirmButton} ${styles.confirmButtonPrimary}`}
99
98
  onClick={onConfirm}
100
99
  disabled={isSubmitting || confirmedCount === 0}
101
100
  >
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
2
  import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
3
- import styles from './open-case-modal.module.css';
3
+ import styles from './case-modal-shared.module.css';
4
4
 
5
5
  interface OpenCaseModalProps {
6
6
  isOpen: boolean;
@@ -74,12 +74,12 @@ export const OpenCaseModal = ({
74
74
  aria-label="Close open case dialog"
75
75
  {...overlayProps}
76
76
  >
77
- <div className={styles.modal} role="dialog" aria-modal="true" aria-label="Open Case">
77
+ <div className={`${styles.modal} ${styles.modalStandard}`} role="dialog" aria-modal="true" aria-label="Open Case">
78
78
  <button {...getCloseButtonProps({ ariaLabel: 'Close open case dialog' })}>
79
79
  ×
80
80
  </button>
81
81
  <h3 className={styles.title}>{title}</h3>
82
- {helperText ? <p className={styles.helperText}>{helperText}</p> : null}
82
+ {helperText ? <p className={styles.subtitle}>{helperText}</p> : null}
83
83
  <input
84
84
  ref={inputRef}
85
85
  type="text"
@@ -105,7 +105,7 @@ export const OpenCaseModal = ({
105
105
  </button>
106
106
  <button
107
107
  type="button"
108
- className={styles.confirmButton}
108
+ className={`${styles.confirmButton} ${styles.confirmButtonSuccess}`}
109
109
  onClick={() => {
110
110
  void handleSubmit();
111
111
  }}