@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.
Files changed (223) hide show
  1. package/.env.example +100 -0
  2. package/LICENSE +190 -0
  3. package/NOTICE +18 -0
  4. package/README.md +133 -0
  5. package/app/components/actions/case-export/core-export.ts +328 -0
  6. package/app/components/actions/case-export/data-processing.ts +167 -0
  7. package/app/components/actions/case-export/download-handlers.ts +900 -0
  8. package/app/components/actions/case-export/index.ts +41 -0
  9. package/app/components/actions/case-export/metadata-helpers.ts +107 -0
  10. package/app/components/actions/case-export/types-constants.ts +56 -0
  11. package/app/components/actions/case-export/validation-utils.ts +25 -0
  12. package/app/components/actions/case-export.ts +4 -0
  13. package/app/components/actions/case-import/annotation-import.ts +35 -0
  14. package/app/components/actions/case-import/confirmation-import.ts +363 -0
  15. package/app/components/actions/case-import/image-operations.ts +61 -0
  16. package/app/components/actions/case-import/index.ts +39 -0
  17. package/app/components/actions/case-import/orchestrator.ts +420 -0
  18. package/app/components/actions/case-import/storage-operations.ts +270 -0
  19. package/app/components/actions/case-import/validation.ts +189 -0
  20. package/app/components/actions/case-import/zip-processing.ts +413 -0
  21. package/app/components/actions/case-manage.ts +524 -0
  22. package/app/components/actions/case-review.ts +4 -0
  23. package/app/components/actions/confirm-export.ts +351 -0
  24. package/app/components/actions/generate-pdf.ts +210 -0
  25. package/app/components/actions/image-manage.ts +385 -0
  26. package/app/components/actions/notes-manage.ts +33 -0
  27. package/app/components/actions/signout.module.css +15 -0
  28. package/app/components/actions/signout.tsx +50 -0
  29. package/app/components/audit/user-audit-viewer.tsx +975 -0
  30. package/app/components/audit/user-audit.module.css +568 -0
  31. package/app/components/auth/auth-provider.tsx +78 -0
  32. package/app/components/auth/mfa-enrollment.module.css +268 -0
  33. package/app/components/auth/mfa-enrollment.tsx +398 -0
  34. package/app/components/auth/mfa-verification.module.css +251 -0
  35. package/app/components/auth/mfa-verification.tsx +295 -0
  36. package/app/components/button/button.module.css +63 -0
  37. package/app/components/button/button.tsx +46 -0
  38. package/app/components/canvas/box-annotations/box-annotations.module.css +170 -0
  39. package/app/components/canvas/box-annotations/box-annotations.tsx +634 -0
  40. package/app/components/canvas/canvas.module.css +314 -0
  41. package/app/components/canvas/canvas.tsx +449 -0
  42. package/app/components/canvas/confirmation/confirmation.module.css +187 -0
  43. package/app/components/canvas/confirmation/confirmation.tsx +214 -0
  44. package/app/components/colors/colors.module.css +59 -0
  45. package/app/components/colors/colors.tsx +68 -0
  46. package/app/components/form/base-form.tsx +21 -0
  47. package/app/components/form/form-button.tsx +28 -0
  48. package/app/components/form/form-field.tsx +53 -0
  49. package/app/components/form/form-message.tsx +17 -0
  50. package/app/components/form/form-toggle.tsx +23 -0
  51. package/app/components/form/form.module.css +427 -0
  52. package/app/components/form/index.ts +6 -0
  53. package/app/components/icon/icon.module.css +3 -0
  54. package/app/components/icon/icon.tsx +27 -0
  55. package/app/components/icon/icons.svg +102 -0
  56. package/app/components/icon/manifest.json +110 -0
  57. package/app/components/sidebar/case-export/case-export.module.css +386 -0
  58. package/app/components/sidebar/case-export/case-export.tsx +317 -0
  59. package/app/components/sidebar/case-import/case-import.module.css +626 -0
  60. package/app/components/sidebar/case-import/case-import.tsx +404 -0
  61. package/app/components/sidebar/case-import/components/CasePreviewSection.tsx +72 -0
  62. package/app/components/sidebar/case-import/components/ConfirmationDialog.tsx +72 -0
  63. package/app/components/sidebar/case-import/components/ConfirmationPreviewSection.tsx +71 -0
  64. package/app/components/sidebar/case-import/components/ExistingCaseSection.tsx +40 -0
  65. package/app/components/sidebar/case-import/components/FileSelector.tsx +161 -0
  66. package/app/components/sidebar/case-import/components/ProgressSection.tsx +46 -0
  67. package/app/components/sidebar/case-import/hooks/useFilePreview.ts +101 -0
  68. package/app/components/sidebar/case-import/hooks/useImportExecution.ts +152 -0
  69. package/app/components/sidebar/case-import/hooks/useImportState.ts +88 -0
  70. package/app/components/sidebar/case-import/index.ts +18 -0
  71. package/app/components/sidebar/case-import/utils/file-validation.ts +43 -0
  72. package/app/components/sidebar/cases/case-sidebar.tsx +827 -0
  73. package/app/components/sidebar/cases/cases-modal.module.css +166 -0
  74. package/app/components/sidebar/cases/cases-modal.tsx +201 -0
  75. package/app/components/sidebar/cases/cases.module.css +713 -0
  76. package/app/components/sidebar/files/files-modal.module.css +209 -0
  77. package/app/components/sidebar/files/files-modal.tsx +239 -0
  78. package/app/components/sidebar/hash/hash-utility.module.css +366 -0
  79. package/app/components/sidebar/hash/hash-utility.tsx +982 -0
  80. package/app/components/sidebar/notes/notes-modal.tsx +51 -0
  81. package/app/components/sidebar/notes/notes-sidebar.tsx +491 -0
  82. package/app/components/sidebar/notes/notes.module.css +360 -0
  83. package/app/components/sidebar/sidebar-container.tsx +149 -0
  84. package/app/components/sidebar/sidebar.module.css +321 -0
  85. package/app/components/sidebar/sidebar.tsx +215 -0
  86. package/app/components/sidebar/upload/image-upload-zone.module.css +123 -0
  87. package/app/components/sidebar/upload/image-upload-zone.tsx +330 -0
  88. package/app/components/theme-provider/theme-provider.tsx +131 -0
  89. package/app/components/theme-provider/theme.ts +155 -0
  90. package/app/components/toast/toast.module.css +137 -0
  91. package/app/components/toast/toast.tsx +56 -0
  92. package/app/components/toolbar/toolbar-color-selector.module.css +171 -0
  93. package/app/components/toolbar/toolbar-color-selector.tsx +129 -0
  94. package/app/components/toolbar/toolbar.module.css +42 -0
  95. package/app/components/toolbar/toolbar.tsx +167 -0
  96. package/app/components/user/delete-account.module.css +274 -0
  97. package/app/components/user/delete-account.tsx +471 -0
  98. package/app/components/user/inactivity-warning.module.css +145 -0
  99. package/app/components/user/inactivity-warning.tsx +84 -0
  100. package/app/components/user/manage-profile.module.css +190 -0
  101. package/app/components/user/manage-profile.tsx +253 -0
  102. package/app/components/user/mfa-phone-update.tsx +739 -0
  103. package/app/config-example/admin-service.json +13 -0
  104. package/app/config-example/config.json +17 -0
  105. package/app/config-example/firebase.ts +21 -0
  106. package/app/config-example/inactivity.ts +13 -0
  107. package/app/config-example/meta-config.json +6 -0
  108. package/app/contexts/auth.context.ts +12 -0
  109. package/app/entry.client.tsx +12 -0
  110. package/app/entry.server.tsx +44 -0
  111. package/app/hooks/useInactivityTimeout.ts +110 -0
  112. package/app/root.tsx +170 -0
  113. package/app/routes/_index.tsx +16 -0
  114. package/app/routes/auth/emailActionHandler.module.css +232 -0
  115. package/app/routes/auth/emailActionHandler.tsx +405 -0
  116. package/app/routes/auth/emailVerification.tsx +120 -0
  117. package/app/routes/auth/login.module.css +523 -0
  118. package/app/routes/auth/login.tsx +654 -0
  119. package/app/routes/auth/passwordReset.module.css +274 -0
  120. package/app/routes/auth/passwordReset.tsx +154 -0
  121. package/app/routes/auth/route.ts +16 -0
  122. package/app/routes/mobile-prevented/mobilePrevented.module.css +47 -0
  123. package/app/routes/mobile-prevented/mobilePrevented.tsx +26 -0
  124. package/app/routes/mobile-prevented/route.ts +14 -0
  125. package/app/routes/striae/striae.module.css +30 -0
  126. package/app/routes/striae/striae.tsx +417 -0
  127. package/app/services/audit-export.service.ts +755 -0
  128. package/app/services/audit.service.ts +1454 -0
  129. package/app/services/firebase-errors.ts +106 -0
  130. package/app/services/firebase.ts +15 -0
  131. package/app/styles/legal-pages.module.css +113 -0
  132. package/app/styles/root.module.css +146 -0
  133. package/app/tailwind.css +225 -0
  134. package/app/types/annotations.ts +45 -0
  135. package/app/types/audit.ts +301 -0
  136. package/app/types/case.ts +90 -0
  137. package/app/types/export.ts +8 -0
  138. package/app/types/file.ts +30 -0
  139. package/app/types/import.ts +107 -0
  140. package/app/types/index.ts +24 -0
  141. package/app/types/user.ts +38 -0
  142. package/app/utils/SHA256.ts +461 -0
  143. package/app/utils/annotation-timestamp.ts +25 -0
  144. package/app/utils/audit-export-signature.ts +117 -0
  145. package/app/utils/auth-action-settings.ts +48 -0
  146. package/app/utils/auth.ts +34 -0
  147. package/app/utils/batch-operations.ts +135 -0
  148. package/app/utils/confirmation-signature.ts +193 -0
  149. package/app/utils/data-operations.ts +871 -0
  150. package/app/utils/device-detection.ts +5 -0
  151. package/app/utils/html-sanitizer.ts +80 -0
  152. package/app/utils/id-generator.ts +36 -0
  153. package/app/utils/meta.ts +48 -0
  154. package/app/utils/mfa-phone.ts +97 -0
  155. package/app/utils/mfa.ts +79 -0
  156. package/app/utils/password-policy.ts +28 -0
  157. package/app/utils/permissions.ts +562 -0
  158. package/app/utils/signature-utils.ts +160 -0
  159. package/app/utils/style.ts +83 -0
  160. package/app/utils/version.ts +5 -0
  161. package/firebase.json +11 -0
  162. package/functions/[[path]].ts +10 -0
  163. package/package.json +138 -0
  164. package/postcss.config.js +6 -0
  165. package/public/.well-known/publickey.info@striae.org.asc +17 -0
  166. package/public/.well-known/security.txt +7 -0
  167. package/public/_headers +28 -0
  168. package/public/_routes.json +13 -0
  169. package/public/assets/striae.jpg +0 -0
  170. package/public/clear.jpg +0 -0
  171. package/public/favicon.ico +0 -0
  172. package/public/favicon.svg +9 -0
  173. package/public/icon-256.png +0 -0
  174. package/public/icon-512.png +0 -0
  175. package/public/logo-dark.png +0 -0
  176. package/public/manifest.json +25 -0
  177. package/public/oin-badge.png +0 -0
  178. package/public/shortcut.png +0 -0
  179. package/public/social-image.png +0 -0
  180. package/public/striae-ascii.txt +10 -0
  181. package/scripts/deploy-all.sh +100 -0
  182. package/scripts/deploy-config.sh +940 -0
  183. package/scripts/deploy-pages.sh +34 -0
  184. package/scripts/deploy-worker-secrets.sh +215 -0
  185. package/scripts/dev.cjs +23 -0
  186. package/scripts/install-workers.sh +88 -0
  187. package/scripts/run-eslint.cjs +35 -0
  188. package/scripts/update-compatibility-dates.cjs +124 -0
  189. package/scripts/update-markdown-versions.cjs +43 -0
  190. package/tailwind.config.ts +22 -0
  191. package/tsconfig.json +33 -0
  192. package/vite.config.ts +35 -0
  193. package/worker-configuration.d.ts +7490 -0
  194. package/workers/audit-worker/package.json +17 -0
  195. package/workers/audit-worker/src/audit-worker.example.ts +195 -0
  196. package/workers/audit-worker/worker-configuration.d.ts +7448 -0
  197. package/workers/audit-worker/wrangler.jsonc.example +29 -0
  198. package/workers/data-worker/package.json +17 -0
  199. package/workers/data-worker/src/data-worker.example.ts +267 -0
  200. package/workers/data-worker/src/signature-utils.ts +79 -0
  201. package/workers/data-worker/src/signing-payload-utils.ts +290 -0
  202. package/workers/data-worker/worker-configuration.d.ts +7448 -0
  203. package/workers/data-worker/wrangler.jsonc.example +30 -0
  204. package/workers/image-worker/package.json +17 -0
  205. package/workers/image-worker/src/image-worker.example.ts +180 -0
  206. package/workers/image-worker/worker-configuration.d.ts +7447 -0
  207. package/workers/image-worker/wrangler.jsonc.example +22 -0
  208. package/workers/keys-worker/package.json +17 -0
  209. package/workers/keys-worker/src/keys.example.ts +66 -0
  210. package/workers/keys-worker/src/keys.ts +66 -0
  211. package/workers/keys-worker/worker-configuration.d.ts +7447 -0
  212. package/workers/keys-worker/wrangler.jsonc.example +22 -0
  213. package/workers/pdf-worker/package.json +17 -0
  214. package/workers/pdf-worker/src/format-striae.ts +534 -0
  215. package/workers/pdf-worker/src/pdf-worker.example.ts +119 -0
  216. package/workers/pdf-worker/src/report-types.ts +69 -0
  217. package/workers/pdf-worker/worker-configuration.d.ts +7448 -0
  218. package/workers/pdf-worker/wrangler.jsonc.example +26 -0
  219. package/workers/user-worker/package.json +17 -0
  220. package/workers/user-worker/src/user-worker.example.ts +636 -0
  221. package/workers/user-worker/worker-configuration.d.ts +7448 -0
  222. package/workers/user-worker/wrangler.jsonc.example +29 -0
  223. 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&apos;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
+ };