Merge branch 'main' into registration-link

This commit is contained in:
David Ernst
2024-01-25 14:50:49 -08:00
59 changed files with 1086 additions and 924 deletions

View File

@@ -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",

View File

@@ -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,

View File

@@ -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<string, { esignature_review: ReviewLog[] }>
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<string, { esignature_review: ReviewLog[] }>
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)
}

View File

@@ -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' })
}

View File

@@ -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' })

View File

@@ -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<string, boolean> = {}
;(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,

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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:
<hr />
${message}
`,
})
res.status(200).json({ message: 'Message received' })
}

View File

@@ -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)
}

View File

@@ -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<unknown>[] = []
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}` })
}

View File

@@ -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}`

View File

@@ -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'

1
pages/demo.tsx Normal file
View File

@@ -0,0 +1 @@
export { DemoPage as default } from '../src/demo/DemoPage'

View File

@@ -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 => {
<Sidebar />
<div id="main-content">
<MobileMenu />
<AllYourElections />
{(section === 'overview' || !election_id) && <ElectionOverview />}
{!election_id && <AllYourElections />}
{!election_id && <CreateNewElection />}
{section === 'observers' && <Observers />}
{section === 'ballot-design' && <BallotDesign />}
{section === 'voters' && <AddVoters />}
@@ -57,7 +57,7 @@ export const AdminPage = (): JSX.Element => {
}
/* When sidebar disappears */
@media (max-width: 500px) {
@media (max-width: 640px) {
#main-content {
left: 0;

View File

@@ -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 (
<>
<h2>
Your Existing Elections: <i>{data?.elections?.length}</i>{' '}
Your Elections: <i>{data?.elections?.length}</i>{' '}
{!!data?.elections?.length && (
<span>
<a onClick={toggle}>[ {show ? '- Hide' : '+ Show'} ]</a>

View File

@@ -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 (
<>
<h2>Ballot Design</h2>
<h2 className="hidden sm:block">Ballot Design</h2>
<AutoSaver {...{ design }} />
<Errors {...{ error }} />
<ModeControls {...{ selected, setSelected }} />
@@ -64,14 +71,6 @@ export const BallotDesign = () => {
)}
<style jsx>{`
/* When sidebar disappears */
@media (max-width: 500px) {
h2 {
opacity: 0;
margin: 0px;
}
}
.mode-container {
display: flex;
width: 100%;

View File

@@ -7,28 +7,6 @@ import { check_for_urgent_ballot_errors } from './check_for_ballot_errors'
import { IOSSwitch } from './IOSSwitch'
export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: string) => void }) => {
/* Features to support
- [x] See current design
- [x] Edit item title
- [x] Edit options name
- [x] Delete existing options
- [x] Toggle 'Write in' allowed
- [x] Create new options
- [x] Add new questions
- [x] Delete questions
- [x] Set item ID
- [ ] Edit option's subline (e.g. Party affiliation)
- [ ] Edit item description
- [ ] Edit item final question ("Should this bill be")
- [ ] Re-order items
- [ ] Reorder existing options
- [ ] Edit option's short_id (if too long)
- [ ] Collapse item's options
*/
const [json, setJson] = useState<Item[]>()
const errors = check_for_urgent_ballot_errors(design)
@@ -38,10 +16,17 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
}, [design])
return (
<div className={`ballot ${errors ? 'errors' : ''}`}>
{json?.map(({ id, options, title, write_in_allowed }, questionIndex) => (
<div className="question" key={questionIndex}>
<label className="id-label">
// Wizard container
<div
className={`flex-1 border border-solid border-gray-300 text-gray-700 p-2.5 bg-[#eee] pb-0 ${
errors ? 'bg-gray-100 opacity-30 cursor-not-allowed' : ''
}`}
>
{json?.map(({ id, multiple_votes_allowed, options, title, write_in_allowed }, questionIndex) => (
// Each question
<div className="p-2.5 bg-white mt-4 first:mt-0" key={questionIndex}>
{/* Question ID Label */}
<label className="block mt-3.5 text-[10px] italic">
Question ID{' '}
<Tooltip
placement="top"
@@ -51,14 +36,15 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
</span>
}
>
<span className="question-id-tooltip">
<span className="relative top-0 text-sm text-indigo-500 left-1">
<QuestionCircleOutlined />
</span>
</Tooltip>
</label>
<div className="id-line">
{/* Question ID Input */}
<div className="flex items-center justify-between">
<input
className="id-input"
className="p-1 text-sm"
value={id}
onChange={({ target }) => {
const new_json = [...json]
@@ -66,9 +52,11 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
setDesign(JSON.stringify(new_json, undefined, 2))
}}
/>
{/* Delete Question btn */}
<Tooltip placement="top" title="Delete Question">
<a
className="delete-question-btn"
className="relative ml-1 text-xl text-center text-gray-700 rounded-full cursor-pointer w-7 bottom-[30px] hover:bg-gray-500 hover:text-white"
onClick={() => {
const new_json = [...json]
new_json.splice(questionIndex, 1)
@@ -79,10 +67,59 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
</a>
</Tooltip>
</div>
<label className="title-label">Question Title:</label>
<div className="title-line">
{/* Type selector */}
<div className="mt-4">
<label className="text-[10px] italic">Voting Type:</label>
{/* Type dropdown */}
<div className="relative">
<span className="absolute z-20 scale-75 right-3 top-2 opacity-60"></span>
<select
className="appearance-none border border-solid border-gray-200 text-[13px] rounded focus:ring-blue-500 focus:border-blue-500 w-full p-2.5 shadow-sm relative"
value={json[questionIndex].type}
onChange={({ target }) => {
const new_json = [...json]
new_json[questionIndex].type = target.value as string
if (
new_json[questionIndex].type == 'multiple-votes-allowed' &&
!new_json[questionIndex].multiple_votes_allowed
) {
new_json[questionIndex].multiple_votes_allowed = 3
}
setDesign(JSON.stringify(new_json, undefined, 2))
}}
>
<option value="choose-only-one">Choose Only One Plurality (FPTP)</option>
<option value="ranked-choice-irv">Ranked Choice Instant Runoff (IRV)</option>
<option value="approval">Approval Vote for All That You Approve Of</option>
<option value="multiple-votes-allowed">Multiple Votes Allowed Choose Up to X</option>
</select>
</div>
</div>
{json[questionIndex].type == 'multiple-votes-allowed' && (
<div className="mt-1">
<label className="text-[10px] italic">Max Selections Allowed?</label>
<input
className="p-1 ml-1 text-sm"
value={multiple_votes_allowed}
onChange={({ target }) => {
const update = +target.value
const new_json = [...json]
new_json[questionIndex].multiple_votes_allowed = update
setDesign(JSON.stringify(new_json, undefined, 2))
}}
/>
</div>
)}
{/* Question Title Label */}
<label className="block mt-4 text-[10px] italic">Question Title:</label>
{/* Question Title Input */}
<div className="flex items-center">
<input
className="title-input"
className="flex-1 p-1 text-sm"
value={title}
onChange={({ target }) => {
const new_json = [...json]
@@ -91,11 +128,14 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
}}
/>
</div>
<ul className="options">
{/* Options list */}
<ul>
{options?.map(({ name }, optionIndex) => (
<li key={optionIndex}>
{/* Option input */}
<input
className="name-input"
className="p-[5px] mb-1.5 text-[13px]"
value={name}
onChange={({ target }) => {
const new_json = [...json]
@@ -103,8 +143,9 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
setDesign(JSON.stringify(new_json, undefined, 2))
}}
/>
{/* Delete Option btn */}
<a
className="delete-option-btn"
className="inline-block pl-px w-5 h-5 ml-[5px] leading-4 text-center text-gray-600 border border-gray-300 border-solid rounded-full cursor-pointer hover:no-underline hover:bg-gray-500 hover:border-transparent hover:text-white"
onClick={() => {
const new_json = [...json]
new_json[questionIndex].options.splice(optionIndex, 1)
@@ -115,8 +156,9 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
</a>
</li>
))}
{/* Add another option btn */}
<a
className="add-option"
className="block pl-2 mt-1 mb-[14px] text-[13px] italic cursor-pointer"
onClick={() => {
const new_json = [...json]
new_json[questionIndex].options.push({ name: '' })
@@ -125,8 +167,14 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
>
+ Add another option
</a>
<li className={`write-in ${write_in_allowed ? 'allowed' : 'disabled'}`}>
<span>{`Write-in ${write_in_allowed ? 'Allowed' : 'Disabled'}`}</span>
{/* Write-in Allowed toggle */}
<li className={`${write_in_allowed ? '' : 'list-none'}`}>
<span
className={`inline-block w-32 pl-2 text-[13px] italic text-gray-800 ${
!write_in_allowed ? 'opacity-60' : ''
}`}
>{`Write-in ${write_in_allowed ? 'Allowed' : 'Disabled'}`}</span>
<IOSSwitch
checked={write_in_allowed}
onChange={() => {
@@ -139,13 +187,17 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
</ul>
</div>
))}
{/* "Add another question" btn */}
<a
className="add-question"
className="block w-full p-2.5 italic bg-white my-[15px] text-[13px] cursor-pointer"
onClick={() => {
const new_json = [...(json || [])]
const new_question_number = new_json.length + 1
new_json.push({
id: `item${new_question_number}`,
type: 'choose-only-one',
// eslint-disable-next-line sort-keys-fix/sort-keys-fix
title: `Question ${new_question_number}`,
// eslint-disable-next-line sort-keys-fix/sort-keys-fix
options: [{ name: 'Option 1' }, { name: 'Option 2' }],
@@ -156,150 +208,6 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
>
+ Add another question
</a>
<style jsx>{`
.ballot {
flex: 1;
border: 1px solid #ccc;
color: #444;
padding: 10px;
background: #eee;
padding-bottom: 0;
}
.errors {
background-color: hsl(0, 0%, 90%);
opacity: 0.3;
cursor: not-allowed;
}
.question {
padding: 10px;
background: #fff;
}
.question:not(:first-child) {
margin-top: 15px;
}
.id-label,
.title-label {
margin-top: 15px;
font-style: italic;
display: block;
font-size: 10px;
}
.question-id-tooltip {
font-size: 12px;
position: relative;
left: 4px;
top: 1px;
color: rgb(90, 102, 233);
}
.id-line {
display: flex;
justify-content: space-between;
align-items: center;
}
.title-line {
display: flex;
align-items: center;
}
.id-input,
.title-input {
font-size: 14px;
padding: 5px;
}
.title-input {
flex: 1;
}
.delete-question-btn {
font-size: 20px;
color: hsl(0, 0%, 28%);
cursor: pointer;
margin-left: 5px;
width: 29px;
text-align: center;
border-radius: 99px;
position: relative;
bottom: 30px;
}
.delete-question-btn:hover {
background-color: hsl(0, 0%, 50%);
color: #fff;
}
.name-input {
padding: 5px;
font-size: 13px;
margin-bottom: 6px;
}
.options li {
white-space: nowrap;
}
.delete-option-btn {
border: 1px solid hsl(0, 0%, 82%);
border-radius: 100px;
width: 20px;
height: 20px;
display: inline-block;
text-align: center;
color: hsl(0, 0%, 42%);
line-height: 16px;
padding-left: 1px;
cursor: pointer;
margin-left: 5px;
}
.delete-option-btn:hover {
background-color: hsl(0, 0%, 42%);
border-color: #0000;
color: white;
text-decoration: none;
}
.add-option,
.add-question {
font-style: italic;
padding-left: 8px;
margin: 3px 0 14px;
display: inline-block;
font-size: 13px;
cursor: pointer;
}
.write-in span {
font-size: 13px;
font-style: italic;
width: 123px;
display: inline-block;
color: hsl(0, 0%, 17%);
padding-left: 8px;
}
.write-in.disabled {
list-style: none; /* Remove bullet */
}
.write-in.disabled span {
opacity: 0.6;
}
.add-question {
margin: 15px 0 !important;
background: #fff;
padding: 10px;
width: 100%;
}
`}</style>
</div>
)
}

View File

@@ -57,14 +57,14 @@ export function check_for_less_urgent_ballot_errors(design: string): string | nu
ids[id] = true
// Validate options
const options: Record<string, boolean> = {}
const optionsSeen: Record<string, boolean> = {}
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) {

View File

@@ -1,6 +1,7 @@
export const default_ballot_design = `[
{
"id": "president",
"type": "choose-only-one",
"title": "Who should become President?",
"options": [
{

View File

@@ -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 (
<>
<h2>Create New Election</h2>
<label>Election Title:</label>
<input
id="election-title"
placeholder="Give your election a name your voters will recognize"

View File

@@ -3,149 +3,58 @@ import Head from 'next/head'
import Link from 'next/link'
import { promptLogout, useUser } from './auth'
import { useDynamicHeaderbarHeight } from './useDynamicHeaderbarHeight'
import { useStored } from './useStored'
export const HeaderBar = (): JSX.Element => {
const { user } = useUser()
const { election_id, election_title } = useStored()
const headerId = useDynamicHeaderbarHeight(election_title)
return (
<div className="bg-gradient-to-r from-[#010b26] to-[#072054] text-white flex w-full justify-between">
<section className="left">
<Link href="/">
<a className="logo">SIV</a>
<div className="bg-gradient-to-r from-[#010b26] to-[#072054] text-white flex w-full justify-between" id={headerId}>
{/* Logo */}
<section className="min-w-[75px] py-4 sm:min-w-[281px]">
<Link href={'/admin'}>
<a
className="font-bold text-[#ddd] px-4 hover:text-white hover:no-underline text-[24px]"
onClick={() => {
const el = document.getElementById('main-content')
if (el) el.scrollTop = 0
}}
>
SIV
</a>
</Link>
</section>
<section className="right">
<div className="title">
<section className="flex justify-between w-full py-4 sm:relative sm:right-8">
<div className="flex">
{election_id && (
<>
<Head>
<title key="title">SIV: Manage {election_title}</title>
</Head>
<Link href="/admin">
<a
className="back-btn"
onClick={() => {
const el = document.getElementById('main-content')
if (el) el.scrollTop = 0
}}
>
</a>
</Link>
<div className="current-election">
Managing: <i>{election_title}</i> <span>ID: {election_id}</span>
{/* Election title */}
<div className="relative bottom-0.5">
<div className="text-[14px] italic">{election_title}</div>
<div className="text-[10px] opacity-80 relative top-1">ID: {election_id}</div>
</div>
</>
)}
</div>
<div className="login-status" onClick={promptLogout}>
{/* Login status */}
<div
className="text-base py-[3px] px-2.5 rounded-md items-center mr-4 cursor-pointer hover:bg-white/20 sm:flex hidden whitespace-nowrap"
onClick={promptLogout}
>
<UserOutlined />
&nbsp; {user.name}
</div>
</section>
<style jsx>{`
.left {
min-width: 200px;
padding: 1rem 0;
}
@media (max-width: 650px) {
.left {
min-width: 80px;
}
}
.logo {
font-size: 24px;
font-weight: 700;
color: #ddd;
padding: 1rem;
}
.logo:hover {
color: #fff;
text-decoration: none;
}
.right {
width: 100%;
padding: 1rem 0rem;
display: flex;
justify-content: space-between;
}
.title {
display: flex;
}
.back-btn {
margin-right: 18px;
color: #fff;
opacity: 0.4;
border-radius: 100px;
width: 30px;
height: 30px;
line-height: 30px;
font-weight: 700;
text-align: center;
}
.back-btn:hover {
opacity: 0.9;
background: #fff2;
cursor: pointer;
text-decoration: none;
}
.current-election span {
display: block;
text-transform: uppercase;
font-size: 10px;
opacity: 0.8;
}
.current-election {
font-size: 14px;
}
.login-status {
font-size: 16px;
padding: 3px 10px;
border-radius: 4px;
display: flex;
align-items: center;
margin-right: 1rem;
}
.login-status:hover {
background: #fff2;
cursor: pointer;
}
/* When Sidebar disappears */
@media (max-width: 500px) {
.right {
width: initial;
position: relative;
right: 2rem;
}
.back-btn {
margin-right: 10px;
}
.login-status {
display: none;
}
}
`}</style>
</div>
)
}

View File

@@ -15,10 +15,16 @@ export const MobileMenu = () => {
const iOS = process?.browser && /iPad|iPhone|iPod/.test(navigator.userAgent)
return (
<div className="mobile-menu">
<OnClickButton style={{ marginLeft: 0, padding: '5px 11px' }} onClick={() => set_menu(true)}>
{section && steps.includes(name) ? `Step ${steps.indexOf(name) + 1}: ${name}` : 'Menu'}
</OnClickButton>
/* Hidden for all but small screens */
<div className="sm:hidden">
{/* Activation button */}
{section && (
<OnClickButton style={{ marginLeft: 0, padding: '5px 11px' }} onClick={() => set_menu(true)}>
{section && steps.includes(name) ? `Step ${steps.indexOf(name) + 1}: ${name}` : 'Menu'}
</OnClickButton>
)}
{/* Sliding in Sidebar */}
<SwipeableDrawer
anchor="left"
disableBackdropTransition={!iOS}
@@ -29,14 +35,6 @@ export const MobileMenu = () => {
>
<SidebarContent closeMenu={() => set_menu(false)} />
</SwipeableDrawer>
<style jsx>{`
/* Hide for all but small screens */
@media (min-width: 500px) {
.mobile-menu {
display: none;
}
}
`}</style>
</div>
)
}

View File

@@ -20,12 +20,12 @@ export const Observers = () => {
useLatestMailgunEvents(election_id, trustees, election_manager)
return (
<div>
<h2>
<div className="container">
<h2 className="hidden sm:block">
Verifying Observers <span>(Optional)</span>
</h2>
<p>
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.
</p>
<p>
@@ -219,13 +219,6 @@ export const Observers = () => {
}
`}</style>
<style jsx>{`
/* When sidebar disappears */
@media (max-width: 500px) {
h2 {
display: none;
}
}
h2 span {
font-size: 12px;
font-weight: 500;

View File

@@ -1,27 +0,0 @@
import { useStored } from '../useStored'
import { ManagerInput } from './ManagerInput'
import { StoredManager } from './StoredManager'
import { StoredTitle } from './StoredTitle'
import { TitleInput } from './TitleInput'
export const ElectionOverview = () => {
const { election_manager, election_title } = useStored()
return (
<>
<h2>{!election_title ? 'Create New Election' : 'Election Overview'}</h2>
<label>Election Title:</label>
{!election_title ? (
<TitleInput />
) : (
<>
<StoredTitle />
<br />
<label>Election Manager:</label>
{!election_manager ? <ManagerInput /> : <StoredManager />}
</>
)}
</>
)
}

View File

@@ -1,51 +0,0 @@
import router from 'next/router'
import { useState } from 'react'
import { api } from '../../api-helper'
import { SaveButton } from '../SaveButton'
import { revalidate, useStored } from '../useStored'
export const ManagerInput = () => {
const { election_id } = useStored()
const [election_manager, set_manager] = useState('')
return (
<>
<input
id="election-manager"
placeholder="Who's responsible for running the election?"
value={election_manager}
onChange={(event) => set_manager(event.target.value)}
onKeyPress={(event) => {
if (event.key === 'Enter') {
document.getElementById('election-manager')?.blur()
document.getElementById('election-manager-save')?.click()
}
}}
/>
<SaveButton
id="election-manager-save"
onPress={async () => {
const response = await api(`election/${election_id}/admin/save-election-manager`, { election_manager })
if (response.status === 201) {
revalidate(election_id)
router.push(`${window.location.origin}/admin/${election_id}/trustees`)
} else {
throw await response.json()
}
}}
/>
<style jsx>{`
input {
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
padding: 8px;
width: 100%;
}
`}</style>
</>
)
}

View File

@@ -1,25 +0,0 @@
import { useStored } from '../useStored'
export const StoredManager = () => {
const { election_manager } = useStored()
return (
<div>
{election_manager || <i>Not set</i>}
<style jsx>{`
div {
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
padding: 8px;
width: 100%;
}
div:hover {
background: #f8f8f8;
cursor: not-allowed;
}
`}</style>
</div>
)
}

View File

@@ -1,25 +0,0 @@
import { useStored } from '../useStored'
export const StoredTitle = () => {
const { election_title } = useStored()
return (
<div>
{election_title || 'Error loading election'}
<style jsx>{`
div {
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
padding: 8px;
width: 100%;
}
div:hover {
background: #f8f8f8;
cursor: not-allowed;
}
`}</style>
</div>
)
}

View File

@@ -5,20 +5,8 @@ import { useRouter } from 'next/router'
import { useStored } from './useStored'
export const Sidebar = () => (
<div className="sidebar">
<div className="hidden sm:block" style={{ height: 'calc(100vh - 66px)' }}>
<SidebarContent />
<style jsx>{`
.sidebar {
height: calc(100vh - 66px);
}
/* Hide for small screens */
@media (max-width: 500px) {
.sidebar {
display: none;
}
}
`}</style>
</div>
)
@@ -38,9 +26,22 @@ export const SidebarContent = ({ closeMenu = () => {} }: { closeMenu?: () => voi
return (
<div className="sidebar">
<main>
<h2 className="logo">SIV</h2>
<Link href="/admin">
<a
className="hover:!bg-white/0 !p-0"
onClick={() => {
closeMenu()
const el = document.getElementById('main-content')
if (el) el.scrollTop = 0
}}
>
<h2 className="logo sm:hidden">SIV</h2>
</a>
</Link>
{election_id && (
<>
{/* Election Management section */}
<>
<label>
<ApartmentOutlined style={{ marginRight: 5 }} /> Election Management
@@ -54,6 +55,8 @@ export const SidebarContent = ({ closeMenu = () => {} }: { closeMenu?: () => voi
</Link>
))}
</>
{/* Post Election section */}
<>
<label>
<SnippetsOutlined style={{ marginRight: 5 }} />
@@ -65,6 +68,8 @@ export const SidebarContent = ({ closeMenu = () => {} }: { closeMenu?: () => voi
</a>
</Link>
</>
{/* Public pages section */}
<>
<label>
<LinkOutlined style={{ marginRight: 5 }} />
@@ -117,13 +122,6 @@ export const SidebarContent = ({ closeMenu = () => {} }: { closeMenu?: () => voi
margin: 16px 8px;
}
/* Rules for persistent sidebar */
@media (min-width: 500px) {
.logo {
display: none;
}
}
label {
display: block;
margin-top: 30px;

View File

@@ -14,7 +14,7 @@ export const AddVoters = () => {
return (
<div className="max-w-[50rem]">
<h2>Voters</h2>
<h2 className="hidden sm:block">Voters</h2>
<h4>Add new voters by email address:</h4>
<MultilineInput state={new_voters} update={set_new_voters} />
@@ -41,14 +41,6 @@ export const AddVoters = () => {
<ToggleRegistration />
<RequestEsignatures />
<ExistingVoters />
<style jsx>{`
/* When sidebar disappears */
@media (max-width: 500px) {
h2 {
display: none;
}
}
`}</style>
</div>
)
}

View File

@@ -9,7 +9,7 @@ import { UnlockedStatus } from './UnlockedStatus'
import { ValidVotersTable } from './ValidVotersTables'
export const ExistingVoters = () => {
const { esignature_requested, valid_voters } = useStored()
const { esignature_requested, valid_voters, voters } = useStored()
const [checked, set_checked] = useState<boolean[]>(new Array(valid_voters?.length).fill(false))
const num_voted = valid_voters?.filter((v) => v.has_voted).length || 0
const num_approved = !esignature_requested
@@ -29,7 +29,7 @@ export const ExistingVoters = () => {
}, [valid_voters?.length])
// Don't show anything if we don't have any voters yet
if (!valid_voters?.length) return null
if (!voters?.length) return null
return (
<>

View File

@@ -1,10 +1,10 @@
import Image from 'next/image'
import { useReducer, useState } from 'react'
import { api } from 'src/api-helper'
import { useStored } from '../useStored'
import { DeliveriesAndFailures } from './DeliveriesAndFailures'
import InvalidatedVoteIcon from './invalidated.png'
import { mask } from './mask-token'
import { QueuedCell } from './QueuedCell'
import { Signature, getStatus } from './Signature'
import { use_multi_select } from './use-multi-select'
@@ -32,52 +32,50 @@ export const InvalidVotersTable = ({
if (!shown_voters || !shown_voters.length) return null
return (
<table className="pb-3">
Invalidated voters:
<thead>
<tr>
<th>
<input
style={{ cursor: 'pointer' }}
type="checkbox"
onChange={(event) => {
const new_checked = [...checked]
new_checked.fill(event.target.checked)
set_checked(new_checked)
set_last_selected(undefined)
}}
/>
</th>
<th>#</th>
<th>email</th>
<th className="hoverable" onClick={toggle_tokens}>
{mask_tokens ? 'masked' : 'full'}
<br />
auth token
</th>
<th style={{ width: 50 }}>invite queued</th>
<th style={{ width: 50 }}>invite delivered</th>
<th>voted</th>
{esignature_requested && (
<th
className="hoverable"
onClick={() => {
if (confirm(`Do you want to approve all ${num_voted} signatures?`)) {
api(`election/${election_id}/admin/review-signature`, {
emails: shown_voters.filter(({ has_voted }) => has_voted).map((v) => v.email),
review: 'approve',
})
}
}}
>
signature
<>
<p className="mt-12 mb-1">Invalidated voters:</p>
<table className="pb-3">
<thead>
<tr>
<th>
<input
style={{ cursor: 'pointer' }}
type="checkbox"
onChange={(event) => {
const new_checked = [...checked]
new_checked.fill(event.target.checked)
set_checked(new_checked)
set_last_selected(undefined)
}}
/>
</th>
)}
</tr>
</thead>
<tbody>
{shown_voters.map(
({ auth_token, email, esignature, esignature_review, has_voted, invite_queued, mailgun_events }, index) => (
<th>#</th>
<th>email</th>
<th className="hoverable" onClick={toggle_tokens}>
{mask_tokens ? 'masked' : 'full'}
<br />
auth token
</th>
<th>voted</th>
{esignature_requested && (
<th
className="hoverable"
onClick={() => {
if (confirm(`Do you want to approve all ${num_voted} signatures?`)) {
api(`election/${election_id}/admin/review-signature`, {
emails: shown_voters.filter(({ has_voted }) => has_voted).map((v) => v.email),
review: 'approve',
})
}
}}
>
signature
</th>
)}
</tr>
</thead>
<tbody>
{shown_voters.map(({ auth_token, email, esignature, esignature_review, has_voted }, index) => (
<tr className={`${checked[index] ? 'checked' : ''}`} key={email}>
{/* Checkbox cell */}
@@ -110,25 +108,24 @@ export const InvalidVotersTable = ({
{mask_tokens ? mask(auth_token) : auth_token}
</td>
<QueuedCell {...{ invite_queued }} />
<DeliveriesAndFailures {...mailgun_events} />
<td style={{ fontWeight: 700, textAlign: 'center' }}>{has_voted ? '✓' : ''}</td>
<td className="p-0 text-center">
{has_voted ? (
<Image className="object-contain scale-25 " height={23} src={InvalidatedVoteIcon} width={23} />
) : null}
</td>
{esignature_requested &&
(has_voted ? <Signature {...{ election_id, email, esignature, esignature_review }} /> : <td />)}
</tr>
),
)}
</tbody>
))}
</tbody>
</table>
<style jsx>{`
table {
border-collapse: collapse;
display: block;
overflow: auto;
width: 100%;
margin-top: 3rem;
}
th,
@@ -178,6 +175,6 @@ export const InvalidVotersTable = ({
opacity: 1 !important;
}
`}</style>
</table>
</>
)
}

View File

@@ -33,25 +33,51 @@ export const InvalidateVotersButton = ({
onClick={async () => {
if (displayOnly) return
const voters_selected = checked.reduce((acc: string[], is_checked, index) => {
if (is_checked) acc.push(valid_voters[index].email)
return acc
}, [])
const amount_to_show = 10
const confirmed = confirm(
`Are you sure you want to invalidate ${
num_checked === 1 ? 'this voter' : `these ${num_checked} voters`
} & their auth token${num_checked === 1 ? '' : 's'}?\n\n${voters_selected
.slice(0, amount_to_show)
.join('\n')}${num_checked > amount_to_show ? `\n\n and ${num_checked - amount_to_show} more.` : ''}`,
const voters_selected = checked.reduce(
(acc: { auth_token: string; email: string; hasVoted: boolean }[], is_checked, index) => {
if (is_checked)
acc.push({
auth_token: valid_voters[index].auth_token,
email: valid_voters[index].email,
hasVoted: valid_voters[index].has_voted,
})
return acc
},
[],
)
const votersWhoVoted = voters_selected.filter((voter) => voter.hasVoted)
const votersWhoDidNotVote = voters_selected.filter((voter) => !voter.hasVoted)
let message = ''
if (votersWhoVoted.length > 0) {
message += `Are you sure you want to invalidate ${
votersWhoVoted.length === 1 ? 'this voter' : `these ${votersWhoVoted.length} voters`
} & their submitted vote${votersWhoVoted.length === 1 ? '' : 's'}? The public will be able to see the vote${
votersWhoVoted.length === 1 ? ' was' : 's were'
} invalidated, but not the vote content.\n\nVoters with votes:\n${votersWhoVoted
.map((voter) => `- ${voter.email}`)
.join('\n')}`
}
if (votersWhoVoted.length > 0 && votersWhoDidNotVote.length > 0) {
message += '\n\n————————————\n\n'
}
if (votersWhoDidNotVote.length > 0) {
message += `Are you sure you want to invalidate ${
votersWhoDidNotVote.length === 1 ? 'this voter' : `these ${votersWhoDidNotVote.length} voters`
} & their auth token${
votersWhoDidNotVote.length === 1 ? '' : 's'
}?\n\nVoters without votes:\n${votersWhoDidNotVote.map((voter) => `- ${voter.email}`).join('\n')}`
}
const confirmed = confirm(message)
if (!confirmed) return
const response = await api(`election/${election_id}/admin/invalidate-voters`, {
voters: voters_selected,
voters_to_invalidate: voters_selected,
})
try {

View File

@@ -23,7 +23,8 @@ export const NumVotedRow = ({
<p className="num-voted-row">
<span>
<i>
{num_voted} of {valid_voters.length} voted ({Math.round((num_voted / valid_voters.length) * 100)}%)
{num_voted} of {valid_voters.length} voted (
{valid_voters.length == 0 ? 0 : Math.round((num_voted / valid_voters.length) * 100)}%)
</i>
{/* Toggle hide voted */}
<a style={{ cursor: 'pointer', fontSize: 12, marginLeft: 10 }} onClick={toggle_hide_voted}>

View File

@@ -55,7 +55,7 @@ export const SendInvitationsButton = ({
// console.log('Got response to chunk', index)
if (response.status !== 201) {
const json = await response.json()
console.error(json)
console.error('Send Invitation error:', json)
set_error(json?.error || `Error w/o message, in chunk ${index}`)
}
}),

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,28 @@
import { useLayoutEffect } from 'react'
/** Whenever our election_title changes or the window is resized, our headerbar may break onto multiple lines.
If so, we need to adjust the #main-content top margin to accommodate. */
export const useDynamicHeaderbarHeight = (election_title?: string) => {
const headerId = 'admin-headerbar'
useLayoutEffect(() => {
function syncMainContentOffsetToHeaderbar() {
const $admin_headerbar = document.getElementById(headerId)
if (!$admin_headerbar) return
const $main_content = document.getElementById('main-content')
if (!$main_content) return
$main_content.style.top = $admin_headerbar.clientHeight + 'px'
}
syncMainContentOffsetToHeaderbar()
// Add event listener for window resize
window.addEventListener('resize', syncMainContentOffsetToHeaderbar)
// Cleanup event listener on unmount
return () => window.removeEventListener('resize', syncMainContentOffsetToHeaderbar)
}, [election_title])
return headerId
}

View File

@@ -87,24 +87,26 @@ SIV prevents accidentally sending invitations for invalidated tokens.
### Scenario III: The voter already voted, but votes havent been unlocked and tallied yet
In the rare circumstance where a vote must be invalidated after it has already been submitted and accepted, SIV prioritizes security.
SIV allows admins to remove votes after they've been cast, but only in a transparent way, and with the vote contents still protected by encryption.
<Todo>
> In the rare circumstance where a vote must be invalidated after it has already been submitted and accepted, SIV prioritizes security.
>
> SIV allows admins to remove votes after they've been cast, but only in a transparent way, and with the vote contents still protected by encryption.
>
> The voter, Verifying Observers, and public will all be able to see that the encrypted vote was invalidated.
>
> This ensures voters can be confident that submitted votes cannot be secretly removed.
>
> This is powered by a Merkle Tree, a powerful cryptographic data structure. This is like a blockchain, but very fast & environmentally-friendly.
</Todo>
**_Admin Interface:_**
- The admin is asked to confirm.
> Are you sure you want to invalidate this voter & their submitted vote?
>
> The public will be able to see the vote was invalidated (but not its contents).
> The public will be able to see the vote was invalidated, but not its contents.
**_Voter Interface:_**
@@ -126,16 +128,18 @@ SIV prevents accidentally sending invitations for invalidated tokens.
>
> ***
<Todo>
**_Verifying Observers Interface:_**
- They will see that an encrypted vote was invalidated by the admin.
</Todo>
**_General Public Interface:_**
- If they review the encrypted votes list, they will see that there was an invalidation issued for this submitted encrypted vote.
</Todo>
---
### Scenario IV: The voter voted, and the votes have already been unlocked and tallied

View File

@@ -12,7 +12,7 @@ export const HeaderBar = () => (
</Link>
</h3>
<span>
<Link href="https://book.siv.org">
<Link href="https://docs.siv.org">
<a>Docs</a>
</Link>
<Link href="https://blog.siv.org">

View File

@@ -37,7 +37,7 @@ export const EncodedsTable = (): JSX.Element => {
<tr key={index}>
<td>{index + 1}.</td>
{columns.map((c) => (
<td key={c}>{stringToPoint(`${vote.tracking}:${vote[c]}`)}</td>
<td key={c}>{stringToPoint(`${vote.tracking}:${vote[c]}`).toString()}</td>
))}
</tr>
))}

View File

@@ -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<EncryptedVote[]>()
const { data: votes, mutate } = useSWR<EncryptedVote[]>(
!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 <div>Loading...</div>
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 (
<section>
<h3>{title_prefix}All Submitted Votes</h3>
<p>
Ordered oldest to newest.{' '}
{has_decrypted_votes ? (
<>
These are the encrypted votes submitted by each authenticated voter.
<br />
For more, see{' '}
<a href="../protocol#3" target="_blank">
SIV Protocol Step 3: Submit Encrypted Vote
</a>
.
</>
) : (
`When the election closes, ${esignature_requested ? 'all approved' : 'these'} votes will
<>
<TotalVotesCast {...{ numVotes }} />
<section className="p-4 mb-8 bg-white rounded-lg shadow-[0px_2px_2px_hsl(0_0%_50%_/0.333),0px_4px_4px_hsl(0_0%_50%_/0.333),0px_6px_6px_hsl(0_0%_50%_/0.333)]">
<h3 className="mt-0 mb-1">{title_prefix}All Submitted Votes</h3>
<p className='mt-0 text-sm italic opacity-70"'>
Ordered oldest to newest.{' '}
{has_decrypted_votes ? (
<>
These are the encrypted votes submitted by each authenticated voter.
<br />
For more, see{' '}
<a href="../protocol#3" target="_blank">
SIV Protocol Step 3: Submit Encrypted Vote
</a>
.
</>
) : (
`When the election closes, ${esignature_requested ? 'all approved' : 'these'} votes will
be shuffled and then unlocked.`
)}
</p>
<table>
<thead>
<tr>
<td rowSpan={2}></td>
{esignature_requested && <th rowSpan={2}>signature approved</th>}
<th rowSpan={2}>auth</th>
{columns.map((c) => (
<th colSpan={2} key={c}>
{c}
</th>
))}
</tr>
<tr className="subheading">
{columns.map((c) => (
<Fragment key={c}>
<th>encrypted</th>
<th>lock</th>
</Fragment>
))}
</tr>
</thead>
<tbody>
{votes.map((vote, index) => (
<tr key={index}>
<td>{index + 1}.</td>
{esignature_requested && <td className="approved">{vote.signature_approved ? '✓' : ''}</td>}
<td>{vote.auth}</td>
{columns.map((key) => {
if (key !== 'auth') {
return (
<Fragment key={key}>
<td className="monospaced">{vote[key]?.encrypted}</td>
<td className="monospaced">{vote[key]?.lock}</td>
</Fragment>
)
}
})}
)}
</p>
<table>
<thead>
<tr>
<td rowSpan={2}></td>
{esignature_requested && <th rowSpan={2}>signature approved</th>}
<th rowSpan={2}>auth</th>
{columns.map((c) => (
<th colSpan={2} key={c}>
{c}
</th>
))}
</tr>
))}
</tbody>
</table>
<style jsx>{`
section {
background: #fff;
padding: 1rem;
border-radius: 8px;
box-shadow: 0px 2px 2px hsl(0 0% 50% / 0.333), 0px 4px 4px hsl(0 0% 50% / 0.333),
0px 6px 6px hsl(0 0% 50% / 0.333);
margin-bottom: 2rem;
}
<tr className="subheading">
{columns.map((c) => (
<Fragment key={c}>
<th>encrypted</th>
<th>lock</th>
</Fragment>
))}
</tr>
</thead>
h3 {
margin: 0 0 5px;
}
<tbody>
{votes.map((vote, index) => (
<tr key={index}>
<td>{index + 1}.</td>
{esignature_requested && (
<td className="font-bold text-center">{vote.signature_approved ? '✓' : ''}</td>
)}
<td>{vote.auth}</td>
{columns.map((key) => {
if (key !== 'auth') {
return (
<Fragment key={key}>
<td className="monospaced">{vote[key]?.encrypted}</td>
<td className="monospaced">{vote[key]?.lock}</td>
</Fragment>
)
}
})}
</tr>
))}
</tbody>
</table>
p {
margin-top: 0px;
font-size: 13px;
font-style: italic;
opacity: 0.7;
}
{!!newVotes && (
<p
className="inline-block mt-3 text-xs text-blue-500 cursor-pointer opacity-70 hover:underline"
onClick={() =>
fetch(`/api/election/${election_id}/accepted-votes?limitToLast=${newVotes}`)
.then((r) => r.json())
.then((newVotes) => setVotes(() => [...votes, ...newVotes]))
}
>
+ Load {newVotes} new
</p>
)}
a {
font-weight: 600;
}
<style jsx>{`
a {
font-weight: 600;
}
table {
border-collapse: collapse;
display: block;
overflow: auto;
margin-top: 2rem;
}
table {
border-collapse: collapse;
display: block;
overflow: auto;
margin-top: 2rem;
}
th,
td {
border: 1px solid #ccc;
padding: 3px 10px;
margin: 0;
max-width: 240px;
}
th,
td {
border: 1px solid #ccc;
padding: 3px 10px;
margin: 0;
max-width: 240px;
}
td.monospaced {
font-family: monospace;
}
td.monospaced {
font-family: monospace;
}
th,
.subheading td {
font-size: 11px;
font-weight: 700;
}
.approved {
font-weight: bold;
text-align: center;
}
`}</style>
</section>
th,
.subheading td {
font-size: 11px;
font-weight: 700;
}
`}</style>
</section>
</>
)
}
@@ -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])
}

View File

@@ -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
<td>{index + 1}.</td>
<td>{vote.tracking?.padStart(14, '0')}</td>
{columns.map((c) => (
<td key={c}>{vote[c]}</td>
<td key={c}>{unTruncateSelection(vote[c], ballot_design, c)}</td>
))}
</tr>
))}

View File

@@ -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 => {
<a onClick={toggle_encrypteds}>{show_encrypteds ? '[-] Hide' : '[+] Show'} Encrypted Submissions</a>
</p>
)}
<div style={{ display: show_encrypteds || !has_decrypted_votes ? 'block' : 'none' }}>
{show_encrypteds && has_decrypted_votes && <Mixnet />}
<AcceptedVotes {...{ ballot_design, esignature_requested, has_decrypted_votes }} />
</div>
{(show_encrypteds || has_decrypted_votes === false) && (
<div>
{show_encrypteds && has_decrypted_votes && <Mixnet />}
<AcceptedVotes {...{ ballot_design, esignature_requested, has_decrypted_votes }} />
<InvalidatedVotes />
</div>
)}
</div>
<Footer />
</main>

View File

@@ -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<EncryptedVote[]>(
!election_id ? null : `/api/election/${election_id}/invalidated-votes`,
fetcher,
)
if (!votes || !votes.length) return null
return (
<section className="p-4 mb-8 bg-white rounded shadow-md">
<h3 className="mb-1">Invalidated Votes</h3>
<p className="text-sm italic opacity-70">
{votes.length} vote{votes.length > 1 ? 's were' : ' was'} invalidated by the election administrator.{' '}
<a className="text-xs cursor-pointer" onClick={() => setTableVisibility(!isTableVisible)}>
{isTableVisible ? 'Hide' : 'Show'}
</a>
</p>
{isTableVisible && (
<>
<div className="text-xs text-gray-400">Auth Tokens:</div>
<table>
<tbody>
{votes.map((vote, index) => (
<tr key={index}>
<td>{index + 1}.</td>
<td className="">{vote.auth}</td>
</tr>
))}
</tbody>
</table>
</>
)}
</section>
)
}

View File

@@ -0,0 +1,7 @@
export const TotalVotesCast = ({ numVotes }: { numVotes: number }) => {
return (
<div className="inline-block px-2 py-1 mb-4 text-center bg-blue-200 shadow rounded-xl text-black/60">
Total votes cast: <span className="font-semibold text-black/90">{numVotes}</span>
</div>
)
}

View File

@@ -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 =>
</span>
)}
</div>
{ballot_design.map(({ id = 'vote', title }) => (
{ballot_design.map(({ id = 'vote', title, type }) => (
<div key={id}>
<h4>{title}</h4>
{type === 'ranked-choice-irv' && (
<div className="p-1 border-2 border-red-400 border-dashed rounded">
This was a Ranked Choice question. SIV does not yet support Ranked Choice tallying. The numbers below
represent who received <i>any</i> votes, ignoring rankings.
<p className="mt-1 mb-0 text-xs text-gray-500">
You can paste the Decrypted Votes table into a tallying program such as{' '}
<a href="https://rcvis.com" rel="noreferrer" target="_blank">
rcvis.com
</a>
</p>
</div>
)}
<ul>
{ordered[id].map((selection) => (
<li key={selection}>
{selection}: {tallies[id][selection]}{' '}
{unTruncateSelection(selection, ballot_design, id)}: {tallies[id][selection]}{' '}
<i style={{ fontSize: 12, marginLeft: 5, opacity: 0.5 }}>
({((100 * tallies[id][selection]) / totalsCastPerItems[id]).toFixed(1)}%)
</i>

View File

@@ -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
}

View File

@@ -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<typeof useSWR>[0],
fetcher: Parameters<typeof useSWR>[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 }
}

View File

@@ -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 <p>Loading ballot...</p>
}
// Calculate maximum write-in string length
const verification_num_length = 15
const max_string_length = maxLength - verification_num_length
if (!state.ballot_design) return <p>Loading ballot...</p>
if (!state.public_key) return <p>This ballot is not ready for votes yet</p>
return (
<NoSsr>
<Paper noFade>
<>
{state.election_title && <h2>{state.election_title}</h2>}
{state.ballot_design.map((item, index) =>
item.multiple_votes_allowed && item.multiple_votes_allowed > 1 ? (
<MultiVoteItem
{...{
...item,
dispatch,
max_string_length,
multiple_votes_allowed: item.multiple_votes_allowed,
state,
}}
key={index}
/>
) : (
<Item {...{ ...item, dispatch, max_string_length, state }} key={index} />
),
)}
{/* Election Title */}
{state.election_title && <h2 className="ml-[13px]">{state.election_title}</h2>}
{state.ballot_design.map((item, index) => {
const max_options = item.options.length + +item.write_in_allowed
// Is it "Approval" ?
if (item.type === 'approval')
return (
<MultiVoteItem {...{ ...item, dispatch, multiple_votes_allowed: max_options, state }} key={index} />
)
// Is it "Choose-up-to" ?
if (
item.type === 'multiple-votes-allowed' &&
item.multiple_votes_allowed &&
item.multiple_votes_allowed > 1
)
return (
<MultiVoteItem
{...{
...item,
dispatch,
multiple_votes_allowed: Math.min(item.multiple_votes_allowed, max_options),
state,
}}
key={index}
/>
)
// Is it "Ranked Choice"?
if (item.type === 'ranked-choice-irv')
return (
<RankedChoiceItem
{...{
...item,
dispatch,
rankings_allowed: Math.min(defaultRankingsAllowed, max_options),
state,
}}
key={index}
/>
)
// Otherwise, load default "Choose-only-one"
return <Item {...{ ...item, dispatch, state }} key={index} />
})}
</>
</Paper>
<style jsx>{`
h2 {
margin-left: 13px;
}
`}</style>
</NoSsr>
)
}

View File

@@ -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<Record<string, string>>
election_id?: string
max_string_length: number
state: State
}): JSX.Element => {
const [other, setOther] = useState<string>()

View File

@@ -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<Record<string, string>>
election_id?: string
max_string_length: number
multiple_votes_allowed: number
state: State
}): JSX.Element => {
@@ -42,8 +42,15 @@ export const MultiVoteItem = ({
return (
<>
<TitleDescriptionQuestion {...{ description, question, title }} />
<p className="remaining">
Remaining votes: {multiple_votes_allowed - selected.size} of {multiple_votes_allowed}
<p className="ml-[13px] italic ">
{type === 'approval' ? (
'Vote for all the options you approve of:'
) : (
<>
Remaining votes: {multiple_votes_allowed - selected.size} of {multiple_votes_allowed}
</>
)}
</p>
<FormGroup style={{ paddingLeft: '1.5rem' }}>
{options.map(({ name, sub, value }) => {
@@ -79,11 +86,6 @@ export const MultiVoteItem = ({
})}
</FormGroup>
<br />
<style jsx>{`
.remaining {
margin: 13px;
}
`}</style>
</>
)
}

View File

@@ -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<Record<string, string>>
election_id?: string
rankings_allowed: number
state: State
}): JSX.Element => {
// console.log(state.plaintext)
return (
<>
<TitleDescriptionQuestion {...{ description, question, title }} />
<table className="ml-3">
{/* Top row Choice labels */}
<thead>
<tr>
<th />
{new Array(rankings_allowed).fill(0).map((_, index) => (
<th className="text-[11px] text-center" key={index}>
{getOrdinal(index + 1)}
</th>
))}
</tr>
</thead>
{/* List one row for each candidate */}
<tbody>
{options.map(({ name, sub, value }) => {
const val = value || name.slice(0, max_string_length)
return (
<tr key={name}>
<td className="relative pr-4 bottom-0.5">
<Label {...{ name, sub }} />
</td>
{/* And one column for each ranking option */}
{new Array(rankings_allowed).fill(0).map((_, index) => (
<td className="ml-2" key={index}>
<input
readOnly
checked={state.plaintext[`${id}_${index + 1}`] === val}
className="w-7 h-7 bg-white border-gray-300 border-solid rounded-full appearance-none cursor-pointer hover:bg-blue-100 checked:!bg-[#002868] border-2 checked:border-white/30"
type="radio"
onClick={() => {
const update: Record<string, string> = {}
// 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)
}}
/>
</td>
))}
</tr>
)
})}
</tbody>
</table>
<br />
</>
)
}

View File

@@ -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 })

24
src/vote/getOrdinal.ts Normal file
View File

@@ -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"

View File

@@ -10,6 +10,7 @@ export type Item = {
options: { name: string; sub?: string; value?: string }[]
question?: string
title: string
type?: string
write_in_allowed: boolean
}

View File

@@ -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 (
<div className="p-3 mb-5 bg-red-100 rounded">
<h3 className="m-0">Your Submitted Vote was invalidated.</h3>
<p>If you believe this was in error, you can write a message to the Election Administrator:</p>
<hr />
<textarea
className="w-full h-16 p-1"
disabled={isSubmitSuccessful}
placeholder="Write your message here..."
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<br />
<button
disabled={isSubmitting || isSubmitSuccessful}
onClick={async () => {
setIsSubmitting(true)
const response = await api(`/election/${election_id}/submit-invalidation-response`, { auth, message })
if (!response.ok) return alert(`Error sending (status ${response.status})`)
setIsSubmitting(false)
setIsSubmitSuccessful(true)
}}
>
{isSubmitSuccessful ? 'Submitted successfully' : 'Submit'}
</button>
</div>
)
}

View File

@@ -4,9 +4,11 @@ import Link from 'next/link'
import { useEffect } from 'react'
import { useReducer } from 'react'
import { defaultRankingsAllowed } from '../Ballot'
import { State } from '../vote-state'
import { DetailedEncryptionReceipt } from './DetailedEncryptionReceipt'
import { EncryptedVote } from './EncryptedVote'
import { InvalidatedVoteMessage } from './InvalidatedVoteMessage'
import { UnlockedVote } from './UnlockedVote'
import { UnverifiedEmailModal } from './UnverifiedEmailModal'
@@ -24,22 +26,23 @@ export function SubmittedScreen({
// Widen the page for the tables
useEffect(() => {
const mainEl = document.getElementsByTagName('main')[0]
if (mainEl) {
mainEl.style.maxWidth = '880px'
}
if (mainEl) mainEl.style.maxWidth = '880px'
})
const columns = flatten(
state.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'
}),
state.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 (
<NoSsr>
<UnverifiedEmailModal />
<InvalidatedVoteMessage />
<Link as={`/election/${election_id}`} href="/election/[election_id]">
<a id="status-page" target="_blank">
<img src="/vote/externallinkicon.jpg" width="15px" />

View File

@@ -1,47 +1,35 @@
import { unTruncateSelection } from 'src/status/un-truncate-selection'
import { State } from '../vote-state'
export const UnlockedVote = ({ columns, state }: { columns: string[]; state: State }) => (
<div>
<table>
<div className="overflow-auto">
<table className="w-auto border-collapse table-auto whitespace-nowrap">
<thead>
<tr>
<th>verification #</th>
<th className="p-1 text-[11px] font-bold border border-gray-300 border-solid">verification #</th>
{columns.map((c) => (
<th key={c}>{c}</th>
<th className="p-1 text-[11px] font-bold border border-gray-300 border-solid" key={c}>
{c}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td style={{ backgroundColor: 'rgba(10, 232, 10, 0.24)' }}>{state.tracking?.padStart(14, '0')}</td>
<td className="p-1 bg-[#0AE80A44] border border-gray-300 border-solid">
{state.tracking?.padStart(14, '0')}
</td>
{columns.map((c) => {
const vote = state.plaintext[c]
return <td key={c}>{vote === 'BLANK' ? '' : vote}</td>
return (
<td className="p-1 border border-gray-300 border-solid max-w-90" key={c}>
{vote === 'BLANK' ? '' : unTruncateSelection(vote, state.ballot_design || [], c)}
</td>
)
})}
</tr>
</tbody>
</table>
<style jsx>{`
table {
border-collapse: collapse;
display: block;
overflow: auto;
overflow-wrap: break-word;
}
th,
td {
border: 1px solid #ccc;
padding: 3px 10px;
margin: 0;
max-width: 360px;
}
th,
.subheading td {
font-size: 11px;
font-weight: 700;
}
`}</style>
</div>
)

View File

@@ -48,8 +48,8 @@ function reducer(prev: State, payload: Map) {
return prev
}
// Generate a new tracking number
newState.tracking = generateTrackingNum()
// Generate Verification number if needed
if (!prev.tracking) newState.tracking = generateTrackingNum()
// Initialize empty dicts for intermediary steps
const randomizer: Map = {}