Merge branch 'main' into registration-link

This commit is contained in:
David Ernst
2024-07-04 09:56:54 +03:00
131 changed files with 2976 additions and 959 deletions

24
.env.local.TEMPLATE Normal file
View File

@@ -0,0 +1,24 @@
# To use /admin interface
JWT_SECRET =
ADMIN_EMAIL =
# Pushover
PUSHOVER_APP_TOKEN =
PUSHOVER_USER_KEY =
# Firebase
FIREBASE_CLIENT_EMAIL =
FIREBASE_PRIVATE_KEY =
FIREBASE_PROJECT_ID =
# Mailgun
MAILGUN_API_KEY =
# Pusher
PUSHER_KEY =
PUSHER_SECRET =
# Supabase
SUPABASE_ADMIN_KEY =
SUPABASE_DB_URL =
SUPABASE_JWT_SECRET =

View File

@@ -23,10 +23,12 @@ module.exports = {
rules: {
'@typescript-eslint/explicit-module-boundary-types': 0, // Verbose
'@typescript-eslint/no-empty-function': 0, // unnecessary
'@typescript-eslint/no-unused-vars': 1, // hint not error
'react/jsx-sort-props': [2, { callbacksLast: true, shorthandFirst: true }], // style
'react/no-unknown-property': [2, { ignore: ['jsx', 'global'] }], // inserted by next's styled-jsx
'react/react-in-jsx-scope': 0, // Handled by Next.js
'sort-destructure-keys/sort-destructure-keys': 2, // style
'sort-keys-fix/sort-keys-fix': 2, // style
'sort-destructure-keys/sort-destructure-keys': 1, // style
'sort-keys-fix/sort-keys-fix': 1, // style
},
settings: {
react: {

2
.gitignore vendored
View File

@@ -14,3 +14,5 @@ cypress.env.json
TODO.md
*.log
*.todo
*.tsbuildinfo

View File

@@ -4,4 +4,7 @@ module.exports = {
},
extends: ['plugin:cypress/recommended'],
plugins: ['cypress'],
rules: {
'cypress/unsafe-to-chain-command': 0,
},
}

View File

@@ -4,7 +4,7 @@ describe('The webapp should render', () => {
})
it('/protocol', () => {
cy.visit('/protocol').contains('Secure Internet Voting (SIV) Protocol Overview')
cy.visit('/protocol').contains('Secure Internet Voting Protocol')
})
it('/about', () => {

View File

@@ -1,7 +1,7 @@
// Beginning of an algorithm to calculate max burst.
// Goal is to use this to see the highest # of votes submitted in a 60 window, 10 second window, 1 second etc.
function calculateMaxBurst(timestamps: number[], windowSize: number): number {
export function calculateMaxBurst(timestamps: number[], windowSize: number): number {
// Initialize variables
let startIdx = 0
let endIdx = 0

1
next-env.d.ts vendored
View File

@@ -1,5 +1,4 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited

View File

@@ -8,6 +8,15 @@ const withTM = require('next-transpile-modules')(['lodash-es'])
**/
const nextConfig = withMDX(
withTM({
async redirects() {
return [
{
destination: 'https://docs.siv.org/research-in-progress/ukraine',
permanent: true,
source: '/ukraine',
},
]
},
typescript: {
// !! WARN !!
// Dangerously allow production builds to successfully complete even if

View File

@@ -1,5 +1,5 @@
{
"name": "siv-demo",
"name": "siv-web",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -12,7 +12,8 @@
"lint": "eslint . --ext ts --ext tsx --ext js",
"start": "next start",
"test": "yarn lint && yarn typecheck && yarn e2e",
"typecheck": "tsc --pretty --noEmit"
"typecheck": "tsc --pretty --noEmit",
"unit": "bun test src"
},
"husky": {
"hooks": {
@@ -33,9 +34,12 @@
},
"dependencies": {
"@ant-design/icons": "~4.7",
"@codemirror/lang-json": "^6.0.1",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.0",
"@fingerprintjs/fingerprintjs-pro": "^3.5.1",
"@material-ui/core": "^4",
"@mdx-js/loader": "^2.0.0",
"@mui/material": "^5.15.13",
"@next/mdx": "^11.0.1",
"@noble/ed25519": "1.6.0",
"@peculiar/webcrypto": "^1.1.7",
@@ -45,9 +49,13 @@
"@types/bluebird": "^3.5.33",
"@types/lodash-es": "^4.17.3",
"@types/react": "18.2.6",
"@types/react-dom": "^18.2.22",
"@uiw/codemirror-extensions-zebra-stripes": "^4.21.25",
"@uiw/codemirror-theme-github": "^4.21.25",
"@uiw/react-codemirror": "^4.21.25",
"bigint-mod-arith": "^3.0.2",
"bluebird": "^3.7.2",
"codemirror": "^5.62.2",
"codemirror": "^6.0.1",
"cookies": "^0.8.0",
"cypress-mailslurp": "^1.3.0",
"email-validator": "^2.0.4",
@@ -56,29 +64,30 @@
"lodash-es": "^4.17.15",
"mailgun-js": "^0.22.0",
"moment": "^2.29.1",
"next": "11",
"next": "12",
"next-transpile-modules": "8",
"patch-package": "^6.4.7",
"pdf-lib": "^1.16.0",
"postinstall-postinstall": "^2.1.0",
"pusher": "^4.0.2",
"pusher-js": "^7.0.2",
"qr-code-styling": "^1.6.0-rc.1",
"react": "^18.2.0",
"react-codemirror2": "scniro/react-codemirror2#0f2bb13",
"react-dom": "^18.2.0",
"react-flip-move": "^3.0.4",
"react-flip-move": "3.0.5",
"react-linkify": "^1.0.0-alpha",
"react-scroll": "^1.8.3",
"react-signature-pad-wrapper": "^1.3.0",
"react-simple-code-editor": "^0.11.0",
"react-signature-pad-wrapper": "^3.3.4",
"react-transition-group": "^4.4.5",
"smoothscroll-polyfill": "^0.4.4",
"swr": "^0.4.0",
"swr": "2",
"throat": "^6.0.1",
"timeago-react": "^3.0.4",
"timeago.js": "^4.0.2",
"ua-parser-js": "^0.7.28"
},
"devDependencies": {
"@types/bun": "^1.0.8",
"@types/cookies": "^0.7.6",
"@types/jsonwebtoken": "^8.5.1",
"@types/mailgun-js": "^0.22.10",
@@ -87,8 +96,8 @@
"@types/react-scroll": "^1.8.2",
"@types/smoothscroll-polyfill": "^0.3.1",
"@types/ua-parser-js": "^0.7.36",
"@typescript-eslint/eslint-plugin": "^5",
"@typescript-eslint/parser": "^5",
"@typescript-eslint/eslint-plugin": "^6",
"@typescript-eslint/parser": "^6",
"autoprefixer": "^10.4.14",
"cypress": "9.4.1",
"cypress-localstorage-commands": "^1.6.1",
@@ -97,9 +106,9 @@
"eslint-config-prettier": "^6.10.1",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-react": "^7.20.1",
"eslint-plugin-sort-destructure-keys": "^1.3.5",
"eslint-plugin-sort-destructure-keys": "1.5.0",
"eslint-plugin-sort-keys-fix": "^1.1.1",
"eslint-plugin-typescript-sort-keys": "^2",
"eslint-plugin-typescript-sort-keys": "3.0",
"husky": "^4.2.3",
"import-sort-style-module": "^6.0.0",
"postcss": "^8.4.23",
@@ -107,12 +116,13 @@
"prettier-plugin-import-sort": "^0.0.4",
"prettier-plugin-packagejson": "^2.2.5",
"tailwindcss": "^3.3.1",
"typescript": "~4.4"
"typescript": "5.4"
},
"importSort": {
".js, .jsx, .ts, .tsx": {
"style": "module",
"parser": "typescript"
}
}
},
"packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72"
}

View File

@@ -0,0 +1 @@
export { ConventionsOverviewPage as default } from 'src/admin/Conventions/ConventionsOverviewPage'

View File

@@ -0,0 +1 @@
export { ManageConventionPage as default } from 'src/admin/Conventions/ManageConventionPage'

View File

@@ -0,0 +1 @@
export { DownloadQRsPage as default } from 'src/admin/Conventions/DownloadQRsPage'

View File

@@ -24,6 +24,10 @@ export const firebase = !Firebase.apps.length
})
: Firebase.app()
type SerializedTimestamp = { _seconds: number }
/** `new Date()`, which Firebase will automatically serialize into `{ _seconds: number }` */
export const newSerializedTimestamp = () => new Date() as unknown as SerializedTimestamp
/** Init mailgun */
export const mailgun = Mailgun({
apiKey: MAILGUN_API_KEY as string,

View File

@@ -5,11 +5,13 @@ import { firebase } from './_services'
import { checkJwt } from './validate-admin-jwt'
export type Election = {
ballot_design_finalized?: boolean
created_at: { _seconds: number }
election_title: string
id: string
num_voters?: number
num_votes?: number
threshold_public_key?: string
}
export default async (req: NextApiRequest, res: NextApiResponse) => {

View File

@@ -0,0 +1,61 @@
import { firebase, newSerializedTimestamp } from 'api/_services'
import { checkJwtOwnsConvention } from 'api/validate-admin-jwt'
import { firestore } from 'firebase-admin'
import { NextApiRequest, NextApiResponse } from 'next'
import { generateAuthToken } from 'src/crypto/generate-auth-tokens'
import { ensureBallotAuthsForQrs } from './ensure-ballot-auths-for-qrs'
export type QR_Id = {
ballot_auths?: { [ballot_id: string]: string }
createdAt: ReturnType<typeof newSerializedTimestamp>
index: number
qr_id: string
setIndex: number
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { convention_id } = req.query
if (!convention_id || typeof convention_id !== 'string')
return res.status(401).json({ error: `Missing convention_id` })
const { numQRs } = req.body
if (!numQRs || typeof numQRs !== 'number') return res.status(401).json({ error: `Missing numQRs` })
// Confirm they created this convention
const jwt = await checkJwtOwnsConvention(req, res, convention_id)
if (!jwt.valid) return
const doc = firebase.firestore().collection('conventions').doc(convention_id)
const createdAt = newSerializedTimestamp()
// Insert new voters in DB
const updateConventionDoc = doc.update({
num_qrs: firestore.FieldValue.increment(numQRs),
qrs: firestore.FieldValue.arrayUnion({ createdAt, number: numQRs }),
})
const { num_qrs: prev_num_qrs = 0 } = jwt
const newSetIndex = jwt.qrs?.length || 0
// Assign unique qr_ids
const createNewQrIds = Array.from({ length: numQRs }, () => generateAuthToken()).map((qr_id, i) =>
doc
.collection('qr_ids')
.doc(qr_id)
.set({
createdAt,
index: i + prev_num_qrs + 1,
qr_id,
setIndex: newSetIndex,
} as QR_Id),
)
await Promise.all(createNewQrIds)
await updateConventionDoc
await ensureBallotAuthsForQrs(convention_id)
return res.status(201).send({ success: true })
}

View File

@@ -0,0 +1,35 @@
import { firebase } from 'api/_services'
import { NextApiRequest, NextApiResponse } from 'next'
import { checkJwtOwnsConvention } from '../../validate-admin-jwt'
import { QR_Id } from './create-qrs'
export type ConventionSet = {
convention_title: string
qrs: QR_Id[]
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { convention_id, set } = req.query
if (!convention_id || typeof convention_id !== 'string')
return res.status(401).json({ error: `Missing convention_id` })
// Confirm they created this convention
const jwt = await checkJwtOwnsConvention(req, res, convention_id)
if (!jwt.valid) return
if (!set || typeof set !== 'string') return res.status(401).json({ error: `Missing set` })
// Get all voters in this set
const qrDocs = await firebase
.firestore()
.collection('conventions')
.doc(convention_id)
.collection('qr_ids')
.where('setIndex', '==', Number(set))
.get()
const qrs = qrDocs.docs.map((d) => d.data())
res.status(200).send({ convention_title: jwt.convention_title, qrs } as ConventionSet)
}

View File

@@ -0,0 +1,61 @@
import { firebase, pushover } from 'api/_services'
import { addVotersToElection } from 'api/election/[election_id]/admin/add-voters'
import { QR_Id } from './create-qrs'
const qrToEmail = (convention_id: string) => (qr: QR_Id) => `${qr.index}.qr.${qr.qr_id}@convention.${convention_id}`
/** Make sure all the current QR Codes for a Convention have matching ballot auth tokens for the active redirect
* Optionally pass in `election_id` if already known, to skip loading it
*/
export const ensureBallotAuthsForQrs = async (convention_id: string, known_election_id?: string) => {
const conventionDoc = firebase.firestore().collection('conventions').doc(convention_id)
let ballot_id = known_election_id
if (ballot_id === undefined) {
// Lookup active redirect
const { active_redirect } = (await conventionDoc.get()).data() as { active_redirect?: string }
ballot_id = active_redirect
}
// Stop if no active redirect
if (!ballot_id || ballot_id === '') return
// Lookup current QR codes
const currentQrs = (await conventionDoc.collection('qr_ids').get()).docs.map((d) => d.data() as QR_Id)
// Filter out only those that need new ballot auths
const needsNewAuth = currentQrs.filter((q) => !(q.ballot_auths || {})[ballot_id])
console.log('createBallotAuthsForQrs', { 'New auths needed': needsNewAuth.length, ballot_id, convention_id })
if (!needsNewAuth.length) return
// Make sure they're ordered lowest to highest
needsNewAuth.sort((a, b) => a.index - b.index)
// Generate unique emails for each QR
const newEmails = needsNewAuth.map(qrToEmail(convention_id))
// Create and store unique ballot auth tokens for each
const { already_added, email_to_auth, unique_new_emails } = await addVotersToElection(newEmails, ballot_id)
// Edge case if ballot already saw email, but the ballot_auth wasn't stored on QR_Id doc.
if (already_added.length > 0) {
console.log('Unexpected error! Already added:', already_added)
pushover('Unexpected convention error creating ballot auths', `Already added: ${already_added.join(', ')}`)
}
// To convert unique_new_emails back to QR objects
const emailToQR = needsNewAuth.reduce(
(memo, q) => ({ ...memo, [qrToEmail(convention_id)(q)]: q }),
{} as Record<string, QR_Id>,
)
// Store all the new auth tokens
await Promise.all(
unique_new_emails.map((email: string) => {
const qr = emailToQR[email]
const update = { ...qr.ballot_auths, [ballot_id]: email_to_auth[email] }
return conventionDoc.collection('qr_ids').doc(qr.qr_id).update({ ballot_auths: update })
}),
)
}

View File

@@ -0,0 +1,15 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { checkJwtOwnsConvention } from '../../validate-admin-jwt'
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { convention_id } = req.query
if (!convention_id || typeof convention_id !== 'string')
return res.status(401).json({ error: `Missing convention_id` })
// Confirm they created this convention
const jwt = await checkJwtOwnsConvention(req, res, convention_id)
if (!jwt.valid) return
res.status(200).send({ ...jwt })
}

View File

@@ -0,0 +1,44 @@
import { firebase } from 'api/_services'
import { NextApiRequest, NextApiResponse } from 'next'
type ConventionInfo = {
active_redirect?: string
convention_title: string
}
export type ConventionRedirectInfo = ConventionInfo & {
active_ballot_auth?: string
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { convention_id, qr_id } = req.query
if (typeof convention_id !== 'string') return res.status(401).json({ error: `Missing convention_id` })
if (typeof qr_id !== 'string') return res.status(401).json({ error: `Missing qr_id` })
// Start loading convention and QR info
const conventionDoc = firebase.firestore().collection('conventions').doc(convention_id)
const loadConvention = conventionDoc.get()
const qrDoc = await conventionDoc.collection('qr_ids').doc(qr_id).get()
// Do they both exist?
if (!(await loadConvention).exists) return res.status(401).json({ error: `Convention not found` })
const convention = { ...(await loadConvention).data() }
const conventionInfo = {
active_redirect: convention.active_redirect,
convention_title: convention.convention_title,
} as ConventionInfo
if (!qrDoc.exists) return res.status(401).json({ error: `QR ID not found`, info: conventionInfo })
const qr = { ...qrDoc.data() }
const active_ballot_auth = (qr.ballot_auths || {})[convention.active_redirect]
return res.status(200).send({
info: {
active_ballot_auth,
...conventionInfo,
} as ConventionRedirectInfo,
})
}

View File

@@ -0,0 +1,34 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { firebase } from '../../_services'
import { checkJwtOwnsConvention } from '../../validate-admin-jwt'
import { ensureBallotAuthsForQrs } from './ensure-ballot-auths-for-qrs'
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { convention_id } = req.query
if (typeof convention_id !== 'string') return res.status(401).json({ error: `Missing convention_id` })
const { election_id } = req.body
if (typeof election_id !== 'string') return res.status(401).json({ error: `Missing election_id` })
// Confirm they created this convention
const jwt = await checkJwtOwnsConvention(req, res, convention_id)
if (!jwt.valid) return
const conventionDoc = firebase.firestore().collection('conventions').doc(convention_id)
// If they're not removing the redirect...
if (election_id !== '') {
// Confirm they own the election
const election = { ...(await firebase.firestore().collection('elections').doc(election_id).get()).data() }
if (election.creator !== jwt.email)
return res.status(401).send({ error: `This user did not create election ${election_id}` })
await ensureBallotAuthsForQrs(convention_id, election_id)
}
// Save redirect in DB
await conventionDoc.update({ active_redirect: election_id })
return res.status(201).send({ success: true })
}

View File

@@ -0,0 +1,26 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { firebase } from '../_services'
import { checkJwt } from '../validate-admin-jwt'
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { convention_title } = req.body
// Confirm they're a valid admin
const jwt = checkJwt(req, res)
if (!jwt.valid) return
// Create a new convention
const convention_id = Number(new Date()).toString()
const convention = firebase.firestore().collection('conventions').doc(convention_id)
await convention.set({
convention_title,
created_at: new Date(),
creator: jwt.email,
id: convention_id,
num_qrs: 0,
})
// Send back our new convention_id
return res.status(201).json({ convention_id })
}

View File

@@ -0,0 +1,23 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { firebase } from '../_services'
import { checkJwt } from '../validate-admin-jwt'
export type Convention = {
convention_title: string
created_at: { _seconds: number }
id: string
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Confirm they're a valid admin
const jwt = checkJwt(req, res)
if (!jwt.valid) return
// Get all conventions created by this admin
const conventions = (
await firebase.firestore().collection('conventions').where('creator', '==', jwt.email).get()
).docs.reduce((acc: Convention[], doc) => [{ id: doc.id, ...doc.data() } as Convention, ...acc], [])
res.status(200).send({ conventions })
}

View File

@@ -6,17 +6,26 @@ import { generateAuthToken } from 'src/crypto/generate-auth-tokens'
export default async (req: NextApiRequest, res: NextApiResponse) => {
const { new_voters } = req.body
const { election_id } = req.query as { election_id: string }
const { election_id } = req.query
if (typeof election_id !== 'string') return res.status(401).json({ error: 'Missing election_id' })
// Confirm they're a valid admin that created this election
const jwt = await checkJwtOwnsElection(req, res, election_id)
if (!jwt.valid) return
if (!election_id) return res.status(401).json({ error: 'Missing election_id' })
const { already_added, unique_new_emails: unique_new_voters } = await addVotersToElection(new_voters, election_id)
// Generate auth token for each voter
const auth_tokens = new_voters.map(generateAuthToken)
return res.status(201).json({ already_added, unique_new_voters })
}
/** IMPORTANT: Assumes you already checked user owns election
*
* 1. Adds non-duplicate email addresses to ballot voter list
* 2. Creates and stores unique auth tokens for each
* 3. Updates election's num_voters tally
*/
export async function addVotersToElection(new_voters: string[], election_id: string) {
// Get existing voter from DB
const electionDoc = firebase.firestore().collection('elections').doc(election_id)
const loadVoters = electionDoc.collection('voters').get()
@@ -27,33 +36,36 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
const deduped_new_voters = Array.from(new Set(new_voters)) as string[]
// Separate uniques from already_added
const unique_new_voters: string[] = []
const unique_new_emails: string[] = []
const already_added: string[] = []
deduped_new_voters.forEach((v: string) => {
if (v) {
existing_voters.has(v) ? already_added.push(v) : unique_new_voters.push(v)
existing_voters.has(v) ? already_added.push(v) : unique_new_emails.push(v)
}
})
console.log({ already_added, unique_new_voters })
console.log('Add-voters:', { already_added, election_id, unique_new_emails })
const email_to_auth: Record<string, string> = {}
// Add uniques to DB
// Generate and store auths for uniques
await Promise.all(
unique_new_voters
.map((voter: string, index: number) =>
electionDoc
unique_new_emails
.map((email: string, index: number) => {
const auth_token = generateAuthToken()
email_to_auth[email] = auth_token
return electionDoc
.collection('voters')
.doc(voter)
.doc(email)
.set({
added_at: new Date(),
auth_token: auth_tokens[index],
email: voter,
auth_token,
email,
index: index + existing_voters.size,
}),
)
})
})
// Increment electionDoc's num_voters cached tally
.concat(electionDoc.update({ num_voters: firestore.FieldValue.increment(unique_new_voters.length) })),
.concat(electionDoc.update({ num_voters: firestore.FieldValue.increment(unique_new_emails.length) })),
)
return res.status(201).json({ already_added, unique_new_voters })
return { already_added, email_to_auth, unique_new_emails }
}

View File

@@ -4,6 +4,7 @@ import { Item } from 'src/vote/storeElectionInfo'
export type ElectionInfo = {
ballot_design?: Item[]
ballot_design_finalized?: boolean
election_title?: string
esignature_requested?: boolean
g?: string
@@ -41,6 +42,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
const {
ballot_design,
ballot_design_finalized,
election_title,
esignature_requested,
g,
@@ -52,6 +54,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
const info: ElectionInfo = {
ballot_design: ballot_design ? JSON.parse(ballot_design) : undefined,
ballot_design_finalized,
election_title,
esignature_requested,
g,

View File

@@ -34,16 +34,14 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
if (!ADMIN_EMAIL) return res.status(501).send('Missing process.env.ADMIN_EMAIL')
const { election_id } = req.query
if (typeof election_id !== 'string') return res.status(400).send('Malformed election_id')
const { body } = req
const { auth, email } = body
console.log('/api/update received:', body)
if (!email) return res.status(404).end()
const electionDoc = firebase
.firestore()
.collection('elections')
.doc(election_id as string)
const electionDoc = firebase.firestore().collection('elections').doc(election_id)
// Begin preloading these docs
const trusteeDoc = electionDoc.collection('trustees').doc(email)

View File

@@ -0,0 +1,30 @@
import { validate as validateEmail } from 'email-validator'
import { NextApiRequest, NextApiResponse } from 'next'
import { allowCors } from './_cors'
import { firebase, pushover } from './_services'
export default allowCors(async (req: NextApiRequest, res: NextApiResponse) => {
const { email: untrimmed } = req.body
const email = untrimmed.trim()
// Validate email
if (!email) return res.status(400).json({ error: 'Email is required' })
if (!validateEmail(email)) return res.status(400).json({ error: 'Invalid email' })
// Store submission in Firestore
await firebase
.firestore()
.collection('ukraine-updates')
.doc(new Date().toISOString() + ' ' + String(Math.random()).slice(2, 7))
.set({
created_at: new Date().toString(),
email,
})
// Notify admin via Pushover
pushover(`SIV ukraine signup`, email)
// Send back success
return res.status(201).json({ success: true })
})

View File

@@ -60,7 +60,7 @@ export async function checkJwtOwnsElection(
return { res: res.status(401).send({ error: `This user did not create election ${election_id}` }), valid: false }
}
// Other it passes
// Otherwise it passes
return {
ballot_design_finalized: election.ballot_design_finalized,
election_manager: election.election_manager,
@@ -68,3 +68,43 @@ export async function checkJwtOwnsElection(
...jwt_status,
}
}
export type Convention = {
active_redirect?: string
convention_title: string
created_at: { _seconds: number }
creator: string
id: string
num_qrs: number
qrs: { createdAt: { _seconds: number }; number: number }[]
}
export async function checkJwtOwnsConvention(
req: NextApiRequest,
res: NextApiResponse,
convention_id: string,
): Promise<{ res: void; valid: false } | ({ valid: true } & Convention & JWT_Payload)> {
const jwt_status = checkJwt(req, res)
// Fail immediately if checkJwt failed
if (!jwt_status.valid) return jwt_status
// Grab this convention info
const convention = {
...(await firebase.firestore().collection('conventions').doc(convention_id).get()).data(),
} as Convention
// Check if this this admin is the creator of the given convention
if (jwt_status.email !== convention.creator) {
return {
res: res.status(401).send({ error: `This user did not create convention ${convention_id}` }),
valid: false,
}
}
// Otherwise it passes
return {
...convention,
...jwt_status,
}
}

View File

@@ -0,0 +1 @@
export { ConventionQRPage as default } from 'src/convention/ConventionQRPage'

1
pages/intro.tsx Normal file
View File

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

View File

@@ -1,14 +0,0 @@
diff --git a/node_modules/next/dist/lib/typescript/missingDependencyError.js b/node_modules/next/dist/lib/typescript/missingDependencyError.js
index 6c0cf4f..41e37c7 100644
--- a/node_modules/next/dist/lib/typescript/missingDependencyError.js
+++ b/node_modules/next/dist/lib/typescript/missingDependencyError.js
@@ -18,7 +18,7 @@ function missingDepsError(dir, missingPackages) {
const packagesCli = missingPackages.map((p)=>p.pkg
).join(' ');
const removalMsg = '\n\n' + _chalk.default.bold('If you are not trying to use TypeScript, please remove the ' + _chalk.default.cyan('tsconfig.json') + ' file from your package root (and any TypeScript files in your pages directory).');
- throw new _fatalError.FatalError(_chalk.default.bold.red(`It looks like you're trying to use TypeScript but do not have the required package(s) installed.`) + '\n\n' + _chalk.default.bold(`Please install ${_chalk.default.bold(packagesHuman)} by running:`) + '\n\n' + `\t${_chalk.default.bold.cyan(((0, _isYarn).isYarn(dir) ? 'yarn add --dev' : 'npm install --save-dev') + ' ' + packagesCli)}` + removalMsg + '\n');
+ // throw new _fatalError.FatalError(_chalk.default.bold.red(`It looks like you're trying to use TypeScript but do not have the required package(s) installed.`) + '\n\n' + _chalk.default.bold(`Please install ${_chalk.default.bold(packagesHuman)} by running:`) + '\n\n' + `\t${_chalk.default.bold.cyan(((0, _isYarn).isYarn(dir) ? 'yarn add --dev' : 'npm install --save-dev') + ' ' + packagesCli)}` + removalMsg + '\n');
}
//# sourceMappingURL=missingDependencyError.js.map
\ No newline at end of file

BIN
public/VOTE.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

350
src/TailwindPreflight.tsx Normal file
View File

@@ -0,0 +1,350 @@
// Lightly adapted from node_modules/tailwindcss/lib/css/preflight.css
// for importing into pages one-by-one
//
// Key change:
// 6 instances: theme($1) -> $1
// Resolving variable gray-400 to its hex
export const TailwindPreflight = () => {
return (
<style global jsx>{`
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
*/
*,
::before,
::after {
box-sizing: border-box; /* 1 */
border-width: 0; /* 2 */
border-style: solid; /* 2 */
border-color: #dae4e9; /* 2 */
}
::before,
::after {
--tw-content: '';
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the 'sans' font-family by default.
*/
html {
line-height: 1.5; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
-moz-tab-size: 4; /* 3 */
tab-size: 4; /* 3 */
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; /* 4 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from 'html' so users can set them as a class directly on the 'html' element.
*/
body {
margin: 0; /* 1 */
line-height: inherit; /* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0; /* 1 */
color: inherit; /* 2 */
border-top-width: 1px; /* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the 'mono' font family by default.
2. Correct the odd 'em' font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; /* 1 */
font-size: 1em; /* 2 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0; /* 1 */
border-color: inherit; /* 2 */
border-collapse: collapse; /* 3 */
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
font-weight: inherit; /* 1 */
line-height: inherit; /* 1 */
color: inherit; /* 1 */
margin: 0; /* 2 */
padding: 0; /* 3 */
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button; /* 1 */
background-color: transparent; /* 2 */
background-image: none; /* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional ':invalid' styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to 'inherit' in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to gray-400.
*/
input::placeholder,
textarea::placeholder {
opacity: 1; /* 1 */
color: #9ca3af; /* 2 */
}
/*
Set the default cursor for buttons.
*/
button,
[role='button'] {
cursor: pointer;
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements 'display: block' by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add 'vertical-align: middle' to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block; /* 1 */
vertical-align: middle; /* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden] {
display: none;
}
`}</style>
)
}

View File

@@ -54,14 +54,14 @@ type OnClickProps = ButtonProps & {
export const OnClickButton = forwardRef<HTMLAnchorElement, OnClickProps>(
(
{
background,
children,
disabled,
disabledExplanation,
onClick,
invertColor,
id,
background,
invertColor,
noBorder,
onClick,
style = {},
}: OnClickProps,
ref,

10
src/_shared/Forms/Row.tsx Normal file
View File

@@ -0,0 +1,10 @@
export const Row = (props: { children?: React.ReactNode; className?: string; style?: React.CSSProperties }) => (
<div
{...props}
style={{
display: 'flex',
margin: '1.5rem 0',
...props.style,
}}
/>
)

25
src/_shared/NoSsr.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react'
interface NoSSRProps {
/** The content to render on client. */
children?: React.JSX.Element | (React.JSX.Element | false)[]
/** Optional content to show before the component renders on client. This renders during server-side rendering (SSR). */
onSSR?: React.FC
}
const EmptySpan = () => <span />
export const NoSsr = (props: NoSSRProps) => {
const { children = <EmptySpan />, onSSR = EmptySpan } = props
const [isMounted, setIsMounted] = useState<boolean>(false)
useEffect(() => {
setIsMounted(true)
return () => {
setIsMounted(false)
}
})
return isMounted ? <>{children}</> : onSSR({})
}

View File

@@ -3,7 +3,8 @@ import React, { Fragment } from 'react'
import { darkBlue } from 'src/homepage/colors'
import { research } from './research'
import { Team } from './Team'
// import { Team } from './Team'
export const Content = () => (
<main>
@@ -42,7 +43,7 @@ export const Content = () => (
<br />
<br />
<Team />
{/* <Team /> */}
<style jsx>{`
main {

View File

@@ -5,6 +5,7 @@ import { Head } from '../Head'
import { AllYourElections } from './AllYourElections'
import { useLoginRequired, useUser } from './auth'
import { BallotDesign } from './BallotDesign/BallotDesign'
import { YourConventionsLink } from './Conventions/YourConventionsLink'
import { CreateNewElection } from './CreateNewElection'
import { HeaderBar } from './HeaderBar'
import { MarkedBallots } from './MarkedBallots/MarkedBallots'
@@ -20,53 +21,35 @@ export const AdminPage = (): JSX.Element => {
useLoginRequired(loggedOut)
usePusher(election_id as string | undefined)
if (loading || loggedOut) return <p className="p-4 text-[21px]">Loading...</p>
if (loading || loggedOut) return <p style={{ fontSize: 21, padding: '1rem' }}>Loading...</p>
return (
<>
<Head title="Create new election" />
<HeaderBar />
<main>
<main className="flex w-full">
<Sidebar />
<div id="main-content">
<div
className="w-full p-4 overflow-auto absolute top-[66px] right-0 bottom-0 sm:left-[215px] sm:px-8 sm:w-auto"
id="main-content"
>
<MobileMenu />
{!election_id && <AllYourElections />}
{!election_id && <CreateNewElection />}
{!election_id && (
<>
<AllYourElections />
<CreateNewElection />
<YourConventionsLink />
</>
)}
{section === 'observers' && <Observers />}
{section === 'ballot-design' && <BallotDesign />}
{section === 'voters' && <AddVoters />}
{section === 'marked-ballots' && <MarkedBallots />}
</div>
</main>
<style jsx>{`
main {
width: 100%;
display: flex;
}
#main-content {
padding: 1rem 2rem;
overflow: auto;
position: absolute;
left: 215px;
top: 66px;
right: 0;
bottom: 0;
}
/* When sidebar disappears */
@media (max-width: 640px) {
#main-content {
left: 0;
width: 100%;
padding: 1rem;
}
}
`}</style>
<GlobalCSS />
<style global jsx>{`
body {

View File

@@ -1,30 +1,32 @@
import { Election } from 'api/admin-all-elections'
import Link from 'next/link'
import { useReducer } from 'react'
import useSWR from 'swr'
import TimeAgo from 'timeago-react'
import { Election } from '../../pages/api/admin-all-elections'
export const fetcher = (url: string) =>
fetch(url).then(async (resp) => {
if (!resp.ok) throw await resp.json()
return await resp.json()
})
export const useAllYourElections = () => useSWR('/api/admin-all-elections', fetcher)
export const AllYourElections = () => {
const [show, toggle] = useReducer((state) => !state, false)
const { data } = useSWR('/api/admin-all-elections', (url: string) =>
fetch(url).then(async (r) => {
if (!r.ok) throw await r.json()
return await r.json()
}),
)
const { data } = useAllYourElections()
return (
<>
<h2>
Your Elections: <i>{data?.elections?.length}</i>{' '}
Your Ballots: <span className="font-normal">{data?.elections?.length}</span>{' '}
{!!data?.elections?.length && (
<span>
<a onClick={toggle}>[ {show ? '- Hide' : '+ Show'} ]</a>
</span>
<a className="text-[14px] font-normal ml-1 cursor-pointer" onClick={toggle}>
[ {show ? '- Hide' : '+ Show'} ]
</a>
)}
</h2>
{show && (
<ul>
{data?.elections?.map(({ created_at, election_title, id }: Election) => (
@@ -39,23 +41,6 @@ export const AllYourElections = () => {
<br />
</ul>
)}
<style jsx>{`
h2 i {
font-weight: 400;
font-style: normal;
}
h2 span {
font-size: 14px;
font-weight: normal;
margin-left: 5px;
}
h2 span:hover {
cursor: pointer;
}
`}</style>
</>
)
}

View File

@@ -1,6 +1,6 @@
import { NoSsr } from '@material-ui/core'
import router from 'next/router'
import { useEffect, useState } from 'react'
import { NoSsr } from 'src/_shared/NoSsr'
import { api } from '../../api-helper'
import { SaveButton } from '../SaveButton'
@@ -39,9 +39,22 @@ export const BallotDesign = () => {
return (
<>
<h2 className="hidden sm:block">Ballot Design</h2>
<h2 className="hidden sm:block">
Ballot Design
{/* Preview button */}
<a
className="inline-block text-[12px] font-semibold !no-underline hover:bg-gray-100 ml-3 px-3 py-1 border border-blue-900 border-solid rounded-lg relative bottom-0.5 text-blue-900"
href={`/election/${election_id}/vote?auth=preview`}
rel="noreferrer"
target="_blank"
>
🔍 Preview
</a>
</h2>
<AutoSaver {...{ design }} />
<Errors {...{ error }} />
<ModeControls {...{ selected, setSelected }} />
<div className="mode-container">
{selected !== 1 && <Wizard {...{ design, setDesign }} />}

View File

@@ -1,7 +1,29 @@
import 'codemirror/lib/codemirror.css'
import 'codemirror/theme/idea.css'
import 'codemirror/mode/javascript/javascript'
import { json } from '@codemirror/lang-json'
import { tags as t } from '@lezer/highlight'
import { githubLightInit } from '@uiw/codemirror-theme-github'
import CodeMirror, { ReactCodeMirrorProps } from '@uiw/react-codemirror'
import { Controlled as CodeMirror } from 'react-codemirror2'
const CodeMirrorComponent = (props: ReactCodeMirrorProps) => (
<>
<CodeMirror
{...props}
basicSetup={{ foldGutter: false }}
extensions={[json()]}
theme={githubLightInit({
settings: { foreground: 'hsl(0, 0%, 60%)', gutterBackground: '#f8f8f8', gutterForeground: '#999' },
styles: [
{ color: 'rgb(23, 26, 79)', tag: t.propertyName },
{ color: 'green', tag: t.string },
{ color: 'rgb(116, 69, 0)', fontWeight: 'bold', tag: t.bool },
],
})}
/>
<style global jsx>{`
.cm-focused {
outline: none !important;
}
`}</style>
</>
)
export default CodeMirror
export default CodeMirrorComponent

View File

@@ -1,61 +0,0 @@
import { Theme, withStyles } from '@material-ui/core/styles'
import Switch, { SwitchClassKey, SwitchProps } from '@material-ui/core/Switch'
const height = 22
interface Styles extends Partial<Record<SwitchClassKey, string>> {
focusVisible?: string
}
export const IOSSwitch = withStyles((theme: Theme) => ({
checked: {},
focusVisible: {},
root: {
height,
margin: 5,
padding: 0,
width: height * 2 - 6,
},
switchBase: {
'&$checked': {
'& + $track': {
backgroundColor: '#009319',
border: 'none',
opacity: 1,
},
color: theme.palette.common.white,
transform: 'translateX(16px)',
},
'&$focusVisible $thumb': {
border: '6px solid #fff',
color: '#009319',
},
padding: 1,
},
thumb: {
height: height - 2,
width: height - 2,
},
track: {
backgroundColor: theme.palette.grey[50],
border: `1px solid ${theme.palette.grey[400]}`,
borderRadius: 26 / 2,
opacity: 1,
transition: theme.transitions.create(['background-color', 'border']),
},
}))(({ classes, ...props }: { classes: Styles } & SwitchProps) => {
return (
<Switch
disableRipple
classes={{
checked: classes.checked,
root: classes.root,
switchBase: classes.switchBase,
thumb: classes.thumb,
track: classes.track,
}}
focusVisibleClassName={classes.focusVisible}
{...props}
/>
)
})

View File

@@ -0,0 +1,17 @@
export const Switch = ({ checked, label, onClick }: { checked: boolean; label: string; onClick: () => void }) => {
return (
<span {...{ onClick }}>
<input
readOnly
className="relative top-2.5 border-solid border border-neutral-400/60 h-[22px] w-[38px] appearance-none rounded-full bg-neutral-100 after:absolute after:z-[2] after:h-[18px] after:w-[18px] after:rounded-full after:shadow-[0_1px_2px_-1px_rgba(0,0,0,0.6)] after:bg-neutral-50 after:ml-px after:mt-px after:transition-[transform_0.2s] after:content-[''] transition-[background-color_0.2s] checked:bg-[#009319] checked:after:ml-[17px] focus:outline-none focus:ring-0 cursor-pointer"
id={`switch-${label}`}
role="switch"
type="checkbox"
{...{ checked }}
/>
<label className="pl-[0.15rem] relative bottom-0.5 cursor-pointer" htmlFor={`switch-${label}`} {...{ onClick }}>
{label}
</label>
</span>
)
}

View File

@@ -1,43 +1,16 @@
import dynamic from 'next/dynamic'
const CodeMirror = dynamic(import('./CodeMirror'), { ssr: false })
const CodeMirror = dynamic(() => import('./CodeMirror'), { ssr: false })
export const TextDesigner = ({ design, setDesign }: { design: string; setDesign: (s: string) => void }) => {
return (
<div>
<div className="relative flex-1">
<CodeMirror
options={{
lineNumbers: true,
mode: 'application/json',
theme: 'idea',
}}
height="auto"
style={{ border: '1px solid #ccc', borderRadius: 3, borderTopRightRadius: 0 }}
value={design}
onBeforeChange={(editor, data, value) => {
setDesign(value)
}}
onChange={(value) => setDesign(value)}
/>
<style jsx>{`
div {
flex: 1;
position: relative;
}
`}</style>
<style global jsx>{`
.react-codemirror2 {
border: 1px solid #ccc;
border-radius: 4px;
border-top-right-radius: 0;
font-size: 12px;
}
.CodeMirror {
height: unset;
color: hsl(0, 0%, 58%) !important;
}
span.cm-property {
color: rgb(23, 26, 79) !important;
}
`}</style>
</div>
)
}

View File

@@ -4,10 +4,15 @@ import { Tooltip } from 'src/admin/Voters/Tooltip'
import { Item } from 'src/vote/storeElectionInfo'
import { check_for_urgent_ballot_errors } from './check_for_ballot_errors'
import { IOSSwitch } from './IOSSwitch'
import { Switch } from './Switch'
export const default_multiple_votes_allowed = 3
export const default_min_score = -5
export const default_max_score = 5
export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: string) => void }) => {
const [json, setJson] = useState<Item[]>()
const saveDesign = (json: Item[]) => setDesign(JSON.stringify(json, undefined, 2))
const errors = check_for_urgent_ballot_errors(design)
@@ -22,175 +27,217 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
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"
title={
<span style={{ fontSize: 14 }}>
This unique short ID is used as the column header for the table of submitted votes.
{json?.map(
({ id, max_score, min_score, 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"
tooltip={
<span style={{ fontSize: 14 }}>
This unique short ID is used as the column header for the table of submitted votes.
</span>
}
>
<span className="relative top-0 text-sm text-indigo-500 left-1">
<QuestionCircleOutlined />
</span>
}
>
<span className="relative top-0 text-sm text-indigo-500 left-1">
<QuestionCircleOutlined />
</span>
</Tooltip>
</label>
{/* Question ID Input */}
<div className="flex items-center justify-between">
<input
className="p-1 text-sm"
value={id}
onChange={({ target }) => {
const new_json = [...json]
new_json[questionIndex].id = target.value
setDesign(JSON.stringify(new_json, undefined, 2))
}}
/>
{/* Delete Question btn */}
<Tooltip placement="top" title="Delete Question">
<a
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)
setDesign(JSON.stringify(new_json, undefined, 2))
}}
>
<DeleteOutlined />
</a>
</Tooltip>
</div>
{/* 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>
</Tooltip>
</label>
{/* Question ID Input */}
<div className="flex items-center justify-between">
<input
className="p-1 ml-1 text-sm"
value={multiple_votes_allowed}
className="p-1 text-sm"
value={id}
onChange={({ target }) => {
const update = +target.value
const new_json = [...json]
new_json[questionIndex].multiple_votes_allowed = update
setDesign(JSON.stringify(new_json, undefined, 2))
new_json[questionIndex].id = target.value
saveDesign(new_json)
}}
/>
</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="flex-1 p-1 text-sm"
value={title}
onChange={({ target }) => {
const new_json = [...json]
new_json[questionIndex].title = target.value
setDesign(JSON.stringify(new_json, undefined, 2))
}}
/>
</div>
{/* Options list */}
<ul>
{options?.map(({ name }, optionIndex) => (
<li key={optionIndex}>
{/* Option input */}
<input
className="p-[5px] mb-1.5 text-[13px]"
value={name}
onChange={({ target }) => {
const new_json = [...json]
new_json[questionIndex].options[optionIndex].name = target.value
setDesign(JSON.stringify(new_json, undefined, 2))
}}
/>
{/* Delete Option btn */}
{/* Delete Question btn */}
<Tooltip placement="top" tooltip="Delete Question">
<a
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"
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[questionIndex].options.splice(optionIndex, 1)
setDesign(JSON.stringify(new_json, undefined, 2))
new_json.splice(questionIndex, 1)
saveDesign(new_json)
}}
>
<DeleteOutlined />
</a>
</li>
))}
{/* Add another option btn */}
<a
className="block pl-2 mt-1 mb-[14px] text-[13px] italic cursor-pointer"
onClick={() => {
const new_json = [...json]
new_json[questionIndex].options.push({ name: '' })
setDesign(JSON.stringify(new_json, undefined, 2))
}}
>
+ Add another option
</a>
</Tooltip>
</div>
{/* 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={() => {
{/* 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 = default_multiple_votes_allowed
}
if (
(new_json[questionIndex].type == 'score' && !new_json[questionIndex].min_score) ||
!new_json[questionIndex].max_score
) {
new_json[questionIndex].min_score = default_min_score
new_json[questionIndex].max_score = default_max_score
}
saveDesign(new_json)
}}
>
<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>
<option value="score">Score Scale from Low to High</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
saveDesign(new_json)
}}
/>
</div>
)}
{json[questionIndex].type == 'score' && (
<div className="mt-1">
<label className="text-[10px] italic">Min score?</label>
<input
className="p-1 m-1 text-sm w-14"
type="number"
value={min_score}
onChange={({ target }) => {
const update = +target.value
const new_json = [...json]
new_json[questionIndex].min_score = update
saveDesign(new_json)
}}
/>
<label className="ml-5 text-[10px] italic">Max score?</label>
<input
className="p-1 ml-1 text-sm w-14"
type="number"
value={max_score}
onChange={({ target }) => {
const update = +target.value
const new_json = [...json]
new_json[questionIndex].max_score = update
saveDesign(new_json)
}}
/>
</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="flex-1 p-1 text-sm"
value={title}
onChange={({ target }) => {
const new_json = [...json]
new_json[questionIndex].write_in_allowed = !write_in_allowed
setDesign(JSON.stringify(new_json, undefined, 2))
new_json[questionIndex].title = target.value
saveDesign(new_json)
}}
/>
</li>
</ul>
</div>
))}
</div>
{/* Options list */}
<ul>
{options?.map(({ name }, optionIndex) => (
<li key={optionIndex}>
{/* Option input */}
<input
className="p-[5px] mb-1.5 text-[13px]"
value={name}
onChange={({ target }) => {
const new_json = [...json]
new_json[questionIndex].options[optionIndex].name = target.value
saveDesign(new_json)
}}
/>
{/* Delete Option btn */}
<a
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)
saveDesign(new_json)
}}
>
</a>
</li>
))}
{/* Add another option btn */}
<a
className="block pl-2 mt-1 mb-[14px] text-[13px] italic cursor-pointer"
onClick={() => {
const new_json = [...json]
new_json[questionIndex].options.push({ name: '' })
saveDesign(new_json)
}}
>
+ Add another option
</a>
{/* 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>
<Switch
checked={write_in_allowed}
label={''}
onClick={() => {
const new_json = [...json]
new_json[questionIndex].write_in_allowed = !write_in_allowed
saveDesign(new_json)
}}
/>
</li>
</ul>
</div>
),
)}
{/* "Add another question" btn */}
<a
className="block w-full p-2.5 italic bg-white my-[15px] text-[13px] cursor-pointer"
className="block w-full p-2.5 italic bg-white my-[15px] text-[13px] cursor-pointer hover:bg-gray-50/80 hover:no-underline"
onClick={() => {
const new_json = [...(json || [])]
const new_question_number = new_json.length + 1
@@ -203,7 +250,7 @@ export const Wizard = ({ design, setDesign }: { design: string; setDesign: (s: s
options: [{ name: 'Option 1' }, { name: 'Option 2' }],
write_in_allowed: false,
})
setDesign(JSON.stringify(new_json, undefined, 2))
saveDesign(new_json)
}}
>
+ Add another question

View File

@@ -0,0 +1,43 @@
import { Convention } from 'api/conventions/list-all-conventions'
import Link from 'next/link'
import { useReducer } from 'react'
import useSWR from 'swr'
import TimeAgo from 'timeago-react'
export const AllYourConventions = () => {
const [show, toggle] = useReducer((state) => !state, true)
const { data } = useSWR('/api/conventions/list-all-conventions', (url: string) =>
fetch(url).then(async (r) => {
if (!r.ok) throw await r.json()
return await r.json()
}),
)
return (
<>
<h2>
Your Conventions: <span className="font-normal">{data?.conventions?.length}</span>{' '}
{!!data?.conventions?.length && (
<a className="text-[14px] font-normal ml-1 cursor-pointer" onClick={toggle}>
[ {show ? '- Hide' : '+ Show'} ]
</a>
)}
</h2>
{show && (
<ul>
{data?.conventions?.map(({ convention_title, created_at, id }: Convention) => (
<li key={id}>
<Link href={`/admin/conventions/${id}`}>
<a>
<TimeAgo datetime={new Date(created_at._seconds * 1000)} />: {convention_title}
</a>
</Link>
</li>
))}
<br />
</ul>
)}
</>
)
}

View File

@@ -0,0 +1,36 @@
import { GlobalCSS } from 'src/GlobalCSS'
import { Head } from 'src/Head'
import { useLoginRequired, useUser } from '../auth'
import { HeaderBar } from '../HeaderBar'
import { AllYourConventions } from './AllYourConventions'
import { CreateNewConvention } from './CreateNewConvention'
import { QRFigure } from './QRFigure'
export const ConventionsOverviewPage = () => {
const { loading, loggedOut } = useUser()
useLoginRequired(loggedOut)
if (loading || loggedOut) return <p className="p-4 text-[21px]">Loading...</p>
return (
<>
<Head title="Your Conventions" />
<HeaderBar />
<main className="max-w-2xl p-4 mx-auto sm:px-8 overflow-clip">
<div className="text-center">
<h2>SIV Conventions</h2>
<p>Create re-usable login credentials for voters to use across multiple ballots in a single day.</p>
<QRFigure className="mb-12" />
</div>
<CreateNewConvention />
<AllYourConventions />
</main>
<GlobalCSS />
</>
)
}

View File

@@ -0,0 +1,43 @@
import router from 'next/router'
import { useRef, useState } from 'react'
import { api } from '../../api-helper'
import { SaveButton } from '../SaveButton'
export const CreateNewConvention = () => {
const [convention_title, set_title] = useState('')
const $input = useRef<HTMLInputElement>(null)
const $saveBtn = useRef<HTMLAnchorElement>(null)
return (
<>
<h2>Create New Convention</h2>
<label>Convention Title:</label>
<input
className="w-full p-2 text-sm border border-gray-300 border-solid rounded"
placeholder="Give your convention a name you will recognize"
ref={$input}
value={convention_title}
onChange={(event) => set_title(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
$input.current?.blur()
$saveBtn.current?.click()
}
}}
/>
<SaveButton
ref={$saveBtn}
onPress={async () => {
const response = await api('conventions/create-convention', { convention_title })
if (response.status !== 201) throw await response.json()
// Set convention_id in URL
const { convention_id } = await response.json()
router.push(`${window.location.origin}/admin/conventions/${convention_id}`)
}}
/>
</>
)
}

View File

@@ -0,0 +1,42 @@
import { useRef, useState } from 'react'
import { api } from 'src/api-helper'
import { SaveButton } from './SaveButton'
import { revalidate } from './useConventionInfo'
export const CreateVoterCredentials = ({ convention_id }: { convention_id: string }) => {
const [numQRs, setNumQRs] = useState<string>('')
const $saveBtn = useRef<HTMLAnchorElement>(null)
return (
<div>
<label>Create how many voter QR credentials?</label>
<input
className="w-20 ml-3 text-lg"
min="1"
placeholder="150"
type="number"
value={numQRs}
onChange={(e) => setNumQRs(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
$saveBtn.current?.click()
}
}}
/>
<SaveButton
disabled={!numQRs}
ref={$saveBtn}
text="Create"
onPress={async () => {
await api(`/conventions/${convention_id}/create-qrs`, { numQRs: Number(numQRs) })
revalidate(convention_id)
setNumQRs('')
}}
/>
</div>
)
}

View File

@@ -0,0 +1,65 @@
import type { ConventionSet } from 'api/conventions/[convention_id]/download-set'
import { useRouter } from 'next/router'
import { Head } from 'src/Head'
import useSWR from 'swr'
import TimeAgo from 'timeago-react'
import { QRCode } from './QRCode'
const fetcher = (url: string) => fetch(url).then((r) => r.json())
export const DownloadQRsPage = () => {
const { c, set } = useRouter().query
// Download voters on page load
const { data, error, isLoading } = useSWR(
!c || !set ? null : `/api/conventions/${c}/download-set?set=${set}`,
fetcher,
)
// Handle error conditions
if (!c || typeof c !== 'string') return <p className="p-4">Missing convention_id</p>
if (!set || typeof set !== 'string') return <p className="p-4">Missing set</p>
if (error) return <p className="p-4">Error: {JSON.stringify(error)}</p>
// console.log('swr ran:', { c, data, isLoading, set })
if (!data || isLoading) return <p className="p-4">Loading...</p>
const { convention_title, qrs } = data as ConventionSet
if (!qrs.length) return <p className="p-4">Empty set</p>
const createdAt = new Date(qrs[0].createdAt._seconds * 1000)
const sortedQrs = qrs.sort((a, b) => a.index - b.index)
return (
<div className="p-4 overflow-auto">
<Head title={`${qrs.length} for ${convention_title}`} />
{/* Header row */}
<p className="flex justify-between">
<span className="font-bold">Right Click {'→'} Print </span>
<span>
{convention_title}: Set of {qrs.length}
</span>
<span>
<span className="text-sm"> Created </span>
{createdAt.toLocaleString()} <span className="text-sm opacity-60">({<TimeAgo datetime={createdAt} />})</span>
</span>
</p>
{/* Grid of QRs */}
<div className="-mx-2.5">
{sortedQrs.map(({ index, qr_id }, i) => (
<div className="mx-2.5 my-1.5 text-center inline-block break-inside-avoid" key={i}>
<span className="text-sm opacity-50">{qr_id}</span>
<QRCode {...{ convention_id: c, qr_id }} />
<span>{index}</span>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import Link from 'next/link'
import TimeAgo from 'timeago-react'
import { useConventionInfo } from './useConventionInfo'
export const ListOfQRSets = () => {
const { id, qrs } = useConventionInfo()
return (
<ol className="inset-0 pl-6 mt-0 ml-0">
{qrs?.map(({ createdAt, number }, i) => (
<li key={i}>
{/* Size */}
<span className="inline-block w-20">Set of {number} </span>
{/* Download */}
<Link href={`/admin/conventions/download?c=${id}&set=${i}`} target="_blank">
<a className="pl-1" target="_blank">
Download
</a>
</Link>
{/* Time */}
<span className="inline-block w-32 text-[13px] text-right opacity-60">
{+new Date() - +new Date(createdAt._seconds * 1000) < 60 * 1000 ? (
'Just now'
) : (
<TimeAgo datetime={new Date(createdAt._seconds * 1000)} />
)}{' '}
</span>
</li>
))}
</ol>
)
}

View File

@@ -0,0 +1,52 @@
import Link from 'next/link'
import { GlobalCSS } from 'src/GlobalCSS'
import { Head } from 'src/Head'
import { useLoginRequired, useUser } from '../auth'
import { HeaderBar } from '../HeaderBar'
import { CreateVoterCredentials } from './CreateVoterCredentials'
import { ListOfQRSets } from './ListOfQRSets'
import { QRFigure } from './QRFigure'
import { SetRedirection } from './SetRedirection'
import { useConventionID } from './useConventionID'
import { useConventionInfo } from './useConventionInfo'
export const ManageConventionPage = () => {
const { convention_id } = useConventionID()
const { convention_title } = useConventionInfo()
const { loading, loggedOut } = useUser()
useLoginRequired(loggedOut)
if (loading || loggedOut || !convention_title) return <p className="p-4 text-[21px]">Loading...</p>
if (!convention_id) return <p>Convention ID error</p>
return (
<>
<Head title={`Manage${convention_title ? `: ${convention_title}` : ' Convention'}`} />
<HeaderBar />
<main className="p-4 sm:px-8 overflow-clip">
{/* Back link */}
<Link href="/admin/conventions">
<a className="block mt-2 transition opacity-60 hover:opacity-100"> Back to all Conventions</a>
</Link>
{/* Title */}
<h2>Manage: {convention_title}</h2>
{/* Set # voters */}
<CreateVoterCredentials {...{ convention_id }} />
{/* List of voter sets */}
<ListOfQRSets />
<QRFigure {...{ convention_id }} className="mt-12 -ml-5" />
{/* Set redirection */}
<SetRedirection />
</main>
<GlobalCSS />
</>
)
}

View File

@@ -0,0 +1,38 @@
import type { Options } from 'qr-code-styling'
import { useEffect, useRef } from 'react'
const qrOptions: Options = {
cornersSquareOptions: { color: '#000000', type: 'square' },
dotsOptions: { type: 'classy-rounded' },
height: 111,
image: '/VOTE.png',
imageOptions: { hideBackgroundDots: true, imageSize: 0.5, margin: 0 },
qrOptions: { errorCorrectionLevel: 'H' },
width: 111,
}
export const QRCode = ({
className,
convention_id = ':conv_id',
qr_id = ':qr_id',
}: {
className?: string
convention_id?: string
qr_id?: string
}) => {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (typeof window === 'undefined') return // Client-side only
import('qr-code-styling').then(({ default: QRCodeStyling }) => {
if (!ref.current) return console.warn('Missing QR container ref')
const customData = `${window.location.origin}/c/${convention_id}/${qr_id}`
const qrCode = new QRCodeStyling({ ...qrOptions, data: customData })
if (ref.current.firstChild) ref.current.removeChild(ref.current.firstChild)
qrCode.append(ref.current)
})
}, [])
return <div {...{ className, ref }} />
}

View File

@@ -0,0 +1,22 @@
import { QRCode } from './QRCode'
export const QRFigure = ({ className, convention_id = ':conv_id' }: { className?: string; convention_id?: string }) => (
<figure className={`mx-0 inline-block ${className}`}>
<div className="flex items-center">
{/* Left of arrow */}
<div className="text-center">
<QRCode {...{ convention_id }} className="relative scale-75 top-3" />
<span className="text-xs opacity-70">QR code</span>
</div>
{/* Arrow */}
<i
className="pl-3 pr-6 text-[30px] opacity-80"
style={{ fontFamily: '"Proxima Nova", "Helvetica Neue", Helvetica, Arial, sans-serif' }}
>
{'→'}
</i>{' '}
{/* Right of Arrow */}
<i className="relative top-0.5 overflow-auto break-words">siv.org/c/{convention_id}/:qr_id</i>
</div>
</figure>
)

View File

@@ -0,0 +1,41 @@
import { forwardRef, useState } from 'react'
import { OnClickButton } from '../../_shared/Button'
import { Spinner } from '../Spinner'
type SaveButtonProps = {
disabled?: boolean
id?: string
onPress: () => Promise<void>
text?: string
}
export const SaveButton = forwardRef<HTMLAnchorElement, SaveButtonProps>(
({ disabled, id, onPress, text }: SaveButtonProps, ref) => {
const [pending, set_pending] = useState(false)
return (
<>
<OnClickButton
id={id}
ref={ref}
style={{ marginRight: 0, padding: '8px 17px' }}
onClick={async () => {
set_pending(true)
await onPress()
set_pending(false)
}}
{...{ disabled }}
>
{!pending ? (
text || 'Save'
) : (
<>
<Spinner /> Saving...
</>
)}
</OnClickButton>
</>
)
},
)
SaveButton.displayName = 'SaveButton'

View File

@@ -0,0 +1,168 @@
import { Election } from 'api/admin-all-elections'
import { useEffect, useState } from 'react'
import { api } from 'src/api-helper'
import TimeAgo from 'timeago-react'
import { useAllYourElections } from '../AllYourElections'
import { revalidate, useConventionInfo } from './useConventionInfo'
export const SetRedirection = () => {
const { data } = useAllYourElections()
const [filteredData, setFilteredData] = useState<Election[]>([])
const [searchText, setSearchText] = useState('')
const { active_redirect, id: convention_id } = useConventionInfo()
const { elections } = (data as { elections: Election[] }) || {}
// Filter list based on search text
useEffect(() => {
if (!elections) return
const filtered = elections.filter((item) => item.election_title.toLowerCase().includes(searchText.toLowerCase()))
setFilteredData(filtered)
}, [searchText, elections])
if (!elections) return <p>Loading elections...</p>
return (
<div>
<h3>Redirect your convention QRs to which of your ballots?</h3>
<table className="mt-1 h-64 px-1.5 py-2 mb-4 overflow-y-scroll rounded-lg shadow-inner bg-neutral-50">
<thead>
<tr>
<th colSpan={7}>
{/* Set filter textbox */}
<input
className="w-full px-3 text-[15px] py-2 text-gray-700 border rounded shadow"
placeholder="Filter ballot by title"
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
{!filteredData.length && <p className="italic opacity-70">No elections found</p>}
</th>
</tr>
{/* Table header */}
<tr className="text-[12px] opacity-60 [&>*]:p-1 [&>*]:py-2">
<th />
<th className="text-left">Ballot name</th>
<th>Created</th>
<th>Voters</th>
<th>Votes Cast</th>
<th className="relative bottom-px">Finalized?</th>
<th>Redirect</th>
</tr>
</thead>
{/* Filtered list */}
{filteredData.map((e, i) => {
const is_active_redirect = e.id === active_redirect
return (
// Each row
<tr
className={`text-center py-1 px-1.5 rounded hover:bg-gray-200 group [&>td]:px-1 ${
is_active_redirect ? 'bg-blue-800/20 hover:!bg-blue-800/30' : ''
}`}
key={e.id}
>
{/* # */}
<td className="opacity-50 text-[11px]">{i + 1}</td>
{/* Ballot name */}
<td className="max-w-[300px] text-left">
<a
className="text-black/90 hover:text-blue-700"
href={`/admin/${e.id}/voters`}
rel="noreferrer"
target="_blank"
>
{e.election_title}
</a>
</td>
{/* Created */}
<td className="opacity-50 text-[11px]">
<TimeAgo datetime={new Date(e.created_at._seconds * 1000)} />
</td>
{/* Voters */}
<td className={!e.num_voters ? 'opacity-30' : ''}>{e.num_voters}</td>
{/* Votes */}
<td className={!e.num_votes ? 'opacity-30' : ''}>{e.num_votes}</td>
{/* Finalized? */}
<td className="!px-2">
<input disabled checked={e.ballot_design_finalized && !!e.threshold_public_key} type="checkbox" />
{/* Hints if active but not finalized */}
{is_active_redirect && (
<>
{/* Ballot not finalized */}
{!e.ballot_design_finalized ? (
<div>
<span className="text-[8px] relative bottom-0.5 opacity-70 hover:no-underline"> </span>
<a
className="text-black/60 hover:text-blue-700"
href={`/admin/${e.id}/ballot-design`}
rel="noreferrer"
target="_blank"
>
Ballot design not finalized
</a>
</div>
) : (
''
)}
{/* Observers not set */}
{!e.threshold_public_key ? (
<div className="text-left">
<span className="text-[8px] relative bottom-0.5 opacity-70"> </span>
<a
className="text-black/60 hover:text-blue-700"
href={`/admin/${e.id}/observers`}
rel="noreferrer"
target="_blank"
>
Observers not set
</a>
</div>
) : (
''
)}
</>
)}
</td>
{/* Redirect: Set / Active */}
<td
className={`text-xs cursor-pointer group-hover:opacity-60 ${
is_active_redirect ? '!opacity-60' : 'opacity-0'
} hover:!opacity-80 hover:bg-blue-800/30`}
onClick={async () =>
confirm(
`${!is_active_redirect ? `Redirect QRs to '${e.election_title}'?` : 'Remove redirection?'}`,
) &&
(await api(`/conventions/${convention_id}/set-redirect`, {
election_id: !is_active_redirect ? e.id : '',
})) &&
revalidate(convention_id || '')
}
>
{is_active_redirect ? (
'Active'
) : (
<span className="px-3 bg-white border border-solid rounded-lg border-black/50">Set</span>
)}
</td>
</tr>
)
})}
</table>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import Link from 'next/link'
export const YourConventionsLink = () => {
return (
<Link href="/admin/conventions">
<div className="pb-2 pl-2 pr-4 -ml-2 transition border-2 border-transparent border-solid rounded-lg cursor-pointer sm:pl-4 sm:-ml-4 hover:border-[#002868] mt-14">
<h2>
Your Conventions <BetaBadge />
</h2>
<p>
Reusable voter credentials, for multiple ballots throughout a day.{' '}
<a className="ml-3 opacity-70 hover:no-underline">Go {'→'}</a>
</p>
</div>
</Link>
)
}
const BetaBadge = () => (
<span className="text-[11px] border-solid border border-[#002868]/70 relative bottom-[3px] text-[#002868]/90 font-bold p-1 rounded ml-1">
Beta
</span>
)

View File

@@ -0,0 +1,10 @@
import { useRouter } from 'next/router'
/** Grabs `convention_id` from the router. Blocks `string[]` type */
export const useConventionID = (): { convention_id?: string } => {
const { convention_id } = useRouter().query
if (Array.isArray(convention_id)) return {}
return { convention_id }
}

View File

@@ -0,0 +1,20 @@
import { Convention } from 'api/validate-admin-jwt'
import { useRouter } from 'next/router'
import useSWR, { mutate } from 'swr'
import { fetcher } from '../AllYourElections'
export const useConventionInfo = () => {
const { convention_id } = useRouter().query
const { data }: { data?: Convention } = useSWR(
!convention_id ? null : `/api/conventions/${convention_id}/load-convention-admin`,
fetcher,
)
return { ...data }
}
export function revalidate(convention_id: string) {
mutate(`/api/conventions/${convention_id}/load-convention-admin`)
}

View File

@@ -1,26 +1,29 @@
import router from 'next/router'
import { useState } from 'react'
import { useRef, useState } from 'react'
import { api } from '../api-helper'
import { SaveButton } from './SaveButton'
export const CreateNewElection = () => {
const [election_title, set_title] = useState('')
const $input = useRef<HTMLInputElement>(null)
const $saveBtn = useRef<HTMLAnchorElement>(null)
return (
<>
<h2>Create New Election</h2>
<label>Election Title:</label>
<h2>Create New Ballot</h2>
<label>Ballot Title:</label>
<input
className="w-full p-2 text-sm border border-gray-300 border-solid rounded"
id="election-title"
placeholder="Give your election a name your voters will recognize"
placeholder="Give your ballot a name your voters will recognize"
value={election_title}
onChange={(event) => set_title(event.target.value)}
onKeyPress={(event) => {
onKeyDown={(event) => {
if (event.key === 'Enter') {
document.getElementById('election-title')?.blur()
document.getElementById('election-title-save')?.click()
$input.current?.blur()
$saveBtn.current?.click()
}
}}
/>
@@ -28,27 +31,13 @@ export const CreateNewElection = () => {
id="election-title-save"
onPress={async () => {
const response = await api('create-election', { election_title })
if (response.status !== 201) throw await response.json()
if (response.status === 201) {
const { election_id } = await response.json()
// Set election_id in URL
router.push(`${window.location.origin}/admin/${election_id}/ballot-design`)
} else {
throw await response.json()
}
// Set election_id in URL
const { election_id } = await response.json()
router.push(`${window.location.origin}/admin/${election_id}/ballot-design`)
}}
/>
<style jsx>{`
input {
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
padding: 8px;
width: 100%;
}
`}</style>
</>
)
}

View File

@@ -1,9 +1,11 @@
import { BoxProps, NoSsr, TextField, TextFieldProps } from '@material-ui/core'
import { TextField, TextFieldProps } from '@mui/material'
import { validate as validateEmail } from 'email-validator'
import router from 'next/router'
import { useEffect, useState } from 'react'
import { OnClickButton, darkBlue } from 'src/_shared/Button'
import { NoSsr } from 'src/_shared/NoSsr'
import { api } from 'src/api-helper'
import { Row } from 'src/homepage/AreYouAVoter'
import { CreatedAccountWaiting } from './CreatedAccountWaiting'
import { breakpoint } from './LoginPage'
@@ -99,20 +101,6 @@ export const CreateAccount = () => {
)
}
const Row = (props: BoxProps) => (
<div
style={{
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
margin: '1.5rem 0',
}}
>
{props.children}
</div>
)
const Field = (props: TextFieldProps & { label: string }) => (
<TextField
fullWidth

View File

@@ -1,4 +1,4 @@
import { TextField } from '@material-ui/core'
import { TextField } from '@mui/material'
import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import { OnClickButton } from 'src/_shared/Button'
@@ -129,5 +129,5 @@ export const EnterCodePage = () => {
`}</style>
<GlobalCSS />
</main>
)
);
}

View File

@@ -1,4 +1,4 @@
import { TextField } from '@material-ui/core'
import { TextField } from '@mui/material'
import { validate as validateEmail } from 'email-validator'
import { useRouter } from 'next/router'
import { ChangeEventHandler, KeyboardEventHandler, useRef, useState } from 'react'

View File

@@ -1,4 +1,4 @@
import SwipeableDrawer from '@material-ui/core/SwipeableDrawer'
import SwipeableDrawer from '@mui/material/SwipeableDrawer'
import { startCase } from 'lodash-es'
import { useRouter } from 'next/router'
import { useState } from 'react'

View File

@@ -1,5 +1,5 @@
import { EditOutlined } from '@ant-design/icons'
import { TextField } from '@material-ui/core'
import { TextField } from '@mui/material'
import { validate as validateEmail } from 'email-validator'
import { useState } from 'react'

View File

@@ -1,48 +1,43 @@
import { useState } from 'react'
import { forwardRef, useState } from 'react'
import { OnClickButton } from '../_shared/Button'
import { Spinner } from './Spinner'
export const SaveButton = ({
disabled,
id,
onPress,
text,
}: {
type SaveButtonProps = {
disabled?: boolean
id?: string
onPress: () => Promise<void>
text?: string
}) => {
const [pending, set_pending] = useState(false)
return (
<>
<div className="right-aligned">
<OnClickButton
id={id}
style={{ marginRight: 0, padding: '8px 17px' }}
onClick={async () => {
set_pending(true)
await onPress()
set_pending(false)
}}
{...{ disabled }}
>
{!pending ? (
text || 'Save'
) : (
<>
<Spinner /> Saving...
</>
)}
</OnClickButton>
</div>
<style jsx>{`
.right-aligned {
text-align: right;
}
`}</style>
</>
)
}
export const SaveButton = forwardRef<HTMLAnchorElement, SaveButtonProps>(
({ disabled, id, onPress, text }: SaveButtonProps, ref) => {
const [pending, set_pending] = useState(false)
return (
<>
<div className="text-right">
<OnClickButton
id={id}
ref={ref}
style={{ marginRight: 0, padding: '8px 17px' }}
onClick={async () => {
set_pending(true)
await onPress()
set_pending(false)
}}
{...{ disabled }}
>
{!pending ? (
text || 'Save'
) : (
<>
<Spinner /> Saving...
</>
)}
</OnClickButton>
</div>
</>
)
},
)
SaveButton.displayName = 'SaveButton'

View File

@@ -1,36 +0,0 @@
import { Tooltip } from './Tooltip'
export const AcceptedCell = ({ accepted }: { accepted?: unknown[] }) => {
return (
<>
<Tooltip
placement="top"
title={
accepted ? (
<>
{(accepted as {
id: string
timestamp: number
}[])?.map((event) => (
<div key={event.id} style={{ fontSize: 14 }}>
{new Date(event.timestamp * 1000).toLocaleString()}
</div>
))}
</>
) : (
''
)
}
>
<td style={{ textAlign: 'center' }}>{accepted?.length}</td>
</Tooltip>
<style jsx>{`
td {
border: 1px solid #ccc;
padding: 3px 10px;
margin: 0;
}
`}</style>
</>
)
}

View File

@@ -3,8 +3,8 @@ import { useState } from 'react'
import { api } from '../../api-helper'
import { SaveButton } from '../SaveButton'
import { revalidate, useStored } from '../useStored'
import { AddVoterTextarea } from './AddVotersTextarea'
import { ExistingVoters } from './ExistingVoters'
import { MultilineInput } from './MultilineInput'
import { RequestEsignatures } from './RequestEsignatures'
import { ToggleRegistration } from './ToggleRegistration'
@@ -16,7 +16,7 @@ export const AddVoters = () => {
<div className="max-w-[50rem]">
<h2 className="hidden sm:block">Voters</h2>
<h4>Add new voters by email address:</h4>
<MultilineInput state={new_voters} update={set_new_voters} />
<AddVoterTextarea state={new_voters} update={set_new_voters} />
{/* Show save button if there are new voters to add */}
{new_voters === '' ? (

View File

@@ -0,0 +1,44 @@
import { zebraStripes } from '@uiw/codemirror-extensions-zebra-stripes'
import { createTheme } from '@uiw/codemirror-themes'
import CodeMirror, { ReactCodeMirrorProps } from '@uiw/react-codemirror'
const theme = createTheme({
settings: {
caret: '#0070f3',
fontFamily: 'sans-serif',
foreground: '#444',
gutterBackground: '#fff',
gutterForeground: '#999',
selectionMatch: '#036dd626',
},
styles: [],
theme: 'light',
})
const CodeMirrorComponent = (props: ReactCodeMirrorProps) => (
<>
<CodeMirror
{...props}
autoFocus
basicSetup={{ highlightActiveLine: false, highlightActiveLineGutter: false, indentOnInput: false }}
extensions={[zebraStripes({ step: 2 })]}
theme={theme}
/>
<style global jsx>{`
.cm-focused {
outline: none !important;
}
.cm-editor {
font-size: 15px;
}
.cm-zebra-stripe {
background-color: #8881 !important;
}
.cm-cursor {
border-left-width: 2px !important;
}
`}</style>
</>
)
export default CodeMirrorComponent

View File

@@ -0,0 +1,15 @@
import dynamic from 'next/dynamic'
const CodeMirror = dynamic(() => import('./AddVotersCodeMirror'), { ssr: false })
export const AddVoterTextarea = ({ state, update }: { state: string; update: (s: string) => void }) => {
return (
<CodeMirror
height="auto"
maxHeight="300px"
style={{ border: '1px solid #ccc', borderRadius: 3 }}
value={state}
onChange={(value) => update(value)}
/>
)
}

View File

@@ -14,9 +14,8 @@ export const DeliveriesAndFailures = ({
return (
<>
<Tooltip
interactive={!!failed}
placement="top"
title={
tooltip={
failed || deliveries ? (
<>
{(

View File

@@ -1,65 +0,0 @@
import { Dispatch, SetStateAction } from 'react'
import Editor from 'react-simple-code-editor'
export const MultilineInput = ({
placeholder,
startAt = 1,
state,
update,
}: {
placeholder?: string
startAt?: number
state: string
update: Dispatch<SetStateAction<string>>
}) => {
return (
<div className="editor-container">
<Editor
autoFocus
className="editor"
highlight={(code) =>
code
.split('\n')
.map((line, i) => `<span class='editorLineNumber'>${i + startAt}</span>${line}`)
.join('\n')
}
placeholder={placeholder}
textareaId="textarea"
value={state}
onValueChange={(v) => update(v)}
/>
<style global jsx>{`
.editor-container {
max-height: 300px;
overflow: auto;
border: 1px solid #ced4da;
border-radius: 3px;
margin-top: 5px;
padding: 5px 0;
}
.editor {
/* Striped background */
line-height: 20px;
background-image: linear-gradient(#fff 50%, #f6f6f6 50%);
background-size: 100% 40px; /* 2x line-height */
background-position: left 0px;
}
.editor #textarea,
.editor pre {
padding-left: 46px !important;
}
.editor .editorLineNumber {
position: absolute;
left: 0px;
opacity: 0.6;
text-align: right;
width: 28px;
font-weight: 200;
}
`}</style>
</div>
)
}

View File

@@ -5,12 +5,14 @@ export const QueuedCell = ({ invite_queued }: { invite_queued?: unknown[] }) =>
<>
<Tooltip
placement="top"
title={
tooltip={
invite_queued ? (
<>
{(invite_queued as {
time: { _seconds: number }
}[])?.map((event, index) => (
{(
invite_queued as {
time: { _seconds: number }
}[]
)?.map((event, index) => (
<div key={index} style={{ fontSize: 14 }}>
{new Date(event.time._seconds * 1000).toLocaleString()}
</div>

View File

@@ -1,9 +1,9 @@
import { Switch } from '@material-ui/core'
import Image from 'next/image'
import esignatureIcon from 'public/esignature.png'
import { useState } from 'react'
import { api } from '../../api-helper'
import { Switch } from '../BallotDesign/Switch'
import { Spinner } from '../Spinner'
import { revalidate, useStored } from '../useStored'
@@ -26,30 +26,17 @@ export const RequestEsignatures = () => {
}
return (
<section>
<label onClick={toggleESignature}>
<div style={{ display: 'inline-block', marginRight: 5, position: 'relative', top: 2 }}>
<div className="mb-8">
<label className="cursor-pointer" onClick={toggleESignature}>
<span className="mr-1.5 relative top-0.5">
<Image height={(218 / 700) * 70} layout="fixed" src={esignatureIcon} width={70} />
</div>
Request <i>eSignatures</i>?
</span>
Request eSignatures?
</label>
<div style={{ bottom: 3, display: 'inline-block', left: 10, position: 'relative' }}>
<Switch checked={!!esignature_requested} color="primary" onClick={toggleESignature} />
</div>
<span className="relative bottom-[3px] ml-3">
<Switch checked={!!esignature_requested} label="" onClick={toggleESignature} />
</span>
{updating && <Spinner />}
<style jsx>{`
section {
margin-bottom: 30px;
}
label {
cursor: pointer;
}
i {
font-weight: 500;
}
`}</style>
</section>
</div>
)
}

View File

@@ -1,16 +1,8 @@
import { makeStyles } from '@material-ui/core/styles'
import { useState } from 'react'
import { ReviewLog } from 'api/election/[election_id]/admin/load-admin'
import { ReviewLog } from '../../../pages/api/election/[election_id]/admin/load-admin'
import { api } from '../../api-helper'
import { Tooltip } from './Tooltip'
const useStyles = makeStyles(() => ({
customWidth: {
width: 300,
},
}))
export const getStatus = (esignature_review?: ReviewLog[]) =>
esignature_review ? esignature_review[esignature_review.length - 1]?.review : undefined
@@ -25,102 +17,47 @@ export const Signature = ({
esignature?: string
esignature_review?: ReviewLog[]
}) => {
const classes = useStyles()
const [open, setOpen] = useState(false)
const storeReview = (review: 'approve' | 'reject' | 'pending') => async () => {
await api(`election/${election_id}/admin/review-signature`, {
const storeReview = (review: 'approve' | 'reject' | 'pending', setIsShown: (setting: boolean) => void) => async () =>
(await api(`election/${election_id}/admin/review-signature`, {
emails: [email],
review,
})
setOpen(false)
}
})) && setIsShown(false)
const status = getStatus(esignature_review)
return (
<td>
<td className="px-0.5 hover:bg-black/5 cursor-pointer" style={{ border: '1px solid #ccc' }}>
<Tooltip
interactive
className={classes.customWidth}
className="w-72"
enterDelay={200}
open={open}
leaveDelay={200}
placement="top"
title={
<div className="tooltip">
{esignature ? <img src={esignature} /> : <p>Signature missing</p>}
<div className="row">
tooltip={({ setIsShown }: { setIsShown: (setting: boolean) => void }) => (
<div>
{esignature ? <img className="max-w-[280px]" src={esignature} /> : <p>Signature missing</p>}
<div className="flex justify-between">
<a
className={status === 'reject' ? 'bold' : ''}
onClick={storeReview(status === 'reject' ? 'pending' : 'reject')}
className={`cursor-pointer ${status === 'reject' ? 'font-bold' : ''}`}
onClick={storeReview(status === 'reject' ? 'pending' : 'reject', setIsShown)}
>
Reject{status === 'reject' ? 'ed' : ''}
</a>
<a
className={status === 'approve' ? 'bold' : ''}
onClick={storeReview(status === 'approve' ? 'pending' : 'approve')}
className={`cursor-pointer ${status === 'approve' ? 'font-bold' : ''}`}
onClick={storeReview(status === 'approve' ? 'pending' : 'approve', setIsShown)}
>
Approve{status === 'approve' ? 'd' : ''}
</a>
</div>
</div>
}
onClick={() => setOpen(!open)}
onClose={() => setOpen(false)}
onOpen={() => ['approve', 'reject'].includes(status || '') || setOpen(true)}
)}
>
<img className={`small ${status || ''}`} src={esignature} />
<img
className="min-h-5 min-h-[20px] max-w-[100px] -mb-1.5 overflow-hidden"
src={esignature}
style={{ border: `2px solid ${status === 'approve' ? 'green' : status === 'reject' ? 'red' : '#fff0'}` }}
/>
</Tooltip>
<style jsx>{`
td {
border: 1px solid #ccc;
margin: 0;
padding: 2px;
}
td:hover {
cursor: pointer;
background-color: #f2f2f2;
}
img.small {
min-height: 20px;
max-width: 100px;
overflow: hidden;
border: 2px solid #fff0;
margin-bottom: -6px;
}
img.small.approve {
border-color: green;
}
img.small.reject {
border-color: red;
}
.tooltip img {
max-width: 280px;
}
.row {
display: flex;
justify-content: space-between;
}
.tooltip a {
cursor: pointer;
font-size: 12px;
}
.tooltip a:first-child {
margin-right: 2rem;
}
.bold {
font-weight: bold;
}
`}</style>
</td>
)
}

View File

@@ -1,15 +1,128 @@
import { Tooltip as MUITooltip, TooltipProps } from '@material-ui/core'
import { withStyles } from '@material-ui/core/styles'
import React, { Fragment, ReactElement, ReactNode, cloneElement, useEffect, useRef, useState } from 'react'
import ReactDOM from 'react-dom'
import { CSSTransition } from 'react-transition-group'
const LightTooltip = withStyles(() => ({
tooltip: {
backgroundColor: '#fffe',
boxShadow: '0px 1px 3px #0006',
color: '#222',
padding: '5px 10px',
},
}))(MUITooltip)
export const Tooltip = (props: TooltipProps) => {
return <LightTooltip {...props} />
type Placement = 'top' | 'bottom' | 'left' | 'right'
interface TooltipProps {
children: ReactElement
className?: string
enterDelay?: number
leaveDelay?: number
placement?: Placement
tooltip: string | ReactNode | (({ setIsShown }: { setIsShown: (setting: boolean) => void }) => ReactNode)
}
export const Tooltip = ({
children,
className = '',
enterDelay = 0,
leaveDelay = 50,
placement = 'top',
tooltip,
}: TooltipProps) => {
const [isShown, setIsShown] = useState(false)
const [tooltipPos, setTooltipPos] = useState({ left: 0, top: 0 })
const tooltipRef = useRef<HTMLDivElement>(null)
const targetRef = useRef<HTMLElement>(null)
const closeTimeoutId = useRef<number | null>(null)
// Calc position whenever shown
useEffect(() => {
if (isShown) updateTooltipPosition()
}, [isShown, placement])
function onMouseEnter() {
closeTimeoutId.current && clearTimeout(closeTimeoutId.current)
setTimeout(() => setIsShown(true), enterDelay)
}
function onMouseLeave() {
// Setup a delay for leaving, but allow cancellation if re-entering
closeTimeoutId.current = setTimeout(() => {
setIsShown(false)
}, leaveDelay) as unknown as number
}
// Clone the child and inject props
const child = cloneElement(children, {
onMouseEnter,
onMouseLeave,
ref: targetRef,
})
function updateTooltipPosition() {
if (!tooltipRef.current) return console.warn('Missing tooltip ref')
if (!targetRef.current) return console.warn('Missing target ref')
const targetRect = targetRef.current.getBoundingClientRect()
const tooltipRect = tooltipRef.current.getBoundingClientRect()
const newPosition = calculatePosition(targetRect, tooltipRect, placement)
setTooltipPos(newPosition)
}
return (
<Fragment>
{child}
{/* Tooltip element */}
{tooltip &&
ReactDOM.createPortal(
<CSSTransition unmountOnExit classNames="tooltip" in={isShown} timeout={10}>
<div
className={`bg-white/90 rounded p-1 fixed z-50 ${className}`}
ref={tooltipRef}
{...{ onMouseEnter, onMouseLeave }}
style={{
border: '1px solid #ccc',
boxShadow: '0px 1px 3px rgba(0, 0, 0, 0.4)',
left: tooltipPos.left,
top: tooltipPos.top,
}}
>
{typeof tooltip === 'function' ? tooltip({ setIsShown }) : tooltip}
</div>
</CSSTransition>,
document.body,
)}
{/* Transitions */}
<style global jsx>{`
.tooltip-enter,
.tooltip-exit-active {
opacity: 0;
transform: translateY(7px);
}
.tooltip-enter-active,
.tooltip-exit {
opacity: 1;
transform: translateY(0);
}
.tooltip-enter-active {
transition: opacity 200ms, transform 100ms;
}
`}</style>
</Fragment>
)
}
function calculatePosition(
{ height, left, top, width }: { height: number; left: number; top: number; width: number },
{ height: tooltipHeight, width: tooltipWidth }: { height: number; width: number },
placement: Placement,
) {
const margin = 10
switch (placement) {
case 'top':
return { left: left + (width - tooltipWidth) / 2, top: top - tooltipHeight - margin }
case 'bottom':
return { left: left + (width - tooltipWidth) / 2, top: top + height }
case 'left':
return { left: left - tooltipWidth, top: top + (height - tooltipHeight) / 2 }
case 'right':
return { left: left + width, top: top + (height - tooltipHeight) / 2 }
default:
return { left: left, top: top }
}
}

View File

@@ -1,10 +1,8 @@
/** Fetch `/api/${route}`. Optional json `body` */
export const api = (route: string, body?: Record<string, unknown>) =>
fetch(`/api/${route}`, {
body: JSON.stringify(body),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
method: 'POST',
})
@@ -12,18 +10,12 @@ export const api = (route: string, body?: Record<string, unknown>) =>
export const vercel60secondTimeout = (route: string, body?: Record<string, unknown>) =>
fetch(`https://siv-git-better-unlock-error-sivteam.vercel.app/api/election/foobar/admin/unlock`, {
body: JSON.stringify(body),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
})
/** https://github.com/dsernst/siv-blog/blob/main/pages/api/timeout.ts */
export const vercel10secondTimeout = (route: string, body?: Record<string, unknown>) =>
fetch(`https://blog.siv.org/api/timeout`, {
body: JSON.stringify(body),
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
})

View File

@@ -1,6 +1,8 @@
import { BoxProps, NoSsr, TextField, TextFieldProps } from '@material-ui/core'
import { TextField, TextFieldProps } from '@mui/material'
import { useCallback, useState } from 'react'
import { OnClickButton } from 'src/_shared/Button'
import { Row } from 'src/_shared/Forms/Row'
import { NoSsr } from 'src/_shared/NoSsr'
import { api } from 'src/api-helper'
export const ContactRepForm = () => {
@@ -75,14 +77,3 @@ export const ContactRepForm = () => {
</form>
)
}
const Row = (props: BoxProps) => (
<div
{...props}
style={{
display: 'flex',
margin: '1.5rem 0',
...props.style,
}}
/>
)

View File

@@ -1,6 +1,8 @@
import { BoxProps, NoSsr, TextField, TextFieldProps } from '@material-ui/core'
import { TextField, TextFieldProps } from '@mui/material'
import { useCallback, useState } from 'react'
import { OnClickButton } from 'src/_shared/Button'
import { Row } from 'src/_shared/Forms/Row'
import { NoSsr } from 'src/_shared/NoSsr'
import { api } from 'src/api-helper'
const suggestedAmounts = { 10: '10', 1_000: '1k', 100_000: '100k', 1_000_000: '1m' }
@@ -115,14 +117,3 @@ export const InvestmentForm = () => {
</form>
)
}
const Row = (props: BoxProps) => (
<div
{...props}
style={{
display: 'flex',
margin: '1.5rem 0',
...props.style,
}}
/>
)

View File

@@ -1,6 +1,8 @@
import { BoxProps, NoSsr, TextField, TextFieldProps } from '@material-ui/core'
import { TextField, TextFieldProps } from '@mui/material'
import { useCallback, useState } from 'react'
import { OnClickButton } from 'src/_shared/Button'
import { Row } from 'src/_shared/Forms/Row'
import { NoSsr } from 'src/_shared/NoSsr'
import { api } from 'src/api-helper'
export const QuestionForm = () => {
@@ -72,14 +74,3 @@ export const QuestionForm = () => {
</form>
)
}
const Row = (props: BoxProps) => (
<div
{...props}
style={{
display: 'flex',
margin: '1.5rem 0',
...props.style,
}}
/>
)

View File

@@ -0,0 +1,59 @@
import { Spinner } from '../admin/Spinner'
import { useConventionRedirect } from './useConventionRedirect'
export const ConventionQRPage = () => {
const { convention_id, conventionRedirectInfo, errorMessage, loading, qr_id } = useConventionRedirect()
const { active_ballot_auth, active_redirect, convention_title } = conventionRedirectInfo || {}
if (!convention_id) return null
return (
<div className="flex flex-col justify-between max-w-lg min-h-screen p-4 pt-6 mx-auto font-sans">
{/* Headerbar */}
<header className="font-semibold text-center">Secure Internet Voting</header>
{/* Middle section */}
{convention_id && (
<main>
{!errorMessage ? (
// Loading or happy path
<div className="-mt-24 text-2xl text-center opacity-90">
{!loading && !active_redirect ? (
<>
<div className="text-lg">{convention_title}</div>
No active election yet.
</>
) : (
<div className="-ml-10 ">
<Spinner />
<span className="ml-2 ">Loading your ballot...</span>
</div>
)}
</div>
) : (
// Error
<>
<div className="text-2xl -mt-28 opacity-90">Error: {errorMessage}</div>
{/* Debug info */}
<div className="mt-10 opacity-70">
<div className="text-sm opacity-70">Debug Info</div>
<div>Convention ID: {convention_id}</div>
<div>QR ID: {qr_id}</div>
<div className="mt-3 text-sm opacity-70">Loaded:</div>
<div>Convention Title: {convention_title}</div>
<div>Active ballot: {active_redirect}</div>
<div>Ballot auth: {active_ballot_auth}</div>
</div>
</>
)}
</main>
)}
{/* Bottom spacer */}
<footer />
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { ConventionRedirectInfo } from 'api/conventions/[convention_id]/load-qr-redirect'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { api } from 'src/api-helper'
/** Looks up redirect info for `convention_id` and `qr_id`, redirects if found. */
export const useConventionRedirect = () => {
const { push, query } = useRouter()
const { convention_id, qr_id } = query
const [loading, setLoading] = useState(true)
const [errorMessage, setErrorMessage] = useState('')
const [conventionRedirectInfo, setConventionRedirectInfo] = useState<ConventionRedirectInfo>()
useEffect(() => {
setErrorMessage('')
if (typeof convention_id !== 'string') return
if (typeof qr_id !== 'string') return setErrorMessage('Missing qr_id')
setLoading(true)
api(`conventions/${convention_id}/load-qr-redirect?qr_id=${qr_id}`).then(async (res) => {
const json = await res.json()
setLoading(false)
if (!res.ok) {
if (json.info) setConventionRedirectInfo(json.info)
return setErrorMessage(json.error || 'Unknown error')
}
if (json) setConventionRedirectInfo(json.info)
const { active_ballot_auth, active_redirect } = json.info as ConventionRedirectInfo
if (!active_redirect) return
if (!active_ballot_auth) return setErrorMessage('Missing active ballot auth')
push(`/election/${active_redirect}/vote?auth=${active_ballot_auth}`)
})
}, [convention_id, qr_id])
return { conventionRedirectInfo, convention_id, errorMessage, loading, qr_id }
}

View File

@@ -51,5 +51,5 @@ function appendBuffer(buffer1: ArrayBuffer, buffer2: ArrayBuffer) {
const temporary = new Uint8Array(buffer1.byteLength + buffer2.byteLength)
temporary.set(new Uint8Array(buffer1), 0)
temporary.set(new Uint8Array(buffer2), buffer1.byteLength)
return temporary.buffer
return temporary.buffer as ArrayBuffer
}

View File

@@ -1,6 +1,7 @@
import { NoSsr, TextField } from '@material-ui/core'
import { TextField } from '@mui/material'
import { useState } from 'react'
import { OnClickButton } from 'src/_shared/Button'
import { NoSsr } from 'src/_shared/NoSsr'
import { api } from '../api-helper'

View File

@@ -1,45 +1,11 @@
import Link from 'next/link'
export const HeaderBar = (): JSX.Element => (
<div className="bg-gradient-to-r p-4 from-[#010b26] to-[#072054] text-white">
<div className="max-w-[750px] w-full mx-auto">
<div className="p-4 bg-gradient-to-r from-[#010b26] to-[#072054]">
<div className="max-w-[750px] mx-auto">
<Link href="/">
<a className="big">Secure Internet Voting</a>
<a className="text-[24px] font-bold hover:no-underline text-white">Secure Internet Voting</a>
</Link>
</div>
<style jsx>{`
.big {
font-size: 24px;
font-weight: 700;
color: white;
}
a:not(:first-child) {
margin-left: 3rem;
color: white;
font-size: 16px;
text-decoration: none;
font-weight: 400;
}
a:hover {
text-decoration: underline;
}
a.big:hover {
text-decoration: none;
}
@media (max-width: 480px) {
a:not(:first-child) {
margin-left: 0;
margin-top: 0.5rem;
}
div {
flex-direction: column;
}
}
`}</style>
</div>
)

View File

@@ -1,4 +1,4 @@
import { NoSsr } from '@material-ui/core'
import { NoSsr } from 'src/_shared/NoSsr'
const GA_ID = 'UA-84279342-7'

View File

@@ -1,7 +1,8 @@
import { BoxProps, NoSsr, TextField, TextFieldProps } from '@material-ui/core'
import { TextField, TextFieldProps } from '@mui/material'
import { omit } from 'lodash-es'
import { useState } from 'react'
import { Element } from 'react-scroll'
import { NoSsr } from 'src/_shared/NoSsr'
import { OnClickButton } from '../_shared/Button'
import { api } from '../api-helper'
@@ -136,7 +137,7 @@ export function AreYouAVoter(): JSX.Element {
)
}
const Row = (props: BoxProps) => (
export const Row = (props: { children?: React.ReactNode; style?: React.CSSProperties }) => (
<div
{...props}
style={{

View File

@@ -1,8 +1,9 @@
import { NoSsr, TextField } from '@material-ui/core'
import { TextField } from '@mui/material'
import { useState } from 'react'
import { NoSsr } from 'src/_shared/NoSsr'
import { api } from '../api-helper'
import { OnClickButton } from '../_shared/Button'
import { api } from '../api-helper'
export const EmailSignup = (): JSX.Element => {
const [saved, setSaved] = useState(false)

20
src/intro/IntroPage.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { TailwindPreflight } from 'src/TailwindPreflight'
export const IntroPage = () => {
return (
<main className="max-w-sm p-3 mx-auto text-center">
<h1 className="text-4xl font-bold text-blue-950">SIV</h1>
<h2 className="text-2xl">Secure Internet Voting</h2>
<ol className="flex mt-10 space-x-10 list-decimal">
<li>One Person, One Vote</li>
<li>Vote in Seconds</li>
<li>Cryptographic Privacy</li>
</ol>
<div className="mt-7">
<div></div>
<div>Voter Verifiable Results</div>
</div>
<TailwindPreflight />
</main>
)
}

View File

@@ -1,5 +1,5 @@
import { NoSsr } from '@material-ui/core'
import Head from 'next/head'
import { NoSsr } from 'src/_shared/NoSsr'
import { GlobalCSS } from 'src/GlobalCSS'
import { EmailsSent } from './EmailsSent'

View File

@@ -1,4 +1,5 @@
import { FormControlLabel, NoSsr, Radio, RadioGroup } from '@material-ui/core'
import { FormControlLabel, Radio, RadioGroup } from '@mui/material'
import { NoSsr } from 'src/_shared/NoSsr'
import { useEffect } from 'react'
import { candidates } from './election-parameters'

View File

@@ -1,29 +1,28 @@
/** Paper w/ drop shadow */
export const Paper = ({
children,
className,
marginBottom,
noFade,
style,
}: {
children: JSX.Element[] | JSX.Element
className?: string
marginBottom?: boolean
noFade?: boolean
style?: React.CSSProperties
}) => (
<>
<div style={style}>{children}</div>
<div {...{ className, style }} className={`rounded break-words py-2 px-6 ${className}`}>
{children}
</div>
<style jsx>{`
div {
border-radius: 4px;
box-shadow: 0px 3px 3px -2px rgba(0, 0, 0, 0.2), 0px 3px 4px 0px rgba(0, 0, 0, 0.14),
0px 1px 8px 0px rgba(0, 0, 0, 0.12);
margin-bottom: ${marginBottom ? '30px' : 0};
opacity: ${noFade ? 1 : 0.7};
overflow-wrap: break-word;
padding: 0.5rem 1.5rem;
}
`}</style>
</>

View File

@@ -1,4 +1,5 @@
import { NoSsr, TextField } from '@material-ui/core'
import { TextField } from '@mui/material'
import { NoSsr } from 'src/_shared/NoSsr'
import { Paper } from './Paper'
import { useVoteContext } from './VoteContext'

View File

@@ -1,4 +1,4 @@
import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@material-ui/core'
import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'
import { range } from 'lodash-es'
import { voters } from './election-parameters'

View File

@@ -1,9 +1,8 @@
import { flatten } from 'lodash-es'
import { useRouter } from 'next/router'
import { Fragment, useEffect, useState } from 'react'
import { CipherStrings } from 'src/crypto/stringify-shuffle'
import { EncryptedVote } from 'src/protocol/EncryptedVote'
import { defaultRankingsAllowed } from 'src/vote/Ballot'
import { generateColumnNames } from 'src/vote/generateColumnNames'
import { Item } from '../vote/storeElectionInfo'
import { TotalVotesCast } from './TotalVotesCast'
@@ -45,15 +44,7 @@ export const AcceptedVotes = ({
const newVotes = numVotes - votes.length
const columns = flatten(
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',
),
)
const { columns } = generateColumnNames({ ballot_design })
return (
<>
@@ -112,8 +103,8 @@ export const AcceptedVotes = ({
if (key !== 'auth') {
return (
<Fragment key={key}>
<td className="monospaced">{vote[key]?.encrypted}</td>
<td className="monospaced">{vote[key]?.lock}</td>
<td className="monospaced text-[11px]">{vote[key]?.encrypted}</td>
<td className="monospaced text-[11px]">{vote[key]?.lock}</td>
</Fragment>
)
}

View File

@@ -1,6 +1,5 @@
import { orderBy } from 'lodash-es'
import { flatten } from 'lodash-es'
import { defaultRankingsAllowed } from 'src/vote/Ballot'
import { generateColumnNames } from 'src/vote/generateColumnNames'
import { unTruncateSelection } from './un-truncate-selection'
import { useDecryptedVotes } from './use-decrypted-votes'
@@ -14,15 +13,7 @@ export const DecryptedVotes = ({ proofsPage }: { proofsPage?: boolean }): JSX.El
const sorted_votes = orderBy(votes, 'tracking')
const columns = flatten(
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',
),
)
const { columns } = generateColumnNames({ ballot_design })
return (
<div>
@@ -47,7 +38,12 @@ 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}>{unTruncateSelection(vote[c], ballot_design, c)}</td>
<td className="text-center" key={c}>
{unTruncateSelection(vote[c], ballot_design, c)}
{/* Fix centering for negative Scores */}
{vote[c]?.match(/^-\d$/) && <span className="inline-block w-1.5" />}
</td>
))}
</tr>
))}

81
src/status/IRVTallies.tsx Normal file
View File

@@ -0,0 +1,81 @@
import { Item } from 'src/vote/storeElectionInfo'
import { RoundResults } from './RoundResults'
import { tally_IRV_Items } from './tallying/rcv-irv'
import { unTruncateSelection } from './un-truncate-selection'
export const IRVTallies = ({
ballot_design,
id,
results,
}: {
ballot_design: Item[]
id: string
results: ReturnType<typeof tally_IRV_Items>[string]
}) => {
return (
<div>
{results.winner && (
<div>
Winner: <b className="font-semibold">{unTruncateSelection(results.winner, ballot_design, id)}</b>{' '}
<span className="text-xs opacity-50">
(after {results.rounds.length} round
{results.rounds.length === 1 ? '' : 's'})
</span>
</div>
)}
{/* Horizontal list of rounds */}
<div className="flex pb-3 -ml-5 overflow-x-scroll">
{results.rounds.map((round, index) => (
// Vertical round results
<div key={index}>
<div className="pl-5 mt-3 text-xs opacity-50">Round {index + 1}</div>
<RoundResults
{...{ ballot_design, id, ordered: round.ordered, tallies: round.tallies, totalVotes: round.totalVotes }}
/>
<CheckForTies {...{ index, ordered: round.ordered, tallies: round.tallies }} />
</div>
))}
</div>
</div>
)
}
/** Component to check and warn about ambiguity if there's a tie among who to eliminate */
const CheckForTies = ({
index,
ordered,
tallies,
}: {
index: number
ordered: string[]
tallies: Record<string, number>
}) => {
const last_place = ordered.at(-1)
if (!last_place) return <></>
const last_votes = tallies[last_place]
const second_last_place = ordered.at(-2)
if (!second_last_place) return <></>
const second_last_votes = tallies[second_last_place]
if (last_votes !== second_last_votes) return <></>
return (
<div
className="px-1 ml-5 border border-yellow-400 border-solid rounded cursor-pointer text-black/70 hover:bg-yellow-50"
onClick={() =>
alert(
`Round ${
index + 1
} has multiple tying bottom options.\n\nCurrently, the alphabetically-last gets eliminated, but your election may require different rules.`,
)
}
>
Tie among bottom choices.{' '}
<span className="px-[4.25px] border border-solid rounded-full opacity-50 text-xs">?</span>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { Item } from 'src/vote/storeElectionInfo'
import { unTruncateSelection } from './un-truncate-selection'
export const RoundResults = ({
ballot_design,
id,
ordered,
tallies,
totalVotes,
}: {
ballot_design: Item[]
id: string
ordered: string[]
tallies: Record<string, number>
totalVotes: number
}) => {
return (
<ul>
{ordered?.map((selection) => (
<li key={selection}>
{unTruncateSelection(selection, ballot_design, id)}: {tallies[selection]}{' '}
<i className="opacity-50 text-[12px] ml-1">({((100 * tallies[selection]) / totalVotes).toFixed(1)}%)</i>
</li>
))}
</ul>
)
}

View File

@@ -0,0 +1,77 @@
import { mapValues, mean, sortBy, sum } from 'lodash-es'
import { useState } from 'react'
import { Item } from 'src/vote/storeElectionInfo'
import { useDecryptedVotes } from './use-decrypted-votes'
export const ScoreTallies = ({ id, options }: { id: string; options: Item['options'] }) => {
const [showSpread, setShowSpread] = useState(false)
const votes = useDecryptedVotes()
const scoresPerOption: Record<string, number[]> = {}
votes.forEach((vote) => {
Object.entries(vote).forEach(([column, score]) => {
if (column.startsWith(id)) {
const option = column.slice(id.length + 1)
if (!scoresPerOption[option]) scoresPerOption[option] = []
scoresPerOption[option].push(+score)
}
})
})
const averagesPerOption = mapValues(scoresPerOption, mean)
const sorted = sortBy(options, ({ name, value }) => averagesPerOption[value || name] ?? -Infinity).reverse()
const deviations = mapValues(scoresPerOption, stdDev)
return (
<table>
<thead>
<tr className="opacity-50 text-[12px]">
<th></th>
<th className="px-4" onClick={() => setShowSpread(!showSpread)}>
Average
</th>
<th># cast</th>
{showSpread && <th className="pl-4">Deviation</th>}
</tr>
</thead>
<tbody>
{sorted.map(({ name, value }) => {
const option = value || name
return (
<tr key={option}>
<td>{name}</td>
<td className="text-center">{formatNumber(averagesPerOption[option]) ?? ''}</td>
<td className="opacity-50 text-[12px] text-center">{(scoresPerOption[option] || []).length}</td>
{showSpread && (
<td className="opacity-50 text-[12px] text-center pl-4">{formatNumber(deviations[option])}</td>
)}
</tr>
)
})}
</tbody>
</table>
)
}
/** Show at most `maxPrecision` decimal places */
const formatNumber = (num?: number, maxPrecision = 2) => {
if (num === undefined) return
if (num % 1 === 0) return num
const currentPrecision = num.toString().split('.')[1].length
const precision = Math.min(currentPrecision, maxPrecision)
return num.toFixed(precision)
}
/** Calc standard deviation of a set of numbers */
const stdDev = (values: number[]) => {
const avg = mean(values)
const diffs = values.map((x) => Math.pow(x - avg, 2))
return Math.sqrt(sum(diffs) / values.length)
}
// console.log(stdDev([-4, +5, -4, +4])) // 4.26
// console.log(stdDev([+2, -1, 0, 0])) // 1.09

View File

@@ -1,8 +1,10 @@
import { keyBy } from 'lodash-es'
import TimeAgo from 'timeago-react'
import { IRVTallies } from './IRVTallies'
import { RoundResults } from './RoundResults'
import { ScoreTallies } from './ScoreTallies'
import { tallyVotes } from './tally-votes'
import { unTruncateSelection } from './un-truncate-selection'
import { useDecryptedVotes } from './use-decrypted-votes'
import { useElectionInfo } from './use-election-info'
@@ -14,74 +16,45 @@ export const Totals = ({ proofsPage }: { proofsPage?: boolean }): JSX.Element =>
if (!ballot_design || !votes || !votes.length) return <></>
const ballot_items_by_id = keyBy(ballot_design, 'id')
const { ordered, tallies, totalsCastPerItems } = tallyVotes(ballot_items_by_id, votes)
const { irv, ordered, tallies, totalsCastPerItems } = tallyVotes(ballot_items_by_id, votes)
return (
<div className="totals" style={{ display: proofsPage ? 'inline-block' : undefined }}>
<div className="title-line">
<h3>Vote Totals:</h3>
<div
className={`p-4 bg-white rounded-lg ${custom_box_shadow}`}
style={{ display: proofsPage ? 'inline-block' : undefined }}
>
<div className="flex items-baseline justify-between">
<h3 className="mt-0">Vote Totals:</h3>
{last_decrypted_at && !proofsPage && (
<span>
<span className="text-[11px] opacity-50 text-right italic">
Last updated: <TimeAgo datetime={last_decrypted_at} opts={{ minInterval: 60 }} />
</span>
)}
</div>
{ballot_design.map(({ id = 'vote', title, type }) => (
{ballot_design.map(({ id = 'vote', options, 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>
{type === 'ranked-choice-irv' ? (
<IRVTallies {...{ ballot_design, id, results: irv[id] }} />
) : type === 'score' ? (
<ScoreTallies {...{ id, options, votes }} />
) : (
<RoundResults
{...{
ballot_design,
id,
ordered: ordered[id],
tallies: tallies[id],
totalVotes: type === 'approval' ? votes.length : totalsCastPerItems[id],
}}
/>
)}
<ul>
{ordered[id].map((selection) => (
<li key={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>
</li>
))}
</ul>
</div>
))}
<style jsx>{`
.totals {
background: #fff;
border-radius: 8px;
padding: 1rem;
box-shadow: 0px 1px 2px hsl(0 0% 50% / 0.333), 0px 3px 4px hsl(0 0% 50% / 0.333),
0px 4px 6px hsl(0 0% 50% / 0.333);
}
.title-line {
display: flex;
justify-content: space-between;
align-items: baseline;
}
h3 {
margin-top: 0;
}
span {
font-size: 11px;
opacity: 0.5;
text-align: right;
font-style: italic;
}
`}</style>
</div>
)
}
const custom_box_shadow =
'shadow-[0px_1px_2px_hsl(0_0%_50%/_0.333),0px_3px_4px_hsl(0_0%_50%/_0.333),0px_4px_6px_hsl(0_0%_50%/_0.333)]'

View File

@@ -1,12 +1,15 @@
import { orderBy } from 'lodash-es'
import { Item } from 'src/vote/storeElectionInfo'
import { mapValues } from '../utils'
import { tally_IRV_Items } from './tallying/rcv-irv'
export function tallyVotes(ballot_items_by_id: Record<string, unknown>, votes: Record<string, string>[]) {
const multi_vote_regex = /_\d+$/
export function tallyVotes(ballot_items_by_id: Record<string, Item>, votes: Record<string, string>[]) {
const multi_vote_regex = /_(\d+)$/
// Sum up votes
const tallies: Record<string, Record<string, number>> = {}
const IRV_columns_seen: Record<string, boolean> = {}
votes.forEach((vote) => {
Object.keys(vote).forEach((key) => {
// Skip 'tracking' key
@@ -19,7 +22,11 @@ export function tallyVotes(ballot_items_by_id: Record<string, unknown>, votes: R
// We'll also check that it's not on the ballot schema, just to be safe
if (multi_suffix && !ballot_items_by_id[key]) {
// If so, we need to add all tallies to seed id, not the derived keys
item = key.slice(0, -(multi_suffix.length + 1))
// Use the matched suffix digit to slice off the right length
item = key.slice(0, -multi_suffix[0].length)
// RCV-IRV items use a different tallying algorithm, so we skip them for now
if (ballot_items_by_id[item]?.type === 'ranked-choice-irv') return (IRV_columns_seen[key] = true)
}
// Init item if new
@@ -32,6 +39,8 @@ export function tallyVotes(ballot_items_by_id: Record<string, unknown>, votes: R
tallies[item][vote[key]]++
})
})
// Calc total votes cast per item, to speed up calc'ing %s
const totalsCastPerItems: Record<string, number> = {}
Object.keys(tallies).forEach((item) => {
totalsCastPerItems[item] = 0
@@ -47,7 +56,8 @@ export function tallyVotes(ballot_items_by_id: Record<string, unknown>, votes: R
),
) as Record<string, string[]>
// const ordered = {}
// Go back and IRV tally any of those that we skipped
const irv = tally_IRV_Items(IRV_columns_seen, ballot_items_by_id, votes)
return { ordered, tallies, totalsCastPerItems }
return { irv, ordered, tallies, totalsCastPerItems }
}

Some files were not shown because too many files have changed in this diff Show More