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
3 changes: 3 additions & 0 deletions components/EditProfilePage/EditProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -114,6 +115,7 @@ export function EditProfileForm({

const { claims, user } = useAuth()
const isPendingUpgrade = claims?.role === "pendingUpgrade"
const isPendingLegislator = claims?.role === "pendingLegislator"

isOrg = isOrg || isPendingUpgrade

Expand Down Expand Up @@ -175,6 +177,7 @@ export function EditProfileForm({
return (
<>
{isPendingUpgrade && <PendingUpgradeBanner />}
{isPendingLegislator && <PendingLegislatorBanner />}

<Container>
<EditProfileHeader
Expand Down
13 changes: 13 additions & 0 deletions components/PendingLegislatorBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { MessageBanner } from "./shared/MessageBanner"
import { useTranslation } from "next-i18next"

export const PendingLegislatorBanner = () => {
const { t } = useTranslation("common")
return (
<MessageBanner
icon={"/Clock.svg"}
heading={t("pending_legislator_warning.header")}
content={t("pending_legislator_warning.content")}
/>
)
}
20 changes: 20 additions & 0 deletions components/api/upgrade-legislator.ts
Original file line number Diff line number Diff line change
@@ -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" })
}
7 changes: 7 additions & 0 deletions components/auth/AuthModal.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -28,6 +29,7 @@ export default function AuthModal() {
onHide={close}
onIndividualUserClick={() => setCurrentModal("userSignUp")}
onOrgUserClick={() => setCurrentModal("orgSignUp")}
onLegislatorUserClick={() => setCurrentModal("legislatorSignUp")}
/>
<SignInModal
show={currentModal === "signIn"}
Expand All @@ -44,6 +46,11 @@ export default function AuthModal() {
onHide={close}
onSuccessfulSubmit={() => setCurrentModal("verifyEmail")}
/>
<LegislatorSignUpModal
show={currentModal === "legislatorSignUp"}
onHide={close}
onSuccessfulSubmit={() => setCurrentModal("verifyEmail")}
/>
<VerifyEmailModal show={currentModal === "verifyEmail"} onHide={close} />
<ForgotPasswordModal
show={currentModal === "forgotPassword"}
Expand Down
242 changes: 242 additions & 0 deletions components/auth/LegislatorSignUpModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import { useEffect, useMemo, useState } from "react"
import clsx from "clsx"
import type { ModalProps } from "react-bootstrap"
import { useForm } from "react-hook-form"
import { Alert, Button, Col, Form, Modal, Row, Stack } from "../bootstrap"
import { LoadingButton } from "../buttons"
import Input from "../forms/Input"
import PasswordInput from "../forms/PasswordInput"
import {
CreateLegislatorWithEmailAndPasswordData,
useCreateLegislatorWithEmailAndPassword
} from "./hooks"
import TermsOfServiceModal from "./TermsOfServiceModal"
import { useTranslation } from "next-i18next"
import { Search } from "../legislatorSearch"
import { useClaimedMemberCodes, useMemberSearch } from "../db/members"
import { ProfileMember } from "../db"

export default function LegislatorSignUpModal({
show,
onHide,
onSuccessfulSubmit
}: Pick<ModalProps, "show" | "onHide"> & {
onSuccessfulSubmit: () => void
onHide: () => void
}) {
const {
register,
handleSubmit,
reset,
getValues,
trigger,
formState: { errors }
} = useForm<CreateLegislatorWithEmailAndPasswordData>()

const [tosStep, setTosStep] = useState<"not-agreed" | "reading" | "agreed">(
"not-agreed"
)
const [selectedMember, setSelectedMember] = useState<ProfileMember | null>(
null
)
const [memberError, setMemberError] = useState<string | undefined>()

const showTos = tosStep === "reading"

const createLegislatorWithEmailAndPassword =
useCreateLegislatorWithEmailAndPassword()

const { index } = useMemberSearch()
const { claimedCodes } = useClaimedMemberCodes()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love the thought you put into the user experience here, but I think we can get away without excluding already claimed member codes. Any legitimate legislator will only try to claim themselves (and we have a search input to help them find themselves) and any illegitimate user doesn't deserve a good user experience.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I think it may be more helpful in the admin view than the user-facing view.

It's possible more than one person will submit a request for a given legislator (possibly, but not necessarily maliciously - it could be the legislator and a staffer requesting from different email addresses). In that case, I think it's fine to let the users submit what they want and let the admins sort out the real deal.

(It's also possible the real legislator tries to claim the account second - so it may be desirable to leave their name in the select box so they can more easily contest ownership)

If so, we do want the MAPLE admin to know if there are multiple requests for a given member (even if one request has been previously accepted) - we should expose that in the admin interface for additional context.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does raise an issue about user verification I'd love to discuss:

  • Should we allow unverified users to be approved as legislators? (e.g. I sign up for a Rebecca Rausch legislator profile with her statehouse email, a MAPLE admin sees the request and approves it, and then I can post anything I want as Rebecca Rausch)
    • Seems like the answer shoud be no
    • Instead, I feel that the flow should be:
      • User signs up as a Pending Legislator
      • We tell them they must verify their email
      • Only after they verify their email does their account show up as "Pending Legislator" in the MAPLE admin view
        • IMO there's not a quick way to hook into the email verification flow (we host those links in an unconventional way, so it might be easiest to just hide pendingLegislator profiles with email_verified: false from the admin view
      • The MAPLE admin accepts them
      • The account is fully upgraded to Legislator status

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a nice-to-have (that may or may not make things easier in this PR, so I'll bring it up anyway):

  • Should we have special handling for government email addresses?
    • On the one hand, it would be a nice UX boost to auto-fill the appropriate legislator when the user uses their government email (e.g. my_legislator@ma.senate.gov) and we could even auto-approve requests to claim a legislator from a verified government email address (to cut down on the worklog for our admins)
    • On the other, they may not actually want to use their government email for things like this (e.g. preferring a personal/campaign email, or a staffer's email).

@mvictor55 How do you plan to approve/reject legislator profile requests? Do we need them to sign up with their government email so we have some proof they're real, or are there other signals we should look/ask for? We should discuss this at hack night.


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 (
<>
<Modal
show={show}
onHide={onHide}
aria-labelledby="legislator-sign-up-modal"
centered
size="lg"
className={clsx(showTos && "opacity-0")}
>
<Modal.Header closeButton>
<Modal.Title id="legislator-sign-up-modal">
{t("signUpAsLegislator") ?? "Sign Up as Legislator"}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<Col md={12} className="mx-auto">
{createLegislatorWithEmailAndPassword.error ? (
<Alert variant="danger">
{createLegislatorWithEmailAndPassword.error.message}
</Alert>
) : null}

<Form noValidate onSubmit={onSubmit}>
<TermsOfServiceModal
show={showTos}
onHide={() => setTosStep("not-agreed")}
onAgree={() => setTosStep("agreed")}
/>

<Stack gap={3} className="mb-4">
<Input
label={t("email") ?? "Email"}
type="email"
{...register("email", {
required: t("emailIsRequired") ?? "An email is required."
})}
error={errors.email?.message}
/>

<Input
label={t("fullName") ?? "Full Name"}
type="text"
{...register("fullName", {
validate: value =>
value.trim().length >= 2 ||
t("errEmptyAndMinLength").toString(),
required: t("nameIsRequired") ?? "A full name is required."
})}
error={errors.fullName?.message}
/>

<Form.Group controlId="legislatorSearch">
<Form.Label>{t("selectLegislatorHeader")}</Form.Label>
<Search
index={memberIndex}
update={member => {
setSelectedMember(member)
if (member) setMemberError(undefined)
}}
memberId={selectedMember?.id}
placeholder={t("searchLegislatorsPlaceholder")}
menuPortalTarget={document.body}
styles={{
menuPortal: (base: any) => ({ ...base, zIndex: 9999 })
}}
/>
{memberError && (
<Form.Text className="text-danger">{memberError}</Form.Text>
)}
</Form.Group>

<Row className="g-3">
<Col md={6}>
<PasswordInput
label={t("password") ?? "Password"}
{...register("password", {
required:
t("passwordRequired") ?? "A password is required.",
minLength: {
value: 8,
message:
t("passwordLength") ??
"Your password must be 8 characters or longer."
},
deps: ["confirmedPassword"]
})}
error={errors.password?.message}
/>
</Col>

<Col md={6}>
<PasswordInput
label={t("confirmPassword") ?? "Confirm Password"}
{...register("confirmedPassword", {
required:
t("mustConfirmPassword") ??
"You must confirm your password.",
validate: confirmedPassword => {
const password = getValues("password")
return confirmedPassword !== password
? t("mustMatch") ??
"Confirmed password must match password."
: undefined
}
})}
error={errors.confirmedPassword?.message}
/>
</Col>
</Row>
</Stack>
{tosStep === "agreed" ? (
<LoadingButton
id="legislator-loading-button"
type="submit"
className="w-100"
loading={createLegislatorWithEmailAndPassword.loading}
>
{t("signUp") ?? "Sign Up"}
</LoadingButton>
) : (
<Button
className="w-100"
type="button"
onClick={handleContinueClick}
>
{t("continue") ?? "Continue"}
</Button>
)}
</Form>
</Col>
</Modal.Body>
</Modal>
</>
)
}
Loading
Loading