@striae-org/striae 5.3.0 → 5.3.2
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/case-export/core-export.ts +3 -0
- package/app/components/actions/case-export/download-handlers.ts +1 -1
- package/app/components/actions/case-import/confirmation-import.ts +62 -22
- package/app/components/actions/case-import/confirmation-package.ts +68 -1
- package/app/components/actions/case-import/index.ts +1 -1
- package/app/components/actions/case-import/orchestrator.ts +78 -53
- package/app/components/actions/case-import/zip-processing.ts +157 -407
- package/app/components/actions/generate-pdf.ts +22 -0
- package/app/components/navbar/case-modals/export-case-modal.module.css +27 -0
- package/app/components/navbar/case-modals/export-case-modal.tsx +132 -0
- package/app/components/navbar/case-modals/export-confirmations-modal.module.css +24 -0
- package/app/components/navbar/case-modals/export-confirmations-modal.tsx +108 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +1 -9
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +36 -5
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +5 -9
- package/app/components/sidebar/case-import/index.ts +1 -4
- package/app/routes/auth/login.tsx +22 -103
- package/app/routes/striae/striae.tsx +77 -13
- package/app/types/case.ts +1 -0
- package/app/types/export.ts +1 -0
- package/app/types/import.ts +10 -0
- package/functions/api/image/[[path]].ts +19 -3
- package/package.json +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/wrangler.jsonc.example +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/wrangler.jsonc.example +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
|
@@ -64,6 +64,28 @@ const resolvePdfImageUrl = async (selectedImage: string | undefined): Promise<st
|
|
|
64
64
|
return await blobToDataUrl(imageBlob);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Signed image URLs routed through the Pages proxy contain a ?st= token.
|
|
68
|
+
// Pre-fetch the image client-side and embed as a data URL so the PDF worker's
|
|
69
|
+
// Puppeteer context doesn't need to make outbound requests for the image.
|
|
70
|
+
if (selectedImage.startsWith('http://') || selectedImage.startsWith('https://')) {
|
|
71
|
+
let parsedUrl: URL;
|
|
72
|
+
try {
|
|
73
|
+
parsedUrl = new URL(selectedImage);
|
|
74
|
+
} catch {
|
|
75
|
+
return selectedImage;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (parsedUrl.searchParams.has('st')) {
|
|
79
|
+
const imageResponse = await fetch(selectedImage);
|
|
80
|
+
if (!imageResponse.ok) {
|
|
81
|
+
throw new Error('Failed to load selected image for PDF generation');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const imageBlob = await imageResponse.blob();
|
|
85
|
+
return await blobToDataUrl(imageBlob);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
return selectedImage;
|
|
68
90
|
};
|
|
69
91
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
.modal {
|
|
2
|
+
width: min(480px, calc(100vw - 2rem));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.description {
|
|
6
|
+
margin: 0 0 0.9rem;
|
|
7
|
+
color: #4b5563;
|
|
8
|
+
font-size: 0.86rem;
|
|
9
|
+
line-height: 1.5;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.confirmButton {
|
|
13
|
+
background: #1f6feb;
|
|
14
|
+
color: #ffffff;
|
|
15
|
+
border-color: #1560d4;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.confirmButton:not(:disabled):hover {
|
|
19
|
+
background: #1560d4;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.emailError {
|
|
23
|
+
margin: 0.45rem 0 0;
|
|
24
|
+
color: #b91c1c;
|
|
25
|
+
font-size: 0.83rem;
|
|
26
|
+
line-height: 1.4;
|
|
27
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
|
|
3
|
+
import sharedStyles from './case-modal-shared.module.css';
|
|
4
|
+
import styles from './export-case-modal.module.css';
|
|
5
|
+
|
|
6
|
+
interface ExportCaseModalProps {
|
|
7
|
+
isOpen: boolean;
|
|
8
|
+
caseNumber: string;
|
|
9
|
+
currentUserEmail?: string;
|
|
10
|
+
isSubmitting?: boolean;
|
|
11
|
+
onClose: () => void;
|
|
12
|
+
onSubmit: (designatedReviewerEmail: string | undefined) => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const ExportCaseModal = ({
|
|
16
|
+
isOpen,
|
|
17
|
+
caseNumber,
|
|
18
|
+
currentUserEmail,
|
|
19
|
+
isSubmitting = false,
|
|
20
|
+
onClose,
|
|
21
|
+
onSubmit,
|
|
22
|
+
}: ExportCaseModalProps) => {
|
|
23
|
+
const [email, setEmail] = useState<string>('');
|
|
24
|
+
|
|
25
|
+
const isSelfEmail =
|
|
26
|
+
email.trim().length > 0 &&
|
|
27
|
+
!!currentUserEmail &&
|
|
28
|
+
email.trim().toLowerCase() === currentUserEmail.toLowerCase();
|
|
29
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
30
|
+
|
|
31
|
+
const handleClose = () => {
|
|
32
|
+
setEmail('');
|
|
33
|
+
onClose();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const isSubmitDisabled = isSubmitting || isSelfEmail;
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
requestClose,
|
|
40
|
+
overlayProps,
|
|
41
|
+
getCloseButtonProps,
|
|
42
|
+
} = useOverlayDismiss({
|
|
43
|
+
isOpen,
|
|
44
|
+
onClose: handleClose,
|
|
45
|
+
canDismiss: !isSubmitting,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!isOpen) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const focusId = window.requestAnimationFrame(() => {
|
|
54
|
+
inputRef.current?.focus();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
window.cancelAnimationFrame(focusId);
|
|
59
|
+
};
|
|
60
|
+
}, [isOpen]);
|
|
61
|
+
|
|
62
|
+
if (!isOpen) return null;
|
|
63
|
+
|
|
64
|
+
const handleSubmit = async () => {
|
|
65
|
+
const trimmed = email.trim() || undefined;
|
|
66
|
+
await onSubmit(trimmed);
|
|
67
|
+
setEmail('');
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className={sharedStyles.overlay}
|
|
73
|
+
aria-label="Close export case dialog"
|
|
74
|
+
{...overlayProps}
|
|
75
|
+
>
|
|
76
|
+
<div
|
|
77
|
+
className={`${sharedStyles.modal} ${styles.modal}`}
|
|
78
|
+
role="dialog"
|
|
79
|
+
aria-modal="true"
|
|
80
|
+
aria-label="Export Case"
|
|
81
|
+
>
|
|
82
|
+
<button {...getCloseButtonProps({ ariaLabel: 'Close export case dialog' })}>
|
|
83
|
+
×
|
|
84
|
+
</button>
|
|
85
|
+
<h3 className={sharedStyles.title}>Export Case</h3>
|
|
86
|
+
<p className={sharedStyles.subtitle}>Case: {caseNumber}</p>
|
|
87
|
+
<p className={styles.description}>
|
|
88
|
+
You may designate a specific email address for review approval. Only the user
|
|
89
|
+
with the supplied email address will be able to open your case for review in
|
|
90
|
+
Striae. (Optional)
|
|
91
|
+
</p>
|
|
92
|
+
<input
|
|
93
|
+
ref={inputRef}
|
|
94
|
+
type="email"
|
|
95
|
+
value={email}
|
|
96
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
97
|
+
className={sharedStyles.input}
|
|
98
|
+
placeholder="Reviewer email address (optional)"
|
|
99
|
+
disabled={isSubmitting}
|
|
100
|
+
onKeyDown={(event) => {
|
|
101
|
+
if (event.key === 'Enter' && !isSubmitDisabled) {
|
|
102
|
+
void handleSubmit();
|
|
103
|
+
}
|
|
104
|
+
}}
|
|
105
|
+
/>
|
|
106
|
+
{isSelfEmail && (
|
|
107
|
+
<p className={styles.emailError}>
|
|
108
|
+
You cannot designate yourself as the reviewer. The recipient must be a different Striae user.
|
|
109
|
+
</p>
|
|
110
|
+
)}
|
|
111
|
+
<div className={sharedStyles.actions}>
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
className={sharedStyles.cancelButton}
|
|
115
|
+
onClick={requestClose}
|
|
116
|
+
disabled={isSubmitting}
|
|
117
|
+
>
|
|
118
|
+
Cancel
|
|
119
|
+
</button>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
className={`${sharedStyles.confirmButton} ${styles.confirmButton}`}
|
|
123
|
+
onClick={() => void handleSubmit()}
|
|
124
|
+
disabled={isSubmitDisabled}
|
|
125
|
+
>
|
|
126
|
+
{isSubmitting ? 'Exporting...' : 'Export Case'}
|
|
127
|
+
</button>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.modal {
|
|
2
|
+
width: min(400px, calc(100vw - 2rem));
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.warningPanel {
|
|
6
|
+
margin-bottom: 0.8rem;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.description {
|
|
10
|
+
margin: 0 0 0.3rem;
|
|
11
|
+
color: #4b5563;
|
|
12
|
+
font-size: 0.86rem;
|
|
13
|
+
line-height: 1.5;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.confirmButton {
|
|
17
|
+
background: #1f6feb;
|
|
18
|
+
color: #ffffff;
|
|
19
|
+
border-color: #1560d4;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.confirmButton:not(:disabled):hover {
|
|
23
|
+
background: #1560d4;
|
|
24
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { useOverlayDismiss } from '~/hooks/useOverlayDismiss';
|
|
3
|
+
import sharedStyles from './case-modal-shared.module.css';
|
|
4
|
+
import styles from './export-confirmations-modal.module.css';
|
|
5
|
+
|
|
6
|
+
interface ExportConfirmationsModalProps {
|
|
7
|
+
isOpen: boolean;
|
|
8
|
+
caseNumber: string;
|
|
9
|
+
confirmedCount: number;
|
|
10
|
+
unconfirmedCount: number;
|
|
11
|
+
isSubmitting?: boolean;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
onConfirm: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const ExportConfirmationsModal = ({
|
|
17
|
+
isOpen,
|
|
18
|
+
caseNumber,
|
|
19
|
+
confirmedCount,
|
|
20
|
+
unconfirmedCount,
|
|
21
|
+
isSubmitting = false,
|
|
22
|
+
onClose,
|
|
23
|
+
onConfirm,
|
|
24
|
+
}: ExportConfirmationsModalProps) => {
|
|
25
|
+
const confirmButtonRef = useRef<HTMLButtonElement>(null);
|
|
26
|
+
|
|
27
|
+
const {
|
|
28
|
+
requestClose,
|
|
29
|
+
overlayProps,
|
|
30
|
+
getCloseButtonProps,
|
|
31
|
+
} = useOverlayDismiss({
|
|
32
|
+
isOpen,
|
|
33
|
+
onClose,
|
|
34
|
+
canDismiss: !isSubmitting,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!isOpen) return;
|
|
39
|
+
|
|
40
|
+
const focusId = window.requestAnimationFrame(() => {
|
|
41
|
+
confirmButtonRef.current?.focus();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
window.cancelAnimationFrame(focusId);
|
|
46
|
+
};
|
|
47
|
+
}, [isOpen]);
|
|
48
|
+
|
|
49
|
+
if (!isOpen) return null;
|
|
50
|
+
|
|
51
|
+
const confirmationLabel = confirmedCount === 1 ? '1 confirmation' : `${confirmedCount} confirmations`;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
className={sharedStyles.overlay}
|
|
56
|
+
aria-label="Close export confirmations dialog"
|
|
57
|
+
{...overlayProps}
|
|
58
|
+
>
|
|
59
|
+
<div
|
|
60
|
+
className={`${sharedStyles.modal} ${styles.modal}`}
|
|
61
|
+
role="dialog"
|
|
62
|
+
aria-modal="true"
|
|
63
|
+
aria-label="Export Confirmations"
|
|
64
|
+
>
|
|
65
|
+
<button {...getCloseButtonProps({ ariaLabel: 'Close export confirmations dialog' })}>
|
|
66
|
+
×
|
|
67
|
+
</button>
|
|
68
|
+
<h3 className={sharedStyles.title}>Export Confirmations</h3>
|
|
69
|
+
<p className={sharedStyles.subtitle}>Case: {caseNumber}</p>
|
|
70
|
+
{unconfirmedCount > 0 && (
|
|
71
|
+
<div className={`${sharedStyles.warningPanel} ${styles.warningPanel}`}>
|
|
72
|
+
<p>
|
|
73
|
+
<strong>
|
|
74
|
+
{unconfirmedCount} image{unconfirmedCount !== 1 ? 's' : ''}{' '}
|
|
75
|
+
{unconfirmedCount !== 1 ? 'are' : 'is'} unconfirmed.
|
|
76
|
+
</strong>
|
|
77
|
+
</p>
|
|
78
|
+
<p>Only confirmed images will be included in this export.</p>
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
<p className={styles.description}>
|
|
82
|
+
{confirmedCount === 0
|
|
83
|
+
? 'No confirmed images found for this case.'
|
|
84
|
+
: `${confirmationLabel} will be exported.`}
|
|
85
|
+
</p>
|
|
86
|
+
<div className={sharedStyles.actions}>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
className={sharedStyles.cancelButton}
|
|
90
|
+
onClick={requestClose}
|
|
91
|
+
disabled={isSubmitting}
|
|
92
|
+
>
|
|
93
|
+
Cancel
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
ref={confirmButtonRef}
|
|
97
|
+
type="button"
|
|
98
|
+
className={`${sharedStyles.confirmButton} ${styles.confirmButton}`}
|
|
99
|
+
onClick={onConfirm}
|
|
100
|
+
disabled={isSubmitting || confirmedCount === 0}
|
|
101
|
+
>
|
|
102
|
+
{isSubmitting ? 'Exporting...' : 'Export Confirmations'}
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
};
|
|
@@ -43,17 +43,10 @@ export const CasePreviewSection = ({
|
|
|
43
43
|
|
|
44
44
|
if (!casePreview) return null;
|
|
45
45
|
|
|
46
|
-
const isEncrypted = casePreview.caseNumber === 'ENCRYPTED';
|
|
47
|
-
|
|
48
46
|
return (
|
|
49
47
|
<div className={styles.previewSection}>
|
|
50
48
|
<h3 className={styles.previewTitle}>Case Import Preview</h3>
|
|
51
|
-
{
|
|
52
|
-
<p className={styles.previewMessage}>
|
|
53
|
-
Encrypted package detected. Case details could not be read from the package.
|
|
54
|
-
</p>
|
|
55
|
-
) : (
|
|
56
|
-
<div className={styles.previewMeta}>
|
|
49
|
+
<div className={styles.previewMeta}>
|
|
57
50
|
<div className={styles.previewMetaRow}>
|
|
58
51
|
<span className={styles.previewMetaLabel}>Case</span>
|
|
59
52
|
<span className={styles.previewMetaValue}>{casePreview.caseNumber}</span>
|
|
@@ -89,7 +82,6 @@ export const CasePreviewSection = ({
|
|
|
89
82
|
</div>
|
|
90
83
|
)}
|
|
91
84
|
</div>
|
|
92
|
-
)}
|
|
93
85
|
{casePreview.archived && (
|
|
94
86
|
<div className={styles.archivedImportNote}>
|
|
95
87
|
{ARCHIVED_SELF_IMPORT_NOTE}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import { type ConfirmationImportPreview } from '~/types';
|
|
1
2
|
import styles from '../case-import.module.css';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
function formatDate(isoDate: string | undefined): string {
|
|
5
|
+
if (!isoDate) return 'Unknown';
|
|
6
|
+
const date = new Date(isoDate);
|
|
7
|
+
if (Number.isNaN(date.getTime())) return isoDate;
|
|
8
|
+
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
9
|
+
}
|
|
4
10
|
|
|
5
11
|
interface ConfirmationPreviewSectionProps {
|
|
6
|
-
confirmationPreview:
|
|
12
|
+
confirmationPreview: ConfirmationImportPreview | null;
|
|
7
13
|
isLoadingPreview: boolean;
|
|
8
14
|
}
|
|
9
15
|
|
|
@@ -23,9 +29,34 @@ export const ConfirmationPreviewSection = ({ confirmationPreview, isLoadingPrevi
|
|
|
23
29
|
return (
|
|
24
30
|
<div className={styles.previewSection}>
|
|
25
31
|
<h3 className={styles.previewTitle}>Confirmation Import Preview</h3>
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
<div className={styles.previewMeta}>
|
|
33
|
+
<div className={styles.previewMetaRow}>
|
|
34
|
+
<span className={styles.previewMetaLabel}>Case</span>
|
|
35
|
+
<span className={styles.previewMetaValue}>{confirmationPreview.caseNumber}</span>
|
|
36
|
+
</div>
|
|
37
|
+
{(confirmationPreview.exportedByName || confirmationPreview.exportedBy) && (
|
|
38
|
+
<div className={styles.previewMetaRow}>
|
|
39
|
+
<span className={styles.previewMetaLabel}>Exported by</span>
|
|
40
|
+
<span className={styles.previewMetaValue}>
|
|
41
|
+
{confirmationPreview.exportedByName || confirmationPreview.exportedBy}
|
|
42
|
+
</span>
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
{confirmationPreview.exportedByCompany && (
|
|
46
|
+
<div className={styles.previewMetaRow}>
|
|
47
|
+
<span className={styles.previewMetaLabel}>Organization</span>
|
|
48
|
+
<span className={styles.previewMetaValue}>{confirmationPreview.exportedByCompany}</span>
|
|
49
|
+
</div>
|
|
50
|
+
)}
|
|
51
|
+
<div className={styles.previewMetaRow}>
|
|
52
|
+
<span className={styles.previewMetaLabel}>Exported</span>
|
|
53
|
+
<span className={styles.previewMetaValue}>{formatDate(confirmationPreview.exportDate)}</span>
|
|
54
|
+
</div>
|
|
55
|
+
<div className={styles.previewMetaRow}>
|
|
56
|
+
<span className={styles.previewMetaLabel}>Confirmations</span>
|
|
57
|
+
<span className={styles.previewMetaValue}>{confirmationPreview.totalConfirmations}</span>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
29
60
|
</div>
|
|
30
61
|
);
|
|
31
62
|
};
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { useState, useCallback } from 'react';
|
|
2
2
|
import type { User } from 'firebase/auth';
|
|
3
|
-
import { previewCaseImport,
|
|
4
|
-
import { type CaseImportPreview } from '~/types';
|
|
5
|
-
import { type ConfirmationPreview } from '../components/ConfirmationPreviewSection';
|
|
3
|
+
import { previewCaseImport, previewConfirmationImport } from '~/components/actions/case-review';
|
|
4
|
+
import { type CaseImportPreview, type ConfirmationImportPreview } from '~/types';
|
|
6
5
|
|
|
7
6
|
interface UseFilePreviewReturn {
|
|
8
7
|
casePreview: CaseImportPreview | null;
|
|
9
|
-
confirmationPreview:
|
|
8
|
+
confirmationPreview: ConfirmationImportPreview | null;
|
|
10
9
|
loadCasePreview: (file: File) => Promise<void>;
|
|
11
10
|
loadConfirmationPreview: (file: File) => Promise<void>;
|
|
12
11
|
clearPreviews: () => void;
|
|
@@ -22,7 +21,7 @@ export const useFilePreview = (
|
|
|
22
21
|
clearImportData: () => void
|
|
23
22
|
): UseFilePreviewReturn => {
|
|
24
23
|
const [casePreview, setCasePreview] = useState<CaseImportPreview | null>(null);
|
|
25
|
-
const [confirmationPreview, setConfirmationPreview] = useState<
|
|
24
|
+
const [confirmationPreview, setConfirmationPreview] = useState<ConfirmationImportPreview | null>(null);
|
|
26
25
|
|
|
27
26
|
const loadCasePreview = useCallback(async (file: File) => {
|
|
28
27
|
if (!user) {
|
|
@@ -51,10 +50,7 @@ export const useFilePreview = (
|
|
|
51
50
|
|
|
52
51
|
setIsLoadingPreview(true);
|
|
53
52
|
try {
|
|
54
|
-
await
|
|
55
|
-
|
|
56
|
-
const preview: ConfirmationPreview = {};
|
|
57
|
-
|
|
53
|
+
const preview = await previewConfirmationImport(file, user);
|
|
58
54
|
setConfirmationPreview(preview);
|
|
59
55
|
} catch (error) {
|
|
60
56
|
console.error('Error loading confirmation preview:', error);
|
|
@@ -12,7 +12,4 @@ export { useFilePreview } from './hooks/useFilePreview';
|
|
|
12
12
|
export { useImportExecution } from './hooks/useImportExecution';
|
|
13
13
|
|
|
14
14
|
// Utils
|
|
15
|
-
export * from './utils/file-validation';
|
|
16
|
-
|
|
17
|
-
// Types
|
|
18
|
-
export type { ConfirmationPreview } from './components/ConfirmationPreviewSection';
|
|
15
|
+
export * from './utils/file-validation';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useState, useEffect, useRef } from 'react';
|
|
2
|
-
import { Link, useSearchParams
|
|
2
|
+
import { Link, useSearchParams } from 'react-router';
|
|
3
3
|
import { auth } from '~/services/firebase';
|
|
4
4
|
import {
|
|
5
|
-
signInWithEmailAndPassword,
|
|
5
|
+
signInWithEmailAndPassword,
|
|
6
6
|
createUserWithEmailAndPassword,
|
|
7
7
|
onAuthStateChanged,
|
|
8
8
|
sendEmailVerification,
|
|
@@ -28,95 +28,7 @@ import { generateUniqueId } from '~/utils/common';
|
|
|
28
28
|
import { evaluatePasswordPolicy, buildActionCodeSettings, userHasMFA } from '~/utils/auth';
|
|
29
29
|
import type { UserData } from '~/types';
|
|
30
30
|
|
|
31
|
-
const
|
|
32
|
-
const SOCIAL_IMAGE_PATH = '/social-image.png';
|
|
33
|
-
const SOCIAL_IMAGE_ALT = 'Striae forensic annotation and comparison workspace';
|
|
34
|
-
const LOGIN_PATH_ALIASES = new Set(['/auth', '/auth/', '/auth/login', '/auth/login/']);
|
|
35
|
-
|
|
36
|
-
type AuthMetaContent = {
|
|
37
|
-
title: string;
|
|
38
|
-
description: string;
|
|
39
|
-
robots: string;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const getCanonicalPath = (pathname: string): string => {
|
|
43
|
-
if (!pathname || LOGIN_PATH_ALIASES.has(pathname)) {
|
|
44
|
-
return '/';
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return pathname.startsWith('/') ? pathname : `/${pathname}`;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const getAuthMetaContent = (mode: string | null, hasActionCode: boolean): AuthMetaContent => {
|
|
51
|
-
if (!mode && !hasActionCode) {
|
|
52
|
-
return {
|
|
53
|
-
title: 'Striae: A Firearms Examiner\'s Comparison Companion',
|
|
54
|
-
description: 'Sign in to Striae to access your comparison annotation workspace, case files, and review tools.',
|
|
55
|
-
robots: 'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1',
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (mode === 'resetPassword') {
|
|
60
|
-
return {
|
|
61
|
-
title: 'Striae | Reset Your Password',
|
|
62
|
-
description: 'Use this secure page to reset your Striae account password and restore access to your workspace.',
|
|
63
|
-
robots: 'noindex,nofollow,noarchive',
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (mode === 'verifyEmail') {
|
|
68
|
-
return {
|
|
69
|
-
title: 'Striae | Verify Your Email Address',
|
|
70
|
-
description: 'Confirm your email address to complete Striae account activation and continue securely.',
|
|
71
|
-
robots: 'noindex,nofollow,noarchive',
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (mode === 'recoverEmail') {
|
|
76
|
-
return {
|
|
77
|
-
title: 'Striae | Recover Email Access',
|
|
78
|
-
description: 'Complete your Striae account email recovery steps securely.',
|
|
79
|
-
robots: 'noindex,nofollow,noarchive',
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return {
|
|
84
|
-
title: 'Striae | Account Action',
|
|
85
|
-
description: 'Complete your Striae account action securely.',
|
|
86
|
-
robots: 'noindex,nofollow,noarchive',
|
|
87
|
-
};
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
export const meta: MetaFunction = ({ location }) => {
|
|
91
|
-
const searchParams = new URLSearchParams(location.search);
|
|
92
|
-
const mode = searchParams.get('mode');
|
|
93
|
-
const hasActionCode = Boolean(searchParams.get('oobCode'));
|
|
94
|
-
|
|
95
|
-
const canonicalPath = getCanonicalPath(location.pathname);
|
|
96
|
-
const canonicalHref = `${APP_CANONICAL_ORIGIN}${canonicalPath}`;
|
|
97
|
-
const socialImageHref = `${APP_CANONICAL_ORIGIN}${SOCIAL_IMAGE_PATH}`;
|
|
98
|
-
const { title, description, robots } = getAuthMetaContent(mode, hasActionCode);
|
|
99
|
-
|
|
100
|
-
return [
|
|
101
|
-
{ title },
|
|
102
|
-
{ name: 'description', content: description },
|
|
103
|
-
{ name: 'robots', content: robots },
|
|
104
|
-
{ property: 'og:site_name', content: 'Striae' },
|
|
105
|
-
{ property: 'og:type', content: 'website' },
|
|
106
|
-
{ property: 'og:url', content: canonicalHref },
|
|
107
|
-
{ property: 'og:title', content: title },
|
|
108
|
-
{ property: 'og:description', content: description },
|
|
109
|
-
{ property: 'og:image', content: socialImageHref },
|
|
110
|
-
{ property: 'og:image:secure_url', content: socialImageHref },
|
|
111
|
-
{ property: 'og:image:alt', content: SOCIAL_IMAGE_ALT },
|
|
112
|
-
{ name: 'twitter:card', content: 'summary_large_image' },
|
|
113
|
-
{ name: 'twitter:title', content: title },
|
|
114
|
-
{ name: 'twitter:description', content: description },
|
|
115
|
-
{ name: 'twitter:image', content: socialImageHref },
|
|
116
|
-
{ name: 'twitter:image:alt', content: SOCIAL_IMAGE_ALT },
|
|
117
|
-
{ tagName: 'link', rel: 'canonical', href: canonicalHref },
|
|
118
|
-
];
|
|
119
|
-
};
|
|
31
|
+
const DEMO_COMPANY_NAME = 'STRIAE DEMO';
|
|
120
32
|
|
|
121
33
|
const SUPPORTED_EMAIL_ACTION_MODES = new Set(['resetPassword', 'verifyEmail', 'recoverEmail']);
|
|
122
34
|
|
|
@@ -157,7 +69,8 @@ export const Login = () => {
|
|
|
157
69
|
const [isClient, setIsClient] = useState(false);
|
|
158
70
|
const [firstName, setFirstName] = useState('');
|
|
159
71
|
const [lastName, setLastName] = useState('');
|
|
160
|
-
const [company, setCompany] = useState(
|
|
72
|
+
const [company, setCompany] = useState(DEMO_COMPANY_NAME);
|
|
73
|
+
const [badgeId, setBadgeId] = useState('');
|
|
161
74
|
const [confirmPasswordValue, setConfirmPasswordValue] = useState('');
|
|
162
75
|
|
|
163
76
|
// MFA state
|
|
@@ -251,7 +164,6 @@ export const Login = () => {
|
|
|
251
164
|
}
|
|
252
165
|
|
|
253
166
|
// Check if user exists in the USER_DB
|
|
254
|
-
let hasBadgeId = true;
|
|
255
167
|
setIsCheckingUser(true);
|
|
256
168
|
try {
|
|
257
169
|
const userData = await checkUserExists(currentUser);
|
|
@@ -262,8 +174,6 @@ export const Login = () => {
|
|
|
262
174
|
setError('This account does not exist or has been deleted');
|
|
263
175
|
return;
|
|
264
176
|
}
|
|
265
|
-
|
|
266
|
-
hasBadgeId = Boolean(userData.badgeId?.trim());
|
|
267
177
|
} catch (error) {
|
|
268
178
|
setIsCheckingUser(false);
|
|
269
179
|
handleSignOut();
|
|
@@ -282,13 +192,8 @@ export const Login = () => {
|
|
|
282
192
|
setShowMfaEnrollment(false);
|
|
283
193
|
|
|
284
194
|
if (shouldShowWelcomeToastRef.current) {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
setWelcomeToastMessage(`Welcome to Striae, ${getUserFirstName(currentUser)}!`);
|
|
288
|
-
} else {
|
|
289
|
-
setWelcomeToastType('warning');
|
|
290
|
-
setWelcomeToastMessage('Your badge or ID number is not set. You can set one in Manage Profile.');
|
|
291
|
-
}
|
|
195
|
+
setWelcomeToastType('success');
|
|
196
|
+
setWelcomeToastMessage(`Welcome to Striae, ${getUserFirstName(currentUser)}!`);
|
|
292
197
|
setIsWelcomeToastVisible(true);
|
|
293
198
|
shouldShowWelcomeToastRef.current = false;
|
|
294
199
|
}
|
|
@@ -373,6 +278,7 @@ export const Login = () => {
|
|
|
373
278
|
const formFirstName = firstName;
|
|
374
279
|
const formLastName = lastName;
|
|
375
280
|
const formCompany = company;
|
|
281
|
+
const formBadgeId = badgeId;
|
|
376
282
|
|
|
377
283
|
try {
|
|
378
284
|
if (!isLogin) {
|
|
@@ -411,7 +317,8 @@ export const Login = () => {
|
|
|
411
317
|
formFirstName,
|
|
412
318
|
formLastName,
|
|
413
319
|
companyName || '',
|
|
414
|
-
true
|
|
320
|
+
true,
|
|
321
|
+
formBadgeId.trim()
|
|
415
322
|
);
|
|
416
323
|
|
|
417
324
|
// Log user registration audit event
|
|
@@ -691,6 +598,17 @@ export const Login = () => {
|
|
|
691
598
|
disabled={isLoading}
|
|
692
599
|
value={company}
|
|
693
600
|
onChange={(e) => setCompany(e.target.value)}
|
|
601
|
+
/>
|
|
602
|
+
<input
|
|
603
|
+
type="text"
|
|
604
|
+
name="badgeId"
|
|
605
|
+
required
|
|
606
|
+
placeholder="Badge/ID # (required)"
|
|
607
|
+
autoComplete="off"
|
|
608
|
+
className={styles.input}
|
|
609
|
+
disabled={isLoading}
|
|
610
|
+
value={badgeId}
|
|
611
|
+
onChange={(e) => setBadgeId(e.target.value)}
|
|
694
612
|
/>
|
|
695
613
|
{passwordStrength && (
|
|
696
614
|
<div className={styles.passwordStrength}>
|
|
@@ -740,6 +658,7 @@ export const Login = () => {
|
|
|
740
658
|
setFirstName('');
|
|
741
659
|
setLastName('');
|
|
742
660
|
setCompany('');
|
|
661
|
+
setBadgeId('');
|
|
743
662
|
setConfirmPasswordValue('');
|
|
744
663
|
}}
|
|
745
664
|
className={styles.toggleButton}
|