Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions pages/api/11-chooses/provisional/get-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { firebase, pushover } from 'api/_services'
import { NextApiRequest, NextApiResponse } from 'next'
import { election_ids_for_11chooses } from 'src/vote/auth/11choosesAuth/CustomAuthFlow'

type ProvisionalStage = 'vote_submitted' | 'email_submitted' | 'voter_reg_submitted'

type ProvisionalStatus =
| { status: 'not_found' }
| {
status: 'ok'
stage: ProvisionalStage
}

export default async function (req: NextApiRequest, res: NextApiResponse<ProvisionalStatus>) {
const { election_id, link_auth } = req.body

if (typeof election_id !== 'string') return res.status(400).json({ status: 'not_found' })
if (!election_ids_for_11chooses.includes(election_id)) return res.status(400).json({ status: 'not_found' })
if (!link_auth || typeof link_auth !== 'string') return res.status(400).json({ status: 'not_found' })

const electionDoc = firebase.firestore().collection('elections').doc(election_id)

try {
const [voterDoc] = (await electionDoc.collection('votes-pending').where('link_auth', '==', link_auth).get()).docs

if (!voterDoc?.exists) {
await pushover('11c/get-status: link_auth not found', JSON.stringify({ election_id, link_auth }))
return res.status(200).json({ status: 'not_found' })
}

const data = voterDoc.data() || {}
const hasEmail = Array.isArray(data.email_submitted) && data.email_submitted.length > 0
const hasVoterRegInfo = Array.isArray(data.voterRegInfo) && data.voterRegInfo.length > 0

let stage: ProvisionalStage = 'vote_submitted'
if (hasEmail) stage = 'email_submitted'
if (hasVoterRegInfo) stage = 'voter_reg_submitted'

return res.status(200).json({ status: 'ok', stage })
} catch (error) {
await pushover('11c/get-status: exception', JSON.stringify({ election_id, link_auth, error }))
return res.status(200).json({ status: 'not_found' })
}
}


6 changes: 5 additions & 1 deletion src/vote/SubmitButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Dispatch, useState } from 'react'

import { OnClickButton } from '../_shared/Button'
import { api } from '../api-helper'
import { storeProvisionalLinkAuth } from './auth/11choosesAuth/Provisional/provisionalStorage'
import { AirGappedSubmission } from './AirGappedSubmission'
import { State } from './vote-state'

Expand Down Expand Up @@ -69,9 +70,12 @@ export const SubmitButton = ({
return setButtonText('Error')
}

// If auth is `link`, redirect to /auth page
// If auth is `link`, handle provisional ballot redirect & local tracking
if (auth === 'link') {
const { link_auth, visit_to_add_auth } = await response.json()
if (election_id && link_auth) {
storeProvisionalLinkAuth(election_id, link_auth)
}
if (embed) {
// console.log('SIV submit button', link_auth, embed)
window.parent.postMessage({ link_auth }, embed)
Expand Down
15 changes: 15 additions & 0 deletions src/vote/auth/11choosesAuth/CustomAuthFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { api } from 'src/api-helper'
import { TailwindPreflight } from 'src/TailwindPreflight'

import { AddEmailPage } from './AddEmailPage'
import { ProvisionalReturnScreen } from './Provisional/ProvisionalReturnScreen'
import { YOBPage } from './YOBPage'

export const election_ids_for_11chooses = [
Expand All @@ -25,6 +26,20 @@ export const hasCustomAuthFlow = (election_id: string) => {

export const CustomAuthFlow = ({ auth, election_id }: { auth: string; election_id: string }) => {
const { query } = useRouter()

// When auth==='link', this is a provisional ballot that has already been submitted.
// Instead of trying to look up voter info (which is not supported for auth='link'),
// show a special flow that lets the voter continue their existing provisional ballot
// or start a new one.
if (auth === 'link') {
return (
<div className="text-center">
<ProvisionalReturnScreen election_id={election_id} />
<TailwindPreflight />
</div>
)
}

const { is_withheld, loaded, voterName } = useVoterInfo(auth, election_id)
const passedYOB = query.passed_yob === 'true'

Expand Down
55 changes: 49 additions & 6 deletions src/vote/auth/11choosesAuth/Provisional/ProvisionalFlow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { api } from 'src/api-helper'
import { Head } from 'src/Head'
import { TailwindPreflight } from 'src/TailwindPreflight'
import { Footer } from 'src/vote/Footer'
Expand All @@ -7,6 +9,8 @@ import { AddEmailPage } from '../AddEmailPage'
import { BackupAuthOptions } from './BackupAuthOptions'
import { VoterRegistrationLookupScreen } from './VoterRegistrationLookupScreen'

type ProvisionalStage = 'email_submitted' | 'vote_submitted' | 'voter_reg_submitted'

export const ProvisionalFlow = () => {
const {
election_id,
Expand All @@ -15,21 +19,60 @@ export const ProvisionalFlow = () => {
submitted_reg_info,
} = useRouter().query as { election_id?: string; link?: string; passed_email?: string; submitted_reg_info?: string }

const [loadingStatus, setLoadingStatus] = useState(true)
const [stage, setStage] = useState<ProvisionalStage>('vote_submitted')

useEffect(() => {
if (!election_id || !link_auth) return

let cancelled = false
;(async () => {
setLoadingStatus(true)
try {
const response = await api('/11-chooses/provisional/get-status', { election_id, link_auth })
if (!response.ok) {
// fall back to default stage logic
return
}
const json = (await response.json()) as { stage: ProvisionalStage; status: 'ok' } | { status: 'not_found' }
if (!cancelled && json.status === 'ok') {
setStage(json.stage)
}
} catch {
// ignore errors and fall back to default
} finally {
if (!cancelled) setLoadingStatus(false)
}
})()

return () => {
cancelled = true
}
}, [election_id, link_auth])

if (!election_id) return <div className="animate-pulse">Loading Election ID...</div>
if (!link_auth) return <div className="animate-pulse">Loading Link Auth...</div>

const emailComplete = passed_email === 'true' || stage === 'email_submitted' || stage === 'voter_reg_submitted'
const voterRegComplete = submitted_reg_info === 'true' || stage === 'voter_reg_submitted'

return (
<main className="max-w-[750px] w-full mx-auto p-4 flex flex-col min-h-screen justify-between text-center">
<Head title="Provisional Ballot Auth" />

{passed_email !== 'true' ? (
<AddEmailPage auth="provisional" {...{ election_id, link_auth }} />
) : submitted_reg_info !== 'true' ? (
<VoterRegistrationLookupScreen {...{ election_id, link_auth }} />
) : (
<BackupAuthOptions {...{ election_id, link_auth }} />
{loadingStatus && (
<p className="mt-4 text-lg italic text-black/50 animate-pulse">Loading your provisional status...</p>
)}

{!loadingStatus &&
(!emailComplete ? (
<AddEmailPage auth="provisional" {...{ election_id, link_auth }} />
) : !voterRegComplete ? (
<VoterRegistrationLookupScreen {...{ election_id, link_auth }} />
) : (
<BackupAuthOptions {...{ election_id, link_auth }} />
))}

<Footer />
<TailwindPreflight />
</main>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'

import { OnClickButton } from 'src/_shared/Button'
import { clearProvisionalLinkAuth, getProvisionalLinkAuth } from './provisionalStorage'

export const ProvisionalReturnScreen = ({ election_id }: { election_id: string }) => {
const router = useRouter()
const [linkAuth, setLinkAuth] = useState<string | null>(null)

useEffect(() => {
if (!election_id) return
const stored = getProvisionalLinkAuth(election_id)
setLinkAuth(stored?.link_auth || null)
}, [election_id])

const hasExisting = !!linkAuth

return (
<div className="mx-auto flex min-h-[60vh] max-w-xl items-center justify-center px-4">
<div className="w-full rounded-2xl border border-purple-100 bg-white/90 p-8 shadow-lg">
<h1 className="text-center text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl">
It looks like you already submitted a Provisional ballot from this browser.
</h1>

<p className="mt-6 text-center text-lg leading-relaxed text-slate-700">
You can continue with your current provisional ballot, or start a new one.
</p>

{!hasExisting && (
<p className="mt-4 text-center text-sm text-red-500">
We couldn&apos;t find a saved provisional ballot for this browser. You can still start a new one.
</p>
)}

<div className="mt-8 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
<OnClickButton
className="w-full max-w-xs text-center text-lg"
disabled={!hasExisting}
onClick={() => {
if (!linkAuth) return
router.push(`/election/${election_id}/auth?link=${encodeURIComponent(linkAuth)}`)
}}
>
Continue with current ballot
</OnClickButton>

<button
className="w-full max-w-xs rounded-lg border border-slate-300 px-4 py-3 text-lg font-medium text-slate-700 hover:bg-slate-50"
onClick={() => {
if (typeof window !== 'undefined') {
window.localStorage.removeItem(`voter-${election_id}-link`)
}
clearProvisionalLinkAuth(election_id)
router.reload()
}}
type="button"
>
Start a new provisional ballot
</button>
</div>
</div>
</div>
)
}


33 changes: 33 additions & 0 deletions src/vote/auth/11choosesAuth/Provisional/provisionalStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export type StoredProvisional = {
election_id: string
link_auth: string
stored_at: number
}

const STORAGE_KEY_PREFIX = 'siv_provisional_link_auth_'

export const storeProvisionalLinkAuth = (election_id: string, link_auth: string) => {
if (typeof window === 'undefined') return
const value: StoredProvisional = { election_id, link_auth, stored_at: Date.now() }
window.localStorage.setItem(`${STORAGE_KEY_PREFIX}${election_id}`, JSON.stringify(value))
}

export const getProvisionalLinkAuth = (election_id: string): StoredProvisional | null => {
if (typeof window === 'undefined') return null
const raw = window.localStorage.getItem(`${STORAGE_KEY_PREFIX}${election_id}`)
if (!raw) return null
try {
const parsed = JSON.parse(raw) as StoredProvisional
if (parsed && typeof parsed.link_auth === 'string') return parsed
} catch {
// ignore parse errors and treat as missing
}
return null
}

export const clearProvisionalLinkAuth = (election_id: string) => {
if (typeof window === 'undefined') return
window.localStorage.removeItem(`${STORAGE_KEY_PREFIX}${election_id}`)
}