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
29 changes: 27 additions & 2 deletions components/auth/VerifyEmailModal.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import type { ModalProps } from "react-bootstrap"
import { Button, Image, Col, Modal, Row, Stack } from "../bootstrap"
import { Alert, Button, Image, Col, Modal, Row, Stack } from "../bootstrap"
import { Internal } from "components/links"
import { useTranslation } from "next-i18next"
import { useRecheckEmailVerified } from "./hooks"

export default function VerifyEmailModal({
show,
onHide
}: Pick<ModalProps, "show" | "onHide">) {
const { t } = useTranslation("auth")
// For an email verified in another tab, which this one can't see by itself.
const recheck = useRecheckEmailVerified()
// A failed check shows its error below; the button is there to try again.
const check = () =>
recheck.execute().then(
verified => verified && onHide?.(),
() => {}
)
return (
<Modal show={show} onHide={onHide} centered>
<Modal show={show} onHide={onHide} onExited={recheck.reset} centered>
<Modal.Header closeButton>
<Modal.Title id="tos-modal">{t("signUp")}</Modal.Title>
</Modal.Header>
Expand All @@ -19,6 +28,22 @@ export default function VerifyEmailModal({
<Image fluid height={`18em`} src="/mailbox.svg" alt="" />
<h2 className={`fw-bold`}>{t("verifyEmail")}</h2>
<h6>{t("verifyLinkSent")}</h6>
<Button
variant="outline-secondary"
className="align-self-center"
onClick={check}
disabled={recheck.loading}
>
{t("verifiedCheck")}
</Button>
{recheck.status === "success" && !recheck.result && (
<p className="mb-0">{t("verifiedNotYet")}</p>
)}
{recheck.error && (
<Alert variant="danger" className="mb-0">
{recheck.error.message}
</Alert>
)}
<Internal href="/edit-profile" className="view-edit-profile">
<Button onClick={onHide}>{t("setUpProfile")}</Button>
</Internal>
Expand Down
38 changes: 36 additions & 2 deletions components/auth/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import {
import { useAsyncCallback } from "react-async-hook"
import { setProfile } from "../db"
import { auth } from "../firebase"
import { useAppDispatch } from "../hooks"
import { authChanged } from "./redux"
import { claimsOf } from "./service"
import { completePhoneVerification, finishSignup, OrgCategory } from "./types"

const errorMessages: Record<string, string | undefined> = {
Expand Down Expand Up @@ -98,7 +101,7 @@ export function useCreateUserWithEmailAndPassword(isOrg: boolean) {
public: true
})
}
await sendEmailVerification(credentials.user)
await sendVerification(credentials.user)

return credentials
}
Expand All @@ -114,8 +117,39 @@ export function useSignInWithEmailAndPassword() {
)
}

/** Sends the verification email, whose page leads back to where the reader
* asked for it, so what they were doing when they signed up picks up there.
* Firebase takes a continue address only on an authorized domain, which a
* preview deploy's is not; there the email goes without one. */
async function sendVerification(user: User) {
try {
await sendEmailVerification(user, { url: location.href })
} catch (err) {
if ((err as { code?: string })?.code !== "auth/unauthorized-continue-uri")
throw err
await sendEmailVerification(user)
}
}

export function useSendEmailVerification() {
return useFirebaseFunction((user: User) => sendEmailVerification(user))
return useFirebaseFunction((user: User) => sendVerification(user))
}

/** Reads the signed-in reader's account again, for an email verified in
* another tab, which this one would not see before a reload. Resolves to
* whether it is now verified. */
export function useRecheckEmailVerified() {
const dispatch = useAppDispatch()
return useFirebaseFunction<void, boolean>(async () => {
const user = auth.currentUser
if (!user) return false
await user.reload()
if (!user.emailVerified) return false
// The token carries the verification to the server's rules.
const claims = claimsOf(await user.getIdTokenResult(true))
dispatch(authChanged({ user, claims }))
return true
})
}

/** Call after the user has linked a phone number via linkWithPhoneNumber + confirm. */
Expand Down
15 changes: 8 additions & 7 deletions components/auth/service.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useProfile } from "components/db"
import { useProfileState } from "components/db/profile/redux"
import { User } from "firebase/auth"
import { IdTokenResult, User } from "firebase/auth"
import Router, { useRouter } from "next/router"
import React, { useCallback, useEffect, useRef } from "react"
import { auth } from "../firebase"
Expand All @@ -16,12 +16,7 @@ export const { Provider } = createService(() => {

useEffect(() => {
const unsubscribe = auth.onAuthStateChanged(async user => {
let claims: Claim | undefined = undefined
if (user) {
let token = await getToken(user)
const fromToken = Claim.validate(token.claims)
if (fromToken.success) claims = fromToken.value
}
const claims = user ? claimsOf(await getToken(user)) : undefined
dispatch(authChanged({ user, claims }))
})
return () => {
Expand All @@ -30,6 +25,12 @@ export const { Provider } = createService(() => {
}, [dispatch, getToken])
})

/** The claims a token carries, if they are what we expect. */
export const claimsOf = (token: IdTokenResult): Claim | undefined => {
const parsed = Claim.validate(token.claims)
return parsed.success ? parsed.value : undefined
}

/**
* The token does not refresh when the user's profile or claims in firebase auth change,
* So we need to manually refresh it if the user's profile or claims change.
Expand Down
13 changes: 1 addition & 12 deletions components/conference/ConferenceByline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Trans, useTranslation } from "next-i18next"
import styled from "styled-components"
import { formatBillId } from "../formatting"
import { maple } from "../links"
import { DottedInternal } from "./styles"
import { DottedInternal, Notice } from "./styles"
import { ConferenceSummary } from "./types"

const Line = styled.p`
Expand All @@ -28,17 +28,6 @@ const Status = styled.span`
font-weight: 600;
`

const Notice = styled.p`
margin: 1rem 0 0;
padding: 0.75rem 1rem;
border-radius: var(--maple-radius-md);
background: var(--maple-color-gray-subtle-bg);
border: 1px solid var(--maple-color-gray-subtle-border);
color: var(--maple-text-strong);
font-size: 0.9375rem;
line-height: 1.45;
`

/**
* What the committee is, in the legislature's own construction: the act it
* exists to pass, and the two texts it reconciles to pass it. Each number goes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jest.mock("next-i18next", () => ({
jest.mock("next/router", () => ({
__esModule: true,
default: { events: { on: () => {} } },
useRouter: () => ({ asPath: "/conferences/194/a" })
useRouter: () => ({ asPath: "/conferences/194/a", query: {} })
}))
jest.mock("usehooks-ts", () => ({ useMediaQuery: () => false }))
jest.mock("../featureFlags", () => ({
Expand Down
122 changes: 120 additions & 2 deletions components/conference/ConferenceDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ jest.mock("next-i18next", () => ({
}))

let asPath = "/conferences/194/data-privacy"
let mockQuery: Record<string, string> = {}
const mockReplace = jest.fn(async () => true)
jest.mock("next/router", () => ({
__esModule: true,
default: { events: { on: jest.fn() } },
useRouter: () => ({ asPath })
useRouter: () => ({ asPath, query: mockQuery, replace: mockReplace })
}))
/** Next starting a change of address, as the hooks hear it. */
const routeChangeStart = (url: string) =>
Expand Down Expand Up @@ -98,8 +100,10 @@ jest.mock("components/api/report", () => ({
}))
jest.mock("components/TestimonyCard/ReportModal", () => ({}))
jest.mock("components/TestimonyCard/ReportToast", () => () => null)
// The panel stands for itself by its title, open or not.
jest.mock("./input/Panel", () => ({
Panel: () => null,
Panel: ({ open, title }: { open: boolean; title: string }) =>
open ? <div role="dialog" aria-label={title} /> : null,
usePanelWidth: () => ({ width: 480, drag: jest.fn() })
}))
const mockDispatch = jest.fn()
Expand Down Expand Up @@ -278,6 +282,9 @@ describe("ConferenceDetails", () => {
afterEach(() => {
mockUser = undefined
mockItems = []
mockQuery = {}
mockReplace.mockClear()
mockDispatch.mockClear()
window.scrollTo = scrollTo
Object.defineProperty(window, "scrollY", { value: 0, configurable: true })
})
Expand Down Expand Up @@ -536,6 +543,117 @@ describe("ConferenceDetails", () => {
}
})

describe("a link to cosign an input", () => {
const letter = {
id: "t1",
authorUid: "org",
authorDisplayName: "Example Coalition",
fullName: "Example Coalition",
authorRole: "organization",
position: "passSomething",
content: "The letter.",
publishedAt: { toDate: () => new Date(2026, 9, 1) }
}
const address = "/conferences/194/student-learning-and-mental-health"
// The page drops ?cosign from its address without moving the reader.
const letGo = () =>
expect(mockReplace).toHaveBeenCalledWith(address, undefined, {
shallow: true,
scroll: false
})
beforeEach(() => {
mockItems = [letter]
mockQuery = { cosign: "t1" }
// Landing on Your Input asks whether to move smoothly.
window.matchMedia = jest.fn(() => ({ matches: true })) as never
})

it("opens the cosign panel for a signed-in reader, and lets go of the link", () => {
mockUser = { uid: "reader", emailVerified: true }
render(<ConferenceDetails {...props} />)
expect(
screen.getByRole("dialog", { name: "input.panel_title" })
).toBeInTheDocument()
letGo()
})

it("sends a signed-out reader to sign in once, keeping the link, and picks up after", () => {
const { rerender } = render(<ConferenceDetails {...props} />)
expect(mockDispatch).toHaveBeenCalledTimes(1)
expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({ payload: "start" })
)
expect(mockReplace).not.toHaveBeenCalled()

mockUser = { uid: "reader", emailVerified: true }
rerender(<ConferenceDetails {...props} />)
expect(
screen.getByRole("dialog", { name: "input.panel_title" })
).toBeInTheDocument()
expect(mockDispatch).toHaveBeenCalledTimes(1)
})

it("waits for a new account's email to be verified", () => {
mockUser = { uid: "reader", emailVerified: false }
render(<ConferenceDetails {...props} />)
expect(mockDispatch).toHaveBeenCalledWith(
expect.objectContaining({ payload: "verifyEmail" })
)
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})

it("asks a reader who signs in to an unverified account to verify", () => {
const { rerender } = render(<ConferenceDetails {...props} />)
mockUser = { uid: "reader", emailVerified: false }
rerender(<ConferenceDetails {...props} />)
expect(mockDispatch).toHaveBeenCalledTimes(2)
expect(mockDispatch).toHaveBeenLastCalledWith(
expect.objectContaining({ payload: "verifyEmail" })
)
})

const says = (why: string) => {
expect(screen.getByRole("status")).toHaveTextContent(
`input.cosign_link_${why}`
)
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
letGo()
}

it("says the input has been removed", () => {
mockQuery = { cosign: "gone" }
render(<ConferenceDetails {...props} />)
says("removed")
})

it("says the conference has closed, without asking a reader to sign in", () => {
const reported = { ...conference, status: "reportFiled" as const }
render(<ConferenceDetails {...props} conference={reported} />)
says("closed")
expect(mockDispatch).not.toHaveBeenCalled()
})

it("says a legislator's input or a cosign can't be cosigned", () => {
mockItems = [{ ...letter, authorRole: "legislator" }]
render(<ConferenceDetails {...props} />)
says("not_cosignable")
})

it("says a reader who has filed here can't cosign, the author included", () => {
mockUser = { uid: "org", emailVerified: true }
render(<ConferenceDetails {...props} />)
says("filed")
})

it("can be dismissed", () => {
mockQuery = { cosign: "gone" }
render(<ConferenceDetails {...props} />)
fireEvent.click(screen.getByRole("button", { name: "input.close" }))
expect(screen.queryByRole("status")).not.toBeInTheDocument()
})
})

it("leads from its tabs to its sections", () => {
const { container } = render(<ConferenceDetails {...props} />)
const tabs = within(screen.getByRole("navigation", { name: "tabs_label" }))
Expand Down
Loading
Loading