diff --git a/docs/docusaurus/docs/03-voters/01-tutorials/02-voter_track_your_vote.md b/docs/docusaurus/docs/03-voters/01-tutorials/02-voter_track_your_vote.md
index db3d4deb553..99de40b982b 100644
--- a/docs/docusaurus/docs/03-voters/01-tutorials/02-voter_track_your_vote.md
+++ b/docs/docusaurus/docs/03-voters/01-tutorials/02-voter_track_your_vote.md
@@ -34,6 +34,8 @@ From this screen, you can directly access the Ballot Locator prefilled to lookup
You can copy the Ballot ID and manually enter it in the Ballot Locator Screen at a later stage. See the explanation for this in Option 2.
+When available for your election, the **Copy Ballot ID** button appears next to the ID on both the Review and Confirmation screens. It copies the complete ID, even when the displayed ID is shortened. If the copy button is hidden on Review, it is also hidden on Confirmation.
+
:::tip
**Alternative:** You can also click in the `Print` button and the same options will be available but originating from the PDF you downloaded instead.
:::
diff --git a/packages/ui-essentials/src/components/BallotHash/BallotHash.test.tsx b/packages/ui-essentials/src/components/BallotHash/BallotHash.test.tsx
index 8d008012414..47a3fe7a420 100644
--- a/packages/ui-essentials/src/components/BallotHash/BallotHash.test.tsx
+++ b/packages/ui-essentials/src/components/BallotHash/BallotHash.test.tsx
@@ -1,13 +1,17 @@
+/** @jest-environment jsdom */
// SPDX-FileCopyrightText: 2026 Sequent Tech Inc
//
// SPDX-License-Identifier: AGPL-3.0-only
import React from "react"
-import {renderToStaticMarkup} from "react-dom/server"
+import {act, fireEvent, render, screen} from "@testing-library/react"
+import "@testing-library/jest-dom"
import {ThemeProvider} from "@mui/material/styles"
import BallotHash, {copyBallotHash, CopyBallotHashStatus} from "./BallotHash"
import theme from "../../services/theme"
+jest.mock("../LinkBehavior/LinkBehavior", () => "a")
+
jest.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, values?: {ballotId: string}): string =>
@@ -40,8 +44,115 @@ describe("copyBallotHash", () => {
})
describe("BallotHash", () => {
+ afterEach(() => {
+ jest.useRealTimers()
+ Reflect.deleteProperty(navigator, "clipboard")
+ })
+
+ it.each([":hover", ":active"])("keeps the copy button's box model unchanged on %s", (state) => {
+ render(
+
+
+
+ )
+ const button = screen.getByRole("button", {name: copyLabels.copy})
+ const restingStyle = getComputedStyle(button)
+ const interactionStyle = document.createElement("button").style
+ interactionStyle.padding = restingStyle.padding
+ interactionStyle.border = restingStyle.border
+ let matchedRules = 0
+
+ // jsdom does not apply pointer pseudo-classes; resolve the matching
+ // Emotion rules against the button's resting box model instead.
+ for (const sheet of Array.from(document.styleSheets)) {
+ for (const cssRule of Array.from(sheet.cssRules)) {
+ const rule = cssRule as CSSStyleRule
+ if (
+ rule.type === CSSRule.STYLE_RULE &&
+ rule.selectorText.includes(state) &&
+ button.matches(rule.selectorText.replaceAll(state, ""))
+ ) {
+ matchedRules += 1
+ for (let index = 0; index < rule.style.length; index++) {
+ const property = rule.style[index]
+ interactionStyle.setProperty(
+ property,
+ rule.style.getPropertyValue(property)
+ )
+ }
+ }
+ }
+ }
+
+ expect(matchedRules).toBeGreaterThan(0)
+ expect(interactionStyle.paddingBottom).toBe(restingStyle.paddingBottom)
+ expect(interactionStyle.borderWidth).toBe(restingStyle.borderWidth)
+ })
+
+ it.each([undefined, copyLabels])("has no copy control for an empty hash (%j)", (labels) => {
+ render(
+
+
+
+ )
+ expect(screen.queryByRole("button", {name: copyLabels.copy})).toBeNull()
+ })
+
+ it("keeps copying opt-in even when there is a hash", () => {
+ render(
+
+
+
+ )
+ expect(screen.queryByRole("button", {name: copyLabels.copy})).toBeNull()
+ })
+
+ it("announces a complete copy and resets feedback after two seconds or a hash change", async () => {
+ jest.useFakeTimers()
+ const writeText = jest.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, "clipboard", {configurable: true, value: {writeText}})
+ const view = render(
+
+
+
+ )
+
+ await act(async () => fireEvent.click(screen.getByRole("button", {name: copyLabels.copy})))
+ expect(writeText).toHaveBeenCalledWith("abc123")
+ expect(screen.getByRole("status")).toHaveTextContent(copyLabels.copied)
+ expect(screen.getByRole("button", {name: copyLabels.copied})).toBeInTheDocument()
+ act(() => jest.advanceTimersByTime(2000))
+ expect(screen.getByRole("status")).toBeEmptyDOMElement()
+
+ await act(async () => fireEvent.click(screen.getByRole("button", {name: copyLabels.copy})))
+ view.rerender(
+
+
+
+ )
+ expect(screen.getByRole("status")).toBeEmptyDOMElement()
+ expect(screen.getByRole("button", {name: copyLabels.copy})).toBeInTheDocument()
+ })
+
+ it.each([undefined, {writeText: jest.fn().mockRejectedValue(new Error("denied"))}])(
+ "announces clipboard failures (%j)",
+ async (clipboard) => {
+ Object.defineProperty(navigator, "clipboard", {configurable: true, value: clipboard})
+ render(
+
+
+
+ )
+ await act(async () =>
+ fireEvent.click(screen.getByRole("button", {name: copyLabels.copy}))
+ )
+ expect(screen.getByRole("status")).toHaveTextContent(copyLabels.error)
+ expect(screen.getByRole("button", {name: copyLabels.error})).toBeInTheDocument()
+ }
+ )
+
it("renders the optional copy control with an accessible name", () => {
- const markup = renderToStaticMarkup(
+ render(
{
)
- expect(markup).toContain('aria-label="Copy ballot ID"')
- expect(markup).toContain('aria-label="About ballot ID"')
- expect(markup).toContain('role="status"')
+ expect(screen.getByRole("button", {name: "Copy ballot ID"})).toBeInTheDocument()
+ expect(screen.getByRole("button", {name: "About ballot ID"})).toBeInTheDocument()
+ expect(screen.getByRole("status")).toBeInTheDocument()
})
it("uses the translated fallback when no help label is supplied", () => {
- const markup = renderToStaticMarkup(
+ render(
)
- expect(markup).toContain('aria-label="About your Ballot ID"')
+ expect(screen.getByRole("button", {name: "About your Ballot ID"})).toBeInTheDocument()
})
})
diff --git a/packages/ui-essentials/src/components/BallotHash/BallotHash.tsx b/packages/ui-essentials/src/components/BallotHash/BallotHash.tsx
index 23d687e4be9..35c029200b0 100644
--- a/packages/ui-essentials/src/components/BallotHash/BallotHash.tsx
+++ b/packages/ui-essentials/src/components/BallotHash/BallotHash.tsx
@@ -99,13 +99,10 @@ export const copyBallotHash = async (
}
}
-const BallotHash: React.FC = ({
+export const BallotHashCopyButton: React.FC> = ({
hash,
- onHelpClick,
- helpButtonLabel,
copyLabels,
}) => {
- const {t} = useTranslation()
const [copyStatus, setCopyStatus] = useState(CopyBallotHashStatus.Idle)
useEffect(() => setCopyStatus(CopyBallotHashStatus.Idle), [hash])
@@ -126,6 +123,41 @@ const BallotHash: React.FC = ({
const copyStatusLabel =
copyLabels?.[copyStatus === CopyBallotHashStatus.Idle ? "copy" : copyStatus]
+ if (!copyLabels || !hash) {
+ return null
+ }
+
+ return (
+ <>
+
+
+ {copyStatus === CopyBallotHashStatus.Idle ? "" : copyStatusLabel}
+
+ >
+ )
+}
+
+const BallotHash: React.FC = ({
+ hash,
+ onHelpClick,
+ helpButtonLabel,
+ copyLabels,
+}) => {
+ const {t} = useTranslation()
+
return (
@@ -138,20 +170,7 @@ const BallotHash: React.FC = ({
{t("ballotHash", {ballotId: hash})}
- {copyLabels && hash ? (
-
- ) : null}
+
= ({
ariaLabel={helpButtonLabel || t("a11y.ballotIdHelp")}
/>
-
- {copyStatus === CopyBallotHashStatus.Idle ? "" : copyStatusLabel}
-
)
}
diff --git a/packages/ui-essentials/src/index.tsx b/packages/ui-essentials/src/index.tsx
index c141f710432..6cd8c7ce18f 100644
--- a/packages/ui-essentials/src/index.tsx
+++ b/packages/ui-essentials/src/index.tsx
@@ -34,7 +34,7 @@ export {
} from "./components/BreadCrumbSteps/BreadCrumbSteps"
export {default as Candidate} from "./components/Candidate/Candidate"
export {getOrdinalSuffix} from "./components/Candidate/ordinalUtils"
-export {default as BallotHash} from "./components/BallotHash/BallotHash"
+export {default as BallotHash, BallotHashCopyButton} from "./components/BallotHash/BallotHash"
export {default as QRCode} from "./components/QRCode/QRCode"
export {default as CandidatesList} from "./components/CandidatesList/CandidatesList"
export {default as SelectElection} from "./components/SelectElection/SelectElection"
diff --git a/packages/ui-essentials/src/services/theme.accessibility.test.tsx b/packages/ui-essentials/src/services/theme.accessibility.test.tsx
new file mode 100644
index 00000000000..7eb1b4c4e5d
--- /dev/null
+++ b/packages/ui-essentials/src/services/theme.accessibility.test.tsx
@@ -0,0 +1,73 @@
+/** @jest-environment jsdom */
+// SPDX-FileCopyrightText: 2026 Sequent Tech Inc
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from "react"
+import {act, fireEvent, render, screen} from "@testing-library/react"
+import "@testing-library/jest-dom"
+import {Checkbox, ThemeProvider, getContrastRatio} from "@mui/material"
+import {faCircleQuestion} from "@fortawesome/free-solid-svg-icons"
+import IconButton from "../components/IconButton/IconButton"
+import theme from "./theme"
+
+jest.mock("../components/LinkBehavior/LinkBehavior", () => "a")
+
+const focusWithKeyboard = async (element: HTMLElement) => {
+ fireEvent.keyDown(document, {key: "Tab"})
+ await act(async () => element.focus())
+}
+
+describe("shared control contrast", () => {
+ it.each([false, true])("keeps an enabled checkbox discernible (checked: %s)", (checked) => {
+ render(
+
+
+
+ )
+ const control = screen.getByRole("checkbox").closest(".MuiCheckbox-root")!
+ const color = getComputedStyle(control).color
+
+ for (const background of ["#ffffff", theme.palette.lightBackground]) {
+ expect(getContrastRatio(color, background)).toBeGreaterThanOrEqual(3)
+ }
+ })
+
+ it("keeps a persistent two-tone focus ring while a checkbox toggles", async () => {
+ render(
+
+
+
+ )
+ const checkbox = screen.getByRole("checkbox")
+ const control = checkbox.closest(".MuiCheckbox-root")!
+ await focusWithKeyboard(checkbox)
+
+ expect(control).toHaveClass("Mui-focusVisible")
+ expect(control).toHaveStyle({
+ outline: "2px solid black",
+ outlineOffset: "2px",
+ boxShadow: "0 0 0 2px white",
+ })
+ fireEvent.click(checkbox)
+ expect(checkbox).toBeChecked()
+ expect(control).toHaveStyle({outline: "2px solid black", boxShadow: "0 0 0 2px white"})
+ })
+
+ it("gives the shared help button a focus ring independent of its icon color", async () => {
+ render(
+
+
+
+ )
+ const help = screen.getByRole("button", {name: "Help"})
+ await focusWithKeyboard(help)
+
+ expect(help).toHaveClass("Mui-focusVisible")
+ expect(help).toHaveStyle({
+ outline: "2px solid black",
+ outlineOffset: "2px",
+ boxShadow: "0 0 0 2px white",
+ })
+ })
+})
diff --git a/packages/ui-essentials/src/services/theme.ts b/packages/ui-essentials/src/services/theme.ts
index 6107dd445e4..4545164b977 100644
--- a/packages/ui-essentials/src/services/theme.ts
+++ b/packages/ui-essentials/src/services/theme.ts
@@ -602,12 +602,20 @@ let MuiDialog: Components["MuiDialog"] = {
},
}
+// A two-tone ring stays visible on light and dark tenant backgrounds.
+const keyboardFocusStyle = {
+ outline: `2px solid ${palette.black}`,
+ outlineOffset: "2px",
+ boxShadow: `0 0 0 2px ${palette.white}`,
+}
+
let MuiIconButton: Components["MuiIconButton"] = {
styleOverrides: {
root: {
"padding": 0,
"border": `2px solid transparent`,
"color": palette.black,
+ "&.Mui-focusVisible": keyboardFocusStyle,
"&:hover": {
padding: 0,
filter: "drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.25))",
@@ -637,7 +645,8 @@ let MuiTextField: Components["MuiTextField"] = {
let MuiCheckbox: Components["MuiCheckbox"] = {
styleOverrides: {
root: {
- "color": palette.extraGrey.main,
+ "color": palette.customGrey.main,
+ "&.Mui-focusVisible": keyboardFocusStyle,
"&:hover": {
backgroundColor: "unset",
},
diff --git a/packages/voting-portal/src/components/StartActions/StartActions.test.tsx b/packages/voting-portal/src/components/StartActions/StartActions.test.tsx
index 6e0b56b4dba..5c942daf2d6 100644
--- a/packages/voting-portal/src/components/StartActions/StartActions.test.tsx
+++ b/packages/voting-portal/src/components/StartActions/StartActions.test.tsx
@@ -75,7 +75,6 @@ const renderStartActions = (
const declarationCheckbox = () => screen.getByRole("checkbox", {name: SPANISH_DECLARATION})
const startButton = () => screen.getByRole("button", {name: "startScreen.startButton"})
-const startLink = () => screen.getByRole("link", {name: "startScreen.startButton"})
describe("StartActions navigation", () => {
it("has one named keyboard stop in both directions", async () => {
@@ -83,27 +82,31 @@ describe("StartActions navigation", () => {
renderStartActions(buildElection(), {isDeclineToVotePolicyEnabled: true})
const decline = screen.getByRole("button", {name: "startScreen.declineToVoteButton"})
- expect(screen.queryByRole("button", {name: "startScreen.startButton"})).toBeNull()
- expect(startLink()).toHaveClass("start-voting-button")
+ expect(screen.queryByRole("link", {name: "startScreen.startButton"})).toBeNull()
+ expect(startButton().tagName).toBe("BUTTON")
+ expect(startButton()).toHaveClass("start-voting-button")
await user.tab()
- expect(startLink()).toHaveFocus()
+ expect(startButton()).toHaveFocus()
await user.tab()
expect(decline).toHaveFocus()
await user.tab({shift: true})
- expect(startLink()).toHaveFocus()
+ expect(startButton()).toHaveFocus()
})
- it("navigates with Enter and preserves route parameters and the query string", async () => {
- const user = userEvent.setup()
- renderStartActions(buildElection())
+ it.each(["{Enter}", " "])(
+ "navigates with %s and preserves the route and query string",
+ async (key) => {
+ const user = userEvent.setup()
+ renderStartActions(buildElection())
- await user.tab()
- await user.keyboard("{Enter}")
+ await user.tab()
+ await user.keyboard(key)
- expect(screen.getByLabelText("Current route")).toHaveTextContent(
- "/tenant/tenant-1/event/event-1/election/election-1/vote?preview=true"
- )
- })
+ expect(screen.getByLabelText("Current route")).toHaveTextContent(
+ "/tenant/tenant-1/event/event-1/election/election-1/vote?preview=true"
+ )
+ }
+ )
it("does not expose a navigation target until the mandatory declaration is accepted", async () => {
const user = userEvent.setup()
@@ -116,7 +119,7 @@ describe("StartActions navigation", () => {
expect(screen.getByLabelText("Current route")).toHaveTextContent("/start?preview=true")
await user.keyboard(" ")
- expect(startLink()).toBeInTheDocument()
+ expect(startButton()).toBeEnabled()
await user.keyboard(" ")
expect(screen.queryByRole("link", {name: "startScreen.startButton"})).toBeNull()
expect(startButton()).toBeDisabled()
@@ -202,7 +205,7 @@ describe("StartActions security confirmation", () => {
await user.click(declarationCheckbox())
- expect(startLink()).toBeInTheDocument()
+ expect(startButton()).toBeEnabled()
expect(
screen.getByRole("button", {name: "startScreen.declineToVoteButton"})
).toBeEnabled()
@@ -221,12 +224,12 @@ describe("StartActions security confirmation", () => {
it.each([
["NONE", ESecurityConfirmationPolicy.NONE],
["unset", undefined],
- ])("renders no declaration and an enabled start link (%s)", (_label, policy) => {
+ ])("renders no declaration and an enabled start button (%s)", (_label, policy) => {
renderStartActions(buildElection(policy), {isDeclineToVotePolicyEnabled: true})
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument()
expect(screen.queryByText(SPANISH_DECLARATION)).not.toBeInTheDocument()
- expect(startLink()).toBeInTheDocument()
+ expect(startButton()).toBeEnabled()
expect(
screen.getByRole("button", {name: "startScreen.declineToVoteButton"})
).toBeEnabled()
diff --git a/packages/voting-portal/src/components/StartActions/StartActions.tsx b/packages/voting-portal/src/components/StartActions/StartActions.tsx
index aebc9c79616..2299e173c69 100644
--- a/packages/voting-portal/src/components/StartActions/StartActions.tsx
+++ b/packages/voting-portal/src/components/StartActions/StartActions.tsx
@@ -6,7 +6,7 @@ import {Box} from "@mui/material"
import Button from "@mui/material/Button"
import {styled} from "@mui/material/styles"
import {useTranslation} from "react-i18next"
-import {Link as RouterLink, useLocation, useParams} from "react-router-dom"
+import {useLocation, useNavigate, useParams} from "react-router-dom"
import {ESecurityConfirmationPolicy, IElection} from "@sequentech/ui-core"
import SecurityConfirmation from "../SecurityConfirmation/SecurityConfirmation"
import type {TenantEventType} from "../.."
@@ -52,6 +52,7 @@ export const StartActions: React.FC = ({
const {t} = useTranslation()
const {tenantId, eventId} = useParams()
const location = useLocation()
+ const navigate = useNavigate()
const [checkboxChecked, setCheckboxChecked] = useState(false)
const hasSecurityCheckbox =
@@ -69,24 +70,18 @@ export const StartActions: React.FC = ({
/>
) : null}
- {disabledStart ? (
-
- {t("startScreen.startButton")}
-
- ) : (
-
- {t("startScreen.startButton")}
-
- )}
+
+ navigate(
+ `/tenant/${tenantId}/event/${eventId}/election/${election.id}/vote${location.search}`
+ )
+ }
+ sx={{margin: "auto 0", width: "100%"}}
+ disabled={disabledStart}
+ >
+ {t("startScreen.startButton")}
+
{isDeclineToVotePolicyEnabled ? (
{
const {hashBallot, hashMultiBallot} = provideBallotService()
const oneBallotStyle = useAppSelector(selectFirstBallotStyle)
const electionBallotStyle = useAppSelector(selectBallotStyleByElectionId(String(electionId)))
+ const auditButtonCfg =
+ electionBallotStyle?.ballot_eml?.election_presentation?.audit_button_cfg ??
+ confirmationScreenData?.auditButtonCfg ??
+ EVotingPortalAuditButtonCfg.SHOW
// Nothing was cast for a fully acclaimed election, so this screen confirms
// what was decided rather than a ballot, and shows no ballot id anywhere.
const isFullyAcclaimed = areAllContestsAcclaimed(electionBallotStyle?.ballot_eml.contests)
@@ -549,12 +555,25 @@ const ConfirmationScreen: React.FC = () => {
>
{t("ballotHash", {ballotId: ballotId.current})}
+ {auditButtonCfg !== EVotingPortalAuditButtonCfg.NOT_SHOW ? (
+
+ ) : null}
diff --git a/packages/voting-portal/src/routes/ReviewScreen.tsx b/packages/voting-portal/src/routes/ReviewScreen.tsx
index 1bb5d5c617f..d7b5147e8c8 100644
--- a/packages/voting-portal/src/routes/ReviewScreen.tsx
+++ b/packages/voting-portal/src/routes/ReviewScreen.tsx
@@ -420,6 +420,7 @@ const ActionButtons: React.FC = ({
// Save contests to session storage and perform reauthentication
const ballotData: SessionBallotData = {
ballotId,
+ auditButtonCfg,
electionId: ballotStyle.election_id,
isDemo: true,
ballot: JSON.stringify("{}"),
@@ -462,6 +463,7 @@ const ActionButtons: React.FC = ({
// Save contests to session storage and perform reauthentication
const ballotData: SessionBallotData = {
ballotId,
+ auditButtonCfg,
electionId: ballotStyle.election_id,
isDemo,
ballot: JSON.stringify(hashableBallot),
@@ -746,13 +748,14 @@ export const ReviewScreen: React.FC = () => {
return submit({error: errorType}, {method: "post"})
}
- // set ConfirmationScreenData (ballotId and isDemo) to a new object in redux state, so it can be read later on from the confirmation screen
+ // Restore confirmation data after the reauthentication reload.
dispatch(
setConfirmationScreenData({
electionId: ballotData.electionId,
confirmationScreenData: {
ballotId: ballotData.ballotId,
isDemo: ballotData.isDemo,
+ auditButtonCfg: ballotData.auditButtonCfg,
},
})
)
diff --git a/packages/voting-portal/src/routes/VotingFlowControls.test.tsx b/packages/voting-portal/src/routes/VotingFlowControls.test.tsx
new file mode 100644
index 00000000000..115b69fd034
--- /dev/null
+++ b/packages/voting-portal/src/routes/VotingFlowControls.test.tsx
@@ -0,0 +1,445 @@
+// SPDX-FileCopyrightText: 2026 Sequent Tech Inc
+//
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from "react"
+import {render, screen, waitFor, within} from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import {ThemeProvider} from "@mui/material/styles"
+import {createMemoryRouter, RouterProvider} from "react-router-dom"
+import {
+ ECastVoteGoldLevelPolicy,
+ EConsolidatedReportPolicy,
+ EVotingPortalAuditButtonCfg,
+} from "@sequentech/ui-core"
+import type {
+ IAuditableBallot,
+ IContest,
+ IElection,
+ IVotingScreenBackPolicy,
+} from "@sequentech/ui-core"
+import theme from "../../../ui-essentials/src/services/theme"
+import {ELECTION_WITH_INVALID} from "../fixtures/election"
+import {RootState, store} from "../store/store"
+import {clearIsVoted} from "../store/extra/extraSlice"
+import confirmationScreenDataReducer, {
+ setConfirmationScreenData,
+} from "../store/castVotes/confirmationScreenDataSlice"
+import {BALLOT_DATA_KEY} from "../store/castVotes/sessionBallotData"
+import VotingScreen from "./VotingScreen"
+import {ReviewScreen} from "./ReviewScreen"
+import ConfirmationScreen from "./ConfirmationScreen"
+
+jest.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string, values?: {ballotId?: string}) =>
+ key === "ballotHash" ? `Ballot ID: ${values?.ballotId?.slice(0, 8)}` : key,
+ i18n: {language: "en"},
+ }),
+}))
+jest.mock("@sequentech/ui-core", () => ({
+ ...jest.requireActual("@sequentech/ui-core"),
+ ...jest.requireActual("../../../ui-core/src/types/ElectionEventPresentation"),
+ ...jest.requireActual("../../../ui-core/src/services/acclamation"),
+ getDefaultVotingScreenBackPolicy: () => "election-selection-screen",
+ check_voting_not_allowed_next_bool: () => false,
+ check_voting_error_dialog_bool: () => false,
+ sortContestList: (contests?: IContest[]) => contests ?? [],
+ hashBallot: () => "0123456789abcdef".repeat(4),
+ hashMultiBallot: () => "0123456789abcdef".repeat(4),
+}))
+jest.mock(
+ "@sequentech/ui-essentials",
+ () => ({
+ PageLimit: jest.requireActual("../../../ui-essentials/src/components/PageLimit/PageLimit")
+ .default,
+ Icon: jest.requireActual("../../../ui-essentials/src/components/Icon/Icon").default,
+ IconButton: jest.requireActual(
+ "../../../ui-essentials/src/components/IconButton/IconButton"
+ ).default,
+ DecorativeIconBox: jest.requireActual(
+ "../../../ui-essentials/src/components/Icon/DecorativeIconBox"
+ ).default,
+ VisuallyHidden: jest.requireActual(
+ "../../../ui-essentials/src/components/VisuallyHidden/VisuallyHidden"
+ ).default,
+ ...jest.requireActual(
+ "../../../ui-essentials/src/components/ConfirmationActions/ConfirmationActions"
+ ),
+ BallotHash: jest.requireActual(
+ "../../../ui-essentials/src/components/BallotHash/BallotHash"
+ ).default,
+ BallotHashCopyButton: jest.requireActual(
+ "../../../ui-essentials/src/components/BallotHash/BallotHash"
+ ).BallotHashCopyButton,
+ theme: jest.requireActual("../../../ui-essentials/src/services/theme").default,
+ Dialog: () => null,
+ QRCode: () => null,
+ }),
+ {virtual: true}
+)
+jest.mock("../store/hooks", () => ({
+ useAppSelector: (selector: (state: RootState) => unknown) => selector(mockState),
+ useAppDispatch: () => mockDispatch,
+}))
+jest.mock("../providers/AuthContextProvider", () => ({
+ AuthContext: jest.requireActual("react").createContext({
+ logout: jest.fn(),
+ isGoldUser: () => mockIsGoldUser,
+ reauthWithGold: (url: string) => mockReauthWithGold(url),
+ }),
+}))
+jest.mock("../providers/SettingsContextProvider", () => ({
+ SettingsContext: jest.requireActual("react").createContext({
+ globalSettings: {
+ get DISABLE_AUTH() {
+ return mockDisableAuth
+ },
+ },
+ }),
+}))
+jest.mock("../services/BallotService", () => ({
+ provideBallotService: () => ({
+ interpretContestSelection: () => [],
+ interpretMultiContestSelection: () => [],
+ hashBallot: () => "0123456789abcdef".repeat(4),
+ hashMultiBallot: () => "0123456789abcdef".repeat(4),
+ toHashableBallot: () => ({}),
+ toHashableMultiBallot: () => ({}),
+ }),
+}))
+jest.mock("../hooks/useEncryptBallotForReview", () => ({
+ useEncryptBallotForReview: () => ({encryptAndStoreBallot: jest.fn()}),
+}))
+jest.mock("../hooks/root-back-link", () => ({
+ useRootBackLink: () => "/tenant/tenant-1/event/event-1/election-chooser",
+}))
+jest.mock("../hooks/public-document-url", () => ({
+ useGetPublicDocumentUrl: () => ({getDocumentUrl: jest.fn()}),
+}))
+jest.mock("../components/Question/Question", () => ({
+ Question: ({question}: {question: IContest}) => {question.name}
,
+}))
+jest.mock("../components/Stepper", () => ({__esModule: true, default: () => null}))
+jest.mock("@apollo/client/react", () => ({
+ useMutation: () => [mockInsertCastVote],
+ useQuery: () => ({
+ data: mockElectionQueryData,
+ startPolling: jest.fn(),
+ stopPolling: jest.fn(),
+ }),
+}))
+
+const mockDispatch = jest.fn()
+const mockReauthWithGold = jest.fn()
+const mockInsertCastVote = jest.fn()
+let mockIsGoldUser = false
+let mockDisableAuth = true
+let mockElectionQueryData:
+ | {
+ sequent_backend_election: Array<{
+ id: string
+ presentation: IElection["presentation"]
+ status: {voting_status: string}
+ }>
+ }
+ | undefined
+let mockState: RootState
+const BALLOT_ID = "0123456789abcdef".repeat(4)
+const ELECTION_PATH = "/tenant/tenant-1/event/event-1/election/election-1"
+
+const setUpState = ({
+ auditButtonCfg,
+ backPolicy,
+ isFullyAcclaimed = false,
+ storedConfirmation = false,
+}: {
+ auditButtonCfg?: EVotingPortalAuditButtonCfg
+ backPolicy?: IVotingScreenBackPolicy
+ isFullyAcclaimed?: boolean
+ storedConfirmation?: boolean
+} = {}) => {
+ const ballotEml = structuredClone(ELECTION_WITH_INVALID)
+ ballotEml.election_id = "election-1"
+ ballotEml.election_presentation = {
+ audit_button_cfg: auditButtonCfg,
+ consolidated_report_policy: EConsolidatedReportPolicy.DO_NOT_GENERATE,
+ }
+ ballotEml.contests = ["First contest", "Second contest"].map((name, index) => ({
+ ...ballotEml.contests[0],
+ id: `contest-${index}`,
+ name,
+ is_acclaimed: isFullyAcclaimed,
+ presentation: {pagination_policy: `page-${index}`},
+ }))
+ mockState = {
+ ...store.getState(),
+ elections: storedConfirmation
+ ? {}
+ : {
+ "election-1": {
+ ...ballotEml,
+ id: "election-1",
+ image_document_id: "",
+ presentation: {
+ ...ballotEml.election_presentation,
+ voting_screen_back_policy: backPolicy,
+ },
+ } as IElection,
+ },
+ ballotStyles: storedConfirmation
+ ? {}
+ : {
+ "election-1": {
+ id: "election-1",
+ election_id: "election-1",
+ election_event_id: "event-1",
+ tenant_id: "tenant-1",
+ ballot_eml: ballotEml,
+ created_at: "",
+ last_updated_at: "",
+ },
+ },
+ ballotSelections: {"election-1": []},
+ auditableBallots: storedConfirmation
+ ? {}
+ : {
+ "election-1": {
+ auditableBallot: {
+ config: ballotEml,
+ ballot_hash: BALLOT_ID,
+ } as IAuditableBallot,
+ isBlankBallot: false,
+ },
+ },
+ confirmationScreenData: storedConfirmation
+ ? {
+ "election-1": {ballotId: BALLOT_ID, isDemo: true},
+ }
+ : {},
+ }
+}
+
+const renderRoute = (element: React.ReactElement, path: string) => {
+ const router = createMemoryRouter(
+ [
+ {
+ path: "/tenant/:tenantId/event/:eventId/election/:electionId/*",
+ element,
+ action: () => null,
+ },
+ {
+ path: "/tenant/:tenantId/event/:eventId/election-chooser",
+ element: Chooser
,
+ },
+ ],
+ {initialEntries: [`${ELECTION_PATH}/${path}?preview=true`]}
+ )
+ const view = render(
+
+
+
+ )
+ return {...view, router}
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ mockDispatch.mockReset()
+ mockInsertCastVote.mockResolvedValue({data: {insert_cast_vote: {id: "cast-vote-1"}}})
+ mockReauthWithGold.mockResolvedValue(undefined)
+ mockIsGoldUser = false
+ mockDisableAuth = true
+ mockElectionQueryData = undefined
+ sessionStorage.clear()
+ setUpState()
+})
+
+afterEach(() => sessionStorage.clear())
+
+describe("selection-screen Back", () => {
+ it.each(["{Enter}", " "])(
+ "returns to the previous contest with %s without leaving the ballot",
+ async (key) => {
+ const user = userEvent.setup()
+ const {router} = renderRoute(, "vote")
+ await user.click(screen.getByRole("button", {name: "votingScreen.reviewButton"}))
+ expect(screen.getByRole("heading", {name: "Second contest"})).toBeInTheDocument()
+ const back = screen.getByRole("button", {name: "votingScreen.backButton"})
+ expect(back.tagName).toBe("BUTTON")
+ await user.tab()
+ await user.tab()
+ expect(back).toHaveFocus()
+ await user.tab()
+ await user.tab({shift: true})
+ expect(back).toHaveFocus()
+ await user.keyboard(key)
+ expect(screen.getByRole("heading", {name: "First contest"})).toBeInTheDocument()
+ expect(router.state.location.pathname + router.state.location.search).toBe(
+ `${ELECTION_PATH}/vote?preview=true`
+ )
+ expect(mockDispatch).not.toHaveBeenCalledWith(clearIsVoted())
+ }
+ )
+
+ it.each([
+ ["start-screen", "{Enter}", `${ELECTION_PATH}/start`],
+ ["start-screen", " ", `${ELECTION_PATH}/start`],
+ ["election-selection-screen", "{Enter}", "/tenant/tenant-1/event/event-1/election-chooser"],
+ ["election-selection-screen", " ", "/tenant/tenant-1/event/event-1/election-chooser"],
+ [undefined, " ", "/tenant/tenant-1/event/event-1/election-chooser"],
+ ] as const)(
+ "uses back policy %s on the first contest with %s",
+ async (backPolicy, key, expectedPath) => {
+ setUpState({backPolicy})
+ const user = userEvent.setup()
+ const {router} = renderRoute(, "vote")
+ await user.tab()
+ await user.tab()
+ await user.tab()
+ expect(screen.getByRole("button", {name: "votingScreen.backButton"})).toHaveFocus()
+ await user.keyboard(key)
+ expect(router.state.location.pathname + router.state.location.search).toBe(
+ `${expectedPath}?preview=true`
+ )
+ expect(mockDispatch).toHaveBeenCalledWith(clearIsVoted())
+ }
+ )
+})
+
+describe("Ballot ID copy visibility", () => {
+ it.each([
+ [EVotingPortalAuditButtonCfg.SHOW, true],
+ [EVotingPortalAuditButtonCfg.SHOW_IN_HELP, true],
+ [EVotingPortalAuditButtonCfg.NOT_SHOW, false],
+ [undefined, true],
+ ] as const)(
+ "preserves copy visibility for %s across gold reauthentication",
+ async (auditButtonCfg, visible) => {
+ setUpState({auditButtonCfg})
+ const election = mockState.elections["election-1"]!
+ const presentation = {
+ ...election.presentation,
+ consolidated_report_policy: EConsolidatedReportPolicy.DO_NOT_GENERATE,
+ cast_vote_gold_level: ECastVoteGoldLevelPolicy.GOLD_LEVEL,
+ }
+ election.presentation = presentation
+ mockDisableAuth = false
+ const user = userEvent.setup()
+ const review = renderRoute(, "review")
+ expect(Boolean(screen.queryByRole("button", {name: "reviewScreen.copyBallotId"}))).toBe(
+ visible
+ )
+ await user.click(screen.getByRole("button", {name: "reviewScreen.castBallotButton"}))
+ await waitFor(() => expect(mockReauthWithGold).toHaveBeenCalledTimes(1))
+ expect(mockInsertCastVote).not.toHaveBeenCalled()
+ expect(JSON.parse(sessionStorage.getItem(BALLOT_DATA_KEY)!)).toMatchObject({
+ ballotId: BALLOT_ID,
+ isDemo: false,
+ auditButtonCfg: auditButtonCfg ?? EVotingPortalAuditButtonCfg.SHOW,
+ })
+ review.unmount()
+
+ mockState = store.getState()
+ mockIsGoldUser = true
+ mockElectionQueryData = {
+ sequent_backend_election: [
+ {
+ id: election.id,
+ presentation: {
+ ...presentation,
+ audit_button_cfg: EVotingPortalAuditButtonCfg.SHOW,
+ },
+ status: {voting_status: "open"},
+ },
+ ],
+ }
+ mockDispatch.mockImplementation((action) => {
+ if (setConfirmationScreenData.match(action)) {
+ mockState = {
+ ...mockState,
+ confirmationScreenData: confirmationScreenDataReducer(
+ mockState.confirmationScreenData,
+ action
+ ),
+ }
+ }
+ })
+ const authenticatedReview = renderRoute(, "review")
+ await waitFor(() =>
+ expect(mockDispatch).toHaveBeenCalledWith(
+ setConfirmationScreenData({
+ electionId: "election-1",
+ confirmationScreenData: {
+ ballotId: BALLOT_ID,
+ isDemo: false,
+ auditButtonCfg: auditButtonCfg ?? EVotingPortalAuditButtonCfg.SHOW,
+ },
+ })
+ )
+ )
+ expect(mockInsertCastVote).toHaveBeenCalledTimes(1)
+ expect(mockInsertCastVote).toHaveBeenCalledWith({
+ variables: {electionId: "election-1", ballotId: BALLOT_ID, content: "{}"},
+ })
+ expect(sessionStorage.getItem(BALLOT_DATA_KEY)).toBeNull()
+ expect(mockState.ballotStyles).toEqual({})
+ expect(mockState.elections).toEqual({})
+ authenticatedReview.unmount()
+
+ renderRoute(, "confirmation")
+ expect(screen.getByText(BALLOT_ID)).toBeInTheDocument()
+ expect(Boolean(screen.queryByRole("button", {name: "reviewScreen.copyBallotId"}))).toBe(
+ visible
+ )
+ }
+ )
+
+ it.each([
+ [EVotingPortalAuditButtonCfg.SHOW, true],
+ [EVotingPortalAuditButtonCfg.SHOW_IN_HELP, true],
+ [EVotingPortalAuditButtonCfg.NOT_SHOW, false],
+ [undefined, true],
+ ] as const)("matches Review on success for audit policy %s", (auditButtonCfg, visible) => {
+ setUpState({auditButtonCfg})
+ const review = renderRoute(, "review")
+ expect(Boolean(screen.queryByRole("button", {name: "reviewScreen.copyBallotId"}))).toBe(
+ visible
+ )
+ review.unmount()
+ renderRoute(, "confirmation")
+ expect(Boolean(screen.queryByRole("button", {name: "reviewScreen.copyBallotId"}))).toBe(
+ visible
+ )
+ expect(screen.getByText(BALLOT_ID)).toBeInTheDocument()
+ const help = within(screen.getByText(BALLOT_ID).parentElement!).getByRole("button", {
+ name: "a11y.helpAbout",
+ })
+ expect(getComputedStyle(help).marginLeft).toBe(visible ? "0px" : "16px")
+ })
+
+ it("shows no copy control on either screen for fully acclaimed elections", () => {
+ setUpState({isFullyAcclaimed: true})
+ const review = renderRoute(, "review")
+ expect(screen.queryByRole("button", {name: "reviewScreen.copyBallotId"})).toBeNull()
+ review.unmount()
+ renderRoute(, "confirmation")
+ expect(screen.queryByRole("button", {name: "reviewScreen.copyBallotId"})).toBeNull()
+ expect(screen.queryByText(BALLOT_ID)).toBeNull()
+ })
+
+ it.each([false, true])(
+ "copies the full success-screen ID, including stored confirmation: %s",
+ async (storedConfirmation) => {
+ setUpState({storedConfirmation})
+ const user = userEvent.setup()
+ const writeText = jest.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
+ renderRoute(, "confirmation")
+ await user.click(screen.getByRole("button", {name: "reviewScreen.copyBallotId"}))
+ expect(writeText).toHaveBeenCalledWith(BALLOT_ID)
+ expect(
+ screen.getByRole("button", {name: "reviewScreen.ballotIdCopied"})
+ ).toBeInTheDocument()
+ }
+ )
+})
diff --git a/packages/voting-portal/src/routes/VotingScreen.tsx b/packages/voting-portal/src/routes/VotingScreen.tsx
index 142d1c48803..9264b698d14 100644
--- a/packages/voting-portal/src/routes/VotingScreen.tsx
+++ b/packages/voting-portal/src/routes/VotingScreen.tsx
@@ -28,14 +28,7 @@ import Typography from "@mui/material/Typography"
import {faCircleQuestion, faAngleLeft, faAngleRight} from "@fortawesome/free-solid-svg-icons"
import {useTranslation} from "react-i18next"
import Button from "@mui/material/Button"
-import {
- Link as RouterLink,
- redirect,
- useLocation,
- useNavigate,
- useParams,
- useSubmit,
-} from "react-router-dom"
+import {redirect, useLocation, useNavigate, useParams, useSubmit} from "react-router-dom"
import {
selectBallotSelectionByElectionId,
resetBallotSelection,
@@ -107,6 +100,7 @@ const ActionButtons: React.FC = ({
const backLink = useRootBackLink()
const {tenantId, eventId, electionId} = useParams()
const location = useLocation()
+ const navigate = useNavigate()
const election = useAppSelector(selectElectionById(String(electionId)))
const ballotStyle = useAppSelector(selectBallotStyleByElectionId(String(electionId)))
const dispatch = useAppDispatch()
@@ -145,10 +139,13 @@ const ActionButtons: React.FC = ({
0 ? {search: location.search} : exitLink}
sx={{margin: "auto 0", width: {xs: "100%", sm: "200px"}}}
- onClick={() => handlePrev()}
+ onClick={() => {
+ handlePrev()
+ if (!pageIndex || pageIndex <= 0) {
+ navigate(exitLink)
+ }
+ }}
>
{t("votingScreen.backButton")}
diff --git a/packages/voting-portal/src/store/castVotes/confirmationScreenDataSlice.ts b/packages/voting-portal/src/store/castVotes/confirmationScreenDataSlice.ts
index ad420b04963..9fe296f5bc5 100644
--- a/packages/voting-portal/src/store/castVotes/confirmationScreenDataSlice.ts
+++ b/packages/voting-portal/src/store/castVotes/confirmationScreenDataSlice.ts
@@ -3,11 +3,12 @@
// SPDX-License-Identifier: AGPL-3.0-only
import {createSlice, PayloadAction} from "@reduxjs/toolkit"
import {RootState} from "../store"
-import {isUndefined} from "@sequentech/ui-core"
+import type {EVotingPortalAuditButtonCfg} from "@sequentech/ui-core"
export interface ConfirmationScreenData {
ballotId: string
isDemo: boolean
+ auditButtonCfg?: EVotingPortalAuditButtonCfg
}
export interface ConfirmationScreenDataState {
diff --git a/packages/voting-portal/src/store/castVotes/sessionBallotData.ts b/packages/voting-portal/src/store/castVotes/sessionBallotData.ts
index f65ec5ab71f..a09e719502a 100644
--- a/packages/voting-portal/src/store/castVotes/sessionBallotData.ts
+++ b/packages/voting-portal/src/store/castVotes/sessionBallotData.ts
@@ -2,12 +2,15 @@
//
// SPDX-License-Identifier: AGPL-3.0-only
+import type {EVotingPortalAuditButtonCfg} from "@sequentech/ui-core"
+
export interface SessionBallotData {
ballotId: string
electionId: string
isDemo: boolean
ballot: string
timestamp?: number
+ auditButtonCfg?: EVotingPortalAuditButtonCfg
}
export const BALLOT_DATA_KEY = "ballotData"