@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,739 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ EmailAuthProvider,
4
+ getMultiFactorResolver,
5
+ PhoneAuthProvider,
6
+ PhoneMultiFactorGenerator,
7
+ RecaptchaVerifier,
8
+ multiFactor,
9
+ reauthenticateWithCredential,
10
+ type MultiFactorError,
11
+ type MultiFactorInfo,
12
+ type MultiFactorResolver,
13
+ type User,
14
+ } from 'firebase/auth';
15
+ import { auditService } from '~/services/audit.service';
16
+ import { auth } from '~/services/firebase';
17
+ import { ERROR_MESSAGES, getValidationError, handleAuthError } from '~/services/firebase-errors';
18
+ import {
19
+ formatPhoneNumberForMfa,
20
+ getMaskedFactorDisplay,
21
+ getPhoneDisplayValue,
22
+ maskPhoneNumber,
23
+ validatePhoneNumber,
24
+ } from '~/utils/mfa-phone';
25
+ import { FormButton, FormMessage } from '../form';
26
+ import styles from './manage-profile.module.css';
27
+
28
+ const MFA_RECAPTCHA_CONTAINER_ID = 'recaptcha-container-manage-profile';
29
+
30
+ interface MfaPhoneUpdateSectionProps {
31
+ user: User | null;
32
+ isOpen: boolean;
33
+ onBusyChange?: (isBusy: boolean) => void;
34
+ }
35
+
36
+ export const MfaPhoneUpdateSection = ({
37
+ user,
38
+ isOpen,
39
+ onBusyChange,
40
+ }: MfaPhoneUpdateSectionProps) => {
41
+ const [mfaPhoneInput, setMfaPhoneInput] = useState('');
42
+ const [currentMfaPhone, setCurrentMfaPhone] = useState('Not configured');
43
+ const [mfaVerificationCode, setMfaVerificationCode] = useState('');
44
+ const [mfaVerificationId, setMfaVerificationId] = useState('');
45
+ const [isMfaCodeSent, setIsMfaCodeSent] = useState(false);
46
+ const [isMfaLoading, setIsMfaLoading] = useState(false);
47
+ const [mfaResendTimer, setMfaResendTimer] = useState(0);
48
+ const [mfaError, setMfaError] = useState('');
49
+ const [mfaSuccess, setMfaSuccess] = useState('');
50
+ const [showMfaReauthPrompt, setShowMfaReauthPrompt] = useState(false);
51
+ const [mfaReauthPassword, setMfaReauthPassword] = useState('');
52
+ const [mfaReauthResolver, setMfaReauthResolver] = useState<MultiFactorResolver | null>(null);
53
+ const [mfaReauthHint, setMfaReauthHint] = useState<MultiFactorInfo | null>(null);
54
+ const [mfaReauthVerificationId, setMfaReauthVerificationId] = useState('');
55
+ const [mfaReauthVerificationCode, setMfaReauthVerificationCode] = useState('');
56
+ const [isMfaReauthCodeSent, setIsMfaReauthCodeSent] = useState(false);
57
+ const [isMfaReauthLoading, setIsMfaReauthLoading] = useState(false);
58
+ const [recaptchaVerifier, setRecaptchaVerifier] = useState<RecaptchaVerifier | null>(null);
59
+
60
+ const isMfaBusy = isMfaLoading || isMfaReauthLoading;
61
+
62
+ const resetMfaReauthFlow = useCallback(() => {
63
+ setShowMfaReauthPrompt(false);
64
+ setMfaReauthPassword('');
65
+ setMfaReauthResolver(null);
66
+ setMfaReauthHint(null);
67
+ setMfaReauthVerificationId('');
68
+ setMfaReauthVerificationCode('');
69
+ setIsMfaReauthCodeSent(false);
70
+ }, []);
71
+
72
+ const refreshCurrentMfaPhone = useCallback(async (currentUser: User) => {
73
+ await currentUser.reload();
74
+
75
+ const phoneFactors = multiFactor(currentUser).enrolledFactors.filter(
76
+ (factor) => factor.factorId === PhoneMultiFactorGenerator.FACTOR_ID
77
+ );
78
+
79
+ if (phoneFactors.length === 0) {
80
+ setCurrentMfaPhone('Not configured');
81
+ return;
82
+ }
83
+
84
+ const latestFactor = phoneFactors[phoneFactors.length - 1];
85
+ const phoneDisplayValue = getPhoneDisplayValue(latestFactor);
86
+ if (!phoneDisplayValue) {
87
+ setCurrentMfaPhone('Configured');
88
+ return;
89
+ }
90
+
91
+ setCurrentMfaPhone(maskPhoneNumber(phoneDisplayValue));
92
+ }, []);
93
+
94
+ const handleResetMfaChange = () => {
95
+ setIsMfaCodeSent(false);
96
+ setMfaVerificationCode('');
97
+ setMfaVerificationId('');
98
+ setMfaError('');
99
+ setMfaSuccess('');
100
+ setMfaResendTimer(0);
101
+ resetMfaReauthFlow();
102
+ };
103
+
104
+ const handleCancelMfaReauth = () => {
105
+ resetMfaReauthFlow();
106
+ setMfaError('');
107
+ setMfaSuccess('');
108
+ };
109
+
110
+ const handleSendMfaVerificationCode = async () => {
111
+ if (!user) {
112
+ setMfaError(ERROR_MESSAGES.NO_USER);
113
+ return;
114
+ }
115
+
116
+ const validation = validatePhoneNumber(mfaPhoneInput);
117
+ if (!validation.isValid) {
118
+ setMfaError(validation.errorMessage || getValidationError('MFA_INVALID_PHONE'));
119
+ setMfaSuccess('');
120
+ return;
121
+ }
122
+
123
+ if (!recaptchaVerifier) {
124
+ setMfaError(getValidationError('MFA_RECAPTCHA_ERROR'));
125
+ setMfaSuccess('');
126
+ return;
127
+ }
128
+
129
+ setIsMfaLoading(true);
130
+ setMfaError('');
131
+ setMfaSuccess('');
132
+
133
+ try {
134
+ const formattedPhone = formatPhoneNumberForMfa(mfaPhoneInput);
135
+ const mfaSession = await multiFactor(user).getSession();
136
+ const phoneInfoOptions = {
137
+ phoneNumber: formattedPhone,
138
+ session: mfaSession,
139
+ };
140
+
141
+ const phoneAuthProvider = new PhoneAuthProvider(auth);
142
+ const verificationId = await phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, recaptchaVerifier);
143
+
144
+ setMfaVerificationId(verificationId);
145
+ setIsMfaCodeSent(true);
146
+ setMfaResendTimer(60);
147
+ resetMfaReauthFlow();
148
+ } catch (err) {
149
+ const { message, data } = handleAuthError(err);
150
+
151
+ if (data?.code === 'auth/requires-recent-login') {
152
+ const supportsPasswordReauth = user.providerData.some(
153
+ (provider) => provider.providerId === 'password'
154
+ );
155
+
156
+ if (supportsPasswordReauth && user.email) {
157
+ resetMfaReauthFlow();
158
+ setShowMfaReauthPrompt(true);
159
+ setMfaSuccess('');
160
+ return;
161
+ }
162
+
163
+ setMfaSuccess('');
164
+ setMfaError('For security, please sign out and sign in again, then try this action again.');
165
+ return;
166
+ }
167
+
168
+ setMfaSuccess('');
169
+ setMfaError(message);
170
+ } finally {
171
+ setIsMfaLoading(false);
172
+ }
173
+ };
174
+
175
+ const handleMfaReauthenticate = async () => {
176
+ if (!user) {
177
+ setMfaError(ERROR_MESSAGES.NO_USER);
178
+ return;
179
+ }
180
+
181
+ if (!user.email) {
182
+ setMfaError('Please sign out and sign in again to continue.');
183
+ return;
184
+ }
185
+
186
+ if (!mfaReauthPassword.trim()) {
187
+ setMfaError('Please enter your password to continue.');
188
+ return;
189
+ }
190
+
191
+ setIsMfaReauthLoading(true);
192
+ setMfaError('');
193
+ setMfaSuccess('');
194
+
195
+ try {
196
+ const credential = EmailAuthProvider.credential(user.email, mfaReauthPassword);
197
+ await reauthenticateWithCredential(user, credential);
198
+
199
+ resetMfaReauthFlow();
200
+
201
+ await handleSendMfaVerificationCode();
202
+ } catch (err) {
203
+ const { message, data } = handleAuthError(err);
204
+
205
+ if (data?.code === 'auth/multi-factor-auth-required') {
206
+ if (!recaptchaVerifier) {
207
+ setMfaSuccess('');
208
+ setMfaError(getValidationError('MFA_RECAPTCHA_ERROR'));
209
+ return;
210
+ }
211
+
212
+ const resolver = getMultiFactorResolver(auth, err as MultiFactorError);
213
+ const phoneHint = resolver.hints.find(
214
+ (hint) => hint.factorId === PhoneMultiFactorGenerator.FACTOR_ID
215
+ );
216
+
217
+ if (!phoneHint) {
218
+ setMfaSuccess('');
219
+ setMfaError('This account requires a non-phone MFA method. Please sign out and sign in again.');
220
+ return;
221
+ }
222
+
223
+ setShowMfaReauthPrompt(true);
224
+ setMfaReauthResolver(resolver);
225
+ setMfaReauthHint(phoneHint);
226
+ setMfaReauthVerificationId('');
227
+ setMfaReauthVerificationCode('');
228
+ setIsMfaReauthCodeSent(false);
229
+ setMfaSuccess('');
230
+ return;
231
+ }
232
+
233
+ setMfaSuccess('');
234
+ setMfaError(message);
235
+ } finally {
236
+ setIsMfaReauthLoading(false);
237
+ }
238
+ };
239
+
240
+ const handleSendMfaReauthVerificationCode = async () => {
241
+ if (!mfaReauthResolver || !mfaReauthHint) {
242
+ setMfaSuccess('');
243
+ setMfaError('Please confirm your password again to continue.');
244
+ return;
245
+ }
246
+
247
+ if (!recaptchaVerifier) {
248
+ setMfaSuccess('');
249
+ setMfaError(getValidationError('MFA_RECAPTCHA_ERROR'));
250
+ return;
251
+ }
252
+
253
+ setIsMfaReauthLoading(true);
254
+ setMfaError('');
255
+ setMfaSuccess('');
256
+
257
+ try {
258
+ const phoneAuthProvider = new PhoneAuthProvider(auth);
259
+ const phoneInfoOptions = {
260
+ multiFactorHint: mfaReauthHint,
261
+ session: mfaReauthResolver.session,
262
+ };
263
+
264
+ const verificationId = await phoneAuthProvider.verifyPhoneNumber(phoneInfoOptions, recaptchaVerifier);
265
+ setMfaReauthVerificationId(verificationId);
266
+ setMfaReauthVerificationCode('');
267
+ setIsMfaReauthCodeSent(true);
268
+ setMfaSuccess('');
269
+ } catch (err) {
270
+ const { message } = handleAuthError(err);
271
+ setMfaSuccess('');
272
+ setMfaError(message);
273
+ } finally {
274
+ setIsMfaReauthLoading(false);
275
+ }
276
+ };
277
+
278
+ const handleVerifyMfaReauthCode = async () => {
279
+ if (!mfaReauthResolver) {
280
+ setMfaSuccess('');
281
+ setMfaError('Please confirm your password again to continue.');
282
+ return;
283
+ }
284
+
285
+ if (!mfaReauthVerificationId) {
286
+ setMfaSuccess('');
287
+ setMfaError(getValidationError('MFA_NO_VERIFICATION_ID'));
288
+ return;
289
+ }
290
+
291
+ if (!mfaReauthVerificationCode.trim()) {
292
+ setMfaSuccess('');
293
+ setMfaError(getValidationError('MFA_CODE_REQUIRED'));
294
+ return;
295
+ }
296
+
297
+ setIsMfaReauthLoading(true);
298
+ setMfaError('');
299
+ setMfaSuccess('');
300
+
301
+ try {
302
+ const credential = PhoneAuthProvider.credential(
303
+ mfaReauthVerificationId,
304
+ mfaReauthVerificationCode.trim()
305
+ );
306
+ const assertion = PhoneMultiFactorGenerator.assertion(credential);
307
+ await mfaReauthResolver.resolveSignIn(assertion);
308
+
309
+ resetMfaReauthFlow();
310
+ await handleSendMfaVerificationCode();
311
+ } catch (err) {
312
+ const { message, data } = handleAuthError(err);
313
+ let errorMessage = message;
314
+
315
+ if (data?.code === 'auth/invalid-verification-code') {
316
+ errorMessage = getValidationError('MFA_INVALID_CODE');
317
+ } else if (data?.code === 'auth/code-expired') {
318
+ errorMessage = getValidationError('MFA_CODE_EXPIRED');
319
+ setIsMfaReauthCodeSent(false);
320
+ setMfaReauthVerificationId('');
321
+ setMfaReauthVerificationCode('');
322
+ }
323
+
324
+ setMfaSuccess('');
325
+ setMfaError(errorMessage);
326
+ } finally {
327
+ setIsMfaReauthLoading(false);
328
+ }
329
+ };
330
+
331
+ const handleUpdateMfaPhoneNumber = async () => {
332
+ if (!user) {
333
+ setMfaError(ERROR_MESSAGES.NO_USER);
334
+ return;
335
+ }
336
+
337
+ if (!mfaVerificationId) {
338
+ setMfaError(getValidationError('MFA_NO_VERIFICATION_ID'));
339
+ return;
340
+ }
341
+
342
+ if (!mfaVerificationCode.trim()) {
343
+ setMfaError(getValidationError('MFA_CODE_REQUIRED'));
344
+ return;
345
+ }
346
+
347
+ const formattedPhone = formatPhoneNumberForMfa(mfaPhoneInput);
348
+ const existingPhoneFactorUids = multiFactor(user).enrolledFactors
349
+ .filter((factor) => factor.factorId === PhoneMultiFactorGenerator.FACTOR_ID)
350
+ .map((factor) => factor.uid);
351
+
352
+ setIsMfaLoading(true);
353
+ setMfaError('');
354
+ setMfaSuccess('');
355
+
356
+ try {
357
+ const credential = PhoneAuthProvider.credential(mfaVerificationId, mfaVerificationCode.trim());
358
+ const multiFactorAssertion = PhoneMultiFactorGenerator.assertion(credential);
359
+
360
+ await multiFactor(user).enroll(multiFactorAssertion, `Phone: ${formattedPhone}`);
361
+
362
+ for (const factorUid of existingPhoneFactorUids) {
363
+ await multiFactor(user).unenroll(factorUid);
364
+ }
365
+
366
+ await refreshCurrentMfaPhone(user);
367
+
368
+ setMfaPhoneInput('');
369
+ setMfaVerificationCode('');
370
+ setMfaVerificationId('');
371
+ setIsMfaCodeSent(false);
372
+ setMfaResendTimer(0);
373
+ setMfaSuccess(
374
+ existingPhoneFactorUids.length > 0
375
+ ? 'MFA phone number updated successfully.'
376
+ : 'MFA phone number added successfully.'
377
+ );
378
+
379
+ try {
380
+ await auditService.logMfaEnrollment(
381
+ user,
382
+ formattedPhone,
383
+ 'sms',
384
+ 'success',
385
+ 1,
386
+ undefined,
387
+ navigator.userAgent
388
+ );
389
+ } catch (auditError) {
390
+ console.error('Failed to log MFA phone update success audit:', auditError);
391
+ }
392
+ } catch (err) {
393
+ const { message, data } = handleAuthError(err);
394
+ let errorMessage = message;
395
+
396
+ if (data?.code === 'auth/invalid-verification-code') {
397
+ errorMessage = getValidationError('MFA_INVALID_CODE');
398
+ } else if (data?.code === 'auth/code-expired') {
399
+ errorMessage = getValidationError('MFA_CODE_EXPIRED');
400
+ setIsMfaCodeSent(false);
401
+ setMfaVerificationId('');
402
+ } else if (data?.code === 'auth/requires-recent-login') {
403
+ const supportsPasswordReauth = user.providerData.some(
404
+ (provider) => provider.providerId === 'password'
405
+ );
406
+
407
+ setIsMfaCodeSent(false);
408
+ setMfaVerificationCode('');
409
+ setMfaVerificationId('');
410
+
411
+ if (supportsPasswordReauth && user.email) {
412
+ resetMfaReauthFlow();
413
+ setShowMfaReauthPrompt(true);
414
+ errorMessage = 'For security, confirm your password to continue.';
415
+ } else {
416
+ errorMessage = 'For security, please sign out and sign in again, then try this action again.';
417
+ }
418
+ }
419
+
420
+ setMfaError(errorMessage);
421
+
422
+ try {
423
+ await auditService.logMfaEnrollment(
424
+ user,
425
+ formattedPhone,
426
+ 'sms',
427
+ 'failure',
428
+ undefined,
429
+ undefined,
430
+ navigator.userAgent,
431
+ [errorMessage]
432
+ );
433
+ } catch (auditError) {
434
+ console.error('Failed to log MFA phone update failure audit:', auditError);
435
+ }
436
+ } finally {
437
+ setIsMfaLoading(false);
438
+ }
439
+ };
440
+
441
+ useEffect(() => {
442
+ onBusyChange?.(isMfaBusy);
443
+ }, [isMfaBusy, onBusyChange]);
444
+
445
+ useEffect(() => {
446
+ return () => {
447
+ onBusyChange?.(false);
448
+ };
449
+ }, [onBusyChange]);
450
+
451
+ useEffect(() => {
452
+ if (isOpen && user) {
453
+ const loadMfaState = async () => {
454
+ try {
455
+ setMfaError('');
456
+ setMfaSuccess('');
457
+ setIsMfaCodeSent(false);
458
+ setMfaVerificationCode('');
459
+ setMfaVerificationId('');
460
+ setMfaResendTimer(0);
461
+ resetMfaReauthFlow();
462
+
463
+ await refreshCurrentMfaPhone(user);
464
+ } catch (err) {
465
+ console.error('Failed to load MFA state:', err);
466
+ }
467
+ };
468
+
469
+ void loadMfaState();
470
+ }
471
+ }, [isOpen, user, refreshCurrentMfaPhone, resetMfaReauthFlow]);
472
+
473
+ useEffect(() => {
474
+ if (mfaResendTimer <= 0) {
475
+ return;
476
+ }
477
+
478
+ const timer = window.setTimeout(() => {
479
+ setMfaResendTimer((previousTimer) => Math.max(0, previousTimer - 1));
480
+ }, 1000);
481
+
482
+ return () => window.clearTimeout(timer);
483
+ }, [mfaResendTimer]);
484
+
485
+ useEffect(() => {
486
+ if (!isOpen || !user) {
487
+ return;
488
+ }
489
+
490
+ const verifier = new RecaptchaVerifier(auth, MFA_RECAPTCHA_CONTAINER_ID, {
491
+ size: 'invisible',
492
+ callback: () => {
493
+ // reCAPTCHA solved automatically for invisible mode.
494
+ },
495
+ 'expired-callback': () => {
496
+ setMfaError(getValidationError('MFA_RECAPTCHA_EXPIRED'));
497
+ },
498
+ });
499
+
500
+ setRecaptchaVerifier(verifier);
501
+
502
+ return () => {
503
+ verifier.clear();
504
+ setRecaptchaVerifier(null);
505
+ };
506
+ }, [isOpen, user]);
507
+
508
+ return (
509
+ <div className={styles.formGroup}>
510
+ <label htmlFor="mfaPhoneInput">Change Phone Number (MFA)</label>
511
+ <input
512
+ id="mfaPhoneInput"
513
+ type="tel"
514
+ value={mfaPhoneInput}
515
+ onChange={(e) => {
516
+ setMfaPhoneInput(e.target.value);
517
+ if (mfaError) setMfaError('');
518
+ if (mfaSuccess) setMfaSuccess('');
519
+ }}
520
+ className={styles.input}
521
+ autoComplete="tel"
522
+ placeholder="ex. +15551234567"
523
+ disabled={isMfaBusy}
524
+ />
525
+ <p className={styles.helpText}>Current MFA phone: {currentMfaPhone}</p>
526
+
527
+ {showMfaReauthPrompt ? (
528
+ <div className={styles.mfaReauthSection}>
529
+ {!mfaReauthResolver ? (
530
+ <>
531
+ <label htmlFor="mfaReauthPassword">Confirm Password</label>
532
+ <p className={styles.helpText}>
533
+ Your session expired. Enter your password to refresh your sign-in.
534
+ </p>
535
+ <input
536
+ id="mfaReauthPassword"
537
+ type="password"
538
+ value={mfaReauthPassword}
539
+ onChange={(e) => {
540
+ setMfaReauthPassword(e.target.value);
541
+ if (mfaError) setMfaError('');
542
+ if (mfaSuccess) setMfaSuccess('');
543
+ }}
544
+ onKeyDown={(e) => {
545
+ if (e.key === 'Enter') {
546
+ e.preventDefault();
547
+ void handleMfaReauthenticate();
548
+ }
549
+ }}
550
+ className={styles.input}
551
+ autoComplete="current-password"
552
+ placeholder="Confirm current password"
553
+ disabled={isMfaBusy}
554
+ />
555
+
556
+ <div className={styles.mfaButtonGroup}>
557
+ <FormButton
558
+ variant="primary"
559
+ type="button"
560
+ onClick={handleMfaReauthenticate}
561
+ isLoading={isMfaReauthLoading}
562
+ loadingText="Confirming..."
563
+ disabled={!mfaReauthPassword.trim()}
564
+ >
565
+ Confirm Password
566
+ </FormButton>
567
+
568
+ <FormButton
569
+ variant="secondary"
570
+ type="button"
571
+ onClick={handleCancelMfaReauth}
572
+ disabled={isMfaReauthLoading}
573
+ >
574
+ Cancel
575
+ </FormButton>
576
+ </div>
577
+ </>
578
+ ) : !isMfaReauthCodeSent ? (
579
+ <>
580
+ <p className={styles.helpText}>
581
+ Password accepted. Send a code to {getMaskedFactorDisplay(mfaReauthHint)} to finish
582
+ re-authentication.
583
+ </p>
584
+
585
+ <div className={styles.mfaButtonGroup}>
586
+ <FormButton
587
+ variant="primary"
588
+ type="button"
589
+ onClick={handleSendMfaReauthVerificationCode}
590
+ isLoading={isMfaReauthLoading}
591
+ loadingText="Sending..."
592
+ >
593
+ Send MFA Code
594
+ </FormButton>
595
+
596
+ <FormButton
597
+ variant="secondary"
598
+ type="button"
599
+ onClick={handleCancelMfaReauth}
600
+ disabled={isMfaReauthLoading}
601
+ >
602
+ Cancel
603
+ </FormButton>
604
+ </div>
605
+ </>
606
+ ) : (
607
+ <>
608
+ <label htmlFor="mfaReauthVerificationCode">MFA Verification Code</label>
609
+ <p className={styles.helpText}>
610
+ Enter the 6-digit code sent to {getMaskedFactorDisplay(mfaReauthHint)}.
611
+ </p>
612
+ <input
613
+ id="mfaReauthVerificationCode"
614
+ type="text"
615
+ value={mfaReauthVerificationCode}
616
+ onChange={(e) => {
617
+ setMfaReauthVerificationCode(e.target.value.replace(/\D/g, ''));
618
+ if (mfaError) setMfaError('');
619
+ if (mfaSuccess) setMfaSuccess('');
620
+ }}
621
+ onKeyDown={(e) => {
622
+ if (e.key === 'Enter') {
623
+ e.preventDefault();
624
+ void handleVerifyMfaReauthCode();
625
+ }
626
+ }}
627
+ className={styles.input}
628
+ autoComplete="one-time-code"
629
+ placeholder="Enter 6-digit code"
630
+ maxLength={6}
631
+ disabled={isMfaBusy}
632
+ />
633
+
634
+ <div className={styles.mfaButtonGroup}>
635
+ <FormButton
636
+ variant="primary"
637
+ type="button"
638
+ onClick={handleVerifyMfaReauthCode}
639
+ isLoading={isMfaReauthLoading}
640
+ loadingText="Verifying..."
641
+ disabled={mfaReauthVerificationCode.trim().length !== 6}
642
+ >
643
+ Verify and Continue
644
+ </FormButton>
645
+
646
+ <FormButton
647
+ variant="secondary"
648
+ type="button"
649
+ onClick={handleSendMfaReauthVerificationCode}
650
+ disabled={isMfaReauthLoading}
651
+ >
652
+ Send New Code
653
+ </FormButton>
654
+
655
+ <FormButton
656
+ variant="secondary"
657
+ type="button"
658
+ onClick={handleCancelMfaReauth}
659
+ disabled={isMfaReauthLoading}
660
+ >
661
+ Cancel
662
+ </FormButton>
663
+ </div>
664
+ </>
665
+ )}
666
+ </div>
667
+ ) : !isMfaCodeSent ? (
668
+ <div className={styles.mfaButtonGroup}>
669
+ <FormButton
670
+ variant="secondary"
671
+ type="button"
672
+ onClick={handleSendMfaVerificationCode}
673
+ isLoading={isMfaLoading}
674
+ loadingText="Sending Code..."
675
+ disabled={!mfaPhoneInput.trim()}
676
+ >
677
+ Send Verification Code
678
+ </FormButton>
679
+ </div>
680
+ ) : (
681
+ <div className={styles.mfaVerificationSection}>
682
+ <input
683
+ id="mfaVerificationCode"
684
+ type="text"
685
+ value={mfaVerificationCode}
686
+ onChange={(e) => {
687
+ setMfaVerificationCode(e.target.value.replace(/\D/g, ''));
688
+ if (mfaError) setMfaError('');
689
+ }}
690
+ className={styles.input}
691
+ placeholder="Enter 6-digit code"
692
+ maxLength={6}
693
+ disabled={isMfaBusy}
694
+ />
695
+
696
+ <div className={styles.mfaButtonGroup}>
697
+ <FormButton
698
+ variant="primary"
699
+ type="button"
700
+ onClick={handleUpdateMfaPhoneNumber}
701
+ isLoading={isMfaLoading}
702
+ loadingText="Updating..."
703
+ disabled={isMfaReauthLoading || mfaVerificationCode.trim().length !== 6}
704
+ >
705
+ Update Phone Number
706
+ </FormButton>
707
+
708
+ <FormButton
709
+ variant="secondary"
710
+ type="button"
711
+ onClick={handleResetMfaChange}
712
+ disabled={isMfaBusy}
713
+ >
714
+ Change Phone Number
715
+ </FormButton>
716
+
717
+ {mfaResendTimer === 0 ? (
718
+ <FormButton
719
+ variant="secondary"
720
+ type="button"
721
+ onClick={handleSendMfaVerificationCode}
722
+ disabled={isMfaBusy}
723
+ >
724
+ Resend Code
725
+ </FormButton>
726
+ ) : (
727
+ <p className={styles.resendTimer}>Resend code in {mfaResendTimer}s</p>
728
+ )}
729
+ </div>
730
+ </div>
731
+ )}
732
+
733
+ {mfaError && <FormMessage type="error" message={mfaError} />}
734
+ {!mfaError && mfaSuccess && <FormMessage type="success" message={mfaSuccess} />}
735
+
736
+ <div id={MFA_RECAPTCHA_CONTAINER_ID} className={styles.recaptchaContainer} />
737
+ </div>
738
+ );
739
+ };