@striae-org/striae 5.3.2 → 5.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/auth/auth.module.css +531 -0
- package/app/components/auth/mfa-enrollment.tsx +132 -79
- package/app/components/auth/mfa-totp-enrollment.tsx +231 -0
- package/app/components/auth/mfa-verification.tsx +162 -33
- package/app/components/{sidebar/cases/cases-modal.tsx → navbar/case-modals/all-cases-modal.tsx} +4 -4
- package/app/components/navbar/case-modals/archive-case-modal.tsx +9 -10
- package/app/components/navbar/case-modals/case-modal-shared.module.css +88 -0
- package/app/components/navbar/case-modals/delete-case-modal.tsx +9 -10
- package/app/components/navbar/case-modals/export-case-modal.tsx +9 -10
- package/app/components/navbar/case-modals/export-confirmations-modal.tsx +9 -10
- package/app/components/navbar/case-modals/open-case-modal.tsx +4 -4
- package/app/components/navbar/case-modals/rename-case-modal.tsx +9 -10
- package/app/components/navbar/navbar.tsx +1 -1
- package/app/components/sidebar/files/delete-files-modal.tsx +3 -3
- package/app/components/sidebar/files/files-modal.module.css +29 -0
- package/app/components/sidebar/notes/{class-details-fields.tsx → class-details/class-details-fields.tsx} +1 -1
- package/app/components/sidebar/notes/{class-details-modal.tsx → class-details/class-details-modal.tsx} +1 -1
- package/app/components/sidebar/notes/{class-details-sections.tsx → class-details/class-details-sections.tsx} +1 -1
- package/app/components/sidebar/notes/notes-editor-form.tsx +2 -2
- package/app/components/sidebar/notes/notes-editor-modal.tsx +6 -6
- package/app/components/sidebar/notes/notes.module.css +52 -0
- package/app/components/toolbar/toolbar-color-selector.tsx +8 -8
- package/app/components/toolbar/toolbar.module.css +181 -2
- package/app/components/user/delete-account.tsx +7 -7
- package/app/components/user/inactivity-warning.tsx +6 -6
- package/app/components/user/manage-profile.tsx +18 -1
- package/app/components/user/mfa-enrolled-factors.tsx +117 -0
- package/app/components/user/mfa-phone-update.tsx +8 -4
- package/app/components/user/mfa-totp-section.tsx +446 -0
- package/app/components/user/user.module.css +665 -0
- package/app/routes/striae/striae.tsx +1 -1
- package/app/services/audit/audit.service.ts +1 -1
- package/app/services/audit/builders/audit-event-builders-user-security.ts +4 -2
- package/app/services/firebase/errors.ts +2 -0
- package/app/utils/auth/mfa.ts +35 -1
- package/package.json +23 -28
- package/scripts/deploy-all.sh +166 -0
- package/scripts/deploy-config/modules/env-utils.sh +322 -0
- package/scripts/deploy-config/modules/keys.sh +404 -0
- package/scripts/deploy-config/modules/prompt.sh +375 -0
- package/scripts/deploy-config/modules/scaffolding.sh +310 -0
- package/scripts/deploy-config/modules/validation.sh +354 -0
- package/scripts/deploy-config.sh +236 -0
- package/scripts/deploy-pages-secrets.sh +231 -0
- package/scripts/deploy-pages.sh +34 -0
- package/scripts/deploy-primershear-emails.sh +167 -0
- package/scripts/deploy-worker-secrets.sh +385 -0
- package/scripts/dev.cjs +23 -0
- package/scripts/enable-totp-mfa.mjs +57 -0
- package/scripts/install-workers.sh +87 -0
- package/scripts/run-eslint.cjs +43 -0
- package/scripts/unenroll-totp-mfa.mjs +82 -0
- package/scripts/update-compatibility-dates.cjs +124 -0
- package/scripts/update-markdown-versions.cjs +43 -0
- package/workers/audit-worker/.editorconfig +12 -0
- package/workers/audit-worker/.prettierrc +6 -0
- package/workers/audit-worker/package.json +1 -1
- package/workers/audit-worker/wrangler.jsonc.example +1 -1
- package/workers/data-worker/.editorconfig +12 -0
- package/workers/data-worker/.prettierrc +6 -0
- package/workers/data-worker/package.json +1 -1
- package/workers/data-worker/wrangler.jsonc.example +1 -1
- package/workers/image-worker/.editorconfig +12 -0
- package/workers/image-worker/.prettierrc +6 -0
- package/workers/image-worker/package.json +1 -1
- package/workers/image-worker/wrangler.jsonc.example +1 -1
- package/workers/pdf-worker/.editorconfig +12 -0
- package/workers/pdf-worker/.prettierrc +6 -0
- package/workers/pdf-worker/package.json +1 -1
- package/workers/pdf-worker/wrangler.jsonc.example +1 -1
- package/workers/user-worker/.editorconfig +12 -0
- package/workers/user-worker/.prettierrc +6 -0
- package/workers/user-worker/package.json +1 -1
- package/workers/user-worker/wrangler.jsonc.example +1 -1
- package/wrangler.toml.example +1 -1
- package/app/components/auth/mfa-enrollment.module.css +0 -276
- package/app/components/auth/mfa-verification.module.css +0 -259
- package/app/components/navbar/case-modals/archive-case-modal.module.css +0 -34
- package/app/components/navbar/case-modals/delete-case-modal.module.css +0 -9
- package/app/components/navbar/case-modals/export-case-modal.module.css +0 -27
- package/app/components/navbar/case-modals/export-confirmations-modal.module.css +0 -24
- package/app/components/navbar/case-modals/open-case-modal.module.css +0 -82
- package/app/components/navbar/case-modals/rename-case-modal.module.css +0 -9
- package/app/components/sidebar/files/delete-files-modal.module.css +0 -26
- package/app/components/sidebar/notes/notes-editor-modal.module.css +0 -49
- package/app/components/toolbar/toolbar-color-selector.module.css +0 -171
- package/app/components/user/delete-account.module.css +0 -277
- package/app/components/user/inactivity-warning.module.css +0 -148
- package/app/components/user/manage-profile.module.css +0 -192
- package/app/routes/auth/login.module.css +0 -523
- package/app/routes/auth/login.tsx +0 -705
- package/workers/audit-worker/worker-configuration.d.ts +0 -7448
- package/workers/data-worker/worker-configuration.d.ts +0 -7448
- package/workers/image-worker/worker-configuration.d.ts +0 -7448
- package/workers/pdf-worker/worker-configuration.d.ts +0 -7447
- package/workers/user-worker/worker-configuration.d.ts +0 -7450
- /package/app/components/{sidebar → navbar}/case-import/case-import.module.css +0 -0
- /package/app/components/{sidebar → navbar}/case-import/case-import.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/components/CasePreviewSection.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/components/ConfirmationDialog.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/components/ConfirmationPreviewSection.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/components/ExistingCaseSection.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/components/FileSelector.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/components/ProgressSection.tsx +0 -0
- /package/app/components/{sidebar → navbar}/case-import/hooks/useFilePreview.ts +0 -0
- /package/app/components/{sidebar → navbar}/case-import/hooks/useImportExecution.ts +0 -0
- /package/app/components/{sidebar → navbar}/case-import/hooks/useImportState.ts +0 -0
- /package/app/components/{sidebar → navbar}/case-import/index.ts +0 -0
- /package/app/components/{sidebar → navbar}/case-import/utils/file-validation.ts +0 -0
- /package/app/components/{sidebar/cases/cases-modal.module.css → navbar/case-modals/all-cases-modal.module.css} +0 -0
- /package/app/components/sidebar/notes/{class-details-shared.ts → class-details/class-details-shared.ts} +0 -0
- /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 './
|
|
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
|
+
};
|