- Created `/frontend/app/api/votes/check/route.ts` to handle GET requests for checking if a user has voted in a specific election. - Added error handling for unauthorized access and missing election ID. - Forwarded requests to the backend API and returned appropriate responses. - Updated `/frontend/app/api/votes/history/route.ts` to fetch user's voting history with error handling. - Ensured both endpoints utilize the authorization token for secure access.
375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useState } from "react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { AlertCircle, CheckCircle, Loader2 } from "lucide-react"
|
|
import { createSignedBallot, PublicKeysResponse } from "@/lib/crypto-client"
|
|
|
|
export interface Candidate {
|
|
id: number
|
|
name: string
|
|
description?: string
|
|
}
|
|
|
|
export interface VotingInterfaceProps {
|
|
electionId: number
|
|
candidates: Candidate[]
|
|
publicKeys?: PublicKeysResponse
|
|
onVoteSubmitted?: (success: boolean, transactionId?: string, candidateId?: number) => void
|
|
}
|
|
|
|
type VotingStep = "select" | "confirm" | "submitting" | "success" | "error"
|
|
|
|
/**
|
|
* Voting Interface Component
|
|
* Handles ballot creation, encryption, and submission
|
|
*/
|
|
export function VotingInterface({
|
|
electionId,
|
|
candidates,
|
|
publicKeys,
|
|
onVoteSubmitted,
|
|
}: VotingInterfaceProps) {
|
|
const [step, setStep] = useState<VotingStep>("select")
|
|
const [selectedCandidate, setSelectedCandidate] = useState<number | null>(null)
|
|
const [error, setError] = useState<string>("")
|
|
const [transactionId, setTransactionId] = useState<string>("")
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
/**
|
|
* Handle candidate selection
|
|
*/
|
|
const handleSelectCandidate = (candidateId: number) => {
|
|
setSelectedCandidate(candidateId)
|
|
setStep("confirm")
|
|
setError("")
|
|
}
|
|
|
|
/**
|
|
* Handle back button to reselect
|
|
*/
|
|
const handleBack = () => {
|
|
setSelectedCandidate(null)
|
|
setStep("select")
|
|
setError("")
|
|
}
|
|
|
|
/**
|
|
* Handle vote submission with encryption and signing
|
|
*/
|
|
const handleSubmitVote = async () => {
|
|
if (selectedCandidate === null) {
|
|
setError("Veuillez sélectionner un candidat")
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
setError("")
|
|
|
|
try {
|
|
// 1. Get voter context from API
|
|
const voterResponse = await fetch("/api/auth/profile", {
|
|
headers: {
|
|
"Authorization": `Bearer ${localStorage.getItem("auth_token")}`
|
|
}
|
|
})
|
|
|
|
if (!voterResponse.ok) {
|
|
throw new Error("Impossible de récupérer le profil du votant")
|
|
}
|
|
|
|
const voterData = await voterResponse.json()
|
|
const voterId = voterData.id.toString()
|
|
|
|
// 2. Get or use provided public keys
|
|
let keysToUse = publicKeys
|
|
|
|
if (!keysToUse) {
|
|
// Fetch public keys from server
|
|
const keysResponse = await fetch(`/api/votes/public-keys?election_id=${electionId}`)
|
|
if (!keysResponse.ok) {
|
|
throw new Error("Impossible de récupérer les clés publiques")
|
|
}
|
|
keysToUse = await keysResponse.json()
|
|
}
|
|
|
|
if (!keysToUse?.elgamal_pubkey) {
|
|
throw new Error("Clés publiques non disponibles")
|
|
}
|
|
|
|
// 3. Create signed ballot with client-side encryption
|
|
// For MVP: Use simple vote encoding (0 or 1)
|
|
// Selected candidate = 1, others = 0
|
|
const voteValue = selectedCandidate ? 1 : 0
|
|
|
|
const ballot = createSignedBallot(
|
|
voteValue,
|
|
voterId,
|
|
keysToUse.elgamal_pubkey,
|
|
"" // Empty private key for signing in MVP
|
|
)
|
|
|
|
// 4. Submit encrypted ballot
|
|
setStep("submitting")
|
|
|
|
const submitResponse = await fetch("/api/votes/submit", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Authorization": `Bearer ${localStorage.getItem("auth_token")}`
|
|
},
|
|
body: JSON.stringify({
|
|
election_id: electionId,
|
|
candidate_id: selectedCandidate,
|
|
encrypted_vote: ballot.encrypted_vote,
|
|
zkp_proof: ballot.zkp_proof,
|
|
signature: ballot.signature,
|
|
timestamp: ballot.timestamp
|
|
})
|
|
})
|
|
|
|
if (!submitResponse.ok) {
|
|
const errorData = await submitResponse.json()
|
|
throw new Error(errorData.detail || "Erreur lors de la soumission du vote")
|
|
}
|
|
|
|
const result = await submitResponse.json()
|
|
|
|
// 5. Success
|
|
setTransactionId(result.transaction_id || result.id)
|
|
setStep("success")
|
|
|
|
// Notify parent component
|
|
if (onVoteSubmitted) {
|
|
onVoteSubmitted(true, result.transaction_id || result.id, selectedCandidate)
|
|
}
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : "Erreur inconnue"
|
|
setError(errorMessage)
|
|
setStep("error")
|
|
|
|
if (onVoteSubmitted) {
|
|
onVoteSubmitted(false)
|
|
}
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset to allow new vote (in testing mode only)
|
|
*/
|
|
const handleReset = () => {
|
|
setSelectedCandidate(null)
|
|
setStep("select")
|
|
setError("")
|
|
setTransactionId("")
|
|
}
|
|
|
|
return (
|
|
<div className="w-full max-w-2xl mx-auto">
|
|
{/* Selection Step */}
|
|
{step === "select" && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold">Sélectionnez votre vote</h2>
|
|
<p className="text-muted-foreground mt-2">
|
|
Choisissez le candidat ou l'option pour lequel vous souhaitez voter
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-4">
|
|
{candidates && candidates.length > 0 ? (
|
|
candidates.map((candidate) => (
|
|
<Card
|
|
key={candidate.id}
|
|
className="cursor-pointer hover:border-accent hover:bg-accent/5 transition-colors"
|
|
onClick={() => handleSelectCandidate(candidate.id)}
|
|
>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold">{candidate.name}</h3>
|
|
{candidate.description && (
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{candidate.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="h-6 w-6 rounded-full border-2 border-muted-foreground" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
) : (
|
|
<p className="text-muted-foreground text-center py-8">
|
|
Aucun candidat disponible pour cette élection
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<Card className="border-red-500 bg-red-50">
|
|
<CardContent className="pt-6 flex gap-4">
|
|
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
|
<p className="text-sm text-red-800">{error}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Confirmation Step */}
|
|
{step === "confirm" && selectedCandidate !== null && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold">Confirmez votre vote</h2>
|
|
<p className="text-muted-foreground mt-2">
|
|
Veuillez vérifier votre sélection avant de soumettre
|
|
</p>
|
|
</div>
|
|
|
|
<Card className="bg-accent/10 border-accent">
|
|
<CardHeader>
|
|
<CardTitle>Vote sélectionné</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
<p className="font-semibold">
|
|
{candidates.find((c) => c.id === selectedCandidate)?.name}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{candidates.find((c) => c.id === selectedCandidate)?.description}
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-blue-50 border-blue-200">
|
|
<CardContent className="pt-6">
|
|
<p className="text-sm text-blue-900">
|
|
<strong>Sécurité du vote:</strong> Votre bulletin sera chiffré avec
|
|
ElGamal homomorphe avant transmission. Seul le décompte final sera connu,
|
|
pas votre vote individuel.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex gap-3">
|
|
<Button variant="outline" onClick={handleBack} disabled={loading}>
|
|
Retour
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmitVote}
|
|
disabled={loading}
|
|
className="flex-1"
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Envoi en cours...
|
|
</>
|
|
) : (
|
|
"Confirmer et voter"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{error && (
|
|
<Card className="border-red-500 bg-red-50">
|
|
<CardContent className="pt-6 flex gap-4">
|
|
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
|
<p className="text-sm text-red-800">{error}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Submitting Step */}
|
|
{step === "submitting" && (
|
|
<Card>
|
|
<CardContent className="pt-6 flex flex-col items-center justify-center py-12">
|
|
<Loader2 className="h-8 w-8 animate-spin text-accent mb-4" />
|
|
<h3 className="font-semibold">Soumission du vote en cours...</h3>
|
|
<p className="text-sm text-muted-foreground mt-2">
|
|
Votre bulletin est en cours de chiffrement et d'enregistrement
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Success Step */}
|
|
{step === "success" && (
|
|
<div className="space-y-6">
|
|
<Card className="bg-green-50 border-green-200">
|
|
<CardContent className="pt-6">
|
|
<div className="flex gap-4">
|
|
<CheckCircle className="h-6 w-6 text-green-600 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<h3 className="font-semibold text-green-900">Vote enregistré avec succès</h3>
|
|
<p className="text-sm text-green-800 mt-1">
|
|
Votre bulletin chiffré a été ajouté à la blockchain électorale
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Détails du vote</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Identifiant de transaction
|
|
</label>
|
|
<p className="font-mono text-sm break-all bg-muted p-2 rounded mt-1">
|
|
{transactionId}
|
|
</p>
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
<p>✓ Bulletin chiffré avec ElGamal</p>
|
|
<p>✓ Signature Dilithium appliquée</p>
|
|
<p>✓ Enregistré dans la blockchain</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Button className="w-full" onClick={handleReset}>
|
|
Fermer
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error Step */}
|
|
{step === "error" && (
|
|
<div className="space-y-6">
|
|
<Card className="border-red-500 bg-red-50">
|
|
<CardContent className="pt-6">
|
|
<div className="flex gap-4">
|
|
<AlertCircle className="h-6 w-6 text-red-600 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<h3 className="font-semibold text-red-900">Erreur lors de la soumission</h3>
|
|
<p className="text-sm text-red-800 mt-1">{error}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex gap-3">
|
|
<Button variant="outline" onClick={handleBack}>
|
|
Retour
|
|
</Button>
|
|
<Button onClick={handleReset} className="flex-1">
|
|
Réessayer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|