diff --git a/package.json b/package.json index d6355b07..c5d3d8ba 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,8 @@ "trailingComma": "all" }, "resolutions": { - "@types/react": "17.0.14", - "@types/react-dom": "17.0.14" + "@types/react": "18.2.6", + "@types/react-dom": "18.2.0" }, "dependencies": { "@ant-design/icons": "~4.7", @@ -44,7 +44,7 @@ "@supabase/supabase-js": "^1.21.3", "@types/bluebird": "^3.5.33", "@types/lodash-es": "^4.17.3", - "@types/react": "17", + "@types/react": "18.2.6", "bigint-mod-arith": "^3.0.2", "bluebird": "^3.7.2", "codemirror": "^5.62.2", @@ -63,9 +63,9 @@ "postinstall-postinstall": "^2.1.0", "pusher": "^4.0.2", "pusher-js": "^7.0.2", - "react": "^17", + "react": "^18.2.0", "react-codemirror2": "scniro/react-codemirror2#0f2bb13", - "react-dom": "^17", + "react-dom": "^18.2.0", "react-flip-move": "^3.0.4", "react-linkify": "^1.0.0-alpha", "react-scroll": "^1.8.3", diff --git a/pages/api/create-election.ts b/pages/api/create-election.ts index 435c51db..06da4214 100644 --- a/pages/api/create-election.ts +++ b/pages/api/create-election.ts @@ -20,6 +20,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { creator: jwt.email, election_manager: jwt.name, election_title, + num_invalidated_votes: 0, num_voters: 0, num_votes: 0, voter_applications_allowed: true, diff --git a/pages/api/election/[election_id]/accepted-votes.ts b/pages/api/election/[election_id]/accepted-votes.ts index 46535814..ce17ed49 100644 --- a/pages/api/election/[election_id]/accepted-votes.ts +++ b/pages/api/election/[election_id]/accepted-votes.ts @@ -5,7 +5,7 @@ import { firebase } from '../../_services' import { ReviewLog } from './admin/load-admin' export default async (req: NextApiRequest, res: NextApiResponse) => { - const { election_id } = req.query + const { election_id, limitToLast } = req.query const electionDoc = firebase .firestore() @@ -13,28 +13,35 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { .doc(election_id as string) // Begin preloading - const loadVotes = electionDoc.collection('votes').orderBy('created_at').get() - const loadVoters = electionDoc.collection('voters').get() + let votesQuery = electionDoc.collection('votes').orderBy('created_at') + if (typeof limitToLast === 'string') votesQuery = votesQuery.limitToLast(+limitToLast) + const loadVotes = votesQuery.get() + + const election = await electionDoc.get() // Is election_id in DB? - if (!(await electionDoc.get()).exists) return res.status(400).json({ error: 'Unknown Election ID.' }) + if (!election.exists) return res.status(400).json({ error: 'Unknown Election ID.' }) - type VotersByAuth = Record - const votersByAuth: VotersByAuth = (await loadVoters).docs.reduce((acc: VotersByAuth, doc) => { - const data = doc.data() - return { ...acc, [data.auth_token]: data } - }, {}) - - // Grab public votes fields - const votes = (await loadVotes).docs.map((doc) => { + let votes = (await loadVotes).docs.map((doc) => { const { auth, encrypted_vote } = doc.data() - const voter = votersByAuth[auth] - return { - auth, - ...encrypted_vote, - signature_approved: getStatus(voter?.esignature_review) === 'approve', - } + return { auth, ...encrypted_vote } }) - res.status(200).json(votes) + // If we need esignatures, we need to load all voters as well to get their esignature status + if (election.data()?.esignature_requested) { + type VotersByAuth = Record + const voters = await electionDoc.collection('voters').get() + const votersByAuth: VotersByAuth = voters.docs.reduce((acc: VotersByAuth, doc) => { + const data = doc.data() + return { ...acc, [data.auth_token]: data } + }, {}) + + // Add signature status + votes = votes.map((vote) => { + const voter = votersByAuth[vote.auth] + return { ...vote, signature_approved: getStatus(voter?.esignature_review) === 'approve' } + }) + } + + return res.status(200).json(votes) } diff --git a/pages/api/election/[election_id]/admin/invalidate-voters.ts b/pages/api/election/[election_id]/admin/invalidate-voters.ts index d286edd7..e614a8cd 100644 --- a/pages/api/election/[election_id]/admin/invalidate-voters.ts +++ b/pages/api/election/[election_id]/admin/invalidate-voters.ts @@ -1,29 +1,61 @@ +import { firebase, sendEmail } from 'api/_services' +import { firestore } from 'firebase-admin' import { NextApiRequest, NextApiResponse } from 'next' -import { firebase } from '../../../_services' import { checkJwtOwnsElection } from '../../../validate-admin-jwt' - -export type QueueLog = { result: string; time: Date } +import { Voter } from './load-admin' export default async (req: NextApiRequest, res: NextApiResponse) => { const { election_id } = req.query as { election_id: string } - const { voters } = req.body + const { voters_to_invalidate } = req.body as { voters_to_invalidate: Voter[] } // Confirm they're a valid admin that created this election const jwt = await checkJwtOwnsElection(req, res, election_id) if (!jwt.valid) return await Promise.all( - voters.map((voter: string) => - firebase - .firestore() - .collection('elections') - .doc(election_id) - .collection('voters') - .doc(voter) - .update({ invalidated_at: new Date() }), - ), + voters_to_invalidate.map(async (voter) => { + const db = firebase.firestore() + const electionDoc = db.collection('elections').doc(election_id) + const voterRef = electionDoc.collection('voters').doc(voter.email) + const votes = await electionDoc.collection('votes').where('auth', '==', voter.auth_token).get() + + // Do all in parallel + return Promise.all([ + // 1. Mark the auth token as invalidated + voterRef.update({ invalidated_at: new Date() }), + + // 2. If votes were cast with this auth, move them to an 'invalidated-votes' collection + (async function invalidateVotes() { + await Promise.all( + votes.docs.map(async (vote) => { + const invalidatedVote = { ...vote.data(), invalidated_at: new Date() } + await electionDoc.collection('invalidated_votes').doc(vote.id).set(invalidatedVote) + await vote.ref.delete() + await electionDoc.update({ num_invalidated_votes: firestore.FieldValue.increment(1) }) + }), + ) + })(), + + // 3. Notify the voter over email + (function notifyVoter() { + // Skip if they have not voted + if (!votes.docs.length) return + + // TODO: Skip if voter's email address is unverified, BLOCKED by PR #125 Registration link + // if (voter.status == 'pending') return + + return sendEmail({ + recipient: voter.email, + subject: 'Your vote has been invalidated', + text: `The election administrator ${jwt.election_manager} invalidated your submitted vote in the election "${jwt.election_title}". + + If you believe this was an error, you can press reply to write to the Election Administrator.`, + }) + })(), + ]) + }), ) - await res.status(201).json({ message: 'Done' }) + return res.status(201).json({ message: 'Done' }) } diff --git a/pages/api/election/[election_id]/admin/invite-voters.ts b/pages/api/election/[election_id]/admin/invite-voters.ts index 0ae72476..33f00278 100644 --- a/pages/api/election/[election_id]/admin/invite-voters.ts +++ b/pages/api/election/[election_id]/admin/invite-voters.ts @@ -54,7 +54,8 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { }), ), ).catch((error) => { - throw res.status(400).json({ error }) + console.log('πŸŸ₯ Error sending voter invitations:', error) + throw res.status(400).json({ error: error.message || JSON.stringify(error) }) }) res.status(201).json({ message: 'Done' }) diff --git a/pages/api/election/[election_id]/admin/load-admin.ts b/pages/api/election/[election_id]/admin/load-admin.ts index b7ef8d3f..767b44a9 100644 --- a/pages/api/election/[election_id]/admin/load-admin.ts +++ b/pages/api/election/[election_id]/admin/load-admin.ts @@ -65,6 +65,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { const loadTrustees = election.collection('trustees').orderBy('index', 'asc').get() const loadVoters = election.collection('voters').orderBy('index', 'asc').get() const loadVotes = election.collection('votes').get() + const loadInvalidatedVotes = election.collection('invalidated_votes').get() // Is election_id in DB? const electionDoc = await loadElection @@ -128,6 +129,13 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { return { ...acc, [data.auth]: [true, data.esignature] } }, {}) + // Gather whose votes were invalidated + const invalidatedVotesByAuth: Record = {} + ;(await loadInvalidatedVotes).docs.forEach((doc) => { + const data = doc.data() + invalidatedVotesByAuth[data.auth] = true + }) + // Build voters objects const voters: Voter[] = (await loadVoters).docs.reduce((acc: Voter[], doc) => { const { @@ -163,7 +171,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { esignature: (votesByAuth[auth_token] || [])[1], esignature_review, first_name, - has_voted: !!votesByAuth[auth_token], + has_voted: !!votesByAuth[auth_token] || !!invalidatedVotesByAuth[auth_token], index, invalidated: invalidated_at ? true : undefined, invite_queued, diff --git a/pages/api/election/[election_id]/invalidated-votes.ts b/pages/api/election/[election_id]/invalidated-votes.ts new file mode 100644 index 00000000..f2ed69db --- /dev/null +++ b/pages/api/election/[election_id]/invalidated-votes.ts @@ -0,0 +1,28 @@ +import { firebase } from 'api/_services' +import { NextApiRequest, NextApiResponse } from 'next' + +export default async (req: NextApiRequest, res: NextApiResponse) => { + const { election_id } = req.query + + const electionDoc = firebase + .firestore() + .collection('elections') + .doc(election_id as string) + + // Begin preloading + const loadInvalidatedVotes = electionDoc.collection('invalidated_votes').get() + + // Is election_id in DB? + if (!(await electionDoc.get()).exists) return res.status(400).json({ error: 'Unknown Election ID.' }) + + // Grab public votes fields including encrypted_vote + const votes = (await loadInvalidatedVotes).docs.map((doc) => { + const { auth, encrypted_vote } = doc.data() + return { + auth, + encrypted_vote, + } + }) + + res.status(200).json(votes) +} diff --git a/pages/api/election/[election_id]/num-accepted-votes.ts b/pages/api/election/[election_id]/num-accepted-votes.ts new file mode 100644 index 00000000..cb486373 --- /dev/null +++ b/pages/api/election/[election_id]/num-accepted-votes.ts @@ -0,0 +1,23 @@ +import { NextApiRequest, NextApiResponse } from 'next' + +import { firebase } from '../../_services' + +export default async (req: NextApiRequest, res: NextApiResponse) => { + const { election_id } = req.query + + const electionDoc = await firebase + .firestore() + .collection('elections') + .doc(election_id as string) + .get() + + // Is election_id in DB? + if (!electionDoc.exists) return res.status(400).json({ error: 'Unknown Election ID.' }) + + const { num_invalidated_votes = 0, num_votes } = { ...electionDoc.data() } as { + num_invalidated_votes?: number + num_votes: number + } + + return res.status(200).json(num_votes - num_invalidated_votes) +} diff --git a/pages/api/election/[election_id]/submit-invalidation-response.ts b/pages/api/election/[election_id]/submit-invalidation-response.ts new file mode 100644 index 00000000..ee77d78b --- /dev/null +++ b/pages/api/election/[election_id]/submit-invalidation-response.ts @@ -0,0 +1,54 @@ +import { firebase, sendEmail } from 'api/_services' +import { firestore } from 'firebase-admin' +import { NextApiRequest, NextApiResponse } from 'next' + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { auth, message } = req.body + const { election_id } = req.query + + const election = firebase + .firestore() + .collection('elections') + .doc(election_id as string) + + // Begin preloading + const electionDoc = election.get() + const loadVoters = election.collection('voters').where('auth_token', '==', auth).get() + + // Store in database on the invalidated_vote doc + const votes = await election.collection('invalidated_votes').where('auth', '==', auth).get() + await Promise.all( + votes.docs.map((vote) => + vote.ref.update({ responses: firestore.FieldValue.arrayUnion({ message, timestamp: new Date() }) }), + ), + ) + + // Send admin email + const voter = (await loadVoters).docs[0].data() + const electionData = (await electionDoc).data() + + sendEmail({ + bcc: 'admin@siv.org', + recipient: electionData?.creator, + subject: 'Invalidated Vote: Voter Response', + text: `You have received a message from a voter whose vote you invalidated. + + Election Title: ${electionData?.election_title} + Election ID: ${election_id} + + Voter details: + - Auth token: ${voter.auth_token} + - Email: ${voter.email} + - First Name: ${voter.first_name || 'Not provided'} + - Last Name: ${voter.last_name || 'Not provided'} + + Their message below: + +
+ + ${message} + `, + }) + + res.status(200).json({ message: 'Message received' }) +} diff --git a/pages/api/election/[election_id]/was-vote-invalidated.ts b/pages/api/election/[election_id]/was-vote-invalidated.ts new file mode 100644 index 00000000..e903d5c1 --- /dev/null +++ b/pages/api/election/[election_id]/was-vote-invalidated.ts @@ -0,0 +1,18 @@ +// given auth and election id, tell us if the vote was invalidated or not + +import { firebase } from 'api/_services' +import { NextApiRequest, NextApiResponse } from 'next' + +export default async function (req: NextApiRequest, res: NextApiResponse) { + const { auth, election_id } = req.query + + const voters = await firebase + .firestore() + .collection('elections') + .doc(election_id as string) + .collection('voters') + .where('auth_token', '==', auth) + .get() + + return res.status(200).json(!!voters.docs[0].data().invalidated_at) +} diff --git a/pages/api/invalidate-voter.ts b/pages/api/invalidate-voter.ts deleted file mode 100644 index 8ace2a49..00000000 --- a/pages/api/invalidate-voter.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next' - -import { firebase, pushover } from './_services' - -const { ADMIN_PASSWORD } = process.env - -// *** Script parameters *** -const election_id = '' -const voter_to_invalidate = '' -// ************************* - -export default async (req: NextApiRequest, res: NextApiResponse) => { - const { password } = req.query - - // Check for required params - if (password !== ADMIN_PASSWORD) return res.status(401).json({ error: 'Invalid Password.' }) - if (!election_id) return res.status(401).json({ error: 'Missing election_id' }) - - // This will hold all our async tasks - const promises: Promise[] = [] - - const electionDoc = firebase.firestore().collection('elections').doc(election_id) - - const loadVotes = electionDoc.collection('votes').get() - const loadVoter = electionDoc.collection('voters').doc(voter_to_invalidate).get() - const election = { ...(await electionDoc.get()).data() } - - const { election_title } = election - - const existing_voter = { ...(await loadVoter).data() } - if (!existing_voter) return res.status(404).json({ error: `Can't find voter ${voter_to_invalidate}` }) - - // Has the voter already voted? - const vote = (await loadVotes).docs - .map((doc) => ({ ...(doc.data() as { auth: string }), id: doc.id })) - .find(({ auth }) => auth === existing_voter.auth) - - if (!vote) console.log({ existing_voter }) - - electionDoc.collection('voters').doc(voter_to_invalidate) - - // TODO finish me.... - // Stick existing vote if they have on on voter object, then stick voter object into invalidated collection - // Remove vote - - // 5. Send Admin push notification - promises.push(pushover(`Invalidated voter`, voter_to_invalidate)) - - await Promise.all(promises) - - return res.status(201).json({ message: `Invalidated voter ${voter_to_invalidate} from ${election_title}` }) -} diff --git a/pages/api/invite-voters.ts b/pages/api/invite-voters.ts index 325c42a2..fbc1e3cc 100644 --- a/pages/api/invite-voters.ts +++ b/pages/api/invite-voters.ts @@ -20,8 +20,9 @@ export const send_invitation_email = ({ voter: string }) => { // Don't send localhost emails to non-admins - if (link.includes('localhost') && !voter.endsWith('@dsernst.com')) + if (link.includes('localhost') && !voter.endsWith('@dsernst.com') && !voter.endsWith('@arianaivan.com')) { throw `Blocking sending 'localhost' email link to ${voter}` + } // Make sure auth_token is well formed if (!/auth=(\d|[a-f]){10}$/.test(link)) throw `Blocking sending malformed auth invite ${link} to ${voter}` diff --git a/pages/demo-wla.tsx b/pages/demo-wla.tsx index fdab2b53..d9f4a09b 100644 --- a/pages/demo-wla.tsx +++ b/pages/demo-wla.tsx @@ -1 +1,4 @@ +// This page was an unguessable link created to share the SIV demo video. On 10/10/2023, it was duplicated to siv.org/demo, to make it easier to find. +// This /demo-wla link is kept so old URLs in emails don't break, but should be considered deprecated. Consider deleting in 2+ years, after 10/10/2025. + export { DemoPage as default } from '../src/demo/DemoPage' diff --git a/pages/demo.tsx b/pages/demo.tsx new file mode 100644 index 00000000..fdab2b53 --- /dev/null +++ b/pages/demo.tsx @@ -0,0 +1 @@ +export { DemoPage as default } from '../src/demo/DemoPage' diff --git a/src/admin/AdminPage.tsx b/src/admin/AdminPage.tsx index fe405d25..fa690e00 100644 --- a/src/admin/AdminPage.tsx +++ b/src/admin/AdminPage.tsx @@ -5,11 +5,11 @@ import { Head } from '../Head' import { AllYourElections } from './AllYourElections' import { useLoginRequired, useUser } from './auth' import { BallotDesign } from './BallotDesign/BallotDesign' +import { CreateNewElection } from './CreateNewElection' import { HeaderBar } from './HeaderBar' import { MarkedBallots } from './MarkedBallots/MarkedBallots' import { MobileMenu } from './MobileMenu' import { Observers } from './Observers/Observers' -import { ElectionOverview } from './Overview/ElectionOverview' import { Sidebar } from './Sidebar' import { usePusher } from './usePusher' import { AddVoters } from './Voters/AddVoters' @@ -31,8 +31,8 @@ export const AdminPage = (): JSX.Element => {
- - {(section === 'overview' || !election_id) && } + {!election_id && } + {!election_id && } {section === 'observers' && } {section === 'ballot-design' && } {section === 'voters' && } @@ -57,7 +57,7 @@ export const AdminPage = (): JSX.Element => { } /* When sidebar disappears */ - @media (max-width: 500px) { + @media (max-width: 640px) { #main-content { left: 0; diff --git a/src/admin/AllYourElections.tsx b/src/admin/AllYourElections.tsx index 4a000e0c..61f6322d 100644 --- a/src/admin/AllYourElections.tsx +++ b/src/admin/AllYourElections.tsx @@ -1,5 +1,4 @@ import Link from 'next/link' -import { useRouter } from 'next/router' import { useReducer } from 'react' import useSWR from 'swr' import TimeAgo from 'timeago-react' @@ -16,12 +15,10 @@ export const AllYourElections = () => { }), ) - if (useRouter().query.election_id) return <> - return ( <>

- Your Existing Elections: {data?.elections?.length}{' '} + Your Elections: {data?.elections?.length}{' '} {!!data?.elections?.length && ( [ {show ? '- Hide' : '+ Show'} ] diff --git a/src/admin/BallotDesign/BallotDesign.tsx b/src/admin/BallotDesign/BallotDesign.tsx index 9a3c6191..315369a2 100644 --- a/src/admin/BallotDesign/BallotDesign.tsx +++ b/src/admin/BallotDesign/BallotDesign.tsx @@ -14,7 +14,7 @@ import { TextDesigner } from './TextDesigner' import { Wizard } from './Wizard' export const BallotDesign = () => { - const { ballot_design: stored_ballot_design, ballot_design_finalized, election_id } = useStored() + const { ballot_design: stored_ballot_design, ballot_design_finalized, election_id, election_title } = useStored() const [selected, setSelected] = useState(0) const designState = useState(stored_ballot_design || default_ballot_design) @@ -30,9 +30,16 @@ export const BallotDesign = () => { set_saving_errors(null) }, [design]) + // Restore stored ballot design on hard refresh + useEffect(() => { + if (!election_title) return + if (!stored_ballot_design) return + setDesign(stored_ballot_design) + }, [election_title]) + return ( <> -

Ballot Design

+

Ballot Design

@@ -64,14 +71,6 @@ export const BallotDesign = () => { )}

) } diff --git a/src/admin/BallotDesign/check_for_ballot_errors.ts b/src/admin/BallotDesign/check_for_ballot_errors.ts index 15caeb04..e6c81f3a 100644 --- a/src/admin/BallotDesign/check_for_ballot_errors.ts +++ b/src/admin/BallotDesign/check_for_ballot_errors.ts @@ -57,14 +57,14 @@ export function check_for_less_urgent_ballot_errors(design: string): string | nu ids[id] = true // Validate options - const options: Record = {} + const optionsSeen: Record = {} question.options.forEach(({ name = '' }: { name?: string }, oIndex: number) => { if (name === '') throw `Can't have empty options. Fix \`${id}\` option #${oIndex + 1}` // Check no duplicate options (case insensitive) - if (options[name.toLowerCase()]) + if (optionsSeen[name.toLowerCase()]) throw `Question ${question.id ? `'${question.id}'` : ''} has duplicate option: ${name}` - options[name.toLowerCase()] = true + optionsSeen[name.toLowerCase()] = true }) }) } catch (e) { diff --git a/src/admin/BallotDesign/default-ballot-design.ts b/src/admin/BallotDesign/default-ballot-design.ts index c74ab14f..b22e9fb1 100644 --- a/src/admin/BallotDesign/default-ballot-design.ts +++ b/src/admin/BallotDesign/default-ballot-design.ts @@ -1,6 +1,7 @@ export const default_ballot_design = `[ { "id": "president", + "type": "choose-only-one", "title": "Who should become President?", "options": [ { diff --git a/src/admin/Overview/TitleInput.tsx b/src/admin/CreateNewElection.tsx similarity index 86% rename from src/admin/Overview/TitleInput.tsx rename to src/admin/CreateNewElection.tsx index 13c7d61c..5c5967d8 100644 --- a/src/admin/Overview/TitleInput.tsx +++ b/src/admin/CreateNewElection.tsx @@ -1,14 +1,17 @@ import router from 'next/router' import { useState } from 'react' -import { api } from '../../api-helper' -import { SaveButton } from '../SaveButton' +import { api } from '../api-helper' +import { SaveButton } from './SaveButton' -export const TitleInput = () => { +export const CreateNewElection = () => { const [election_title, set_title] = useState('') return ( <> +

Create New Election

+ + { const { user } = useUser() const { election_id, election_title } = useStored() + + const headerId = useDynamicHeaderbarHeight(election_title) + return ( -
-
- - SIV +
+ {/* Logo */} +
+ + { + const el = document.getElementById('main-content') + if (el) el.scrollTop = 0 + }} + > + SIV +
-
-
+
+
{election_id && ( <> SIV: Manage {election_title} - - { - const el = document.getElementById('main-content') - if (el) el.scrollTop = 0 - }} - > - ← - - -
- Managing: {election_title} ID: {election_id} + + {/* Election title */} +
+
{election_title}
+
ID: {election_id}
)}
-
+ {/* Login status */} +
  {user.name}
-
) } diff --git a/src/admin/MobileMenu.tsx b/src/admin/MobileMenu.tsx index fdb18ee2..b78e213b 100644 --- a/src/admin/MobileMenu.tsx +++ b/src/admin/MobileMenu.tsx @@ -15,10 +15,16 @@ export const MobileMenu = () => { const iOS = process?.browser && /iPad|iPhone|iPod/.test(navigator.userAgent) return ( -
- set_menu(true)}> - {section && steps.includes(name) ? `Step ${steps.indexOf(name) + 1}: ${name}` : 'Menu'} - + /* Hidden for all but small screens */ +
+ {/* Activation button */} + {section && ( + set_menu(true)}> + {section && steps.includes(name) ? `Step ${steps.indexOf(name) + 1}: ${name}` : 'Menu'} + + )} + + {/* Sliding in Sidebar */} { > set_menu(false)} /> -
) } diff --git a/src/admin/Observers/Observers.tsx b/src/admin/Observers/Observers.tsx index a5a1d147..2a1b7978 100644 --- a/src/admin/Observers/Observers.tsx +++ b/src/admin/Observers/Observers.tsx @@ -20,12 +20,12 @@ export const Observers = () => { useLatestMailgunEvents(election_id, trustees, election_manager) return ( -
-

+
+

Verifying Observers (Optional)

- This lets you give indepedent Verifying Observers complete cryptographic proof that votes are private & tallied + This lets you give independent Verifying Observers complete cryptographic proof that votes are private & tallied correctly.

@@ -219,13 +219,6 @@ export const Observers = () => { } `} - - ) -} diff --git a/src/admin/Overview/StoredManager.tsx b/src/admin/Overview/StoredManager.tsx deleted file mode 100644 index 3fb43643..00000000 --- a/src/admin/Overview/StoredManager.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useStored } from '../useStored' - -export const StoredManager = () => { - const { election_manager } = useStored() - - return ( -

- {election_manager || Not set} - -
- ) -} diff --git a/src/admin/Overview/StoredTitle.tsx b/src/admin/Overview/StoredTitle.tsx deleted file mode 100644 index 1b959118..00000000 --- a/src/admin/Overview/StoredTitle.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useStored } from '../useStored' - -export const StoredTitle = () => { - const { election_title } = useStored() - - return ( -
- {election_title || 'Error loading election'} - -
- ) -} diff --git a/src/admin/Sidebar.tsx b/src/admin/Sidebar.tsx index 0541c5b1..f56c3c8d 100644 --- a/src/admin/Sidebar.tsx +++ b/src/admin/Sidebar.tsx @@ -5,20 +5,8 @@ import { useRouter } from 'next/router' import { useStored } from './useStored' export const Sidebar = () => ( -
+
-
) @@ -38,9 +26,22 @@ export const SidebarContent = ({ closeMenu = () => {} }: { closeMenu?: () => voi return (
-

SIV

+ + { + closeMenu() + const el = document.getElementById('main-content') + if (el) el.scrollTop = 0 + }} + > +

SIV

+
+ + {election_id && ( <> + {/* Election Management section */} <>

- + Docs diff --git a/src/proofs/EncodedsTable.tsx b/src/proofs/EncodedsTable.tsx index bcf4b716..352019ba 100644 --- a/src/proofs/EncodedsTable.tsx +++ b/src/proofs/EncodedsTable.tsx @@ -37,7 +37,7 @@ export const EncodedsTable = (): JSX.Element => { {index + 1}. {columns.map((c) => ( - {stringToPoint(`${vote.tracking}:${vote[c]}`)} + {stringToPoint(`${vote.tracking}:${vote[c]}`).toString()} ))} ))} diff --git a/src/status/AcceptedVotes.tsx b/src/status/AcceptedVotes.tsx index 6ce063c0..fe214263 100644 --- a/src/status/AcceptedVotes.tsx +++ b/src/status/AcceptedVotes.tsx @@ -1,11 +1,13 @@ import { flatten } from 'lodash-es' import { useRouter } from 'next/router' -import { Fragment, useEffect } from 'react' +import { Fragment, useEffect, useState } from 'react' import { CipherStrings } from 'src/crypto/stringify-shuffle' -import { pusher } from 'src/pusher-helper' -import useSWR from 'swr' +import { EncryptedVote } from 'src/protocol/EncryptedVote' +import { defaultRankingsAllowed } from 'src/vote/Ballot' import { Item } from '../vote/storeElectionInfo' +import { TotalVotesCast } from './TotalVotesCast' +import { useSWRExponentialBackoff } from './useSWRExponentialBackoff' export type EncryptedVote = { auth: string } & { [index: string]: CipherStrings } const fetcher = (url: string) => fetch(url).then((r) => r.json()) @@ -22,145 +24,150 @@ export const AcceptedVotes = ({ title_prefix?: string }): JSX.Element => { const { election_id } = useRouter().query + const [votes, setVotes] = useState() - const { data: votes, mutate } = useSWR( - !election_id ? null : `/api/election/${election_id}/accepted-votes`, + // Exponentially poll for num votes (just a single read) + const { data: numVotes } = useSWRExponentialBackoff( + !election_id ? null : `/api/election/${election_id}/num-accepted-votes`, fetcher, - ) + 1, + ) as { data: number } - // Subscribe to pusher updates of new votes - subscribeToUpdates(mutate, election_id) + // Load all the encrypted votes (heavy, so only on first load) + useEffect(() => { + if (!election_id) return + fetch(`/api/election/${election_id}/accepted-votes`) + .then((r) => r.json()) + .then(setVotes) + }, [election_id]) if (!votes || !ballot_design) return
Loading...
+ const newVotes = numVotes - votes.length + const columns = flatten( - ballot_design.map(({ id, multiple_votes_allowed }) => { - return multiple_votes_allowed - ? new Array(multiple_votes_allowed).fill('').map((_, index) => `${id || 'vote'}_${index + 1}`) - : id || 'vote' - }), + ballot_design?.map(({ id, multiple_votes_allowed, type }) => + multiple_votes_allowed || type === 'ranked-choice-irv' + ? new Array(multiple_votes_allowed || defaultRankingsAllowed) + .fill('') + .map((_, index) => `${id || 'vote'}_${index + 1}`) + : id || 'vote', + ), ) return ( -
-

{title_prefix}All Submitted Votes

-

- Ordered oldest to newest.{' '} - {has_decrypted_votes ? ( - <> - These are the encrypted votes submitted by each authenticated voter. -
- For more, see{' '} - - SIV Protocol Step 3: Submit Encrypted Vote - - . - - ) : ( - `When the election closes, ${esignature_requested ? 'all approved' : 'these'} votes will + <> + +

+

{title_prefix}All Submitted Votes

+

+ Ordered oldest to newest.{' '} + {has_decrypted_votes ? ( + <> + These are the encrypted votes submitted by each authenticated voter. +
+ For more, see{' '} + + SIV Protocol Step 3: Submit Encrypted Vote + + . + + ) : ( + `When the election closes, ${esignature_requested ? 'all approved' : 'these'} votes will be shuffled and then unlocked.` - )} -

- - - - - {esignature_requested && } - - {columns.map((c) => ( - - ))} - - - - {columns.map((c) => ( - - - - - ))} - - - - - {votes.map((vote, index) => ( - - - {esignature_requested && } - - {columns.map((key) => { - if (key !== 'auth') { - return ( - - - - - ) - } - })} + )} +

+
signature approvedauth - {c} -
encryptedlock
{index + 1}.{vote.signature_approved ? 'βœ“' : ''}{vote.auth}{vote[key]?.encrypted}{vote[key]?.lock}
+ + + + {esignature_requested && } + + {columns.map((c) => ( + + ))} - ))} - -
signature approvedauth + {c} +
- -
+ th, + .subheading td { + font-size: 11px; + font-weight: 700; + } + `} +
+ ) } @@ -170,28 +177,3 @@ export const stringifyEncryptedVote = (vote: EncryptedVote) => key === 'auth' ? '' : `, ${key}: { encrypted: '${vote[key].encrypted}', lock: '${vote[key].lock}' }`, ) .join('')} }` - -function subscribeToUpdates(loadVotes: () => void, election_id?: string | string[]) { - function subscribe() { - if (!pusher) return alert('Pusher not initialized') - - const channel = pusher.subscribe(`status-${election_id}`) - - channel.bind(`votes`, () => { - console.log('πŸ†• Pusher new vote submitted') - loadVotes() - }) - - // Return cleanup code - return () => { - channel.unbind() - } - } - - // Subscribe when we get election_id - useEffect(() => { - if (election_id) { - return subscribe() - } - }, [election_id]) -} diff --git a/src/status/DecryptedVotes.tsx b/src/status/DecryptedVotes.tsx index 2c334be1..1333770d 100644 --- a/src/status/DecryptedVotes.tsx +++ b/src/status/DecryptedVotes.tsx @@ -1,6 +1,8 @@ import { orderBy } from 'lodash-es' import { flatten } from 'lodash-es' +import { defaultRankingsAllowed } from 'src/vote/Ballot' +import { unTruncateSelection } from './un-truncate-selection' import { useDecryptedVotes } from './use-decrypted-votes' import { useElectionInfo } from './use-election-info' @@ -13,11 +15,13 @@ export const DecryptedVotes = ({ proofsPage }: { proofsPage?: boolean }): JSX.El const sorted_votes = orderBy(votes, 'tracking') const columns = flatten( - ballot_design.map(({ id, multiple_votes_allowed }) => { - return multiple_votes_allowed - ? new Array(multiple_votes_allowed).fill('').map((_, index) => `${id || 'vote'}_${index + 1}`) - : id || 'vote' - }), + ballot_design?.map(({ id, multiple_votes_allowed, type }) => + multiple_votes_allowed || type === 'ranked-choice-irv' + ? new Array(multiple_votes_allowed || defaultRankingsAllowed) + .fill('') + .map((_, index) => `${id || 'vote'}_${index + 1}`) + : id || 'vote', + ), ) return ( @@ -43,7 +47,7 @@ export const DecryptedVotes = ({ proofsPage }: { proofsPage?: boolean }): JSX.El {index + 1}. {vote.tracking?.padStart(14, '0')} {columns.map((c) => ( - {vote[c]} + {unTruncateSelection(vote[c], ballot_design, c)} ))} ))} diff --git a/src/status/ElectionStatusPage.tsx b/src/status/ElectionStatusPage.tsx index 2307919d..5b41585b 100644 --- a/src/status/ElectionStatusPage.tsx +++ b/src/status/ElectionStatusPage.tsx @@ -6,6 +6,7 @@ import { Head } from '../Head' import { Footer } from '../vote/Footer' import { AcceptedVotes } from './AcceptedVotes' import { DecryptedVotes } from './DecryptedVotes' +import { InvalidatedVotes } from './InvalidatedVotes' import { Mixnet, debug } from './Mixnet/Mixnet' import { OnlyMixnet } from './OnlyMixnet' import { Totals } from './Totals' @@ -49,10 +50,14 @@ export const ElectionStatusPage = (): JSX.Element => { {show_encrypteds ? '[-] Hide' : '[+] Show'} Encrypted Submissions

)} -
- {show_encrypteds && has_decrypted_votes && } - -
+ + {(show_encrypteds || has_decrypted_votes === false) && ( +
+ {show_encrypteds && has_decrypted_votes && } + + +
+ )}
diff --git a/src/status/InvalidatedVotes.tsx b/src/status/InvalidatedVotes.tsx new file mode 100644 index 00000000..284958e9 --- /dev/null +++ b/src/status/InvalidatedVotes.tsx @@ -0,0 +1,47 @@ +import { useRouter } from 'next/router' +import { useState } from 'react' +import useSWR from 'swr' + +import { EncryptedVote } from './AcceptedVotes' + +const fetcher = (url: string) => fetch(url).then((r) => r.json()) + +export const InvalidatedVotes = () => { + const { election_id } = useRouter().query + const [isTableVisible, setTableVisibility] = useState(false) + + const { data: votes } = useSWR( + !election_id ? null : `/api/election/${election_id}/invalidated-votes`, + fetcher, + ) + + if (!votes || !votes.length) return null + + return ( +
+

Invalidated Votes

+

+ {votes.length} vote{votes.length > 1 ? 's were' : ' was'} invalidated by the election administrator.{' '} + setTableVisibility(!isTableVisible)}> + {isTableVisible ? 'Hide' : 'Show'} + +

+ + {isTableVisible && ( + <> +
Auth Tokens:
+ + + {votes.map((vote, index) => ( + + + + + ))} + +
{index + 1}.{vote.auth}
+ + )} +
+ ) +} diff --git a/src/status/TotalVotesCast.tsx b/src/status/TotalVotesCast.tsx new file mode 100644 index 00000000..dba6cf7c --- /dev/null +++ b/src/status/TotalVotesCast.tsx @@ -0,0 +1,7 @@ +export const TotalVotesCast = ({ numVotes }: { numVotes: number }) => { + return ( +
+ Total votes cast: {numVotes} +
+ ) +} diff --git a/src/status/Totals.tsx b/src/status/Totals.tsx index 6e6227ec..92b6d05a 100644 --- a/src/status/Totals.tsx +++ b/src/status/Totals.tsx @@ -2,6 +2,7 @@ import { keyBy } from 'lodash-es' import TimeAgo from 'timeago-react' import { tallyVotes } from './tally-votes' +import { unTruncateSelection } from './un-truncate-selection' import { useDecryptedVotes } from './use-decrypted-votes' import { useElectionInfo } from './use-election-info' @@ -25,13 +26,27 @@ export const Totals = ({ proofsPage }: { proofsPage?: boolean }): JSX.Element => )}
- {ballot_design.map(({ id = 'vote', title }) => ( + {ballot_design.map(({ id = 'vote', title, type }) => (

{title}

+ + {type === 'ranked-choice-irv' && ( +
+ This was a Ranked Choice question. SIV does not yet support Ranked Choice tallying. The numbers below + represent who received any votes, ignoring rankings. +

+ You can paste the Decrypted Votes table into a tallying program such as{' '} + + rcvis.com + +

+
+ )} +
    {ordered[id].map((selection) => (
  • - {selection}: {tallies[id][selection]}{' '} + {unTruncateSelection(selection, ballot_design, id)}: {tallies[id][selection]}{' '} ({((100 * tallies[id][selection]) / totalsCastPerItems[id]).toFixed(1)}%) diff --git a/src/status/un-truncate-selection.ts b/src/status/un-truncate-selection.ts new file mode 100644 index 00000000..f7d598e6 --- /dev/null +++ b/src/status/un-truncate-selection.ts @@ -0,0 +1,26 @@ +import { Item } from 'src/vote/storeElectionInfo' + +import { max_string_length } from '../vote/Ballot' + +/** +Un-truncate-logic: +We want a function that takes a decrypted vote selection, + +checks if it is the max number of allowed characters (i.e. could have been truncated) + +checks the ballot design, looks through the options for that particular question, and checks if any of the options begin with the truncated text. + +If there is a match, replace the truncated text with the original selection option. +*/ +export function unTruncateSelection(selection: string, ballot_design: Item[], column_key: string): string { + if (!selection) return selection + + if (selection.length !== max_string_length) return selection + + const ballot_design_item = ballot_design.find((item) => item.id === column_key) + if (!ballot_design_item) return selection + + const truncated_selection = ballot_design_item.options.find((option) => option.name.startsWith(selection)) + + return truncated_selection?.name || selection +} diff --git a/src/status/useSWRExponentialBackoff.ts b/src/status/useSWRExponentialBackoff.ts new file mode 100644 index 00000000..955c740c --- /dev/null +++ b/src/status/useSWRExponentialBackoff.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react' +import useSWR, { mutate } from 'swr' + +/** Customize useSWR to revalidate using exponential backoff. +Auto-resets if data changes. */ +export function useSWRExponentialBackoff( + key: Parameters[0], + fetcher: Parameters[1], + baseDelaySeconds: number, +) { + const { data, error } = useSWR(key, fetcher) + + useEffect(() => { + let retries = 0 + let timerId: NodeJS.Timeout | null = null + + function revalidate() { + mutate(key) + retries += 1 + + // Calculate delay using exponential backoff logic + const delay = baseDelaySeconds * 1000 * 2 ** retries + timerId = setTimeout(revalidate, delay) + } + + revalidate() + + // Cleanup function to clear the interval + return () => { + if (timerId) clearTimeout(timerId) + } + }, [key, baseDelaySeconds, data, error]) + + return { data, error } +} diff --git a/src/vote/Ballot.tsx b/src/vote/Ballot.tsx index 42e7a8d6..904baa77 100644 --- a/src/vote/Ballot.tsx +++ b/src/vote/Ballot.tsx @@ -5,8 +5,14 @@ import { maxLength } from 'src/crypto/curve' import { Paper } from '../protocol/Paper' import { Item } from './Item' import { MultiVoteItem } from './MultiVoteItem' +import { RankedChoiceItem } from './RankedChoiceItem' import { State } from './vote-state' +// Calculate maximum write-in string length +const verification_num_length = 15 +export const max_string_length = maxLength - verification_num_length +export const defaultRankingsAllowed = 3 + export const Ballot = ({ dispatch, state, @@ -15,42 +21,62 @@ export const Ballot = ({ election_id?: string state: State }): JSX.Element => { - if (!state.ballot_design || !state.public_key) { - return

    Loading ballot...

    - } - - // Calculate maximum write-in string length - const verification_num_length = 15 - const max_string_length = maxLength - verification_num_length + if (!state.ballot_design) return

    Loading ballot...

    + if (!state.public_key) return

    This ballot is not ready for votes yet

    return ( <> - {state.election_title &&

    {state.election_title}

    } - {state.ballot_design.map((item, index) => - item.multiple_votes_allowed && item.multiple_votes_allowed > 1 ? ( - - ) : ( - - ), - )} + {/* Election Title */} + {state.election_title &&

    {state.election_title}

    } + + {state.ballot_design.map((item, index) => { + const max_options = item.options.length + +item.write_in_allowed + + // Is it "Approval" ? + if (item.type === 'approval') + return ( + + ) + + // Is it "Choose-up-to" ? + if ( + item.type === 'multiple-votes-allowed' && + item.multiple_votes_allowed && + item.multiple_votes_allowed > 1 + ) + return ( + + ) + + // Is it "Ranked Choice"? + if (item.type === 'ranked-choice-irv') + return ( + + ) + + // Otherwise, load default "Choose-only-one" + return + })}
    -
    ) } diff --git a/src/vote/Item.tsx b/src/vote/Item.tsx index b8e45a6e..0d625b9c 100644 --- a/src/vote/Item.tsx +++ b/src/vote/Item.tsx @@ -2,6 +2,7 @@ import { FormControlLabel, Radio, RadioGroup, TextField } from '@material-ui/cor import { Dispatch, useState } from 'react' import Linkify from 'react-linkify' +import { max_string_length } from './Ballot' import { Item as ItemType } from './storeElectionInfo' import { State } from './vote-state' @@ -9,7 +10,6 @@ export const Item = ({ description, dispatch, id = 'vote', - max_string_length, options, question, state, @@ -18,7 +18,6 @@ export const Item = ({ }: ItemType & { dispatch: Dispatch> election_id?: string - max_string_length: number state: State }): JSX.Element => { const [other, setOther] = useState() diff --git a/src/vote/MultiVoteItem.tsx b/src/vote/MultiVoteItem.tsx index 2198b786..2c931291 100644 --- a/src/vote/MultiVoteItem.tsx +++ b/src/vote/MultiVoteItem.tsx @@ -1,6 +1,7 @@ import { Checkbox, FormControlLabel, FormGroup } from '@material-ui/core' import { Dispatch, useEffect, useState } from 'react' +import { max_string_length } from './Ballot' import { Label, TitleDescriptionQuestion } from './Item' import { Item as ItemType } from './storeElectionInfo' import { State } from './vote-state' @@ -9,15 +10,14 @@ export const MultiVoteItem = ({ description, dispatch, id = 'vote', - max_string_length, multiple_votes_allowed, options, question, title, + type, }: ItemType & { dispatch: Dispatch> election_id?: string - max_string_length: number multiple_votes_allowed: number state: State }): JSX.Element => { @@ -42,8 +42,15 @@ export const MultiVoteItem = ({ return ( <> -

    - Remaining votes: {multiple_votes_allowed - selected.size} of {multiple_votes_allowed} + +

    + {type === 'approval' ? ( + 'Vote for all the options you approve of:' + ) : ( + <> + Remaining votes: {multiple_votes_allowed - selected.size} of {multiple_votes_allowed} + + )}

    {options.map(({ name, sub, value }) => { @@ -79,11 +86,6 @@ export const MultiVoteItem = ({ })}
    - ) } diff --git a/src/vote/RankedChoiceItem.tsx b/src/vote/RankedChoiceItem.tsx new file mode 100644 index 00000000..6cda2d1d --- /dev/null +++ b/src/vote/RankedChoiceItem.tsx @@ -0,0 +1,92 @@ +import { Dispatch } from 'react' + +import { max_string_length } from './Ballot' +import { getOrdinal } from './getOrdinal' +import { Label, TitleDescriptionQuestion } from './Item' +import { Item as ItemType } from './storeElectionInfo' +import { State } from './vote-state' + +export const RankedChoiceItem = ({ + description, + dispatch, + id = 'vote', + options, + question, + rankings_allowed, + state, + title, +}: ItemType & { + dispatch: Dispatch> + election_id?: string + rankings_allowed: number + state: State +}): JSX.Element => { + // console.log(state.plaintext) + + return ( + <> + + + + {/* Top row Choice labels */} + + + + ))} + + + + {/* List one row for each candidate */} + + {options.map(({ name, sub, value }) => { + const val = value || name.slice(0, max_string_length) + + return ( + + + + {/* And one column for each ranking option */} + {new Array(rankings_allowed).fill(0).map((_, index) => ( + + ))} + + ) + })} + +
    + + {new Array(rankings_allowed).fill(0).map((_, index) => ( + + {getOrdinal(index + 1)} +
    + + { + const update: Record = {} + + // Fill in all unchecked rankings to prevent encryption holes + for (let i = 1; i <= rankings_allowed; i++) { + update[`${id}_${i}`] = state.plaintext[`${id}_${i}`] || 'BLANK' + } + + const key = `${id}_${index + 1}` + update[key] = val + // console.log(key, val) + + // Are they deselecting their existing selection? + if (state.plaintext[key] === val) update[key] = 'BLANK' + + dispatch(update) + }} + /> +
    + +
    + + ) +} diff --git a/src/vote/SubmitButton.tsx b/src/vote/SubmitButton.tsx index e037e639..3189f657 100644 --- a/src/vote/SubmitButton.tsx +++ b/src/vote/SubmitButton.tsx @@ -1,8 +1,8 @@ import * as Sentry from '@sentry/browser' import { Dispatch, useState } from 'react' -import { api } from '../api-helper' import { OnClickButton } from '../_shared/Button' +import { api } from '../api-helper' import { State } from './vote-state' export const SubmitButton = ({ @@ -29,7 +29,12 @@ export const SubmitButton = ({ // Add plaintext "BLANK" for questions left blank state.ballot_design?.map((item) => { const id = item.id || 'vote' - if (!state.plaintext[id]) dispatch({ [id]: 'BLANK' }) + + if (state.plaintext[id]) return + if (item.multiple_votes_allowed) return + if (item.type === 'ranked-choice-irv') return + + dispatch({ [id]: 'BLANK' }) }) const response = await api('submit-vote', { auth, election_id, encrypted_vote: state.encrypted }) diff --git a/src/vote/getOrdinal.ts b/src/vote/getOrdinal.ts new file mode 100644 index 00000000..8f799dce --- /dev/null +++ b/src/vote/getOrdinal.ts @@ -0,0 +1,24 @@ +export function getOrdinal(n: number): string { + const lastTwoDigits = n % 100 + + // Handle the exceptions 11, 12, & 13 + if (lastTwoDigits >= 11 && lastTwoDigits <= 13) return n + 'th' + + const lastDigit = n % 10 + switch (lastDigit) { + case 1: + return n + 'st' + case 2: + return n + 'nd' + case 3: + return n + 'rd' + default: + return n + 'th' + } +} + +// console.log(getOrdinal(3)) // Output: "3rd" +// console.log(getOrdinal(22)) // Output: "22nd" +// console.log(getOrdinal(15)) // Output: "15th" +// console.log(getOrdinal(11)) // Output: "11th" +// console.log(getOrdinal(1)) // Output: "1st" diff --git a/src/vote/storeElectionInfo.ts b/src/vote/storeElectionInfo.ts index bf22c1f7..864cf55c 100644 --- a/src/vote/storeElectionInfo.ts +++ b/src/vote/storeElectionInfo.ts @@ -10,6 +10,7 @@ export type Item = { options: { name: string; sub?: string; value?: string }[] question?: string title: string + type?: string write_in_allowed: boolean } diff --git a/src/vote/submitted/InvalidatedVoteMessage.tsx b/src/vote/submitted/InvalidatedVoteMessage.tsx new file mode 100644 index 00000000..cd57660c --- /dev/null +++ b/src/vote/submitted/InvalidatedVoteMessage.tsx @@ -0,0 +1,50 @@ +import { useRouter } from 'next/router' +import React, { useEffect, useState } from 'react' +import { api } from 'src/api-helper' + +export const InvalidatedVoteMessage = () => { + const [message, setMessage] = useState('') + const [wasVoteInvalidated, setWasVoteInvalidated] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isSubmitSuccessful, setIsSubmitSuccessful] = useState(false) + const router = useRouter() + const { auth, election_id } = router.query + + useEffect(() => { + api(`/election/${election_id}/was-vote-invalidated?auth=${auth}`) + .then((response) => response.json()) + .then((data) => setWasVoteInvalidated(data)) + }, []) + + if (!wasVoteInvalidated) return null + + return ( +
    +

    Your Submitted Vote was invalidated.

    +

    If you believe this was in error, you can write a message to the Election Administrator:

    +
    +