mirror of
https://github.com/siv-org/siv.git
synced 2026-01-10 02:47:58 -05:00
Merge branch 'main' into registration-link
This commit is contained in:
10
package.json
10
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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
pages/api/election/[election_id]/invalidated-votes.ts
Normal file
28
pages/api/election/[election_id]/invalidated-votes.ts
Normal 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)
|
||||
}
|
||||
23
pages/api/election/[election_id]/num-accepted-votes.ts
Normal file
23
pages/api/election/[election_id]/num-accepted-votes.ts
Normal 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)
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
18
pages/api/election/[election_id]/was-vote-invalidated.ts
Normal file
18
pages/api/election/[election_id]/was-vote-invalidated.ts
Normal 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)
|
||||
}
|
||||
@@ -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}` })
|
||||
}
|
||||
@@ -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}`
|
||||
|
||||
@@ -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
1
pages/demo.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { DemoPage as default } from '../src/demo/DemoPage'
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const default_ballot_design = `[
|
||||
{
|
||||
"id": "president",
|
||||
"type": "choose-only-one",
|
||||
"title": "Who should become President?",
|
||||
"options": [
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
@@ -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 />
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}),
|
||||
|
||||
BIN
src/admin/Voters/invalidated.png
Normal file
BIN
src/admin/Voters/invalidated.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
28
src/admin/useDynamicHeaderbarHeight.ts
Normal file
28
src/admin/useDynamicHeaderbarHeight.ts
Normal 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
|
||||
}
|
||||
@@ -87,24 +87,26 @@ SIV prevents accidentally sending invitations for invalidated tokens.
|
||||
|
||||
### Scenario III: The voter already voted, but votes haven’t 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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
47
src/status/InvalidatedVotes.tsx
Normal file
47
src/status/InvalidatedVotes.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
src/status/TotalVotesCast.tsx
Normal file
7
src/status/TotalVotesCast.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
26
src/status/un-truncate-selection.ts
Normal file
26
src/status/un-truncate-selection.ts
Normal 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
|
||||
}
|
||||
35
src/status/useSWRExponentialBackoff.ts
Normal file
35
src/status/useSWRExponentialBackoff.ts
Normal 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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
92
src/vote/RankedChoiceItem.tsx
Normal file
92
src/vote/RankedChoiceItem.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
24
src/vote/getOrdinal.ts
Normal 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"
|
||||
@@ -10,6 +10,7 @@ export type Item = {
|
||||
options: { name: string; sub?: string; value?: string }[]
|
||||
question?: string
|
||||
title: string
|
||||
type?: string
|
||||
write_in_allowed: boolean
|
||||
}
|
||||
|
||||
|
||||
50
src/vote/submitted/InvalidatedVoteMessage.tsx
Normal file
50
src/vote/submitted/InvalidatedVoteMessage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
Reference in New Issue
Block a user