Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion components/EditProfilePage/EditProfileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Row className={`my-5`}>
Expand All @@ -30,6 +36,25 @@ export const EditProfileHeader = ({
onClick={() => onSettingsModalOpen()}
/>
<ProfileEditToggle formUpdated={formUpdated} role={role} uid={uid} />
{phoneVerificationUI &&
(phoneVerified === true ? (
<div className="d-flex align-items-center justify-content-center gap-1 py-1 col-12 text-capitalize text-nowrap">
<span className="text-secondary">{t("verifiedUser")}</span>
<img
src="/images/verifiedUser.png"
alt={t("verifiedUserBadgeAlt")}
width={24}
height={24}
className="flex-shrink-0"
/>
</div>
) : onGetVerifiedClick ? (
<OutlineButton
className={`py-1`}
label={t("getVerified")}
onClick={onGetVerifiedClick}
/>
) : null)}
</Col>
</Row>
)
Expand Down
9 changes: 9 additions & 0 deletions components/EditProfilePage/EditProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Frequency>(
notificationFrequency || "Weekly"
)
Expand Down Expand Up @@ -178,8 +181,10 @@ export function EditProfileForm({
<EditProfileHeader
formUpdated={formUpdated}
onSettingsModalOpen={onSettingsModalOpen}
onGetVerifiedClick={() => setShowPhoneVerificationModal(true)}
uid={uid}
role={profile.role}
phoneVerified={profile.phoneVerified}
/>
<TabContainer
defaultActiveKey="about-you"
Expand Down Expand Up @@ -211,6 +216,10 @@ export function EditProfileForm({
onSettingsModalClose={() => setSettingsModal(null)}
show={settingsModal === "show"}
/>
<PhoneVerificationModal
show={showPhoneVerificationModal}
onHide={() => setShowPhoneVerificationModal(false)}
/>
</>
)
}
234 changes: 234 additions & 0 deletions components/EditProfilePage/PhoneVerificationModal.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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<ModalProps, "show" | "onHide">) {
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<string | null>(null)
const [sendingCode, setSendingCode] = useState(false)
const [verifying, setVerifying] = useState(false)
const [confirmationResult, setConfirmationResult] =
useState<ConfirmationResult | null>(null)
const recaptchaVerifierRef = useRef<RecaptchaVerifier | null>(null)
const phoneInputRef = useRef<HTMLInputElement | null>(null)
const codeInputRef = useRef<HTMLInputElement | null>(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 (
<Modal
show={show}
onHide={onHide}
aria-labelledby="phone-verification-modal"
centered
>
<Modal.Header closeButton>
<Modal.Title id="phone-verification-modal">
{t("phoneVerificationModalTitle")}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<Col md={10} className="mx-auto">
{error ? (
<Alert variant="danger" dismissible onClose={() => setError(null)}>
<span style={{ whiteSpace: "pre-line" }}>{error}</span>
</Alert>
) : null}

{step === "phone" ? (
<Form
noValidate
onSubmit={e => {
e.preventDefault()
handleSendCode()
}}
>
<Input
ref={phoneInputRef}
label={t("phoneVerification.phoneLabel")}
type="tel"
placeholder={t("phoneVerification.phonePlaceholder")}
value={phone}
onChange={e => setPhone(e.target.value)}
className="mb-3"
/>
<div id={RECAPTCHA_CONTAINER_ID} />
<LoadingButton
type="submit"
className="w-100"
loading={sendingCode}
>
{t("phoneVerification.continue")}
</LoadingButton>
</Form>
) : (
<Form
noValidate
onSubmit={e => {
e.preventDefault()
handleVerify()
}}
>
<Input
ref={codeInputRef}
label={t("phoneVerification.codeLabel")}
type="text"
placeholder={t("phoneVerification.codePlaceholder")}
value={code}
onChange={e => setCode(e.target.value)}
className="mb-3"
/>
<LoadingButton
type="submit"
className="w-100"
loading={verifying}
>
{t("phoneVerification.verify")}
</LoadingButton>
</Form>
)}
</Col>
</Modal.Body>
</Modal>
)
}
29 changes: 25 additions & 4 deletions components/auth/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> = {
"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!"
}
Expand All @@ -39,7 +51,9 @@ function useFirebaseFunction<Params, Result>(
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)
}
Expand Down Expand Up @@ -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<void, { phoneVerified: true }>(
async () => (await completePhoneVerification()).data
)
}

export type SendPasswordResetEmailData = { email: string }

export function useSendPasswordResetEmail() {
Expand Down
5 changes: 5 additions & 0 deletions components/auth/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ export const finishSignup = httpsCallable<
{ requestedRole: Role } | Partial<Profile>,
void
>(functions, "finishSignup")

export const completePhoneVerification = httpsCallable<
void,
{ phoneVerified: true }
>(functions, "completePhoneVerification")
1 change: 1 addition & 0 deletions components/db/profile/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,5 @@ export type Profile = {
contactInfo?: ContactInfo
location?: string
orgCategories?: OrgCategory[] | ""
phoneVerified?: boolean
}
Loading
Loading