Changes:
- Add next-themes dependency for theme management
- Create ThemeProvider wrapper for app root layout
- Set dark mode as default theme
- Create ThemeToggle component with Sun/Moon icons
- Add theme toggle to home page navigation
- Add theme toggle to dashboard header
- App now starts in dark mode with ability to switch to light mode
Styling uses existing Tailwind dark mode variables configured in
tailwind.config.ts and globals.css. All existing components automatically
support dark theme.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
369 lines
12 KiB
TypeScript
369 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) => 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)
|
|
}
|
|
} 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.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>
|
|
))}
|
|
</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>
|
|
)
|
|
}
|