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")}
+

+
+ ) : 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" ? (
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
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",