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/FollowUserCard.tsx b/components/EditProfilePage/FollowUserCard.tsx new file mode 100644 index 000000000..66de8fa7a --- /dev/null +++ b/components/EditProfilePage/FollowUserCard.tsx @@ -0,0 +1,59 @@ +import { usePublicProfile } from "components/db" +import { Internal } from "components/links" +import { FollowUserButton } from "components/shared/FollowButton" +import { useTranslation } from "next-i18next" +import React from "react" +import { Col, Row, Spinner } from "../bootstrap" +import { OrgIconSmall } from "./StyledEditProfileComponents" + +export function FollowUserCard({ + profileId, + confirmUnfollow +}: { + profileId: string + confirmUnfollow?: boolean +}) { + const { result: profile, loading } = usePublicProfile(profileId) + const { t } = useTranslation("profile") + + if (loading) { + return ( +
+ + + +
+
+ ) + } + + const { fullName, profileImage, public: isPublic } = profile || {} + const displayName = isPublic && fullName ? fullName : t("anonymousUser") + + return ( +
+ + + + {isPublic ? ( + {displayName} + ) : ( + {displayName} + )} + + {isPublic ? ( + + + + ) : null} + +
+
+ ) +} diff --git a/components/EditProfilePage/FollowersTab.tsx b/components/EditProfilePage/FollowersTab.tsx index 384fdc167..b03561bed 100644 --- a/components/EditProfilePage/FollowersTab.tsx +++ b/components/EditProfilePage/FollowersTab.tsx @@ -1,15 +1,13 @@ import { functions } from "components/firebase" import { httpsCallable } from "firebase/functions" import { useTranslation } from "next-i18next" -import { Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react" +import { Dispatch, SetStateAction, useEffect, useState } from "react" import { useAuth } from "../auth" -import { usePublicProfile } from "components/db" -import { Internal } from "components/links" -import { FollowUserButton } from "components/shared/FollowButton" -import React from "react" -import { Col, Row, Spinner, Stack, Alert } from "../bootstrap" -import { TitledSectionCard } from "../shared" -import { OrgIconSmall } from "./StyledEditProfileComponents" +import { FollowUserCard } from "./FollowUserCard" +import { + LoadableItemsState, + PaginatedItemsCard +} from "components/shared/PaginatedItemsCard" export const FollowersTab = ({ className, @@ -19,99 +17,51 @@ export const FollowersTab = ({ setFollowerCount: Dispatch> }) => { const uid = useAuth().user?.uid - const [followerIds, setFollowerIds] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const [state, setState] = useState>( + { + items: [], + loading: true, + error: null + } + ) const { t } = useTranslation("editProfile") + const fetchFollowers = async () => { + try { + const { data: profileIds } = await httpsCallable( + functions, + "getFollowers" + )() + setState({ + items: profileIds.map(profileId => ({ profileId })), + loading: false, + error: null + }) + setFollowerCount(profileIds.length) + } catch (err) { + console.error("Error fetching followerIds", err) + setState({ + items: [], + loading: false, + error: t("content.error") + }) + } + } useEffect(() => { - const fetchFollowers = async () => { - try { - const { data: followerIds } = await httpsCallable( - functions, - "getFollowers" - )() - setFollowerIds(followerIds) - setFollowerCount(followerIds.length) - setLoading(false) - } catch (err) { - console.error("Error fetching followerIds", err) - setError("Error fetching followers.") - setLoading(false) - return - } + if (uid) { + setState(prev => ({ ...prev, loading: true, error: null })) + fetchFollowers() + } else { + setState({ items: [], loading: false, error: null }) } - if (uid) fetchFollowers() }, [uid]) return ( - -
- -

{t("follow.your_followers")}

-

- {t("follow.follower_info_disclaimer")} -

-
- {error ? ( - {error} - ) : loading ? ( - - ) : ( - followerIds.map((profileId, i) => ( - - )) - )} -
-
-
-
- ) -} - -const FollowerCard = ({ profileId }: { profileId: string }) => { - const { result: profile, loading } = usePublicProfile(profileId) - const { t } = useTranslation("profile") - if (loading) { - return ( - - - - ) - } - const { fullName, profileImage, public: isPublic } = profile || {} - const displayName = isPublic && fullName ? fullName : t("anonymousUser") - return ( - - - - {isPublic ? ( - {displayName} - ) : ( - {displayName} - )} - - {isPublic ? ( - - - - ) : ( - <> - )} - + ) } - -const FollowerCardWrapper = ({ children }: { children: ReactNode }) => ( -
- - {children} - -
-
-) diff --git a/components/EditProfilePage/FollowingTab.tsx b/components/EditProfilePage/FollowingTab.tsx index ae9231979..0c5a33e0d 100644 --- a/components/EditProfilePage/FollowingTab.tsx +++ b/components/EditProfilePage/FollowingTab.tsx @@ -1,195 +1,125 @@ -import { collection, getDocs, query, where } from "firebase/firestore" +import { useBill } from "components/db" +import { formatBillId } from "components/formatting" +import { Internal } from "components/links" +import { FollowBillButton } from "components/shared/FollowButton" +import { collection, onSnapshot, query, where } from "firebase/firestore" import { useTranslation } from "next-i18next" -import { useCallback, useEffect, useMemo, useState } from "react" +import { ComponentProps, useEffect, useMemo, useState } from "react" import { useAuth } from "../auth" -import { Stack } from "../bootstrap" +import { Alert, Col, Row, Spinner } from "../bootstrap" import { firestore } from "../firebase" -import { TitledSectionCard } from "../shared" -import UnfollowItem, { UnfollowModalConfig } from "./UnfollowModal" -import { FollowedItem } from "./FollowingTabComponents" -import { BillElement, UserElement } from "./FollowingTabComponents" -import { deleteItem } from "components/shared/FollowingQueries" -import { PaginationButtons } from "../table" +import { FollowUserCard } from "./FollowUserCard" +import { + LoadableItemsState, + PaginatedItemsCard +} from "components/shared/PaginatedItemsCard" export function FollowingTab({ className }: { className?: string }) { - const { user } = useAuth() - const uid = user?.uid + const { t } = useTranslation("editProfile") + return ( + <> + + } + {...useFollowedUsers()} + /> + + ) +} + +const useFollowedBills = (): LoadableItemsState< + ComponentProps +> => useTopicSubscription("bill") + +const useFollowedUsers = (): LoadableItemsState< + ComponentProps +> => useTopicSubscription("testimony") + +function useTopicSubscription( + type: "bill" | "testimony" +): LoadableItemsState { + const [state, setState] = useState>({ + items: [], + loading: false, + error: null + }) + const { t } = useTranslation("editProfile") + const uid = useAuth().user?.uid const subscriptionRef = useMemo( () => - // returns new object only if uid changes uid ? collection(firestore, `/users/${uid}/activeTopicSubscriptions/`) : null, [uid] ) - - const [unfollow, setUnfollow] = useState(null) - const close = () => setUnfollow(null) - - const [billsFollowing, setBillsFollowing] = useState([]) - const [usersFollowing, setUsersFollowing] = useState([]) - - const [currentBillsPage, setCurrentBillsPage] = useState(1) - const [currentUsersPage, setCurrentUsersPage] = useState(1) - const itemsPerPage = 10 - - const billsFollowingQuery = useCallback(async () => { - if (!subscriptionRef) return // handle the case where subscriptionRef is null - const billList: BillElement[] = [] - const q = query( - subscriptionRef, - where("uid", "==", `${uid}`), - where("type", "==", "bill") - ) - const querySnapshot = await getDocs(q) - querySnapshot.forEach(doc => { - // doc.data() is never undefined for query doc snapshots - billList.push(doc.data().billLookup) - }) - if (billsFollowing.length === 0 && billList.length != 0) { - setBillsFollowing(billList) - } // this limits the code from falling into an infinite loop - }, [subscriptionRef, uid, billsFollowing]) + const topicKey = type === "bill" ? "billLookup" : "userLookup" useEffect(() => { - uid ? billsFollowingQuery() : null - }, [uid, billsFollowingQuery]) - - const orgsFollowingQuery = useCallback(async () => { - if (!subscriptionRef) return // handle the case where subscriptionRef is null - const usersList: UserElement[] = [] - const q = query( - subscriptionRef, - where("uid", "==", `${uid}`), - where("type", "==", "testimony") + if (!subscriptionRef || !uid) return + + setState(prev => ({ ...prev, loading: true, error: null })) + const unsubscribe = onSnapshot( + query( + subscriptionRef, + where("uid", "==", uid), + where("type", "==", type) + ), + snap => + setState({ + items: snap.docs.map(doc => doc.data()[topicKey]), + loading: false, + error: null + }), + err => { + console.error(`Error listening to followed ${type}`, err) + setState(prev => ({ + ...prev, + loading: false, + error: t("content.error") + })) + } ) - const querySnapshot = await getDocs(q) - querySnapshot.forEach(doc => { - // doc.data() is never undefined for query doc snapshots - usersList.push(doc.data().userLookup) - }) - - if (usersFollowing.length === 0 && usersList.length != 0) { - setUsersFollowing(usersList) - } // this limits the code from falling into an infinite loop - }, [subscriptionRef, uid, usersFollowing]) - - const fetchFollowedItems = useCallback(async () => { - if (uid) { - billsFollowingQuery() - orgsFollowingQuery() - } - }, [uid, billsFollowingQuery, orgsFollowingQuery]) - - useEffect(() => { - fetchFollowedItems() - }, [billsFollowing, usersFollowing, fetchFollowedItems]) - - const handleUnfollowClick = async (unfollow: UnfollowModalConfig | null) => { - if (!unfollow || !unfollow.typeId) { - // handle the case where unfollow is null or unfollow.typeId is undefined - console.error( - "handleUnfollowClick was called but unfollow or unfollow.typeId is undefined" - ) - return - } - if (unfollow === null) { - return - } - try { - deleteItem({ uid, unfollowItem: unfollow }) - } catch (error: any) { - console.log(error.message) - } + return () => unsubscribe() + }, [subscriptionRef, uid, type]) - setBillsFollowing([]) - setUsersFollowing([]) - setUnfollow(null) - } - - const getPaginatedBills = () => { - const startIndex = (currentBillsPage - 1) * itemsPerPage - const endIndex = startIndex + itemsPerPage - return billsFollowing.slice(startIndex, endIndex) - } - - const getPaginatedUsers = () => { - const startIndex = (currentUsersPage - 1) * itemsPerPage - const endIndex = startIndex + itemsPerPage - return usersFollowing.slice(startIndex, endIndex) - } - - const totalBillsPages = Math.ceil(billsFollowing.length / itemsPerPage) - const totalUsersPages = Math.ceil(usersFollowing.length / itemsPerPage) + return state +} +function FollowedBillCard({ + court, + billId +}: { + court: number + billId: string +}) { + const { loading, error, result: bill } = useBill(court, billId) const { t } = useTranslation("editProfile") + if (loading) return + if (error) return {t("content.error")} + if (!bill) return null return ( - <> - -
- -

{t("follow.bills")}

- {getPaginatedBills().map((element: BillElement, index: number) => ( - - ))} - {billsFollowing.length > 0 && ( - 1, - nextPage: () => setCurrentBillsPage(prev => prev + 1), - previousPage: () => setCurrentBillsPage(prev => prev - 1), - itemsPerPage - }} - /> - )} -
-
-
- -
- -

{t("follow.orgs")}

- {getPaginatedUsers().map((element: UserElement, index: number) => ( - - ))} - {usersFollowing.length > 0 && ( - 1, - nextPage: () => setCurrentUsersPage(prev => prev + 1), - previousPage: () => setCurrentUsersPage(prev => prev - 1), - itemsPerPage - }} - /> - )} -
-
-
- setUnfollow(null)} - show={unfollow ? true : false} - unfollowItem={unfollow} - /> - +
+ + + {formatBillId(billId)} + + +
{bill.content.Title}
+ + + + +
+
+
) } diff --git a/components/EditProfilePage/FollowingTabComponents.tsx b/components/EditProfilePage/FollowingTabComponents.tsx deleted file mode 100644 index 2ac6ec7bb..000000000 --- a/components/EditProfilePage/FollowingTabComponents.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useTranslation } from "next-i18next" -import { Alert, Row, Spinner } from "../bootstrap" -import { useBill, usePublicProfile } from "components/db" -import { Dispatch, SetStateAction } from "react" -import { Col } from "../bootstrap" -import { TextButton } from "../buttons" -import { UnfollowModalConfig } from "./UnfollowModal" -import { formatBillId } from "components/formatting" -import { Internal } from "components/links" -import { OrgIconSmall } from "./StyledEditProfileComponents" - -export function BillFollowingTitle({ - court, - id -}: { - court: number - id: string -}) { - const { loading, error, result: bill } = useBill(court, id) - const { t } = useTranslation("editProfile") - if (loading) { - return ( - - - - ) - } else if (error) { - return {t("content.error")} - } else if (bill) { - return
{bill?.content.Title}
- } - return null -} - -export type BillElement = { - court: number - billId: string -} -export type UserElement = { - profileId: string - fullName: string -} - -export type Element = BillElement | UserElement - -export const isBillElement = (element: Element): element is BillElement => { - return (element as BillElement).billId !== undefined -} - -export const isUserElement = (element: Element): element is UserElement => { - return (element as UserElement).profileId !== undefined -} - -export function UnfollowButton({ - fullName, - element, - setUnfollow, - type -}: { - fullName: string - element: Element - setUnfollow: Dispatch> - type: string -}) { - const handleClick = () => { - if (!element) { - console.error("handleClick was called but element is undefined") - return - } - if (isBillElement(element)) { - setUnfollow({ - court: element.court, - userName: "", - type: "bill", - typeId: element.billId - }) - } else { - setUnfollow({ - court: 0, - userName: fullName, - type: "testimony", - typeId: element.profileId - }) - } - } - const { t } = useTranslation("editProfile") - return ( - { - handleClick() - }} - > - - - ) -} -export function FollowedItem({ - element, - setUnfollow, - type -}: { - index: number - element: Element - setUnfollow: Dispatch> - type: string -}) { - const elementId = isUserElement(element) ? element.profileId : element.billId - - const { result: profile, loading } = usePublicProfile(elementId) - - if (loading) { - return - } - if (!element) { - console.log("element is undefined") - return null - } - - return ( -
- - {isBillElement(element) ? ( - <> - - {formatBillId(element.billId)} - - - - - - ) : ( - <> - - - - {profile?.fullName} - - - - )} - - -
-
- ) -} 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/EditProfilePage/UnfollowModal.tsx b/components/EditProfilePage/UnfollowModal.tsx deleted file mode 100644 index c5d24a1f2..000000000 --- a/components/EditProfilePage/UnfollowModal.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type { ModalProps } from "react-bootstrap" -import styled from "styled-components" -import { Button, Modal, Stack } from "../bootstrap" -import { formatBillId } from "../formatting" -import { useTranslation } from "next-i18next" -import { FillButton, OutlineButton } from "components/buttons" - -type Props = Pick & { - handleUnfollowClick: ( - unfollowItem: UnfollowModalConfig | null - ) => Promise - onHide: () => void - onUnfollowClose: () => void - show: boolean - unfollowItem: UnfollowModalConfig | null -} - -export type UnfollowModalConfig = { - court: number - userName: string - type: string - typeId: string -} - -export default function UnfollowItem({ - handleUnfollowClick, - onHide, - onUnfollowClose, - show, - unfollowItem -}: Props) { - const { t } = useTranslation("editProfile") - - const handleTopic = () => { - if (unfollowItem?.type == "bill") { - return ` Bill ${formatBillId(unfollowItem?.typeId)}` - } else { - return ` ${unfollowItem?.userName}` - } - } - - return ( - - - {t("follow.unfollow")} - - -
- {t("confirmation.unfollowMessage")} - {handleTopic()}? -
-
- - { - handleUnfollowClick(unfollowItem) - }} - label={t("confirmation.yes")} - /> -
-
-
- ) -} 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/components/hearing/Transcriptions.tsx b/components/hearing/Transcriptions.tsx index 27d0280d3..b451c0917 100644 --- a/components/hearing/Transcriptions.tsx +++ b/components/hearing/Transcriptions.tsx @@ -200,7 +200,7 @@ export const Transcriptions = ({ const resultString: string = convertToString(startTime) let currentIndex = transcriptData.findIndex( - element => parseInt(resultString, 10) <= element.end / 1000 + element => parseInt(resultString, 10) < element.end / 1000 ) // Set the initial scroll target when we have a startTime and transcripts @@ -233,7 +233,7 @@ export const Transcriptions = ({ const handleTimeUpdate = () => { videoLoaded ? (currentIndex = transcriptData.findIndex( - element => videoRef.current.currentTime <= element.end / 1000 + element => videoRef.current.currentTime < element.end / 1000 )) : null if (containerRef.current && currentIndex !== highlightedId) { diff --git a/components/shared/FollowButton.tsx b/components/shared/FollowButton.tsx index 87fa5ec81..701a79886 100644 --- a/components/shared/FollowButton.tsx +++ b/components/shared/FollowButton.tsx @@ -1,131 +1,187 @@ import { StyledImage } from "components/ProfilePage/StyledProfileComponents" import { useTranslation } from "next-i18next" -import { useEffect, useContext } from "react" +import { useEffect, useContext, useMemo, useState } from "react" import { Button } from "react-bootstrap" import { useAuth } from "../auth" import { Bill } from "../db" -import { TopicQuery, setFollow, setUnfollow } from "./FollowingQueries" +import { + followsTopic, + followBill, + followProfile, + unfollowBill, + unfollowProfile, + billTopicName, + profileTopicName +} from "./FollowingQueries" import { FollowContext } from "./FollowContext" +import { Modal } from "components/bootstrap" +import { FillButton, OutlineButton } from "components/buttons" +import { formatBillId } from "components/formatting" + +export function FollowUserButton({ + profileId, + confirmFollow, + confirmUnfollow, + userName +}: { + profileId: string + confirmFollow?: boolean + confirmUnfollow?: boolean + userName?: string +}) { + const uid = useAuth().user?.uid + return ( + followProfile(uid, profileId)} + unfollowAction={() => unfollowProfile(uid, profileId)} + confirmFollow={confirmFollow} + confirmUnfollow={confirmUnfollow} + displayName={userName} + /> + ) +} + +export function FollowBillButton({ + bill, + confirmFollow, + confirmUnfollow +}: { + bill: Bill + confirmFollow?: boolean + confirmUnfollow?: boolean +}) { + const uid = useAuth().user?.uid + return ( + followBill(uid, bill)} + unfollowAction={() => unfollowBill(uid, bill)} + confirmFollow={confirmFollow} + confirmUnfollow={confirmUnfollow} + displayName={useTranslation("testimony").t("bill", { + billId: formatBillId(bill.id) + })} + /> + ) +} export const BaseFollowButton = ({ topicName, followAction, unfollowAction, - hide + hide, + confirmFollow = false, + confirmUnfollow = false, + displayName = "" }: { topicName: string followAction: () => Promise unfollowAction: () => Promise hide?: boolean + confirmFollow?: boolean + confirmUnfollow?: boolean + displayName?: string }) => { - const { t } = useTranslation(["profile"]) - - const { user } = useAuth() - const uid = user?.uid - + const { t } = useTranslation("common") + const uid = useAuth().user?.uid const { followStatus, setFollowStatus } = useContext(FollowContext) + const [modalAction, setModalAction] = useState<"follow" | "unfollow" | null>( + null + ) useEffect(() => { - uid - ? TopicQuery(uid, topicName).then(result => { - setFollowStatus(prevOrgFollowGroup => { - return { ...prevOrgFollowGroup, [topicName]: Boolean(result) } - }) - }) - : null + if (!uid) return + followsTopic(uid, topicName).then(result => + setFollowStatus(prev => ({ ...prev, [topicName]: result })) + ) }, [uid, topicName, setFollowStatus]) const FollowClick = async () => { await followAction() - setFollowStatus({ ...followStatus, [topicName]: true }) + setFollowStatus(prev => ({ ...prev, [topicName]: true })) } const UnfollowClick = async () => { await unfollowAction() - setFollowStatus({ ...followStatus, [topicName]: false }) + setFollowStatus(prev => ({ ...prev, [topicName]: false })) } const isFollowing = followStatus[topicName] - const text = isFollowing ? t("button.following") : t("button.follow") - const checkmark = isFollowing ? ( - - ) : null - const handleClick = (event: React.FormEvent) => { - event.preventDefault() - isFollowing ? UnfollowClick() : FollowClick() - } + const onClick = isFollowing + ? () => (confirmUnfollow ? setModalAction("unfollow") : UnfollowClick()) + : () => (confirmFollow ? setModalAction("follow") : FollowClick()) return ( <> {!hide && ( - +
+ +
)} + setModalAction(null)} + onConfirm={() => + modalAction === "follow" ? FollowClick() : UnfollowClick() + } + /> ) } -export const ButtonWithCheckmark = ({ - checkmark, - handleClick, - text, - className -}: { - checkmark: JSX.Element | null - handleClick: any - text: string - className?: string -}) => { - return ( -
- -
- ) -} - -export function FollowUserButton({ - className, - profileId +function ConfirmFollowModal({ + action, + displayName, + onCancel, + onConfirm }: { - className?: string - profileId: string + action: "follow" | "unfollow" | null + displayName: string + onCancel: () => void + onConfirm: () => void | Promise }) { - const { user } = useAuth() - const uid = user?.uid - const topicName = `testimony-${profileId}` - const followAction = () => - setFollow(uid, topicName, undefined, undefined, undefined, profileId) - const unfollowAction = () => setUnfollow(uid, topicName) + const { t } = useTranslation("common") - return ( - + const title = useMemo( + () => (action === "unfollow" ? t("button.unfollow") : t("button.follow")), + [action, t] ) -} -export function FollowBillButton({ bill }: { bill: Bill }) { - const { user } = useAuth() - const uid = user?.uid - const { id: billId, court: courtId } = bill - const topicName = `bill-${courtId}-${billId}` - const followAction = () => - setFollow(uid, topicName, bill, billId, courtId, undefined) - const unfollowAction = () => setUnfollow(uid, topicName) + const message = useMemo( + () => t(`confirmation.${action}Message`, { displayName }), + [action, displayName, t] + ) return ( - + + + {title} + + +
{message}
+
+ + +
+
+
) } diff --git a/components/shared/FollowingQueries.tsx b/components/shared/FollowingQueries.tsx index 288da17e2..a73b688b6 100644 --- a/components/shared/FollowingQueries.tsx +++ b/components/shared/FollowingQueries.tsx @@ -1,122 +1,58 @@ -import { - collection, - deleteDoc, - doc, - getDocs, - query, - setDoc, - where -} from "firebase/firestore" +import { collection, deleteDoc, doc, getDoc, setDoc } from "firebase/firestore" import { Bill } from "../db" import { firestore } from "../firebase" -import { UnfollowModalConfig } from "components/EditProfilePage/UnfollowModal" -export type Results = { [key: string]: string[] } - -function setSubscriptionRef(uid: string | undefined) { - return collection(firestore, `/users/${uid}/activeTopicSubscriptions/`) +function getTopicRef(uid: string | undefined, topicName: string) { + return doc( + collection(firestore, `/users/${uid}/activeTopicSubscriptions/`), + topicName + ) } -export async function deleteItem({ - uid, - unfollowItem -}: { - uid: string | undefined - unfollowItem: UnfollowModalConfig | null -}) { - const subscriptionRef = setSubscriptionRef(uid) - - if (unfollowItem !== null) { - let topicName = "" - if (unfollowItem.type == "bill") { - topicName = `bill-${unfollowItem.court.toString()}-${unfollowItem.typeId}` - } else { - topicName = `testimony-${unfollowItem.typeId}` - } - - await deleteDoc(doc(subscriptionRef, topicName)) - } +export const billTopicName = (court: number, billId: string) => + `bill-${court}-${billId}` +export const profileTopicName = (profileId: string) => `testimony-${profileId}` + +export const followBill = async (uid: string | undefined, bill: Bill) => { + const topicName = billTopicName(bill.court, bill.id) + await setDoc(getTopicRef(uid, topicName), { + topicName, + uid, + billLookup: { + billId: bill.id, + court: bill.court + }, + type: "bill" + }) } -export async function FollowingQuery(uid: string | undefined) { - let results: Results = { - bills: [], - orgs: [] - } - - const subscriptionRef = setSubscriptionRef(uid) +const unfollowTopic = async (uid: string | undefined, topicName: string) => + await deleteDoc(getTopicRef(uid, topicName)) - const q1 = query( - subscriptionRef, - where("uid", "==", `${uid}`), - where("type", "==", `bill`) - ) - const querySnapshotBills = await getDocs(q1) - querySnapshotBills.forEach(doc => { - // doc.data() is never undefined for query doc snapshots - doc.data().billLookup ? results.bills.push(doc.data().billLookup) : null - }) +export const unfollowBill = async ( + uid: string | undefined, + bill: Pick +) => await unfollowTopic(uid, billTopicName(bill.court, bill.id)) - const q2 = query( - subscriptionRef, - where("uid", "==", `${uid}`), - where("type", "==", `org`) - ) - const querySnapshotOrgs = await getDocs(q2) - querySnapshotOrgs.forEach(doc => { - // doc.data() is never undefined for query doc snapshots - doc.data().userLookup ? results.orgs.push(doc.data().userLookup) : null +export const followProfile = async ( + uid: string | undefined, + profileId: string +) => { + const topicName = profileTopicName(profileId) + await setDoc(getTopicRef(uid, topicName), { + topicName, + uid, + userLookup: { profileId }, + type: "testimony" }) - - return results } -export async function setFollow( +export const unfollowProfile = async ( uid: string | undefined, - topicName: string, - bill: Bill | undefined, - billId: string | undefined, - courtId: number | undefined, - profileId: string | undefined -) { - const subscriptionRef = setSubscriptionRef(uid) - - bill - ? await setDoc(doc(subscriptionRef, topicName), { - topicName: topicName, - uid: uid, - billLookup: { - billId: billId, - court: courtId - }, - type: "bill" - }) - : await setDoc(doc(subscriptionRef, topicName), { - topicName: topicName, - uid: uid, - userLookup: { - profileId: profileId - }, - type: "testimony" - }) -} - -export async function setUnfollow(uid: string | undefined, topicName: string) { - const subscriptionRef = setSubscriptionRef(uid) - - await deleteDoc(doc(subscriptionRef, topicName)) -} + profileId: string +) => await unfollowTopic(uid, profileTopicName(profileId)) -export async function TopicQuery(uid: string | undefined, topicName: string) { - let result = "" - - const subscriptionRef = setSubscriptionRef(uid) - - const q = query(subscriptionRef, where("topicName", "==", topicName)) - const querySnapshot = await getDocs(q) - querySnapshot.forEach(doc => { - // doc.data() is never undefined for query doc snapshots - result = doc.data().topicName - }) - return result -} +export const followsTopic = async ( + uid: string | undefined, + topicName: string +) => !!uid && (await getDoc(getTopicRef(uid, topicName))).exists() diff --git a/components/shared/PaginatedItemsCard.tsx b/components/shared/PaginatedItemsCard.tsx new file mode 100644 index 000000000..a223e96a1 --- /dev/null +++ b/components/shared/PaginatedItemsCard.tsx @@ -0,0 +1,79 @@ +import React, { useEffect, useMemo, useState } from "react" +import { Alert, Spinner, Stack } from "../bootstrap" +import { TitledSectionCard } from "../shared" +import { PaginationButtons } from "../table" + +export type LoadableItemsState = { + items: readonly T[] + loading: boolean + error: string | null +} + +export function PaginatedItemsCard({ + className, + title, + items, + itemsPerPage = 10, + ItemCard, + loading = false, + error = null, + description +}: LoadableItemsState & { + className?: string + title: string | React.ReactNode + itemsPerPage?: number + ItemCard: React.ComponentType + description?: React.ReactNode +}) { + const [currentPage, setCurrentPage] = useState(1) + + const totalPages = useMemo( + () => Math.max(1, Math.ceil(items.length / itemsPerPage)), + [items.length, itemsPerPage] + ) + + useEffect(() => { + // Reset or clamp page when the list changes or page size changes + setCurrentPage(prev => Math.min(Math.max(1, prev), totalPages)) + }, [items.length, itemsPerPage, totalPages]) + + const paginatedItems = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage + const endIndex = startIndex + itemsPerPage + return items.slice(startIndex, endIndex) + }, [items, currentPage, itemsPerPage]) + + return ( + +
+ +

{title}

+ {description &&
{description}
} +
+ {error ? ( + {error} + ) : loading ? ( + + ) : ( + paginatedItems.map((item, index) => ( + + )) + )} +
+ {!loading && items.length > 0 && ( + 1, + nextPage: () => setCurrentPage(prev => prev + 1), + previousPage: () => setCurrentPage(prev => prev - 1), + itemsPerPage + }} + /> + )} +
+
+
+ ) +} diff --git a/components/testimony/TestimonyDetailPage/PolicyActions.tsx b/components/testimony/TestimonyDetailPage/PolicyActions.tsx index 90d8c4d52..b79c1d3f9 100644 --- a/components/testimony/TestimonyDetailPage/PolicyActions.tsx +++ b/components/testimony/TestimonyDetailPage/PolicyActions.tsx @@ -6,7 +6,7 @@ import { FC, ReactElement, useContext, useEffect } from "react" import { useCurrentTestimonyDetails } from "./testimonyDetailSlice" import { useTranslation } from "next-i18next" import { useAuth } from "components/auth" -import { TopicQuery } from "components/shared/FollowingQueries" +import { followsTopic } from "components/shared/FollowingQueries" import { StyledImage } from "components/ProfilePage/StyledProfileComponents" import { FollowContext } from "components/shared/FollowContext" @@ -44,22 +44,20 @@ export const PolicyActions: FC> = ({ useEffect(() => { uid - ? TopicQuery(uid, topicName).then(result => { - setFollowStatus(prevOrgFollowGroup => { - return { ...prevOrgFollowGroup, [topicName]: Boolean(result) } - }) + ? followsTopic(uid, topicName).then(result => { + setFollowStatus(prev => ({ ...prev, [topicName]: result })) }) : null }, [uid, topicName, setFollowStatus]) const FollowClick = async () => { await followAction() - setFollowStatus({ ...followStatus, [topicName]: true }) + setFollowStatus(prev => ({ ...prev, [topicName]: true })) } const UnfollowClick = async () => { await unfollowAction() - setFollowStatus({ ...followStatus, [topicName]: false }) + setFollowStatus(prev => ({ ...prev, [topicName]: false })) } const isFollowing = followStatus[topicName] diff --git a/components/testimony/TestimonyDetailPage/TestimonyDetailPage.tsx b/components/testimony/TestimonyDetailPage/TestimonyDetailPage.tsx index 82a76d9f5..abba5800d 100644 --- a/components/testimony/TestimonyDetailPage/TestimonyDetailPage.tsx +++ b/components/testimony/TestimonyDetailPage/TestimonyDetailPage.tsx @@ -15,7 +15,11 @@ import { TestimonyDetail } from "./TestimonyDetail" import { VersionBanner } from "./TestimonyVersionBanner" import { useAuth } from "components/auth" import { useMediaQuery } from "usehooks-ts" -import { setFollow, setUnfollow } from "components/shared/FollowingQueries" +import { + billTopicName, + followBill, + unfollowBill +} from "components/shared/FollowingQueries" export const TestimonyDetailPage: FC> = () => { const [isReporting, setIsReporting] = useState(false) @@ -24,18 +28,9 @@ export const TestimonyDetailPage: FC> = () => { const isMobile = useMediaQuery("(max-width: 768px)") const { authorUid, revision } = useCurrentTestimonyDetails() const { bill } = useCurrentTestimonyDetails() - const { user } = useAuth() - const isUser = user?.uid === authorUid - const handleReporting = (boolean: boolean) => { - setIsReporting(boolean) - } + const uid = useAuth().user?.uid + const isUser = uid === authorUid const { t } = useTranslation("testimony", { keyPrefix: "reportModal" }) - const uid = user?.uid - const { id: billId, court: courtId } = bill - const topicName = `bill-${courtId}-${billId}` - const followAction = () => - setFollow(uid, topicName, bill, billId, courtId, undefined) - const unfollowAction = () => setUnfollow(uid, topicName) return ( <> @@ -56,10 +51,10 @@ export const TestimonyDetailPage: FC> = () => { className="mb-4" isUser={isUser} isReporting={isReporting} - setReporting={handleReporting} - topicName={topicName} - followAction={followAction} - unfollowAction={unfollowAction} + setReporting={setIsReporting} + topicName={billTopicName(bill.court, bill.id)} + followAction={() => followBill(uid, bill)} + unfollowAction={() => unfollowBill(uid, bill)} /> )} diff --git a/firestore.rules b/firestore.rules index 5cbca817b..978809c0f 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/malegislature.test.ts b/functions/src/malegislature.test.ts deleted file mode 100644 index 8d63e9a1a..000000000 --- a/functions/src/malegislature.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { listHearings } from "./malegislature" - -jest.setTimeout(40000) -it("works", async () => { - await listHearings() -}) 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/functions/src/testimony/resolveReport.ts b/functions/src/testimony/resolveReport.ts index b3170c5dc..181b7c604 100644 --- a/functions/src/testimony/resolveReport.ts +++ b/functions/src/testimony/resolveReport.ts @@ -5,7 +5,6 @@ import { fail, checkRequestZod, checkAuth, checkAdmin } from "../common" // import { performDeleteTestimony } from "./deleteTestimony" import { first } from "lodash" import { Testimony } from "./types" -import { Profile } from "../profile/types" export type Request = z.infer const Request = z.object({ @@ -45,12 +44,10 @@ export const resolveReport = functions.https.onCall( // 3. Get the moderator's profile document const moderatorUid = context.auth!.uid - const moderator = Profile.check( - await db - .doc(`profiles/${moderatorUid}`) - .get() - .then(d => d.data()) - ) + const moderatorName = await db + .doc(`profiles/${moderatorUid}`) + .get() + .then(d => d.data()?.fullName) // ***archived testiomny Id === published testimony Id*** @@ -76,7 +73,7 @@ export const resolveReport = functions.https.onCall( archivedTestimonyId: testimonyId } if (reason) resolutionObj.reason = reason - if (moderator.fullName) resolutionObj.moderatorName = moderator.fullName + if (moderatorName) resolutionObj.moderatorName = moderatorName await reportRef.update({ resolution: resolutionObj 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/common.json b/public/locales/en/common.json index 419f56be0..a0a3d5fe2 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -73,10 +73,16 @@ "button": { "follow": "Follow", "following": "Following", - "followed": "Followed" + "followed": "Followed", + "unfollow": "Unfollow" }, "calendar": "Our Calendar", "copiedToClipboard": "Copied to Clipboard!", + "confirmation": { + "unfollowMessage": "Are you sure you want to unfollow?", + "yes": "Yes", + "no": "No" + }, "hideAns": "Hide Answer", "date": "{{date, datetime(year: 'numeric'; month: 'long'; day: 'numeric')}}", "edit": "Edit", diff --git a/public/locales/en/editProfile.json b/public/locales/en/editProfile.json index 9f8e4402b..f138871af 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", @@ -32,11 +52,6 @@ "follow": "Follow", "follower_info_disclaimer": "Names and follower counts are not publicly available; only visible to you." }, - "confirmation": { - "unfollowMessage": "Are you sure you want to unfollow", - "yes": "Yes", - "no": "No" - }, "email": { "frequencyQuery": "How often would you like to receive emails?", "daily": "Daily", diff --git a/scripts/firebase-admin/migrateProfileEmails.ts b/scripts/firebase-admin/migrateProfileEmails.ts new file mode 100644 index 000000000..2d393b2f1 --- /dev/null +++ b/scripts/firebase-admin/migrateProfileEmails.ts @@ -0,0 +1,47 @@ +import { FieldValue } from "functions/src/firebase" +import { Script } from "./types" + +// Migrate emails from profiles to users collection and delete from profiles +export const script: Script = async ({ db, auth }) => { + const profilesSnapshot = await db + .collection("profiles") + .where("email", ">", "") + .get() + let emailUpdateCount = 0 + + console.log(`Migrating emails for ${profilesSnapshot.size} profiles`) + + const bulkWriter = db.bulkWriter() + + for (const profileDoc of profilesSnapshot.docs) { + const profileData = profileDoc.data() + const userId = profileDoc.id + const email = profileData.email + + if (email) { + const userRef = db.collection("users").doc(userId) + const userDoc = await userRef.get() + const userEmail = userDoc.exists ? userDoc.data()?.email : undefined + + if (userEmail) { + if (userEmail !== email) { + console.error( + `Email mismatch for userId ${userId}: profile email "${email}", user email "${userEmail}"` + ) + } else { + // Email is the same, just delete from profile collection + bulkWriter.update(profileDoc.ref, { email: FieldValue.delete() }) + } + } else { + // No email in user collection, set it + bulkWriter.set(userRef, { email }, { merge: true }) + bulkWriter.update(profileDoc.ref, { email: FieldValue.delete() }) + emailUpdateCount++ + } + } + } + + await bulkWriter.close() + + console.log(`Updated emails for ${emailUpdateCount} users`) +}