@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.
- package/.env.example +3 -0
- package/app/components/actions/generate-pdf.ts +22 -0
- package/app/components/auth/auth.module.css +531 -0
- package/app/components/auth/mfa-enrollment.tsx +132 -79
- package/app/components/auth/mfa-totp-enrollment.tsx +231 -0
- package/app/components/auth/mfa-verification.tsx +155 -33
- package/app/components/{sidebar/cases/cases-modal.tsx → navbar/case-modals/all-cases-modal.tsx} +4 -4
- package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -10
- package/app/components/navbar/case-modals/case-modal-shared.module.css +88 -0
- package/app/components/navbar/case-modals/delete-case-modal.tsx +9 -10
- package/app/components/navbar/case-modals/export-case-modal.tsx +9 -10
- package/app/components/navbar/case-modals/export-confirmations-modal.tsx +9 -10
- package/app/components/navbar/case-modals/open-case-modal.tsx +4 -4
- package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -10
- package/app/components/navbar/navbar.tsx +1 -1
- package/app/components/sidebar/files/delete-files-modal.tsx +3 -3
- package/app/components/sidebar/files/files-modal.module.css +29 -0
- package/app/components/sidebar/notes/{class-details-fields.tsx → class-details/class-details-fields.tsx} +1 -1
- package/app/components/sidebar/notes/{class-details-modal.tsx → class-details/class-details-modal.tsx} +1 -1
- package/app/components/sidebar/notes/{class-details-sections.tsx → class-details/class-details-sections.tsx} +1 -1
- package/app/components/sidebar/notes/notes-editor-form.tsx +2 -2
- package/app/components/sidebar/notes/notes-editor-modal.tsx +6 -6
- package/app/components/sidebar/notes/notes.module.css +52 -0
- package/app/components/toolbar/toolbar-color-selector.tsx +8 -8
- package/app/components/toolbar/toolbar.module.css +181 -2
- package/app/components/user/delete-account.tsx +7 -7
- package/app/components/user/inactivity-warning.tsx +6 -6
- package/app/components/user/manage-profile.tsx +18 -1
- package/app/components/user/mfa-enrolled-factors.tsx +117 -0
- package/app/components/user/mfa-phone-update.tsx +8 -4
- package/app/components/user/mfa-totp-section.tsx +446 -0
- package/app/components/user/user.module.css +665 -0
- package/app/routes/striae/striae.tsx +1 -1
- package/app/services/audit/audit.service.ts +1 -1
- package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
- package/app/services/firebase/errors.ts +2 -0
- package/app/utils/auth/mfa.ts +35 -1
- package/functions/api/image/[[path]].ts +19 -3
- package/package.json +16 -21
- package/scripts/deploy-all.sh +166 -0
- package/scripts/deploy-config/modules/env-utils.sh +322 -0
- package/scripts/deploy-config/modules/keys.sh +404 -0
- package/scripts/deploy-config/modules/prompt.sh +375 -0
- package/scripts/deploy-config/modules/scaffolding.sh +310 -0
- package/scripts/deploy-config/modules/validation.sh +354 -0
- package/scripts/deploy-config.sh +236 -0
- package/scripts/deploy-pages-secrets.sh +231 -0
- package/scripts/deploy-pages.sh +34 -0
- package/scripts/deploy-primershear-emails.sh +167 -0
- package/scripts/deploy-worker-secrets.sh +385 -0
- package/scripts/dev.cjs +23 -0
- package/scripts/enable-totp-mfa.mjs +57 -0
- package/scripts/install-workers.sh +87 -0
- package/scripts/run-eslint.cjs +43 -0
- package/scripts/update-compatibility-dates.cjs +124 -0
- package/scripts/update-markdown-versions.cjs +43 -0
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/package.json +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/package.json +1 -1
- package/workers/image-worker/src/image-worker.example.ts +36 -2
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/package.json +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/app/components/auth/mfa-enrollment.module.css +0 -276
- package/app/components/auth/mfa-verification.module.css +0 -259
- package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -34
- package/app/components/navbar/case-modals/delete-case-modal.module.css +0 -9
- package/app/components/navbar/case-modals/export-case-modal.module.css +0 -27
- package/app/components/navbar/case-modals/export-confirmations-modal.module.css +0 -24
- package/app/components/navbar/case-modals/open-case-modal.module.css +0 -82
- package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -9
- package/app/components/sidebar/files/delete-files-modal.module.css +0 -26
- package/app/components/sidebar/notes/notes-editor-modal.module.css +0 -49
- package/app/components/toolbar/toolbar-color-selector.module.css +0 -171
- package/app/components/user/delete-account.module.css +0 -277
- package/app/components/user/inactivity-warning.module.css +0 -148
- package/app/components/user/manage-profile.module.css +0 -192
- package/app/routes/auth/login.module.css +0 -523
- package/app/routes/auth/login.tsx +0 -705
- /package/app/components/{sidebar → navbar}/case-import/case-import.module.css +0 -0
- /package/app/components/{sidebar → navbar}/case-import/case-import.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/components/CasePreviewSection.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/components/ConfirmationDialog.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/components/ConfirmationPreviewSection.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/components/ExistingCaseSection.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/components/FileSelector.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/components/ProgressSection.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/hooks/useFilePreview.ts +0 -0
- /package/app/components/{sidebar → navbar}/case-import/hooks/useImportExecution.ts +0 -0
- /package/app/components/{sidebar → navbar}/case-import/hooks/useImportState.ts +0 -0
- /package/app/components/{sidebar → navbar}/case-import/index.ts +0 -0
- /package/app/components/{sidebar → navbar}/case-import/utils/file-validation.ts +0 -0
- /package/app/components/{sidebar/cases/cases-modal.module.css → navbar/case-modals/all-cases-modal.module.css} +0 -0
- /package/app/components/sidebar/notes/{class-details-shared.ts → class-details/class-details-shared.ts} +0 -0
- /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 './
|
|
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
|
|
307
|
+
{getHintLabel(hint, index)}
|
|
224
308
|
</option>
|
|
225
309
|
))}
|
|
226
310
|
</select>
|
|
227
311
|
</div>
|
|
228
312
|
)}
|
|
229
313
|
|
|
230
|
-
{
|
|
231
|
-
<div className={styles.sendCode}>
|
|
232
|
-
<p className={styles.description}>
|
|
233
|
-
We'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
|
|
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('');
|
|
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={
|
|
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'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}>
|
package/app/components/{sidebar/cases/cases-modal.tsx → navbar/case-modals/all-cases-modal.tsx}
RENAMED
|
@@ -15,9 +15,9 @@ import {
|
|
|
15
15
|
renameCase,
|
|
16
16
|
validateCaseNumber,
|
|
17
17
|
} from '~/components/actions/case-manage';
|
|
18
|
-
import { RenameCaseModal } from '
|
|
19
|
-
import { ArchiveCaseModal } from '
|
|
20
|
-
import { DeleteCaseModal } from '
|
|
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
|
|
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={
|
|
68
|
+
className={styles.overlay}
|
|
70
69
|
aria-label="Close archive case dialog"
|
|
71
70
|
{...overlayProps}
|
|
72
71
|
>
|
|
73
|
-
<div className={`${
|
|
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={
|
|
78
|
-
<p className={
|
|
76
|
+
<h3 className={styles.title}>Archive Case</h3>
|
|
77
|
+
<p className={styles.subtitle}>Case: {currentCase}</p>
|
|
79
78
|
|
|
80
|
-
<div className={
|
|
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={
|
|
106
|
+
<div className={styles.actions}>
|
|
108
107
|
<button
|
|
109
108
|
type="button"
|
|
110
|
-
className={
|
|
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={`${
|
|
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
|
|
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={
|
|
37
|
+
className={styles.overlay}
|
|
39
38
|
aria-label="Close delete case dialog"
|
|
40
39
|
{...overlayProps}
|
|
41
40
|
>
|
|
42
|
-
<div className={`${
|
|
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={
|
|
48
|
-
<p className={
|
|
46
|
+
<h3 className={styles.title}>Delete Case</h3>
|
|
47
|
+
<p className={styles.subtitle}>Case: {currentCase}</p>
|
|
49
48
|
|
|
50
|
-
<div className={
|
|
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={
|
|
55
|
+
<div className={styles.actions}>
|
|
57
56
|
<button
|
|
58
57
|
type="button"
|
|
59
|
-
className={
|
|
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={`${
|
|
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
|
|
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={
|
|
71
|
+
className={styles.overlay}
|
|
73
72
|
aria-label="Close export case dialog"
|
|
74
73
|
{...overlayProps}
|
|
75
74
|
>
|
|
76
75
|
<div
|
|
77
|
-
className={`${
|
|
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={
|
|
86
|
-
<p className={
|
|
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={
|
|
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={
|
|
110
|
+
<div className={styles.actions}>
|
|
112
111
|
<button
|
|
113
112
|
type="button"
|
|
114
|
-
className={
|
|
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={`${
|
|
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
|
|
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={
|
|
54
|
+
className={styles.overlay}
|
|
56
55
|
aria-label="Close export confirmations dialog"
|
|
57
56
|
{...overlayProps}
|
|
58
57
|
>
|
|
59
58
|
<div
|
|
60
|
-
className={`${
|
|
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={
|
|
69
|
-
<p className={
|
|
67
|
+
<h3 className={styles.title}>Export Confirmations</h3>
|
|
68
|
+
<p className={styles.subtitle}>Case: {caseNumber}</p>
|
|
70
69
|
{unconfirmedCount > 0 && (
|
|
71
|
-
<div className={
|
|
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={
|
|
85
|
+
<div className={styles.actions}>
|
|
87
86
|
<button
|
|
88
87
|
type="button"
|
|
89
|
-
className={
|
|
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={`${
|
|
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 './
|
|
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.
|
|
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
|
}}
|