Skip to content
Draft
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
246 changes: 246 additions & 0 deletions components/auth/LegislatorSignUpModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
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()

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("selectLegislator") ?? "Select your legislator profile"}
</Form.Label>
<Search
index={memberIndex}
update={member => {
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 && (
<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>
</>
)
}
30 changes: 29 additions & 1 deletion components/auth/ProfileTypeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand All @@ -24,13 +25,16 @@ export default function ProfileTypeModal({
show,
onHide,
onIndividualUserClick,
onOrgUserClick
onOrgUserClick,
onLegislatorUserClick
}: Pick<ModalProps, "show" | "onHide"> & {
onHide: () => void
onIndividualUserClick: () => void
onOrgUserClick: () => void
onLegislatorUserClick: () => void
}) {
const { t } = useTranslation("auth")
const { legislators } = useFlags()

return (
<Modal
Expand Down Expand Up @@ -85,6 +89,30 @@ export default function ProfileTypeModal({
</Col>
</Row>
</StyledButton>

{legislators && (
<StyledButton
type="button"
variant="secondary"
onClick={onLegislatorUserClick}
>
<Row>
<Col xs="auto" className="d-flex align-items-center">
<Image alt="" src="/profile-individual-white.svg" />
</Col>

<Col>
<p>
<b>{t("legislator") ?? "Legislator"}</b>
</p>
<p>
{t("legislatorDescription") ??
"I am an elected MA legislator"}
</p>
</Col>
</Row>
</StyledButton>
)}
</Stack>
<p>{t("orgVetting")}</p>
<hr />
Expand Down
Loading
Loading