@striae-org/striae 3.0.4
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 +100 -0
- package/LICENSE +190 -0
- package/NOTICE +18 -0
- package/README.md +133 -0
- package/app/components/actions/case-export/core-export.ts +328 -0
- package/app/components/actions/case-export/data-processing.ts +167 -0
- package/app/components/actions/case-export/download-handlers.ts +900 -0
- package/app/components/actions/case-export/index.ts +41 -0
- package/app/components/actions/case-export/metadata-helpers.ts +107 -0
- package/app/components/actions/case-export/types-constants.ts +56 -0
- package/app/components/actions/case-export/validation-utils.ts +25 -0
- package/app/components/actions/case-export.ts +4 -0
- package/app/components/actions/case-import/annotation-import.ts +35 -0
- package/app/components/actions/case-import/confirmation-import.ts +363 -0
- package/app/components/actions/case-import/image-operations.ts +61 -0
- package/app/components/actions/case-import/index.ts +39 -0
- package/app/components/actions/case-import/orchestrator.ts +420 -0
- package/app/components/actions/case-import/storage-operations.ts +270 -0
- package/app/components/actions/case-import/validation.ts +189 -0
- package/app/components/actions/case-import/zip-processing.ts +413 -0
- package/app/components/actions/case-manage.ts +524 -0
- package/app/components/actions/case-review.ts +4 -0
- package/app/components/actions/confirm-export.ts +351 -0
- package/app/components/actions/generate-pdf.ts +210 -0
- package/app/components/actions/image-manage.ts +385 -0
- package/app/components/actions/notes-manage.ts +33 -0
- package/app/components/actions/signout.module.css +15 -0
- package/app/components/actions/signout.tsx +50 -0
- package/app/components/audit/user-audit-viewer.tsx +975 -0
- package/app/components/audit/user-audit.module.css +568 -0
- package/app/components/auth/auth-provider.tsx +78 -0
- package/app/components/auth/mfa-enrollment.module.css +268 -0
- package/app/components/auth/mfa-enrollment.tsx +398 -0
- package/app/components/auth/mfa-verification.module.css +251 -0
- package/app/components/auth/mfa-verification.tsx +295 -0
- package/app/components/button/button.module.css +63 -0
- package/app/components/button/button.tsx +46 -0
- package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
- package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
- package/app/components/canvas/canvas.module.css +314 -0
- package/app/components/canvas/canvas.tsx +449 -0
- package/app/components/canvas/confirmation/confirmation.module.css +187 -0
- package/app/components/canvas/confirmation/confirmation.tsx +214 -0
- package/app/components/colors/colors.module.css +59 -0
- package/app/components/colors/colors.tsx +68 -0
- package/app/components/form/base-form.tsx +21 -0
- package/app/components/form/form-button.tsx +28 -0
- package/app/components/form/form-field.tsx +53 -0
- package/app/components/form/form-message.tsx +17 -0
- package/app/components/form/form-toggle.tsx +23 -0
- package/app/components/form/form.module.css +427 -0
- package/app/components/form/index.ts +6 -0
- package/app/components/icon/icon.module.css +3 -0
- package/app/components/icon/icon.tsx +27 -0
- package/app/components/icon/icons.svg +102 -0
- package/app/components/icon/manifest.json +110 -0
- package/app/components/sidebar/case-export/case-export.module.css +386 -0
- package/app/components/sidebar/case-export/case-export.tsx +317 -0
- package/app/components/sidebar/case-import/case-import.module.css +626 -0
- package/app/components/sidebar/case-import/case-import.tsx +404 -0
- package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
- package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
- package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
- package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
- package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
- package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
- package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
- package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
- package/app/components/sidebar/case-import/index.ts +18 -0
- package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
- package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
- package/app/components/sidebar/cases/cases-modal.module.css +166 -0
- package/app/components/sidebar/cases/cases-modal.tsx +201 -0
- package/app/components/sidebar/cases/cases.module.css +713 -0
- package/app/components/sidebar/files/files-modal.module.css +209 -0
- package/app/components/sidebar/files/files-modal.tsx +239 -0
- package/app/components/sidebar/hash/hash-utility.module.css +366 -0
- package/app/components/sidebar/hash/hash-utility.tsx +982 -0
- package/app/components/sidebar/notes/notes-modal.tsx +51 -0
- package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
- package/app/components/sidebar/notes/notes.module.css +360 -0
- package/app/components/sidebar/sidebar-container.tsx +149 -0
- package/app/components/sidebar/sidebar.module.css +321 -0
- package/app/components/sidebar/sidebar.tsx +215 -0
- package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
- package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
- package/app/components/theme-provider/theme-provider.tsx +131 -0
- package/app/components/theme-provider/theme.ts +155 -0
- package/app/components/toast/toast.module.css +137 -0
- package/app/components/toast/toast.tsx +56 -0
- package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
- package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
- package/app/components/toolbar/toolbar.module.css +42 -0
- package/app/components/toolbar/toolbar.tsx +167 -0
- package/app/components/user/delete-account.module.css +274 -0
- package/app/components/user/delete-account.tsx +471 -0
- package/app/components/user/inactivity-warning.module.css +145 -0
- package/app/components/user/inactivity-warning.tsx +84 -0
- package/app/components/user/manage-profile.module.css +190 -0
- package/app/components/user/manage-profile.tsx +253 -0
- package/app/components/user/mfa-phone-update.tsx +739 -0
- package/app/config-example/admin-service.json +13 -0
- package/app/config-example/config.json +17 -0
- package/app/config-example/firebase.ts +21 -0
- package/app/config-example/inactivity.ts +13 -0
- package/app/config-example/meta-config.json +6 -0
- package/app/contexts/auth.context.ts +12 -0
- package/app/entry.client.tsx +12 -0
- package/app/entry.server.tsx +44 -0
- package/app/hooks/useInactivityTimeout.ts +110 -0
- package/app/root.tsx +170 -0
- package/app/routes/_index.tsx +16 -0
- package/app/routes/auth/emailActionHandler.module.css +232 -0
- package/app/routes/auth/emailActionHandler.tsx +405 -0
- package/app/routes/auth/emailVerification.tsx +120 -0
- package/app/routes/auth/login.module.css +523 -0
- package/app/routes/auth/login.tsx +654 -0
- package/app/routes/auth/passwordReset.module.css +274 -0
- package/app/routes/auth/passwordReset.tsx +154 -0
- package/app/routes/auth/route.ts +16 -0
- package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
- package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
- package/app/routes/mobile-prevented/route.ts +14 -0
- package/app/routes/striae/striae.module.css +30 -0
- package/app/routes/striae/striae.tsx +417 -0
- package/app/services/audit-export.service.ts +755 -0
- package/app/services/audit.service.ts +1454 -0
- package/app/services/firebase-errors.ts +106 -0
- package/app/services/firebase.ts +15 -0
- package/app/styles/legal-pages.module.css +113 -0
- package/app/styles/root.module.css +146 -0
- package/app/tailwind.css +225 -0
- package/app/types/annotations.ts +45 -0
- package/app/types/audit.ts +301 -0
- package/app/types/case.ts +90 -0
- package/app/types/export.ts +8 -0
- package/app/types/file.ts +30 -0
- package/app/types/import.ts +107 -0
- package/app/types/index.ts +24 -0
- package/app/types/user.ts +38 -0
- package/app/utils/SHA256.ts +461 -0
- package/app/utils/annotation-timestamp.ts +25 -0
- package/app/utils/audit-export-signature.ts +117 -0
- package/app/utils/auth-action-settings.ts +48 -0
- package/app/utils/auth.ts +34 -0
- package/app/utils/batch-operations.ts +135 -0
- package/app/utils/confirmation-signature.ts +193 -0
- package/app/utils/data-operations.ts +871 -0
- package/app/utils/device-detection.ts +5 -0
- package/app/utils/html-sanitizer.ts +80 -0
- package/app/utils/id-generator.ts +36 -0
- package/app/utils/meta.ts +48 -0
- package/app/utils/mfa-phone.ts +97 -0
- package/app/utils/mfa.ts +79 -0
- package/app/utils/password-policy.ts +28 -0
- package/app/utils/permissions.ts +562 -0
- package/app/utils/signature-utils.ts +160 -0
- package/app/utils/style.ts +83 -0
- package/app/utils/version.ts +5 -0
- package/firebase.json +11 -0
- package/functions/[[path]].ts +10 -0
- package/package.json +138 -0
- package/postcss.config.js +6 -0
- package/public/.well-known/publickey.info@striae.org.asc +17 -0
- package/public/.well-known/security.txt +7 -0
- package/public/_headers +28 -0
- package/public/_routes.json +13 -0
- package/public/assets/striae.jpg +0 -0
- package/public/clear.jpg +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +9 -0
- package/public/icon-256.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/logo-dark.png +0 -0
- package/public/manifest.json +25 -0
- package/public/oin-badge.png +0 -0
- package/public/shortcut.png +0 -0
- package/public/social-image.png +0 -0
- package/public/striae-ascii.txt +10 -0
- package/scripts/deploy-all.sh +100 -0
- package/scripts/deploy-config.sh +940 -0
- package/scripts/deploy-pages.sh +34 -0
- package/scripts/deploy-worker-secrets.sh +215 -0
- package/scripts/dev.cjs +23 -0
- package/scripts/install-workers.sh +88 -0
- package/scripts/run-eslint.cjs +35 -0
- package/scripts/update-compatibility-dates.cjs +124 -0
- package/scripts/update-markdown-versions.cjs +43 -0
- package/tailwind.config.ts +22 -0
- package/tsconfig.json +33 -0
- package/vite.config.ts +35 -0
- package/worker-configuration.d.ts +7490 -0
- package/workers/audit-worker/package.json +17 -0
- package/workers/audit-worker/src/audit-worker.example.ts +195 -0
- package/workers/audit-worker/worker-configuration.d.ts +7448 -0
- package/workers/audit-worker/wrangler.jsonc.example +29 -0
- package/workers/data-worker/package.json +17 -0
- package/workers/data-worker/src/data-worker.example.ts +267 -0
- package/workers/data-worker/src/signature-utils.ts +79 -0
- package/workers/data-worker/src/signing-payload-utils.ts +290 -0
- package/workers/data-worker/worker-configuration.d.ts +7448 -0
- package/workers/data-worker/wrangler.jsonc.example +30 -0
- package/workers/image-worker/package.json +17 -0
- package/workers/image-worker/src/image-worker.example.ts +180 -0
- package/workers/image-worker/worker-configuration.d.ts +7447 -0
- package/workers/image-worker/wrangler.jsonc.example +22 -0
- package/workers/keys-worker/package.json +17 -0
- package/workers/keys-worker/src/keys.example.ts +66 -0
- package/workers/keys-worker/src/keys.ts +66 -0
- package/workers/keys-worker/worker-configuration.d.ts +7447 -0
- package/workers/keys-worker/wrangler.jsonc.example +22 -0
- package/workers/pdf-worker/package.json +17 -0
- package/workers/pdf-worker/src/format-striae.ts +534 -0
- package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
- package/workers/pdf-worker/src/report-types.ts +69 -0
- package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
- package/workers/pdf-worker/wrangler.jsonc.example +26 -0
- package/workers/user-worker/package.json +17 -0
- package/workers/user-worker/src/user-worker.example.ts +636 -0
- package/workers/user-worker/worker-configuration.d.ts +7448 -0
- package/workers/user-worker/wrangler.jsonc.example +29 -0
- package/wrangler.toml.example +8 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
.overlay {
|
|
2
|
+
position: fixed;
|
|
3
|
+
top: 0;
|
|
4
|
+
left: 0;
|
|
5
|
+
right: 0;
|
|
6
|
+
bottom: 0;
|
|
7
|
+
background-color: rgba(0, 0, 0, 0.75);
|
|
8
|
+
display: flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
z-index: 1000;
|
|
12
|
+
padding: 1rem;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.modal {
|
|
16
|
+
background: white;
|
|
17
|
+
border-radius: 12px;
|
|
18
|
+
padding: 2rem;
|
|
19
|
+
max-width: 500px;
|
|
20
|
+
width: 100%;
|
|
21
|
+
max-height: 90vh;
|
|
22
|
+
overflow-y: auto;
|
|
23
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.header {
|
|
27
|
+
text-align: center;
|
|
28
|
+
margin-bottom: 2rem;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.header h2 {
|
|
32
|
+
color: #333;
|
|
33
|
+
margin: 0 0 1rem 0;
|
|
34
|
+
font-size: 1.5rem;
|
|
35
|
+
font-weight: 600;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.header p {
|
|
39
|
+
color: #666;
|
|
40
|
+
margin: 0;
|
|
41
|
+
line-height: 1.5;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.content {
|
|
45
|
+
margin-bottom: 2rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.phoneStep,
|
|
49
|
+
.codeStep {
|
|
50
|
+
text-align: center;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.phoneStep h3,
|
|
54
|
+
.codeStep h3 {
|
|
55
|
+
color: #333;
|
|
56
|
+
margin: 0 0 1rem 0;
|
|
57
|
+
font-size: 1.2rem;
|
|
58
|
+
font-weight: 500;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.input {
|
|
62
|
+
width: 100%;
|
|
63
|
+
padding: 1rem;
|
|
64
|
+
border: 2px solid #e1e5e9;
|
|
65
|
+
border-radius: 8px;
|
|
66
|
+
font-size: 1rem;
|
|
67
|
+
margin-bottom: 1rem;
|
|
68
|
+
transition: border-color 0.2s ease;
|
|
69
|
+
box-sizing: border-box;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.input:focus {
|
|
73
|
+
outline: none;
|
|
74
|
+
border-color: #4285f4;
|
|
75
|
+
box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.input:disabled {
|
|
79
|
+
background-color: #f5f5f5;
|
|
80
|
+
cursor: not-allowed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.note {
|
|
84
|
+
color: #666;
|
|
85
|
+
font-size: 0.9rem;
|
|
86
|
+
margin: 0 0 1.5rem 0;
|
|
87
|
+
line-height: 1.4;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.buttonGroup {
|
|
91
|
+
display: flex;
|
|
92
|
+
flex-direction: column;
|
|
93
|
+
gap: 0.75rem;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.primaryButton {
|
|
97
|
+
background-color: #4285f4;
|
|
98
|
+
color: white;
|
|
99
|
+
border: none;
|
|
100
|
+
padding: 1rem 2rem;
|
|
101
|
+
border-radius: 8px;
|
|
102
|
+
font-size: 1rem;
|
|
103
|
+
font-weight: 500;
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
transition: background-color 0.2s ease;
|
|
106
|
+
width: 100%;
|
|
107
|
+
box-sizing: border-box;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.primaryButton:hover:not(:disabled) {
|
|
111
|
+
background-color: #3367d6;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.primaryButton:disabled {
|
|
115
|
+
background-color: #ccc;
|
|
116
|
+
cursor: not-allowed;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.secondaryButton {
|
|
120
|
+
background-color: transparent;
|
|
121
|
+
color: #4285f4;
|
|
122
|
+
border: 2px solid #4285f4;
|
|
123
|
+
padding: 0.75rem 1.5rem;
|
|
124
|
+
border-radius: 8px;
|
|
125
|
+
font-size: 0.9rem;
|
|
126
|
+
font-weight: 500;
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
transition: all 0.2s ease;
|
|
129
|
+
width: 100%;
|
|
130
|
+
box-sizing: border-box;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.secondaryButton:hover:not(:disabled) {
|
|
134
|
+
background-color: #4285f4;
|
|
135
|
+
color: white;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.secondaryButton:disabled {
|
|
139
|
+
border-color: #ccc;
|
|
140
|
+
color: #ccc;
|
|
141
|
+
cursor: not-allowed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.resendTimer {
|
|
145
|
+
color: #999;
|
|
146
|
+
font-size: 0.9rem;
|
|
147
|
+
margin: 0;
|
|
148
|
+
text-align: center;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.errorMessage {
|
|
152
|
+
background: linear-gradient(135deg,
|
|
153
|
+
color-mix(in lab, var(--error) 12%, transparent),
|
|
154
|
+
color-mix(in lab, var(--error) 8%, transparent)
|
|
155
|
+
);
|
|
156
|
+
border: 1px solid color-mix(in lab, var(--error) 30%, transparent);
|
|
157
|
+
border-left: 4px solid var(--error);
|
|
158
|
+
color: color-mix(in lab, var(--error) 90%, var(--black));
|
|
159
|
+
padding: var(--spaceL);
|
|
160
|
+
border-radius: var(--spaceS);
|
|
161
|
+
font-size: 0.9rem;
|
|
162
|
+
margin-bottom: 1rem;
|
|
163
|
+
text-align: left;
|
|
164
|
+
line-height: 1.4;
|
|
165
|
+
position: relative;
|
|
166
|
+
overflow: hidden;
|
|
167
|
+
box-shadow: 0 4px 16px color-mix(in lab, var(--text) 10%, transparent);
|
|
168
|
+
animation: slideInError var(--durationM) var(--bezierFastoutSlowin);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.errorMessage::before {
|
|
172
|
+
content: '';
|
|
173
|
+
position: absolute;
|
|
174
|
+
top: 0;
|
|
175
|
+
left: 0;
|
|
176
|
+
right: 0;
|
|
177
|
+
height: 2px;
|
|
178
|
+
background: linear-gradient(90deg, var(--error), color-mix(in lab, var(--error) 60%, transparent));
|
|
179
|
+
animation: shimmer 2s ease-in-out infinite;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.errorMessage:empty {
|
|
183
|
+
display: none;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.footer {
|
|
187
|
+
border-top: 1px solid #e1e5e9;
|
|
188
|
+
padding-top: 1.5rem;
|
|
189
|
+
text-align: center;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.skipButton {
|
|
193
|
+
background-color: transparent;
|
|
194
|
+
color: #666;
|
|
195
|
+
border: none;
|
|
196
|
+
padding: 0.75rem 1.5rem;
|
|
197
|
+
border-radius: 8px;
|
|
198
|
+
font-size: 0.9rem;
|
|
199
|
+
cursor: pointer;
|
|
200
|
+
transition: color 0.2s ease;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.skipButton:hover:not(:disabled) {
|
|
204
|
+
color: #333;
|
|
205
|
+
text-decoration: underline;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.skipButton:disabled {
|
|
209
|
+
color: #ccc;
|
|
210
|
+
cursor: not-allowed;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.signOutContainer {
|
|
214
|
+
margin-top: var(--spaceL);
|
|
215
|
+
padding-top: var(--spaceL);
|
|
216
|
+
border-top: 1px solid var(--borderLight);
|
|
217
|
+
text-align: center;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.signOutText {
|
|
221
|
+
color: var(--textLight);
|
|
222
|
+
font-size: var(--fontSizeSmall);
|
|
223
|
+
margin-bottom: var(--spaceM);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* Animations */
|
|
227
|
+
@keyframes slideInError {
|
|
228
|
+
from {
|
|
229
|
+
opacity: 0;
|
|
230
|
+
transform: translateY(calc(-1 * var(--spaceM))) scaleY(0.8);
|
|
231
|
+
max-height: 0;
|
|
232
|
+
padding-top: 0;
|
|
233
|
+
padding-bottom: 0;
|
|
234
|
+
margin-top: 0;
|
|
235
|
+
margin-bottom: 0;
|
|
236
|
+
}
|
|
237
|
+
to {
|
|
238
|
+
opacity: 1;
|
|
239
|
+
transform: translateY(0) scaleY(1);
|
|
240
|
+
max-height: 200px;
|
|
241
|
+
padding-top: var(--spaceL);
|
|
242
|
+
padding-bottom: var(--spaceL);
|
|
243
|
+
margin-top: 0;
|
|
244
|
+
margin-bottom: 1rem;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
@keyframes shimmer {
|
|
249
|
+
0%, 100% {
|
|
250
|
+
opacity: 0.6;
|
|
251
|
+
transform: translateX(-100%);
|
|
252
|
+
}
|
|
253
|
+
50% {
|
|
254
|
+
opacity: 1;
|
|
255
|
+
transform: translateX(100%);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* Reduce motion for accessibility */
|
|
260
|
+
@media (prefers-reduced-motion: reduce) {
|
|
261
|
+
.errorMessage {
|
|
262
|
+
animation: none;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.errorMessage::before {
|
|
266
|
+
animation: none;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/* eslint-disable react/prop-types */
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { auth } from '~/services/firebase';
|
|
4
|
+
import {
|
|
5
|
+
PhoneAuthProvider,
|
|
6
|
+
PhoneMultiFactorGenerator,
|
|
7
|
+
RecaptchaVerifier,
|
|
8
|
+
multiFactor,
|
|
9
|
+
User
|
|
10
|
+
} from 'firebase/auth';
|
|
11
|
+
import { handleAuthError, getValidationError } from '~/services/firebase-errors';
|
|
12
|
+
import { SignOut } from '~/components/actions/signout';
|
|
13
|
+
import { auditService } from '~/services/audit.service';
|
|
14
|
+
import styles from './mfa-enrollment.module.css';
|
|
15
|
+
|
|
16
|
+
interface MFAEnrollmentProps {
|
|
17
|
+
user: User;
|
|
18
|
+
onSuccess: () => void;
|
|
19
|
+
onError: (error: string) => void;
|
|
20
|
+
onSkip?: () => void; // Optional skip for non-mandatory scenarios
|
|
21
|
+
mandatory?: boolean; // Whether MFA enrollment is required
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const MFAEnrollment: React.FC<MFAEnrollmentProps> = ({
|
|
25
|
+
user,
|
|
26
|
+
onSuccess,
|
|
27
|
+
onError,
|
|
28
|
+
onSkip,
|
|
29
|
+
mandatory = true
|
|
30
|
+
}) => {
|
|
31
|
+
const [phoneNumber, setPhoneNumber] = useState('');
|
|
32
|
+
const [verificationCode, setVerificationCode] = useState('');
|
|
33
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
34
|
+
const [codeSent, setCodeSent] = useState(false);
|
|
35
|
+
const [recaptchaVerifier, setRecaptchaVerifier] = useState<RecaptchaVerifier | null>(null);
|
|
36
|
+
const [verificationId, setVerificationId] = useState('');
|
|
37
|
+
const [resendTimer, setResendTimer] = useState(0);
|
|
38
|
+
const [isClient, setIsClient] = useState(false);
|
|
39
|
+
const [errorMessage, setErrorMessage] = useState('');
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setIsClient(true);
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!isClient) return;
|
|
47
|
+
|
|
48
|
+
// Initialize reCAPTCHA verifier
|
|
49
|
+
const verifier = new RecaptchaVerifier(auth, 'recaptcha-container-enrollment', {
|
|
50
|
+
size: 'invisible',
|
|
51
|
+
callback: () => {
|
|
52
|
+
// reCAPTCHA solved, allow SMS sending
|
|
53
|
+
},
|
|
54
|
+
'expired-callback': () => {
|
|
55
|
+
const error = getValidationError('MFA_RECAPTCHA_EXPIRED');
|
|
56
|
+
setErrorMessage(error);
|
|
57
|
+
onError(error);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
setRecaptchaVerifier(verifier);
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
verifier.clear();
|
|
64
|
+
};
|
|
65
|
+
}, [onError, isClient]);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (resendTimer > 0) {
|
|
69
|
+
const timer = setTimeout(() => setResendTimer(resendTimer - 1), 1000);
|
|
70
|
+
return () => clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
}, [resendTimer]);
|
|
73
|
+
|
|
74
|
+
// Phone number validation function
|
|
75
|
+
const validatePhoneNumber = (phone: string): { isValid: boolean; errorMessage?: string } => {
|
|
76
|
+
if (!phone.trim()) {
|
|
77
|
+
return { isValid: false, errorMessage: getValidationError('MFA_INVALID_PHONE') };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Remove all non-digit characters for validation
|
|
81
|
+
const cleanPhone = phone.replace(/\D/g, '');
|
|
82
|
+
|
|
83
|
+
// Prevent use of example phone numbers
|
|
84
|
+
if (cleanPhone === '15551234567' || cleanPhone === '5551234567') {
|
|
85
|
+
return { isValid: false, errorMessage: 'Please enter your actual phone number, not the demo number.' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check for valid phone number patterns
|
|
89
|
+
// US/Canada: 10 or 11 digits (with or without country code)
|
|
90
|
+
// International: 7-15 digits with country code
|
|
91
|
+
if (cleanPhone.length < 7 || cleanPhone.length > 15) {
|
|
92
|
+
return { isValid: false, errorMessage: 'Phone number must be between 7-15 digits.' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// US/Canada specific validation (most common case)
|
|
96
|
+
if (phone.startsWith('+1') || (!phone.startsWith('+') && cleanPhone.length === 10)) {
|
|
97
|
+
const usPhone = cleanPhone.startsWith('1') ? cleanPhone.slice(1) : cleanPhone;
|
|
98
|
+
|
|
99
|
+
if (usPhone.length !== 10) {
|
|
100
|
+
return { isValid: false, errorMessage: 'US/Canada phone numbers must be 10 digits.' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check for invalid area codes (starting with 0 or 1)
|
|
104
|
+
if (usPhone[0] === '0' || usPhone[0] === '1') {
|
|
105
|
+
return { isValid: false, errorMessage: 'Invalid area code. Area codes cannot start with 0 or 1.' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check for invalid exchange codes (starting with 0 or 1)
|
|
109
|
+
if (usPhone[3] === '0' || usPhone[3] === '1') {
|
|
110
|
+
return { isValid: false, errorMessage: 'Invalid phone number format.' };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Basic international validation for numbers with country codes
|
|
115
|
+
if (phone.startsWith('+') && cleanPhone.length < 8) {
|
|
116
|
+
return { isValid: false, errorMessage: 'International phone numbers must have at least 8 digits including country code.' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { isValid: true };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const sendVerificationCode = async () => {
|
|
123
|
+
const validation = validatePhoneNumber(phoneNumber);
|
|
124
|
+
if (!validation.isValid) {
|
|
125
|
+
setErrorMessage(validation.errorMessage!);
|
|
126
|
+
onError(validation.errorMessage!);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!recaptchaVerifier) {
|
|
131
|
+
const error = getValidationError('MFA_RECAPTCHA_ERROR');
|
|
132
|
+
setErrorMessage(error);
|
|
133
|
+
onError(error);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
setIsLoading(true);
|
|
138
|
+
setErrorMessage(''); // Clear any previous errors
|
|
139
|
+
try {
|
|
140
|
+
// Format phone number if it doesn't start with +
|
|
141
|
+
const formattedPhone = phoneNumber.startsWith('+') ? phoneNumber : `+1${phoneNumber}`;
|
|
142
|
+
|
|
143
|
+
const multiFactorSession = await multiFactor(user).getSession();
|
|
144
|
+
const phoneInfoOptions = {
|
|
145
|
+
phoneNumber: formattedPhone,
|
|
146
|
+
session: multiFactorSession
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const phoneAuthProvider = new PhoneAuthProvider(auth);
|
|
150
|
+
const verificationId = await phoneAuthProvider.verifyPhoneNumber(
|
|
151
|
+
phoneInfoOptions,
|
|
152
|
+
recaptchaVerifier
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
setVerificationId(verificationId);
|
|
156
|
+
setCodeSent(true);
|
|
157
|
+
setResendTimer(60); // 60 second cooldown for resend
|
|
158
|
+
onError(''); // Clear any previous errors
|
|
159
|
+
} catch (error: unknown) {
|
|
160
|
+
const authError = error as { code?: string; message?: string };
|
|
161
|
+
const errorMsg = handleAuthError(authError).message;
|
|
162
|
+
setErrorMessage(errorMsg);
|
|
163
|
+
onError(errorMsg);
|
|
164
|
+
} finally {
|
|
165
|
+
setIsLoading(false);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const enrollMFA = async () => {
|
|
170
|
+
if (!verificationCode.trim()) {
|
|
171
|
+
const error = getValidationError('MFA_CODE_REQUIRED');
|
|
172
|
+
setErrorMessage(error);
|
|
173
|
+
onError(error);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!verificationId) {
|
|
178
|
+
const error = getValidationError('MFA_NO_VERIFICATION_ID');
|
|
179
|
+
setErrorMessage(error);
|
|
180
|
+
onError(error);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
setIsLoading(true);
|
|
185
|
+
setErrorMessage(''); // Clear any previous errors
|
|
186
|
+
try {
|
|
187
|
+
const cred = PhoneAuthProvider.credential(verificationId, verificationCode);
|
|
188
|
+
const multiFactorAssertion = PhoneMultiFactorGenerator.assertion(cred);
|
|
189
|
+
|
|
190
|
+
await multiFactor(user).enroll(multiFactorAssertion, `Phone: ${phoneNumber}`);
|
|
191
|
+
|
|
192
|
+
// Log successful MFA enrollment audit event
|
|
193
|
+
try {
|
|
194
|
+
await auditService.logMfaEnrollment(
|
|
195
|
+
user,
|
|
196
|
+
phoneNumber,
|
|
197
|
+
'sms',
|
|
198
|
+
'success',
|
|
199
|
+
1, // Assuming this is their first successful attempt since we got here
|
|
200
|
+
undefined, // sessionId not available in enrollment context
|
|
201
|
+
navigator.userAgent
|
|
202
|
+
);
|
|
203
|
+
} catch (auditError) {
|
|
204
|
+
console.error('Failed to log MFA enrollment success audit:', auditError);
|
|
205
|
+
// Continue with enrollment success flow even if audit logging fails
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Mark email verification as successful (retroactive)
|
|
209
|
+
// Since MFA enrollment requires email verification to be completed first,
|
|
210
|
+
// we can safely mark any pending email verification as successful
|
|
211
|
+
try {
|
|
212
|
+
await auditService.markEmailVerificationSuccessful(
|
|
213
|
+
user,
|
|
214
|
+
'MFA enrollment completed - email verification implied',
|
|
215
|
+
undefined, // sessionId not available in enrollment context
|
|
216
|
+
navigator.userAgent
|
|
217
|
+
);
|
|
218
|
+
} catch (auditError) {
|
|
219
|
+
console.error('Failed to log retroactive email verification success:', auditError);
|
|
220
|
+
// Continue with enrollment success flow even if audit logging fails
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
onSuccess();
|
|
224
|
+
} catch (error: unknown) {
|
|
225
|
+
console.error('Error enrolling MFA:', error);
|
|
226
|
+
const authError = error as { code?: string; message?: string };
|
|
227
|
+
let errorMsg = '';
|
|
228
|
+
if (authError.code === 'auth/invalid-verification-code') {
|
|
229
|
+
errorMsg = getValidationError('MFA_INVALID_CODE');
|
|
230
|
+
} else if (authError.code === 'auth/code-expired') {
|
|
231
|
+
errorMsg = getValidationError('MFA_CODE_EXPIRED');
|
|
232
|
+
setCodeSent(false);
|
|
233
|
+
} else {
|
|
234
|
+
errorMsg = handleAuthError(authError).message;
|
|
235
|
+
}
|
|
236
|
+
setErrorMessage(errorMsg);
|
|
237
|
+
onError(errorMsg);
|
|
238
|
+
|
|
239
|
+
// Log security violation for failed MFA enrollment attempts
|
|
240
|
+
try {
|
|
241
|
+
let severity: 'low' | 'medium' | 'high' | 'critical' = 'medium';
|
|
242
|
+
|
|
243
|
+
if (authError.code === 'auth/invalid-verification-code') {
|
|
244
|
+
severity = 'high'; // Invalid MFA codes during enrollment are serious
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
await auditService.logSecurityViolation(
|
|
248
|
+
user, // User object available during enrollment
|
|
249
|
+
'unauthorized-access',
|
|
250
|
+
severity,
|
|
251
|
+
`Failed MFA enrollment: ${authError.code} - ${errorMsg}`,
|
|
252
|
+
'mfa-enrollment-endpoint',
|
|
253
|
+
true // Blocked by system
|
|
254
|
+
);
|
|
255
|
+
} catch (auditError) {
|
|
256
|
+
console.error('Failed to log MFA enrollment security violation audit:', auditError);
|
|
257
|
+
// Continue with error flow even if audit logging fails
|
|
258
|
+
}
|
|
259
|
+
} finally {
|
|
260
|
+
setIsLoading(false);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const handleSkip = () => {
|
|
265
|
+
if (onSkip && !mandatory) {
|
|
266
|
+
onSkip();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (!isClient) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div className={styles.overlay}>
|
|
276
|
+
<div className={styles.modal}>
|
|
277
|
+
<div className={styles.header}>
|
|
278
|
+
<h2>Security Setup Required</h2>
|
|
279
|
+
<p>
|
|
280
|
+
{mandatory
|
|
281
|
+
? 'Two-factor authentication is required for all accounts. Please set up SMS verification to continue.'
|
|
282
|
+
: 'Enhance your account security with two-factor authentication.'
|
|
283
|
+
}
|
|
284
|
+
</p>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<div className={styles.content}>
|
|
288
|
+
{errorMessage && (
|
|
289
|
+
<div className={styles.errorMessage}>
|
|
290
|
+
{errorMessage}
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
|
|
294
|
+
{!codeSent ? (
|
|
295
|
+
<div className={styles.phoneStep}>
|
|
296
|
+
<h3>Step 1: Enter Your Mobile Number</h3>
|
|
297
|
+
<input
|
|
298
|
+
type="tel"
|
|
299
|
+
value={phoneNumber}
|
|
300
|
+
onChange={(e) => {
|
|
301
|
+
setPhoneNumber(e.target.value);
|
|
302
|
+
if (errorMessage) setErrorMessage(''); // Clear error on input
|
|
303
|
+
}}
|
|
304
|
+
placeholder="ex. +15551234567"
|
|
305
|
+
className={styles.input}
|
|
306
|
+
disabled={isLoading}
|
|
307
|
+
/>
|
|
308
|
+
<p className={styles.note}>
|
|
309
|
+
We'll send a verification code to this number.
|
|
310
|
+
</p>
|
|
311
|
+
<button
|
|
312
|
+
onClick={sendVerificationCode}
|
|
313
|
+
disabled={isLoading || !phoneNumber.trim()}
|
|
314
|
+
className={styles.primaryButton}
|
|
315
|
+
>
|
|
316
|
+
{isLoading ? 'Sending...' : 'Send Verification Code'}
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
) : (
|
|
320
|
+
<div className={styles.codeStep}>
|
|
321
|
+
<h3>Step 2: Enter Verification Code</h3>
|
|
322
|
+
<p className={styles.note}>
|
|
323
|
+
Enter the 6-digit code sent to {phoneNumber}
|
|
324
|
+
</p>
|
|
325
|
+
<input
|
|
326
|
+
type="text"
|
|
327
|
+
value={verificationCode}
|
|
328
|
+
onChange={(e) => {
|
|
329
|
+
setVerificationCode(e.target.value.replace(/\D/g, ''));
|
|
330
|
+
if (errorMessage) setErrorMessage(''); // Clear error on input
|
|
331
|
+
}}
|
|
332
|
+
placeholder="123456"
|
|
333
|
+
maxLength={6}
|
|
334
|
+
className={styles.input}
|
|
335
|
+
disabled={isLoading}
|
|
336
|
+
/>
|
|
337
|
+
|
|
338
|
+
<div className={styles.buttonGroup}>
|
|
339
|
+
<button
|
|
340
|
+
onClick={enrollMFA}
|
|
341
|
+
disabled={isLoading || verificationCode.length !== 6}
|
|
342
|
+
className={styles.primaryButton}
|
|
343
|
+
>
|
|
344
|
+
{isLoading ? 'Verifying...' : 'Complete Setup'}
|
|
345
|
+
</button>
|
|
346
|
+
|
|
347
|
+
<button
|
|
348
|
+
onClick={() => {
|
|
349
|
+
setCodeSent(false);
|
|
350
|
+
setVerificationCode('');
|
|
351
|
+
setErrorMessage(''); // Clear errors when changing phone number
|
|
352
|
+
}}
|
|
353
|
+
disabled={isLoading}
|
|
354
|
+
className={styles.secondaryButton}
|
|
355
|
+
>
|
|
356
|
+
Change Phone Number
|
|
357
|
+
</button>
|
|
358
|
+
|
|
359
|
+
{resendTimer === 0 ? (
|
|
360
|
+
<button
|
|
361
|
+
onClick={sendVerificationCode}
|
|
362
|
+
disabled={isLoading}
|
|
363
|
+
className={styles.secondaryButton}
|
|
364
|
+
>
|
|
365
|
+
Resend Code
|
|
366
|
+
</button>
|
|
367
|
+
) : (
|
|
368
|
+
<p className={styles.resendTimer}>
|
|
369
|
+
Resend code in {resendTimer}s
|
|
370
|
+
</p>
|
|
371
|
+
)}
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
{!mandatory && (
|
|
378
|
+
<div className={styles.footer}>
|
|
379
|
+
<button
|
|
380
|
+
onClick={handleSkip}
|
|
381
|
+
disabled={isLoading}
|
|
382
|
+
className={styles.skipButton}
|
|
383
|
+
>
|
|
384
|
+
Skip for now
|
|
385
|
+
</button>
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
388
|
+
|
|
389
|
+
<div className={styles.signOutContainer}>
|
|
390
|
+
<p className={styles.signOutText}>Need to sign in with a different account?</p>
|
|
391
|
+
<SignOut redirectTo="/" />
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
<div id="recaptcha-container-enrollment" />
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
);
|
|
398
|
+
};
|