diff --git a/components/EditProfilePage/EditProfileHeader.tsx b/components/EditProfilePage/EditProfileHeader.tsx index b5c02f5b7..8f0b1a111 100644 --- a/components/EditProfilePage/EditProfileHeader.tsx +++ b/components/EditProfilePage/EditProfileHeader.tsx @@ -3,19 +3,25 @@ import { Role } from "../auth" import { Col, Row } from "../bootstrap" import { GearIcon, OutlineButton } from "../buttons" import { ProfileEditToggle } from "components/ProfilePage/ProfileButtons" +import { useFlags } from "components/featureFlags" export const EditProfileHeader = ({ formUpdated, onSettingsModalOpen, + onGetVerifiedClick, uid, - role + role, + phoneVerified }: { formUpdated: boolean onSettingsModalOpen: () => void + onGetVerifiedClick?: () => void uid: string role: Role + phoneVerified?: boolean }) => { const { t } = useTranslation("editProfile") + const { phoneVerificationUI } = useFlags() return ( @@ -30,6 +36,25 @@ export const EditProfileHeader = ({ onClick={() => onSettingsModalOpen()} /> + {phoneVerificationUI && + (phoneVerified === true ? ( +
+ {t("verifiedUser")} + {t("verifiedUserBadgeAlt")} +
+ ) : onGetVerifiedClick ? ( + + ) : null)}
) diff --git a/components/EditProfilePage/EditProfilePage.tsx b/components/EditProfilePage/EditProfilePage.tsx index ad7eb6e0f..2fff9d96a 100644 --- a/components/EditProfilePage/EditProfilePage.tsx +++ b/components/EditProfilePage/EditProfilePage.tsx @@ -17,6 +17,7 @@ import { import { EditProfileHeader } from "./EditProfileHeader" import { FollowingTab } from "./FollowingTab" import { PersonalInfoTab } from "./PersonalInfoTab" +import PhoneVerificationModal from "./PhoneVerificationModal" import ProfileSettingsModal from "./ProfileSettingsModal" import { StyledTabContent, @@ -87,6 +88,8 @@ export function EditProfileForm({ const [formUpdated, setFormUpdated] = useState(false) const [settingsModal, setSettingsModal] = useState<"show" | null>(null) + const [showPhoneVerificationModal, setShowPhoneVerificationModal] = + useState(false) const [notifications, setNotifications] = useState( notificationFrequency || "Weekly" ) @@ -178,8 +181,10 @@ export function EditProfileForm({ setShowPhoneVerificationModal(true)} uid={uid} role={profile.role} + phoneVerified={profile.phoneVerified} /> setSettingsModal(null)} show={settingsModal === "show"} /> + setShowPhoneVerificationModal(false)} + /> ) } diff --git a/components/EditProfilePage/PhoneVerificationModal.tsx b/components/EditProfilePage/PhoneVerificationModal.tsx new file mode 100644 index 000000000..718dbc299 --- /dev/null +++ b/components/EditProfilePage/PhoneVerificationModal.tsx @@ -0,0 +1,234 @@ +import { + type ConfirmationResult, + linkWithPhoneNumber, + RecaptchaVerifier +} from "firebase/auth" +import { useEffect, useRef, useState } from "react" +import type { ModalProps } from "react-bootstrap" +import { Alert, Col, Form, Modal } from "../bootstrap" +import { LoadingButton } from "../buttons" +import Input from "../forms/Input" +import { useAuth } from "../auth" +import { getErrorMessage } from "../auth/hooks" +import { useCompletePhoneVerification } from "../auth/hooks" +import { auth } from "../firebase" +import { useTranslation } from "next-i18next" + +const US_REGEX = + /^(\([2-9][0-9]{2}\)|[2-9][0-9]{2})[- ]?([0-9]{3})[- ]?([0-9]{4})$/ + +const AUTH_ERROR_CODE_TO_KEY: Record = { + "auth/credential-already-in-use": + "phoneVerification.errors.credentialAlreadyInUse", + "auth/account-exists-with-different-credential": + "phoneVerification.errors.credentialAlreadyInUse", + "auth/provider-already-linked": + "phoneVerification.errors.providerAlreadyLinked", + "auth/invalid-phone-number": "phoneVerification.errors.invalidPhoneNumber", + "auth/operation-not-allowed": "phoneVerification.errors.operationNotAllowed" +} + +export default function PhoneVerificationModal({ + show, + onHide +}: Pick) { + const { t } = useTranslation("editProfile") + const { user } = useAuth() + const completePhoneVerification = useCompletePhoneVerification() + + const [step, setStep] = useState<"phone" | "code">("phone") + const [phone, setPhone] = useState("") + const [code, setCode] = useState("") + const [error, setError] = useState(null) + const [sendingCode, setSendingCode] = useState(false) + const [verifying, setVerifying] = useState(false) + const [confirmationResult, setConfirmationResult] = + useState(null) + const recaptchaVerifierRef = useRef(null) + const phoneInputRef = useRef(null) + const codeInputRef = useRef(null) + const RECAPTCHA_CONTAINER_ID = "phone-verification-recaptcha-container" + + const getModalErrorMessage = (code: string | undefined) => { + if (!code) return getErrorMessage(code) + const key = AUTH_ERROR_CODE_TO_KEY[code] + return key ? t(key) : getErrorMessage(code) + } + + useEffect(() => { + if (!show) { + setStep("phone") + setPhone("") + setCode("") + setError(null) + setConfirmationResult(null) + setSendingCode(false) + setVerifying(false) + if (recaptchaVerifierRef.current) { + try { + recaptchaVerifierRef.current.clear() + } catch { + // ignore if already cleared + } + recaptchaVerifierRef.current = null + } + completePhoneVerification.reset() + } + // could not add a reference to completePhoneVerification.reset to dep array without triggering an infinite effect, so: + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [show]) + + const handleSendCode = async () => { + setError(null) + const trimmed = phone.trim() + if (!US_REGEX.test(trimmed)) { + setError(getModalErrorMessage("auth/invalid-phone-number")) + return + } + const phoneDigits = trimmed.replace(/\D/g, "") + const firebasePhoneFormat = `+1${phoneDigits}` + + if (!user) { + setError(t("phoneVerification.signedInRequired")) + return + } + + setSendingCode(true) + try { + if (!recaptchaVerifierRef.current) { + recaptchaVerifierRef.current = new RecaptchaVerifier( + RECAPTCHA_CONTAINER_ID, + { size: "invisible" }, + auth + ) + } + const result = await linkWithPhoneNumber( + user, + firebasePhoneFormat, + recaptchaVerifierRef.current + ) + setConfirmationResult(result) + setStep("code") + } catch (err: unknown) { + const code = (err as { code?: string })?.code + setError( + getModalErrorMessage(code) || + (err as Error)?.message || + getErrorMessage() + ) + } finally { + setSendingCode(false) + } + } + + const handleVerify = async () => { + setError(null) + if (!confirmationResult || !code.trim()) { + setError(t("phoneVerification.enterVerificationCode")) + return + } + setVerifying(true) + try { + await confirmationResult.confirm(code.trim()) + if (completePhoneVerification.execute) { + await completePhoneVerification.execute() + } + onHide?.() + } catch (err: unknown) { + const code = (err as { code?: string })?.code + setError( + getModalErrorMessage(code) || + (err as Error)?.message || + getErrorMessage() + ) + } finally { + setVerifying(false) + } + } + + useEffect(() => { + if (!show) return + const el = step === "phone" ? phoneInputRef.current : codeInputRef.current + if (el) { + const id = requestAnimationFrame(() => el.focus()) + return () => cancelAnimationFrame(id) + } + }, [show, step]) + + return ( + + + + {t("phoneVerificationModalTitle")} + + + + + {error ? ( + setError(null)}> + {error} + + ) : null} + + {step === "phone" ? ( +
{ + e.preventDefault() + handleSendCode() + }} + > + setPhone(e.target.value)} + className="mb-3" + /> +
+ + {t("phoneVerification.continue")} + + + ) : ( +
{ + e.preventDefault() + handleVerify() + }} + > + setCode(e.target.value)} + className="mb-3" + /> + + {t("phoneVerification.verify")} + +
+ )} + + + + ) +} diff --git a/components/auth/hooks.ts b/components/auth/hooks.ts index bfdb49415..a618d6460 100644 --- a/components/auth/hooks.ts +++ b/components/auth/hooks.ts @@ -12,17 +12,29 @@ import { import { useAsyncCallback } from "react-async-hook" import { setProfile } from "../db" import { auth } from "../firebase" -import { finishSignup, OrgCategory } from "./types" +import { completePhoneVerification, finishSignup, OrgCategory } from "./types" const errorMessages: Record = { "auth/email-already-exists": "You already have an account.", "auth/email-already-in-use": "You already have an account.", "auth/wrong-password": "Your password is wrong.", "auth/invalid-email": "The email you provided is not a valid email.", - "auth/user-not-found": "You don't have an account." + "auth/user-not-found": "You don't have an account.", + "functions/failed-precondition": + "Phone number is not linked to this account. Complete phone verification first.", + "auth/credential-already-in-use": + "This phone number is already linked to another account.", + "auth/account-exists-with-different-credential": + "This phone number is already linked to another account.", + "auth/provider-already-linked": + "This account already has a phone number linked.", + "auth/invalid-phone-number": + "Please enter a valid phone number (e.g. 617 555-1234).", + "auth/operation-not-allowed": + "Phone verification is not enabled. Please try again later or contact us at info@mapletestimony.org." } -function getErrorMessage(errorCode?: string) { +export function getErrorMessage(errorCode?: string) { const niceErrorMessage = errorCode ? errorMessages[errorCode] : undefined return niceErrorMessage || "Something went wrong!" } @@ -39,7 +51,9 @@ function useFirebaseFunction( console.log(err) const message = getErrorMessage( - err instanceof FirebaseError ? err.code : undefined + err instanceof FirebaseError + ? err.code + : (err as { code?: string })?.code ) throw new Error(message) } @@ -104,6 +118,13 @@ export function useSendEmailVerification() { return useFirebaseFunction((user: User) => sendEmailVerification(user)) } +/** Call after the user has linked a phone number via linkWithPhoneNumber + confirm. */ +export function useCompletePhoneVerification() { + return useFirebaseFunction( + async () => (await completePhoneVerification()).data + ) +} + export type SendPasswordResetEmailData = { email: string } export function useSendPasswordResetEmail() { diff --git a/components/auth/types.tsx b/components/auth/types.tsx index c3170e281..ec1b49309 100644 --- a/components/auth/types.tsx +++ b/components/auth/types.tsx @@ -9,3 +9,8 @@ export const finishSignup = httpsCallable< { requestedRole: Role } | Partial, void >(functions, "finishSignup") + +export const completePhoneVerification = httpsCallable< + void, + { phoneVerified: true } +>(functions, "completePhoneVerification") diff --git a/components/db/profile/types.ts b/components/db/profile/types.ts index b99b80d5d..898b84f2e 100644 --- a/components/db/profile/types.ts +++ b/components/db/profile/types.ts @@ -43,4 +43,5 @@ export type Profile = { contactInfo?: ContactInfo location?: string orgCategories?: OrgCategory[] | "" + phoneVerified?: boolean } diff --git a/components/featureFlags.ts b/components/featureFlags.ts index 11f3b438f..5e080a850 100644 --- a/components/featureFlags.ts +++ b/components/featureFlags.ts @@ -15,7 +15,9 @@ export const FeatureFlags = z.object({ /** LLM Bill Summary and Tags **/ showLLMFeatures: z.boolean().default(false), /** Hearings and Transcriptions **/ - hearingsAndTranscriptions: z.boolean().default(false) + hearingsAndTranscriptions: z.boolean().default(false), + /** Phone Verification UI changes **/ + phoneVerificationUI: z.boolean().default(false) }) export type FeatureFlags = z.infer @@ -35,7 +37,8 @@ const defaults: Record = { followOrg: true, lobbyingTable: false, showLLMFeatures: true, - hearingsAndTranscriptions: true + hearingsAndTranscriptions: true, + phoneVerificationUI: true }, production: { testimonyDiffing: false, @@ -44,7 +47,8 @@ const defaults: Record = { followOrg: true, lobbyingTable: false, showLLMFeatures: true, - hearingsAndTranscriptions: true + hearingsAndTranscriptions: true, + phoneVerificationUI: false }, test: { testimonyDiffing: false, @@ -53,7 +57,8 @@ const defaults: Record = { followOrg: true, lobbyingTable: false, showLLMFeatures: true, - hearingsAndTranscriptions: true + hearingsAndTranscriptions: true, + phoneVerificationUI: true } } diff --git a/firestore.rules b/firestore.rules index e33d279e2..56d395b58 100644 --- a/firestore.rules +++ b/firestore.rules @@ -34,6 +34,10 @@ service cloud.firestore { // email digest notification times return !request.resource.data.diff(resource.data).affectedKeys().hasAny(['nextDigestAt']) } + function doesNotChangePhoneVerified() { + // Only the completePhoneVerification cloud function (Admin SDK) sets phoneVerified + return !request.resource.data.diff(resource.data).affectedKeys().hasAny(['phoneVerified']) + } // either the change doesn't include the public field, // or the user is a base user (i.e. not an org) function validPublicChange() { @@ -52,7 +56,7 @@ service cloud.firestore { // Allow users to make updates except to delete their profile or set the role field. // Only admins can delete a user profile or set the user role field. - allow update: if validUser() && doesNotChangeRole() && validPublicChange() && doesNotChangeNextDigestAt() + allow update: if validUser() && doesNotChangeRole() && validPublicChange() && doesNotChangeNextDigestAt() && doesNotChangePhoneVerified() } // Allow querying publications individually or with a collection group. match /{path=**}/publishedTestimony/{id} { diff --git a/functions/src/index.ts b/functions/src/index.ts index d3effd4be..b396108d9 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -31,7 +31,7 @@ export { fetchMemberBatch, startMemberBatches } from "./members" -export { finishSignup } from "./profile" +export { completePhoneVerification, finishSignup } from "./profile" export { checkSearchIndexVersion, searchHealthCheck } from "./search" export { deleteTestimony, diff --git a/functions/src/profile/completePhoneVerification.ts b/functions/src/profile/completePhoneVerification.ts new file mode 100644 index 000000000..b5a28bed2 --- /dev/null +++ b/functions/src/profile/completePhoneVerification.ts @@ -0,0 +1,25 @@ +import * as functions from "firebase-functions" +import { db, auth } from "../firebase" +import { checkAuth, fail } from "../common" + +export const completePhoneVerification = functions.https.onCall( + async (_, context) => { + const uid = checkAuth(context) + + const user = await auth.getUser(uid) + const hasPhone = user.providerData?.some(p => p.providerId === "phone") + + if (!hasPhone) { + throw fail( + "failed-precondition", + "Phone number is not linked to this account. Complete phone verification first." + ) + } + + await db + .doc(`/profiles/${uid}`) + .set({ phoneVerified: true }, { merge: true }) + + return { phoneVerified: true } + } +) diff --git a/functions/src/profile/index.ts b/functions/src/profile/index.ts index a897f8e16..1b6fde6df 100644 --- a/functions/src/profile/index.ts +++ b/functions/src/profile/index.ts @@ -1 +1,2 @@ +export * from "./completePhoneVerification" export * from "./finishSignup" diff --git a/functions/src/profile/types.ts b/functions/src/profile/types.ts index 733a558df..cd2e2e265 100644 --- a/functions/src/profile/types.ts +++ b/functions/src/profile/types.ts @@ -33,7 +33,8 @@ export const Profile = Record({ profileImage: Optional(String), billsFollowing: Optional(Array(String)), contactInfo: Optional(Dictionary(String)), - location: Optional(String) + location: Optional(String), + phoneVerified: Optional(Boolean) }) export type Profile = Static diff --git a/public/images/verifiedUser.png b/public/images/verifiedUser.png new file mode 100644 index 000000000..049f65ce0 Binary files /dev/null and b/public/images/verifiedUser.png differ diff --git a/public/locales/en/editProfile.json b/public/locales/en/editProfile.json index 9f8e4402b..7d873d1c4 100644 --- a/public/locales/en/editProfile.json +++ b/public/locales/en/editProfile.json @@ -1,5 +1,25 @@ { "header": "Edit Profile", + "getVerified": "Get Verified", + "phoneVerificationModalTitle": "Verify your phone number", + "phoneVerification": { + "phoneLabel": "Phone number (Ex 617 555-1234)", + "phonePlaceholder": "617 555-1234", + "continue": "Continue", + "codeLabel": "Verification code", + "codePlaceholder": "Enter 6-digit code", + "verify": "Verify", + "errors": { + "credentialAlreadyInUse": "This phone number is already linked to another account.", + "providerAlreadyLinked": "This account already has a phone number linked.", + "invalidPhoneNumber": "Please enter a valid phone number\n(e.g. 617 555-1234).", + "operationNotAllowed": "Phone verification is not enabled. Please try again later or contact us at info@mapletestimony.org." + }, + "signedInRequired": "You must be signed in to verify your phone.", + "enterVerificationCode": "Please enter the verification code." + }, + "verifiedUser": "Verified User", + "verifiedUserBadgeAlt": "Verified user badge", "setting": "Settings", "privacySetting": "Privacy Settings", "save": "Save",