@striae-org/striae 5.3.1 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.env.example +3 -0
  2. package/app/components/actions/generate-pdf.ts +22 -0
  3. package/app/components/auth/auth.module.css +531 -0
  4. package/app/components/auth/mfa-enrollment.tsx +132 -79
  5. package/app/components/auth/mfa-totp-enrollment.tsx +231 -0
  6. package/app/components/auth/mfa-verification.tsx +155 -33
  7. package/app/components/{sidebar/cases/cases-modal.tsx → navbar/case-modals/all-cases-modal.tsx} +4 -4
  8. package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -10
  9. package/app/components/navbar/case-modals/case-modal-shared.module.css +88 -0
  10. package/app/components/navbar/case-modals/delete-case-modal.tsx +9 -10
  11. package/app/components/navbar/case-modals/export-case-modal.tsx +9 -10
  12. package/app/components/navbar/case-modals/export-confirmations-modal.tsx +9 -10
  13. package/app/components/navbar/case-modals/open-case-modal.tsx +4 -4
  14. package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -10
  15. package/app/components/navbar/navbar.tsx +1 -1
  16. package/app/components/sidebar/files/delete-files-modal.tsx +3 -3
  17. package/app/components/sidebar/files/files-modal.module.css +29 -0
  18. package/app/components/sidebar/notes/{class-details-fields.tsx → class-details/class-details-fields.tsx} +1 -1
  19. package/app/components/sidebar/notes/{class-details-modal.tsx → class-details/class-details-modal.tsx} +1 -1
  20. package/app/components/sidebar/notes/{class-details-sections.tsx → class-details/class-details-sections.tsx} +1 -1
  21. package/app/components/sidebar/notes/notes-editor-form.tsx +2 -2
  22. package/app/components/sidebar/notes/notes-editor-modal.tsx +6 -6
  23. package/app/components/sidebar/notes/notes.module.css +52 -0
  24. package/app/components/toolbar/toolbar-color-selector.tsx +8 -8
  25. package/app/components/toolbar/toolbar.module.css +181 -2
  26. package/app/components/user/delete-account.tsx +7 -7
  27. package/app/components/user/inactivity-warning.tsx +6 -6
  28. package/app/components/user/manage-profile.tsx +18 -1
  29. package/app/components/user/mfa-enrolled-factors.tsx +117 -0
  30. package/app/components/user/mfa-phone-update.tsx +8 -4
  31. package/app/components/user/mfa-totp-section.tsx +446 -0
  32. package/app/components/user/user.module.css +665 -0
  33. package/app/routes/striae/striae.tsx +1 -1
  34. package/app/services/audit/audit.service.ts +1 -1
  35. package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
  36. package/app/services/firebase/errors.ts +2 -0
  37. package/app/utils/auth/mfa.ts +35 -1
  38. package/functions/api/image/[[path]].ts +19 -3
  39. package/package.json +16 -21
  40. package/scripts/deploy-all.sh +166 -0
  41. package/scripts/deploy-config/modules/env-utils.sh +322 -0
  42. package/scripts/deploy-config/modules/keys.sh +404 -0
  43. package/scripts/deploy-config/modules/prompt.sh +375 -0
  44. package/scripts/deploy-config/modules/scaffolding.sh +310 -0
  45. package/scripts/deploy-config/modules/validation.sh +354 -0
  46. package/scripts/deploy-config.sh +236 -0
  47. package/scripts/deploy-pages-secrets.sh +231 -0
  48. package/scripts/deploy-pages.sh +34 -0
  49. package/scripts/deploy-primershear-emails.sh +167 -0
  50. package/scripts/deploy-worker-secrets.sh +385 -0
  51. package/scripts/dev.cjs +23 -0
  52. package/scripts/enable-totp-mfa.mjs +57 -0
  53. package/scripts/install-workers.sh +87 -0
  54. package/scripts/run-eslint.cjs +43 -0
  55. package/scripts/update-compatibility-dates.cjs +124 -0
  56. package/scripts/update-markdown-versions.cjs +43 -0
  57. package/workers/audit-worker/package.json +1 -1
  58. package/workers/audit-worker/wrangler.jsonc.example +1 -1
  59. package/workers/data-worker/package.json +1 -1
  60. package/workers/data-worker/wrangler.jsonc.example +1 -1
  61. package/workers/image-worker/package.json +1 -1
  62. package/workers/image-worker/src/image-worker.example.ts +36 -2
  63. package/workers/image-worker/wrangler.jsonc.example +1 -1
  64. package/workers/pdf-worker/package.json +1 -1
  65. package/workers/pdf-worker/wrangler.jsonc.example +1 -1
  66. package/workers/user-worker/package.json +1 -1
  67. package/workers/user-worker/wrangler.jsonc.example +1 -1
  68. package/wrangler.toml.example +1 -1
  69. package/app/components/auth/mfa-enrollment.module.css +0 -276
  70. package/app/components/auth/mfa-verification.module.css +0 -259
  71. package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -34
  72. package/app/components/navbar/case-modals/delete-case-modal.module.css +0 -9
  73. package/app/components/navbar/case-modals/export-case-modal.module.css +0 -27
  74. package/app/components/navbar/case-modals/export-confirmations-modal.module.css +0 -24
  75. package/app/components/navbar/case-modals/open-case-modal.module.css +0 -82
  76. package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -9
  77. package/app/components/sidebar/files/delete-files-modal.module.css +0 -26
  78. package/app/components/sidebar/notes/notes-editor-modal.module.css +0 -49
  79. package/app/components/toolbar/toolbar-color-selector.module.css +0 -171
  80. package/app/components/user/delete-account.module.css +0 -277
  81. package/app/components/user/inactivity-warning.module.css +0 -148
  82. package/app/components/user/manage-profile.module.css +0 -192
  83. package/app/routes/auth/login.module.css +0 -523
  84. package/app/routes/auth/login.tsx +0 -705
  85. /package/app/components/{sidebar → navbar}/case-import/case-import.module.css +0 -0
  86. /package/app/components/{sidebar → navbar}/case-import/case-import.tsx +0 -0
  87. /package/app/components/{sidebar → navbar}/case-import/components/CasePreviewSection.tsx +0 -0
  88. /package/app/components/{sidebar → navbar}/case-import/components/ConfirmationDialog.tsx +0 -0
  89. /package/app/components/{sidebar → navbar}/case-import/components/ConfirmationPreviewSection.tsx +0 -0
  90. /package/app/components/{sidebar → navbar}/case-import/components/ExistingCaseSection.tsx +0 -0
  91. /package/app/components/{sidebar → navbar}/case-import/components/FileSelector.tsx +0 -0
  92. /package/app/components/{sidebar → navbar}/case-import/components/ProgressSection.tsx +0 -0
  93. /package/app/components/{sidebar → navbar}/case-import/hooks/useFilePreview.ts +0 -0
  94. /package/app/components/{sidebar → navbar}/case-import/hooks/useImportExecution.ts +0 -0
  95. /package/app/components/{sidebar → navbar}/case-import/hooks/useImportState.ts +0 -0
  96. /package/app/components/{sidebar → navbar}/case-import/index.ts +0 -0
  97. /package/app/components/{sidebar → navbar}/case-import/utils/file-validation.ts +0 -0
  98. /package/app/components/{sidebar/cases/cases-modal.module.css → navbar/case-modals/all-cases-modal.module.css} +0 -0
  99. /package/app/components/sidebar/notes/{class-details-shared.ts → class-details/class-details-shared.ts} +0 -0
  100. /package/app/components/sidebar/notes/{use-class-details-state.ts → class-details/use-class-details-state.ts} +0 -0
@@ -0,0 +1,117 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { multiFactor, type MultiFactorInfo, type User } from 'firebase/auth';
3
+ import { handleAuthError } from '~/services/firebase/errors';
4
+ import { getMfaMethodLabel } from '~/utils/auth';
5
+ import { FormButton, FormMessage } from '../form';
6
+ import styles from './user.module.css';
7
+
8
+ interface MfaEnrolledFactorsProps {
9
+ user: User | null;
10
+ refreshKey?: number;
11
+ onFactorRemoved: () => void;
12
+ onBusyChange?: (isBusy: boolean) => void;
13
+ }
14
+
15
+ export const MfaEnrolledFactors = ({
16
+ user,
17
+ refreshKey,
18
+ onFactorRemoved,
19
+ onBusyChange,
20
+ }: MfaEnrolledFactorsProps) => {
21
+ const [enrolledFactors, setEnrolledFactors] = useState<MultiFactorInfo[]>([]);
22
+ const [removingUid, setRemovingUid] = useState<string | null>(null);
23
+ const [error, setError] = useState('');
24
+ const [success, setSuccess] = useState('');
25
+
26
+ const loadFactors = useCallback(async (currentUser: User) => {
27
+ try {
28
+ await currentUser.reload();
29
+ setEnrolledFactors([...multiFactor(currentUser).enrolledFactors]);
30
+ } catch (err) {
31
+ console.error('Failed to reload user factors:', err);
32
+ }
33
+ }, []);
34
+
35
+ useEffect(() => {
36
+ if (user) {
37
+ void loadFactors(user);
38
+ }
39
+ }, [user, loadFactors, refreshKey]);
40
+
41
+ const handleRemove = async (factor: MultiFactorInfo) => {
42
+ if (!user) return;
43
+ if (enrolledFactors.length <= 1) return;
44
+
45
+ setRemovingUid(factor.uid);
46
+ setError('');
47
+ setSuccess('');
48
+ onBusyChange?.(true);
49
+
50
+ try {
51
+ await multiFactor(user).unenroll(factor.uid);
52
+ setSuccess(`${getMfaMethodLabel(factor.factorId)} removed successfully.`);
53
+ await loadFactors(user);
54
+ onFactorRemoved();
55
+ } catch (err) {
56
+ const { data, message } = handleAuthError(err);
57
+ if (data?.code === 'auth/user-token-expired' || data?.code === 'auth/requires-recent-login') {
58
+ setError('For security, please sign out and sign in again, then remove this factor.');
59
+ } else {
60
+ setError(message);
61
+ }
62
+ } finally {
63
+ setRemovingUid(null);
64
+ onBusyChange?.(false);
65
+ }
66
+ };
67
+
68
+ useEffect(() => {
69
+ return () => {
70
+ onBusyChange?.(false);
71
+ };
72
+ }, [onBusyChange]);
73
+
74
+ if (!user || enrolledFactors.length === 0) return null;
75
+
76
+ return (
77
+ <div className={styles.formGroup}>
78
+ <p className={styles.sectionLabel}>Enrolled 2-Step Verification Methods</p>
79
+ {error && <FormMessage type="error" message={error} />}
80
+ {success && <FormMessage type="success" message={success} />}
81
+ <ul className={styles.enrolledFactorsList}>
82
+ {enrolledFactors.map((factor) => (
83
+ <li key={factor.uid} className={styles.enrolledFactorItem}>
84
+ <div className={styles.enrolledFactorInfo}>
85
+ <span className={styles.enrolledFactorLabel}>
86
+ {getMfaMethodLabel(factor.factorId)}
87
+ </span>
88
+ {factor.displayName && factor.displayName !== getMfaMethodLabel(factor.factorId) && (
89
+ <span className={styles.enrolledFactorName}>{factor.displayName}</span>
90
+ )}
91
+ {factor.enrollmentTime && (
92
+ <span className={styles.enrolledFactorDate}>
93
+ Added {new Date(factor.enrollmentTime).toLocaleDateString()}
94
+ </span>
95
+ )}
96
+ </div>
97
+ <FormButton
98
+ variant="secondary"
99
+ type="button"
100
+ onClick={() => handleRemove(factor)}
101
+ isLoading={removingUid === factor.uid}
102
+ loadingText="Removing…"
103
+ disabled={removingUid !== null || enrolledFactors.length <= 1}
104
+ >
105
+ Remove
106
+ </FormButton>
107
+ </li>
108
+ ))}
109
+ </ul>
110
+ {enrolledFactors.length <= 1 && (
111
+ <p className={styles.enrolledFactorsNote}>
112
+ At least one 2-step verification method is required and cannot be removed.
113
+ </p>
114
+ )}
115
+ </div>
116
+ );
117
+ };
@@ -23,7 +23,7 @@ import {
23
23
  validatePhoneNumber,
24
24
  } from '~/utils/auth';
25
25
  import { FormButton, FormMessage } from '../form';
26
- import styles from './manage-profile.module.css';
26
+ import styles from './user.module.css';
27
27
 
28
28
  const MFA_RECAPTCHA_CONTAINER_ID = 'recaptcha-container-manage-profile';
29
29
 
@@ -47,6 +47,7 @@ export const MfaPhoneUpdateSection = ({
47
47
  const [mfaResendTimer, setMfaResendTimer] = useState(0);
48
48
  const [mfaError, setMfaError] = useState('');
49
49
  const [mfaSuccess, setMfaSuccess] = useState('');
50
+ const [isSmsEnabled, setIsSmsEnabled] = useState(false);
50
51
  const [showMfaReauthPrompt, setShowMfaReauthPrompt] = useState(false);
51
52
  const [mfaReauthPassword, setMfaReauthPassword] = useState('');
52
53
  const [mfaReauthResolver, setMfaReauthResolver] = useState<MultiFactorResolver | null>(null);
@@ -79,6 +80,7 @@ export const MfaPhoneUpdateSection = ({
79
80
 
80
81
  if (phoneFactors.length === 0) {
81
82
  setCurrentMfaPhone('Not configured');
83
+ setIsSmsEnabled(false);
82
84
  return;
83
85
  }
84
86
 
@@ -86,10 +88,12 @@ export const MfaPhoneUpdateSection = ({
86
88
  const phoneDisplayValue = getPhoneDisplayValue(latestFactor);
87
89
  if (!phoneDisplayValue) {
88
90
  setCurrentMfaPhone('Configured');
91
+ setIsSmsEnabled(true);
89
92
  return;
90
93
  }
91
94
 
92
95
  setCurrentMfaPhone(maskPhoneNumber(phoneDisplayValue));
96
+ setIsSmsEnabled(true);
93
97
  }, []);
94
98
 
95
99
  const handleResetMfaChange = () => {
@@ -508,7 +512,7 @@ export const MfaPhoneUpdateSection = ({
508
512
 
509
513
  return (
510
514
  <div className={styles.formGroup}>
511
- <label htmlFor="mfaPhoneInput">Change Phone Number (MFA)</label>
515
+ <label htmlFor="mfaPhoneInput">{isSmsEnabled ? 'Change Phone Number (MFA)' : 'Set Up SMS MFA'}</label>
512
516
  <input
513
517
  id="mfaPhoneInput"
514
518
  type="tel"
@@ -523,7 +527,7 @@ export const MfaPhoneUpdateSection = ({
523
527
  placeholder="ex. +15551234567"
524
528
  disabled={isMfaBusy}
525
529
  />
526
- <p className={styles.helpText}>Current MFA phone: {currentMfaPhone}</p>
530
+ {isSmsEnabled && <p className={styles.helpText}>Current MFA phone: {currentMfaPhone}</p>}
527
531
 
528
532
  {showMfaReauthPrompt ? (
529
533
  <div className={styles.mfaReauthSection}>
@@ -705,7 +709,7 @@ export const MfaPhoneUpdateSection = ({
705
709
  loadingText="Updating..."
706
710
  disabled={isMfaReauthLoading || mfaVerificationCode.trim().length !== 6}
707
711
  >
708
- Update Phone Number
712
+ {isSmsEnabled ? 'Update Phone Number' : 'Set Up Phone Number'}
709
713
  </FormButton>
710
714
 
711
715
  <FormButton
@@ -0,0 +1,446 @@
1
+ import { useState, useCallback, useEffect } 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 { auth } from '~/services/firebase';
16
+ import { ERROR_MESSAGES, getValidationError, handleAuthError } from '~/services/firebase/errors';
17
+ import { hasTotpEnrolled, getMaskedFactorDisplay } from '~/utils/auth';
18
+ import { MfaTotpEnrollment } from '../auth/mfa-totp-enrollment';
19
+ import { FormButton, FormMessage } from '../form';
20
+ import styles from './user.module.css';
21
+
22
+ const TOTP_RECAPTCHA_CONTAINER_ID = 'recaptcha-container-totp-section';
23
+
24
+ interface MfaTotpSectionProps {
25
+ user: User | null;
26
+ isOpen: boolean;
27
+ onBusyChange?: (isBusy: boolean) => void;
28
+ onTotpEnrolled: () => void;
29
+ }
30
+
31
+ export const MfaTotpSection = ({
32
+ user,
33
+ isOpen,
34
+ onBusyChange,
35
+ onTotpEnrolled,
36
+ }: MfaTotpSectionProps) => {
37
+ const [showEnrollment, setShowEnrollment] = useState(false);
38
+ const [isLoading, setIsLoading] = useState(false);
39
+ const [error, setError] = useState('');
40
+ const [success, setSuccess] = useState('');
41
+ const [isTotpEnrolled, setIsTotpEnrolled] = useState(false);
42
+
43
+ // Re-auth state
44
+ const [showReauthPrompt, setShowReauthPrompt] = useState(false);
45
+ const [reauthPassword, setReauthPassword] = useState('');
46
+ const [reauthResolver, setReauthResolver] = useState<MultiFactorResolver | null>(null);
47
+ const [reauthHint, setReauthHint] = useState<MultiFactorInfo | null>(null);
48
+ const [reauthVerificationId, setReauthVerificationId] = useState('');
49
+ const [reauthVerificationCode, setReauthVerificationCode] = useState('');
50
+ const [isReauthCodeSent, setIsReauthCodeSent] = useState(false);
51
+ const [isReauthLoading, setIsReauthLoading] = useState(false);
52
+ const [recaptchaVerifier, setRecaptchaVerifier] = useState<RecaptchaVerifier | null>(null);
53
+
54
+ const isBusy = isLoading || isReauthLoading;
55
+
56
+ const resetReauthFlow = useCallback(() => {
57
+ setShowReauthPrompt(false);
58
+ setReauthPassword('');
59
+ setReauthResolver(null);
60
+ setReauthHint(null);
61
+ setReauthVerificationId('');
62
+ setReauthVerificationCode('');
63
+ setIsReauthCodeSent(false);
64
+ }, []);
65
+
66
+ const refreshTotpStatus = useCallback(async (currentUser: User) => {
67
+ await currentUser.reload();
68
+ setIsTotpEnrolled(hasTotpEnrolled(currentUser));
69
+ }, []);
70
+
71
+ useEffect(() => {
72
+ if (isOpen && user) {
73
+ void refreshTotpStatus(user);
74
+ setShowEnrollment(false);
75
+ setError('');
76
+ setSuccess('');
77
+ resetReauthFlow();
78
+ }
79
+ }, [isOpen, user, refreshTotpStatus, resetReauthFlow]);
80
+
81
+ useEffect(() => {
82
+ onBusyChange?.(isBusy);
83
+ }, [isBusy, onBusyChange]);
84
+
85
+ useEffect(() => {
86
+ return () => {
87
+ onBusyChange?.(false);
88
+ };
89
+ }, [onBusyChange]);
90
+
91
+ useEffect(() => {
92
+ if (!isOpen || !user) return;
93
+
94
+ const verifier = new RecaptchaVerifier(auth, TOTP_RECAPTCHA_CONTAINER_ID, {
95
+ size: 'invisible',
96
+ callback: () => {},
97
+ 'expired-callback': () => {
98
+ setError(getValidationError('MFA_RECAPTCHA_EXPIRED'));
99
+ },
100
+ });
101
+ setRecaptchaVerifier(verifier);
102
+
103
+ return () => {
104
+ verifier.clear();
105
+ setRecaptchaVerifier(null);
106
+ };
107
+ }, [isOpen, user]);
108
+
109
+ const handleStartEnrollment = async () => {
110
+ if (!user) {
111
+ setError(ERROR_MESSAGES.NO_USER);
112
+ return;
113
+ }
114
+
115
+ setIsLoading(true);
116
+ setError('');
117
+ setSuccess('');
118
+
119
+ try {
120
+ // Attempt a dummy getSession to trigger re-auth if needed
121
+ await multiFactor(user).getSession();
122
+ setShowEnrollment(true);
123
+ } catch (err) {
124
+ const { data, message } = handleAuthError(err);
125
+
126
+ if (data?.code === 'auth/requires-recent-login') {
127
+ const supportsPasswordReauth = user.providerData.some(
128
+ (p) => p.providerId === 'password'
129
+ );
130
+ if (supportsPasswordReauth && user.email) {
131
+ resetReauthFlow();
132
+ setShowReauthPrompt(true);
133
+ return;
134
+ }
135
+ setError('For security, please sign out and sign in again, then try this action again.');
136
+ return;
137
+ }
138
+
139
+ setError(message);
140
+ } finally {
141
+ setIsLoading(false);
142
+ }
143
+ };
144
+
145
+ const handleReauthenticate = async () => {
146
+ if (!user) {
147
+ setError(ERROR_MESSAGES.NO_USER);
148
+ return;
149
+ }
150
+ if (!user.email) {
151
+ setError('Please sign out and sign in again to continue.');
152
+ return;
153
+ }
154
+ if (!reauthPassword.trim()) {
155
+ setError('Please enter your password to continue.');
156
+ return;
157
+ }
158
+
159
+ setIsReauthLoading(true);
160
+ setError('');
161
+
162
+ try {
163
+ const credential = EmailAuthProvider.credential(user.email, reauthPassword);
164
+ await reauthenticateWithCredential(user, credential);
165
+ resetReauthFlow();
166
+ setShowEnrollment(true);
167
+ } catch (err) {
168
+ const { data, message } = handleAuthError(err);
169
+
170
+ if (data?.code === 'auth/multi-factor-auth-required') {
171
+ if (!recaptchaVerifier) {
172
+ setError(getValidationError('MFA_RECAPTCHA_ERROR'));
173
+ return;
174
+ }
175
+
176
+ const resolver = getMultiFactorResolver(auth, err as MultiFactorError);
177
+ const phoneHint = resolver.hints.find(
178
+ (h) => h.factorId === PhoneMultiFactorGenerator.FACTOR_ID
179
+ );
180
+
181
+ if (!phoneHint) {
182
+ setError('This account requires a non-phone MFA method. Please sign out and sign in again.');
183
+ return;
184
+ }
185
+
186
+ setShowReauthPrompt(true);
187
+ setReauthResolver(resolver);
188
+ setReauthHint(phoneHint);
189
+ setReauthVerificationId('');
190
+ setReauthVerificationCode('');
191
+ setIsReauthCodeSent(false);
192
+ return;
193
+ }
194
+
195
+ setError(message);
196
+ } finally {
197
+ setIsReauthLoading(false);
198
+ }
199
+ };
200
+
201
+ const handleSendReauthCode = async () => {
202
+ if (!reauthResolver || !reauthHint || !recaptchaVerifier) {
203
+ setError(getValidationError('MFA_RECAPTCHA_ERROR'));
204
+ return;
205
+ }
206
+
207
+ setIsReauthLoading(true);
208
+ setError('');
209
+
210
+ try {
211
+ const phoneAuthProvider = new PhoneAuthProvider(auth);
212
+ const verificationId = await phoneAuthProvider.verifyPhoneNumber(
213
+ { multiFactorHint: reauthHint, session: reauthResolver.session },
214
+ recaptchaVerifier
215
+ );
216
+ setReauthVerificationId(verificationId);
217
+ setReauthVerificationCode('');
218
+ setIsReauthCodeSent(true);
219
+ } catch (err) {
220
+ const { message } = handleAuthError(err);
221
+ setError(message);
222
+ } finally {
223
+ setIsReauthLoading(false);
224
+ }
225
+ };
226
+
227
+ const handleVerifyReauthCode = async () => {
228
+ if (!reauthResolver || !reauthVerificationId || !reauthVerificationCode.trim()) {
229
+ setError(getValidationError('MFA_CODE_REQUIRED'));
230
+ return;
231
+ }
232
+
233
+ setIsReauthLoading(true);
234
+ setError('');
235
+
236
+ try {
237
+ const credential = PhoneAuthProvider.credential(reauthVerificationId, reauthVerificationCode.trim());
238
+ const assertion = PhoneMultiFactorGenerator.assertion(credential);
239
+ await reauthResolver.resolveSignIn(assertion);
240
+ resetReauthFlow();
241
+ setShowEnrollment(true);
242
+ } catch (err) {
243
+ const { data, message } = handleAuthError(err);
244
+ let errorMessage = message;
245
+ if (data?.code === 'auth/invalid-verification-code') {
246
+ errorMessage = getValidationError('MFA_INVALID_CODE');
247
+ } else if (data?.code === 'auth/code-expired') {
248
+ errorMessage = getValidationError('MFA_CODE_EXPIRED');
249
+ setIsReauthCodeSent(false);
250
+ setReauthVerificationId('');
251
+ setReauthVerificationCode('');
252
+ }
253
+ setError(errorMessage);
254
+ } finally {
255
+ setIsReauthLoading(false);
256
+ }
257
+ };
258
+
259
+ const handleEnrollmentSuccess = async () => {
260
+ setShowEnrollment(false);
261
+ setSuccess('Authenticator app added successfully.');
262
+ if (user) await refreshTotpStatus(user);
263
+ onTotpEnrolled();
264
+ };
265
+
266
+ const handleEnrollmentError = (msg: string) => {
267
+ setError(msg);
268
+ };
269
+
270
+ if (!user) return null;
271
+
272
+ return (
273
+ <div className={styles.formGroup}>
274
+ <p className={styles.sectionLabel}>Authenticator App (TOTP)</p>
275
+
276
+ {error && <FormMessage type="error" message={error} />}
277
+ {success && <FormMessage type="success" message={success} />}
278
+
279
+ {!showEnrollment && !showReauthPrompt && (
280
+ <>
281
+ <p className={styles.helpText}>
282
+ Current status:{' '}
283
+ <strong>{isTotpEnrolled ? 'Configured' : 'Not configured'}</strong>
284
+ </p>
285
+ {!isTotpEnrolled && (
286
+ <div className={styles.mfaButtonGroup}>
287
+ <FormButton
288
+ variant="secondary"
289
+ type="button"
290
+ onClick={handleStartEnrollment}
291
+ isLoading={isLoading}
292
+ loadingText="Setting up…"
293
+ >
294
+ Add Authenticator App
295
+ </FormButton>
296
+ </div>
297
+ )}
298
+ </>
299
+ )}
300
+
301
+ {showReauthPrompt && (
302
+ <div className={styles.mfaReauthSection}>
303
+ {!reauthResolver ? (
304
+ <>
305
+ <label htmlFor="totpReauthPassword">Confirm Password</label>
306
+ <p className={styles.helpText}>
307
+ Your session expired. Enter your password to refresh your sign-in.
308
+ </p>
309
+ <input
310
+ id="totpReauthPassword"
311
+ type="password"
312
+ value={reauthPassword}
313
+ onChange={(e) => {
314
+ setReauthPassword(e.target.value);
315
+ if (error) setError('');
316
+ }}
317
+ onKeyDown={(e) => {
318
+ if (e.key === 'Enter') {
319
+ e.preventDefault();
320
+ void handleReauthenticate();
321
+ }
322
+ }}
323
+ className={styles.input}
324
+ autoComplete="current-password"
325
+ placeholder="Confirm current password"
326
+ disabled={isBusy}
327
+ />
328
+ <div className={styles.mfaButtonGroup}>
329
+ <FormButton
330
+ variant="primary"
331
+ type="button"
332
+ onClick={handleReauthenticate}
333
+ isLoading={isReauthLoading}
334
+ loadingText="Confirming…"
335
+ disabled={!reauthPassword.trim()}
336
+ >
337
+ Confirm Password
338
+ </FormButton>
339
+ <FormButton
340
+ variant="secondary"
341
+ type="button"
342
+ onClick={() => { resetReauthFlow(); setError(''); }}
343
+ disabled={isReauthLoading}
344
+ >
345
+ Cancel
346
+ </FormButton>
347
+ </div>
348
+ </>
349
+ ) : !isReauthCodeSent ? (
350
+ <>
351
+ <p className={styles.helpText}>
352
+ Password accepted. Send a code to {getMaskedFactorDisplay(reauthHint)} to finish
353
+ re-authentication.
354
+ </p>
355
+ <div className={styles.mfaButtonGroup}>
356
+ <FormButton
357
+ variant="primary"
358
+ type="button"
359
+ onClick={handleSendReauthCode}
360
+ isLoading={isReauthLoading}
361
+ loadingText="Sending…"
362
+ >
363
+ Send MFA Code
364
+ </FormButton>
365
+ <FormButton
366
+ variant="secondary"
367
+ type="button"
368
+ onClick={() => { resetReauthFlow(); setError(''); }}
369
+ disabled={isReauthLoading}
370
+ >
371
+ Cancel
372
+ </FormButton>
373
+ </div>
374
+ </>
375
+ ) : (
376
+ <>
377
+ <label htmlFor="totpReauthCode">MFA Verification Code</label>
378
+ <p className={styles.helpText}>
379
+ Enter the 6-digit code sent to {getMaskedFactorDisplay(reauthHint)}.
380
+ </p>
381
+ <input
382
+ id="totpReauthCode"
383
+ type="text"
384
+ value={reauthVerificationCode}
385
+ onChange={(e) => {
386
+ setReauthVerificationCode(e.target.value.replace(/\D/g, ''));
387
+ if (error) setError('');
388
+ }}
389
+ onKeyDown={(e) => {
390
+ if (e.key === 'Enter') {
391
+ e.preventDefault();
392
+ void handleVerifyReauthCode();
393
+ }
394
+ }}
395
+ className={styles.input}
396
+ autoComplete="one-time-code"
397
+ placeholder="Enter 6-digit code"
398
+ maxLength={6}
399
+ disabled={isBusy}
400
+ />
401
+ <div className={styles.mfaButtonGroup}>
402
+ <FormButton
403
+ variant="primary"
404
+ type="button"
405
+ onClick={handleVerifyReauthCode}
406
+ isLoading={isReauthLoading}
407
+ loadingText="Verifying…"
408
+ disabled={reauthVerificationCode.trim().length !== 6}
409
+ >
410
+ Verify and Continue
411
+ </FormButton>
412
+ <FormButton
413
+ variant="secondary"
414
+ type="button"
415
+ onClick={handleSendReauthCode}
416
+ disabled={isReauthLoading}
417
+ >
418
+ Send New Code
419
+ </FormButton>
420
+ <FormButton
421
+ variant="secondary"
422
+ type="button"
423
+ onClick={() => { resetReauthFlow(); setError(''); }}
424
+ disabled={isReauthLoading}
425
+ >
426
+ Cancel
427
+ </FormButton>
428
+ </div>
429
+ </>
430
+ )}
431
+ </div>
432
+ )}
433
+
434
+ {showEnrollment && user && (
435
+ <MfaTotpEnrollment
436
+ user={user}
437
+ onSuccess={handleEnrollmentSuccess}
438
+ onError={handleEnrollmentError}
439
+ onBack={() => { setShowEnrollment(false); setError(''); }}
440
+ />
441
+ )}
442
+
443
+ <div id={TOTP_RECAPTCHA_CONTAINER_ID} className={styles.recaptchaContainer} />
444
+ </div>
445
+ );
446
+ };