diff --git a/components/EditProfilePage/EditProfilePage.tsx b/components/EditProfilePage/EditProfilePage.tsx index 6f3d4c2d1..ed1c966e6 100644 --- a/components/EditProfilePage/EditProfilePage.tsx +++ b/components/EditProfilePage/EditProfilePage.tsx @@ -28,6 +28,7 @@ import { TestimoniesTab } from "./TestimoniesTab" import { useFlags } from "components/featureFlags" import LoginPage from "components/Login/Login" import { PendingUpgradeBanner } from "components/PendingUpgradeBanner" +import { PendingLegislatorBanner } from "components/PendingLegislatorBanner" import { FollowersTab } from "./FollowersTab" const tabTitle = ["about-you", "testimonies", "following", "followers"] as const @@ -114,6 +115,7 @@ export function EditProfileForm({ const { claims, user } = useAuth() const isPendingUpgrade = claims?.role === "pendingUpgrade" + const isPendingLegislator = claims?.role === "pendingLegislator" isOrg = isOrg || isPendingUpgrade @@ -175,6 +177,7 @@ export function EditProfileForm({ return ( <> {isPendingUpgrade && } + {isPendingLegislator && } { + const { t } = useTranslation("common") + return ( + + ) +} diff --git a/components/api/upgrade-legislator.ts b/components/api/upgrade-legislator.ts new file mode 100644 index 000000000..75ee47436 --- /dev/null +++ b/components/api/upgrade-legislator.ts @@ -0,0 +1,20 @@ +import { mapleClient } from "./maple-client" + +/** + * Changes the user's role to "legislator", approving their legislator account request. + * + * Requires the logged-in user to be an admin. + */ +export async function acceptLegislatorRequest(userId: string) { + return mapleClient.patch(`/api/users/${userId}`, { role: "legislator" }) +} + +/** + * Rejects a pending legislator request by reverting the user's role to "user". + * Also releases the claimed member code so it can be claimed by others. + * + * Requires the logged-in user to be an admin. + */ +export async function rejectLegislatorRequest(userId: string) { + return mapleClient.patch(`/api/users/${userId}`, { role: "user" }) +} diff --git a/components/auth/AuthModal.tsx b/components/auth/AuthModal.tsx index 079f07538..a775d6d22 100644 --- a/components/auth/AuthModal.tsx +++ b/components/auth/AuthModal.tsx @@ -1,6 +1,7 @@ import SignInModal from "./SignInModal" import UserSignUpModal from "./UserSignUpModal" import OrgSignUpModal from "./OrgSignUpModal" +import LegislatorSignUpModal from "./LegislatorSignUpModal" import StartModal from "./StartModal" import ForgotPasswordModal from "./ForgotPasswordModal" import VerifyEmailModal from "./VerifyEmailModal" @@ -28,6 +29,7 @@ export default function AuthModal() { onHide={close} onIndividualUserClick={() => setCurrentModal("userSignUp")} onOrgUserClick={() => setCurrentModal("orgSignUp")} + onLegislatorUserClick={() => setCurrentModal("legislatorSignUp")} /> setCurrentModal("verifyEmail")} /> + setCurrentModal("verifyEmail")} + /> & { + onSuccessfulSubmit: () => void + onHide: () => void +}) { + const { + register, + handleSubmit, + reset, + getValues, + trigger, + formState: { errors } + } = useForm() + + const [tosStep, setTosStep] = useState<"not-agreed" | "reading" | "agreed">( + "not-agreed" + ) + const [selectedMember, setSelectedMember] = useState( + null + ) + const [memberError, setMemberError] = useState() + + const showTos = tosStep === "reading" + + const createLegislatorWithEmailAndPassword = + useCreateLegislatorWithEmailAndPassword() + + const { index } = useMemberSearch() + const { claimedCodes } = useClaimedMemberCodes() + + const memberIndex = useMemo(() => { + const all = [...(index?.representatives ?? []), ...(index?.senators ?? [])] + if (!claimedCodes) return all + return all.filter(m => !claimedCodes.has(m.MemberCode)) + }, [index, claimedCodes]) + + const { t } = useTranslation("auth") + + useEffect(() => { + if (!show) { + reset() + setTosStep("not-agreed") + setSelectedMember(null) + setMemberError(undefined) + createLegislatorWithEmailAndPassword.reset() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [show, reset]) + + const onSubmit = handleSubmit(newUser => { + if (!selectedMember) { + setMemberError( + t("legislatorRequired") ?? "Please select your legislator profile." + ) + return + } + setMemberError(undefined) + const promise = createLegislatorWithEmailAndPassword.execute({ + ...newUser, + memberCode: selectedMember.id + }) + promise.then(onSuccessfulSubmit).catch(() => {}) + }) + + async function handleContinueClick() { + if (!selectedMember) { + setMemberError( + t("legislatorRequired") ?? "Please select your legislator profile." + ) + return + } + setMemberError(undefined) + const isValid = await trigger() + if (isValid) { + setTosStep("reading") + } + } + + useEffect(() => { + if (tosStep === "agreed") { + const loadingbtn = document.getElementById("legislator-loading-button") + loadingbtn?.click() + } + }, [tosStep]) + + return ( + <> + + + + {t("signUpAsLegislator") ?? "Sign Up as Legislator"} + + + + + {createLegislatorWithEmailAndPassword.error ? ( + + {createLegislatorWithEmailAndPassword.error.message} + + ) : null} + +
+ setTosStep("not-agreed")} + onAgree={() => setTosStep("agreed")} + /> + + + + + + value.trim().length >= 2 || + t("errEmptyAndMinLength").toString(), + required: t("nameIsRequired") ?? "A full name is required." + })} + error={errors.fullName?.message} + /> + + + + {t("selectLegislator") ?? "Select your legislator profile"} + + { + setSelectedMember(member) + if (member) setMemberError(undefined) + }} + memberId={selectedMember?.id} + placeholder={ + t("searchLegislators") ?? "Search by name or district..." + } + menuPortalTarget={document.body} + styles={{ + menuPortal: (base: any) => ({ ...base, zIndex: 9999 }) + }} + /> + {memberError && ( + {memberError} + )} + + + + + + + + + { + const password = getValues("password") + return confirmedPassword !== password + ? t("mustMatch") ?? + "Confirmed password must match password." + : undefined + } + })} + error={errors.confirmedPassword?.message} + /> + + + + {tosStep === "agreed" ? ( + + {t("signUp") ?? "Sign Up"} + + ) : ( + + )} + + +
+
+ + ) +} diff --git a/components/auth/ProfileTypeModal.tsx b/components/auth/ProfileTypeModal.tsx index 7fd3b7d4e..4cea76f3d 100644 --- a/components/auth/ProfileTypeModal.tsx +++ b/components/auth/ProfileTypeModal.tsx @@ -2,6 +2,7 @@ import type { ModalProps } from "react-bootstrap" import { Button, Col, Modal, Row, Stack, Image } from "../bootstrap" import styled from "styled-components" import { useTranslation } from "next-i18next" +import { useFlags } from "../featureFlags" export const StyledButton = styled(Button)` width: 100%; @@ -24,13 +25,16 @@ export default function ProfileTypeModal({ show, onHide, onIndividualUserClick, - onOrgUserClick + onOrgUserClick, + onLegislatorUserClick }: Pick & { onHide: () => void onIndividualUserClick: () => void onOrgUserClick: () => void + onLegislatorUserClick: () => void }) { const { t } = useTranslation("auth") + const { legislators } = useFlags() return ( + + {legislators && ( + + + + + + + +

+ {t("legislator") ?? "Legislator"} +

+

+ {t("legislatorDescription") ?? + "I am an elected MA legislator"} +

+ +
+
+ )}

{t("orgVetting")}


diff --git a/components/auth/hooks.ts b/components/auth/hooks.ts index a618d6460..e21ee9185 100644 --- a/components/auth/hooks.ts +++ b/components/auth/hooks.ts @@ -68,6 +68,11 @@ export type CreateUserWithEmailAndPasswordData = { orgCategory?: OrgCategory } +export type CreateLegislatorWithEmailAndPasswordData = + CreateUserWithEmailAndPasswordData & { + memberCode: string + } + export function useCreateUserWithEmailAndPassword(isOrg: boolean) { return useFirebaseFunction( async ({ @@ -105,6 +110,31 @@ export function useCreateUserWithEmailAndPassword(isOrg: boolean) { ) } +export function useCreateLegislatorWithEmailAndPassword() { + return useFirebaseFunction( + async ({ + email, + fullName, + password, + memberCode + }: CreateLegislatorWithEmailAndPasswordData) => { + const credentials = await createUserWithEmailAndPassword( + auth, + email, + password + ) + await finishSignup({ + requestedRole: "pendingLegislator", + fullName, + memberCode, + email: credentials.user.email + }) + await sendEmailVerification(credentials.user) + return credentials + } + ) +} + export type SignInWithEmailAndPasswordData = { email: string; password: string } export function useSignInWithEmailAndPassword() { diff --git a/components/auth/redux.ts b/components/auth/redux.ts index cf1b622a4..8db7d1374 100644 --- a/components/auth/redux.ts +++ b/components/auth/redux.ts @@ -9,6 +9,7 @@ export type AuthFlowStep = | "signIn" | "userSignUp" | "orgSignUp" + | "legislatorSignUp" | "forgotPassword" | "verifyEmail" | "chooseProfileType" diff --git a/components/db/members.ts b/components/db/members.ts index d4cd503bc..b3a6c6360 100644 --- a/components/db/members.ts +++ b/components/db/members.ts @@ -1,7 +1,9 @@ +import { collection, getDocs } from "firebase/firestore" import { Timestamp } from "firebase/firestore" import { useMemo } from "react" import { useAsync } from "react-async-hook" import { currentGeneralCourt } from "functions/src/shared" +import { firestore } from "../firebase" import { loadDoc } from "./common" export type CommitteeReference = { CommitteeCode: string @@ -55,6 +57,11 @@ export function useMemberSearch() { return { index, loading } } +export function useClaimedMemberCodes() { + const { result: claimedCodes, loading } = useAsync(getClaimedMemberCodes, []) + return { claimedCodes, loading } +} + async function getMember( court: number, memberCode?: string @@ -69,3 +76,8 @@ async function getMemberSearchIndex(): Promise { `/generalCourts/${currentGeneralCourt}/indexes/memberSearch` ) as any } + +async function getClaimedMemberCodes(): Promise> { + const snap = await getDocs(collection(firestore, "claimedMemberCodes")) + return new Set(snap.docs.map(d => d.id)) +} diff --git a/components/db/profile/types.ts b/components/db/profile/types.ts index 8f9106a7a..69aa63f59 100644 --- a/components/db/profile/types.ts +++ b/components/db/profile/types.ts @@ -46,4 +46,6 @@ export type Profile = { phoneVerified?: boolean memberId?: string website?: string + memberCode?: string + legislatorBiography?: string } diff --git a/components/moderation/ListProfiles.tsx b/components/moderation/ListProfiles.tsx index f8cfc1ffc..fef6e69b6 100644 --- a/components/moderation/ListProfiles.tsx +++ b/components/moderation/ListProfiles.tsx @@ -16,6 +16,10 @@ import { rejectOrganizationRequest, acceptOrganizationRequest } from "components/api/upgrade-org" +import { + acceptLegislatorRequest, + rejectLegislatorRequest +} from "components/api/upgrade-legislator" import { Profile } from "components/db" import { Internal } from "components/links" @@ -39,7 +43,9 @@ const UserRoleToolBar = () => { } const pendingCount = - data?.filter(d => d.role === "pendingUpgrade").length ?? 0 + data?.filter( + d => d.role === "pendingUpgrade" || d.role === "pendingLegislator" + ).length ?? 0 const fakeOrgRequest = useCallback(async () => { const uid = nanoid(8) @@ -58,7 +64,12 @@ const UserRoleToolBar = () => {
Upgrade Requests: {pendingCount} pending upgrades
- {["pendingUpgrade", "organization"].map(role => { + {[ + "pendingUpgrade", + "organization", + "pendingLegislator", + "legislator" + ].map(role => { return ( + ) + + return variant === "ballotQuestion" ? ( + + ) : ( + + ) +} diff --git a/firestore.rules b/firestore.rules index a95586279..d01246abd 100644 --- a/firestore.rules +++ b/firestore.rules @@ -38,6 +38,10 @@ service cloud.firestore { // Only the completePhoneVerification cloud function (Admin SDK) sets phoneVerified return !request.resource.data.diff(resource.data).affectedKeys().hasAny(['phoneVerified']) } + function doesNotChangeMemberCode() { + // memberCode is set at signup and is immutable - only Admin SDK can write it + return !request.resource.data.diff(resource.data).affectedKeys().hasAny(['memberCode']) + } // either the change doesn't include the public field, // or the user is a base user (i.e. not an org) function validPublicChange() { @@ -56,7 +60,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() && doesNotChangePhoneVerified() + allow update: if validUser() && doesNotChangeRole() && doesNotChangeMemberCode() && validPublicChange() && doesNotChangeNextDigestAt() && doesNotChangePhoneVerified() } // Allow querying publications individually or with a collection group. match /{path=**}/publishedTestimony/{id} { @@ -99,6 +103,10 @@ service cloud.firestore { allow read, write: if request.auth.token.get("role", "user") == "admin" } } + match /claimedMemberCodes/{memberCode} { + allow read: if true; + allow write: if false; + } match /ballotQuestions/{id} { allow read: if true; allow write: if false; diff --git a/functions/src/auth/setRole.ts b/functions/src/auth/setRole.ts index 169b30607..6791f5154 100644 --- a/functions/src/auth/setRole.ts +++ b/functions/src/auth/setRole.ts @@ -75,10 +75,9 @@ const updateTestimony = async ( const isPublic = (profile: Profile | undefined, role: Role) => { switch (role) { case "pendingUpgrade": + case "pendingLegislator": case "admin": - return false case "legislator": - case "pendingUpgrade": return false case "organization": return true diff --git a/functions/src/auth/types.ts b/functions/src/auth/types.ts index c48619c40..648ca7f11 100644 --- a/functions/src/auth/types.ts +++ b/functions/src/auth/types.ts @@ -6,6 +6,7 @@ export const Role = Union( L("admin"), L("legislator"), L("pendingUpgrade"), + L("pendingLegislator"), L("organization") ) export type Role = Static @@ -15,6 +16,7 @@ export const ZRole = z.enum([ "admin", "legislator", "pendingUpgrade", + "pendingLegislator", "organization" ]) diff --git a/functions/src/profile/finishSignup.ts b/functions/src/profile/finishSignup.ts index 0f82728bc..6dcf72db8 100644 --- a/functions/src/profile/finishSignup.ts +++ b/functions/src/profile/finishSignup.ts @@ -5,7 +5,12 @@ import { checkRequestZod, checkAuth } from "../common" import { setRole } from "../auth" const CreateProfileRequest = z.object({ - requestedRole: z.enum(["user", "organization", "pendingUpgrade"]) + requestedRole: z.enum([ + "user", + "organization", + "pendingUpgrade", + "pendingLegislator" + ]) }) export const finishSignup = functions.https.onCall(async (data, context) => { @@ -18,6 +23,7 @@ export const finishSignup = functions.https.onCall(async (data, context) => { orgCategories, notificationFrequency, email, + memberCode, public: isPublic } = data @@ -31,6 +37,15 @@ export const finishSignup = functions.https.onCall(async (data, context) => { uid, newProfile: { fullName, email, orgCategories } }) + } else if (requestedRole === "pendingLegislator") { + await setRole({ + role: "pendingLegislator", + auth, + db, + uid, + newProfile: { fullName, email, memberCode } + }) + await db.doc(`/claimedMemberCodes/${memberCode}`).set({ uid }) } else { await setRole({ role: "user", diff --git a/functions/src/profile/types.ts b/functions/src/profile/types.ts index cd2e2e265..38fa28dc5 100644 --- a/functions/src/profile/types.ts +++ b/functions/src/profile/types.ts @@ -34,7 +34,9 @@ export const Profile = Record({ billsFollowing: Optional(Array(String)), contactInfo: Optional(Dictionary(String)), location: Optional(String), - phoneVerified: Optional(Boolean) + phoneVerified: Optional(Boolean), + memberCode: Optional(String), + legislatorBiography: Optional(String) }) export type Profile = Static diff --git a/pages/api/users/[uid].ts b/pages/api/users/[uid].ts index aa2181838..763f3d23b 100644 --- a/pages/api/users/[uid].ts +++ b/pages/api/users/[uid].ts @@ -33,7 +33,9 @@ const ROLES = [ "user", // Regular old "admin", // Can do anything, set in the db manually for now "pendingUpgrade", // Sign up as org, admin has to manually upgrade - "organization" // An upgraded organization approved by an admin + "organization", // An upgraded organization approved by an admin + "pendingLegislator", // Sign up as legislator, admin has to manually approve + "legislator" // An approved legislator account ] as const const BodySchema = z.object({ @@ -68,6 +70,15 @@ async function patch(req: NextApiRequest, res: NextApiResponse) { try { const user = await auth.getUser(uid) + // When rejecting a pending legislator request, release their claimed member code + if (role === "user") { + const profileSnap = await db.doc(`/profiles/${uid}`).get() + const memberCode = profileSnap.data()?.memberCode + if (memberCode) { + await db.doc(`/claimedMemberCodes/${memberCode}`).delete() + } + } + await setRole({ uid: user.uid, role, diff --git a/public/locales/en/common.json b/public/locales/en/common.json index d1dbd8e47..a17918634 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -166,6 +166,10 @@ "header": "Organization Request In Progress", "content": "Your request to be upgraded to an organization is currently in progress. You will be notified by email when your request has been reviewed." }, + "pending_legislator_warning": { + "header": "Legislator Account Pending Verification", + "content": "Your legislator account is currently under review. You will be able to submit testimony and enhance your legislator profile once your account is approved." + }, "primarySponsor": "Primary Sponsor", "privacyPolicy": "Privacy Policy", "short_description": "MAPLE makes it easy for anyone to view and submit testimony to the Massachusetts Legislature about the bills that will shape our future.", diff --git a/public/locales/en/testimony.json b/public/locales/en/testimony.json index 12da1f4b3..dbea6ef2e 100644 --- a/public/locales/en/testimony.json +++ b/public/locales/en/testimony.json @@ -32,6 +32,10 @@ "title": "Pending Upgrade", "label": "Your Organization Registration is Pending" }, + "pendingLegislator": { + "title": "Pending Verification", + "label": "Your Legislator Registration is Pending Approval" + }, "signedOut": { "title": "Sign In to Add Testimony" },