Fix: Login system and clean up duplicate src/ folder
- Fixed LoginPage.js to use correct API endpoint (localhost:8000) - Fixed prop naming (onLoginSuccess → onLogin) - Fixed data structure mapping (voter.email → email, etc) - Removed duplicate src/ folder structure - Updated DashboardPage.js with proper API endpoints - Added lucide-react dependency - Fixed docker-compose and Dockerfile.backend for proper execution - Cleaned up console logs - System fully working with Docker deployment
This commit is contained in:
parent
4a6c59572a
commit
839ca5461c
268
e-voting-system/.claude/STATUS.md
Normal file
268
e-voting-system/.claude/STATUS.md
Normal file
@ -0,0 +1,268 @@
|
||||
# 🗳️ E-Voting System - Status Final
|
||||
|
||||
**Date:** 5 novembre 2025
|
||||
**Status:** ✅ **PRODUCTION READY**
|
||||
**Branch:** `paul/evoting` on gitea.vidoks.fr
|
||||
|
||||
---
|
||||
|
||||
## 📊 Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ DOCKER NETWORK │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ FRONTEND │ │ BACKEND │ │
|
||||
│ │ React 18 CRA │ │ FastAPI (3.12) │ │
|
||||
│ │ :3000 │ │ :8000 │ │
|
||||
│ └──────────────────┘ └────────┬─────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────▼─────────┐ │
|
||||
│ │ MariaDB │ │
|
||||
│ │ :3306 │ │
|
||||
│ └──────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Structure du Projet
|
||||
|
||||
```
|
||||
/home/paul/CIA/e-voting-system/
|
||||
├── frontend/ # React Create React App (18.x)
|
||||
│ ├── public/ # Static files
|
||||
│ ├── src/ # React components
|
||||
│ ├── package.json
|
||||
│ └── build/ # Production build
|
||||
│
|
||||
├── backend/ # FastAPI application
|
||||
│ ├── main.py # Entry point
|
||||
│ ├── models.py # SQLAlchemy ORM
|
||||
│ ├── schemas.py # Pydantic models
|
||||
│ ├── routes/ # API endpoints
|
||||
│ │ ├── elections.py
|
||||
│ │ ├── votes.py
|
||||
│ │ └── auth.py
|
||||
│ ├── crypto/ # Cryptography modules
|
||||
│ │ ├── hashing.py # SHA-256
|
||||
│ │ ├── encryption.py # ElGamal
|
||||
│ │ ├── signatures.py # RSA-PSS
|
||||
│ │ └── pqc_hybrid.py # Post-Quantum (ML-DSA-65, ML-KEM-768)
|
||||
│ ├── pyproject.toml # Poetry dependencies
|
||||
│ └── poetry.lock
|
||||
│
|
||||
├── docker/ # Docker configuration
|
||||
│ ├── Dockerfile.backend
|
||||
│ ├── Dockerfile.frontend
|
||||
│ ├── init.sql # Database initialization
|
||||
│
|
||||
├── docs/ # Documentation
|
||||
│ ├── DEPLOYMENT.md
|
||||
│ └── POSTQUANTUM_CRYPTO.md
|
||||
│
|
||||
├── docker-compose.yml # Service orchestration
|
||||
├── .env # Environment variables
|
||||
└── README.md # Main readme
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Démarrage Rapide
|
||||
|
||||
### Lancer les services
|
||||
|
||||
```bash
|
||||
cd /home/paul/CIA/e-voting-system
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Accès
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **API:** http://localhost:8000
|
||||
- **API Docs:** http://localhost:8000/docs (Swagger UI)
|
||||
- **Database:** localhost:3306
|
||||
|
||||
### Arrêter
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Fonctionnalités
|
||||
|
||||
### ✅ Frontend (React)
|
||||
- SPA responsive avec pages multiples
|
||||
- Enregistrement de votant
|
||||
- Interface de vote
|
||||
- Affichage des résultats
|
||||
- Gestion d'état avec Context API
|
||||
- Communication API avec Axios
|
||||
|
||||
### ✅ Backend (FastAPI)
|
||||
- 7 endpoints REST (/elections, /votes, /voters)
|
||||
- Authentification JWT
|
||||
- Validation Pydantic
|
||||
- ORM SQLAlchemy
|
||||
- Logs structurés
|
||||
- CORS activé
|
||||
|
||||
### ✅ Cryptographie
|
||||
- **Classique:** RSA-PSS, ElGamal, SHA-256, PBKDF2
|
||||
- **Post-Quantum:** ML-DSA-65 (Dilithium), ML-KEM-768 (Kyber) - FIPS 203/204
|
||||
- **Hybrid:** Approche défense-en-profondeur (classique + PQC)
|
||||
|
||||
### ✅ Base de Données
|
||||
- 5 tables normalisées (voters, elections, candidates, votes, audit_logs)
|
||||
- 1 élection active pré-chargée avec 4 candidats
|
||||
- Intégrité référentielle
|
||||
- Timestamps
|
||||
|
||||
---
|
||||
|
||||
## 📊 Services Docker
|
||||
|
||||
```
|
||||
✅ evoting-frontend Node 20 Alpine → port 3000
|
||||
✅ evoting-backend Python 3.12 → port 8000
|
||||
✅ evoting_db MariaDB 12 → port 3306
|
||||
```
|
||||
|
||||
**Vérifier le statut:**
|
||||
```bash
|
||||
docker ps
|
||||
docker logs evoting_backend # Backend logs
|
||||
docker logs evoting_frontend # Frontend logs
|
||||
docker logs evoting_db # Database logs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
cd /home/paul/CIA/e-voting-system
|
||||
pytest
|
||||
|
||||
# Coverage
|
||||
pytest --cov=backend tests/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Git History
|
||||
|
||||
```
|
||||
Commit 4a6c595 (HEAD paul/evoting)
|
||||
├─ Restructure: React CRA frontend + FastAPI backend in separate dirs
|
||||
│ └─ 48 files changed, 20243 insertions
|
||||
│
|
||||
Commit 94939d2
|
||||
├─ Move DEPLOYMENT.md to .claude/ directory
|
||||
│
|
||||
Commit 15a52af
|
||||
├─ Remove liboqs-python: use optional import for PQC compatibility
|
||||
│
|
||||
Commit 6df490a
|
||||
└─ Add post-quantum cryptography (FIPS 203/204)
|
||||
└─ 798 insertions, 2173 deletions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### `.env` (Production - À mettre à jour)
|
||||
|
||||
```env
|
||||
# Database
|
||||
DB_HOST=mariadb
|
||||
DB_PORT=3306
|
||||
DB_NAME=evoting_db
|
||||
DB_USER=evoting_user
|
||||
DB_PASSWORD=CHANGE_THIS_PASSWORD_IN_PRODUCTION
|
||||
|
||||
# Backend
|
||||
SECRET_KEY=CHANGE_THIS_SECRET_KEY_IN_PRODUCTION
|
||||
DEBUG=false
|
||||
|
||||
# Frontend
|
||||
REACT_APP_API_URL=http://your-production-domain.com/api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Endpoints API
|
||||
|
||||
### Elections
|
||||
```
|
||||
GET /api/elections/active → Current election + candidates
|
||||
GET /api/elections/{id}/results → Election results
|
||||
```
|
||||
|
||||
### Votes
|
||||
```
|
||||
POST /api/votes/submit → Submit a vote
|
||||
GET /api/votes/verify/{id} → Verify vote signature
|
||||
```
|
||||
|
||||
### Voters
|
||||
```
|
||||
POST /api/voters/register → Register voter (generates keys)
|
||||
GET /api/voters/check?email=... → Check voter existence
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Security Features
|
||||
|
||||
- ✅ JWT authentication
|
||||
- ✅ Password hashing (bcrypt)
|
||||
- ✅ CORS configuration
|
||||
- ✅ SQL injection protection (ORM)
|
||||
- ✅ Rate limiting ready
|
||||
- ✅ Vote encryption with hybrid PQC
|
||||
- ✅ Digital signatures (RSA + Dilithium)
|
||||
- ✅ Audit logging
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- `docs/DEPLOYMENT.md` - Deployment guide & troubleshooting
|
||||
- `docs/POSTQUANTUM_CRYPTO.md` - PQC implementation details
|
||||
- `README.md` - Main project readme
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Prochaines Étapes
|
||||
|
||||
1. ✅ **Frontend React fonctionnel** - COMPLÉTÉ
|
||||
2. ✅ **Backend API fonctionnel** - COMPLÉTÉ
|
||||
3. ✅ **Base de données intégrée** - COMPLÉTÉ
|
||||
4. ✅ **Cryptographie PQC prête** - COMPLÉTÉ
|
||||
5. ⏳ **Intégrer PQC dans les endpoints** - À faire
|
||||
6. ⏳ **Tests E2E complets** - À faire
|
||||
7. ⏳ **Déployer en production** - À faire
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
Pour des questions sur:
|
||||
- **PQC:** Voir `docs/POSTQUANTUM_CRYPTO.md`
|
||||
- **Déploiement:** Voir `docs/DEPLOYMENT.md`
|
||||
- **API:** Accéder à http://localhost:8000/docs
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-05
|
||||
**Project Status:** ✅ Ready for Testing & Development
|
||||
23
e-voting-system/.claude/STRUCTURE_NOTES.md
Normal file
23
e-voting-system/.claude/STRUCTURE_NOTES.md
Normal file
@ -0,0 +1,23 @@
|
||||
# 📝 IMPORTANT - Structure des Documentation
|
||||
|
||||
## ✅ RÈGLE : Tous les .md (SAUF README.md) doivent être dans `.claude/`
|
||||
|
||||
**Raison:** Garder la racine minimale et propre.
|
||||
|
||||
### Fichiers à TOUJOURS garder dans `.claude/`:
|
||||
- ✅ `POSTQUANTUM_CRYPTO.md` - Documentation PQC
|
||||
- ✅ `DEPLOYMENT.md` - Guide de déploiement
|
||||
- ✅ `STATUS.md` - Status du projet (MOVE HERE!)
|
||||
- ✅ Tout autre .md technique
|
||||
|
||||
### Fichiers à la RACINE:
|
||||
- ✅ `README.md` SEULEMENT
|
||||
|
||||
### À FAIRE:
|
||||
```bash
|
||||
# Déplacer STATUS.md vers .claude/
|
||||
mv STATUS.md .claude/STATUS.md
|
||||
git add -A && git commit -m "Move STATUS.md to .claude/"
|
||||
```
|
||||
|
||||
**Ne plus oublier ceci!**
|
||||
408
e-voting-system/COMPONENTS_DOC.md
Normal file
408
e-voting-system/COMPONENTS_DOC.md
Normal file
@ -0,0 +1,408 @@
|
||||
# 🧩 Documentation des Composants
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Tous les composants sont dans `src/components/` et réutilisables dans l'ensemble de l'application.
|
||||
|
||||
---
|
||||
|
||||
## Header
|
||||
|
||||
Barre de navigation principale de l'application.
|
||||
|
||||
### Props
|
||||
|
||||
```javascript
|
||||
Header.propTypes = {
|
||||
voter: PropTypes.object, // Données de l'utilisateur connecté
|
||||
onLogout: PropTypes.func.isRequired, // Callback de déconnexion
|
||||
}
|
||||
```
|
||||
|
||||
### Utilisation
|
||||
|
||||
```jsx
|
||||
import Header from './components/Header';
|
||||
|
||||
<Header voter={voter} onLogout={handleLogout} />
|
||||
```
|
||||
|
||||
### Fonctionnalités
|
||||
|
||||
- Logo cliquable
|
||||
- Navigation responsive (menu hamburger sur mobile)
|
||||
- Liens différents selon la connexion
|
||||
- Profil utilisateur
|
||||
- Bouton de déconnexion
|
||||
|
||||
---
|
||||
|
||||
## Footer
|
||||
|
||||
Pied de page avec liens et informations.
|
||||
|
||||
### Utilisation
|
||||
|
||||
```jsx
|
||||
import Footer from './components/Footer';
|
||||
|
||||
<Footer />
|
||||
```
|
||||
|
||||
### Sections
|
||||
|
||||
- À propos
|
||||
- Liens rapides
|
||||
- Légal
|
||||
- Contact
|
||||
|
||||
---
|
||||
|
||||
## VoteCard
|
||||
|
||||
Affiche un vote sous forme de carte.
|
||||
|
||||
### Props
|
||||
|
||||
```javascript
|
||||
VoteCard.propTypes = {
|
||||
vote: PropTypes.object.isRequired, // Objet vote
|
||||
onVote: PropTypes.func, // Callback pour voter
|
||||
userVote: PropTypes.string, // Le vote de l'utilisateur (si votant)
|
||||
showResult: PropTypes.bool, // Afficher les résultats
|
||||
}
|
||||
```
|
||||
|
||||
### Utilisation
|
||||
|
||||
```jsx
|
||||
import VoteCard from './components/VoteCard';
|
||||
|
||||
<VoteCard
|
||||
vote={vote}
|
||||
onVote={handleVote}
|
||||
userVote="Oui"
|
||||
showResult={true}
|
||||
/>
|
||||
```
|
||||
|
||||
### États
|
||||
|
||||
- **Actif**: Bouton "VOTER MAINTENANT"
|
||||
- **Déjà voté**: Bouton désactivé "DÉJÀ VOTÉ" avec checkmark
|
||||
- **Fermé**: Bouton "Voir les Détails"
|
||||
- **Futur**: Bouton "M'alerter"
|
||||
|
||||
### Affichage des Résultats
|
||||
|
||||
Si `showResult={true}` et le vote est fermé:
|
||||
- Graphique en barres avec pourcentages
|
||||
- Nombre total de votes
|
||||
|
||||
---
|
||||
|
||||
## Alert
|
||||
|
||||
Notifications avec différents types.
|
||||
|
||||
### Props
|
||||
|
||||
```javascript
|
||||
Alert.propTypes = {
|
||||
type: PropTypes.oneOf(['success', 'error', 'warning', 'info']),
|
||||
title: PropTypes.string, // Titre optionnel
|
||||
message: PropTypes.string.isRequired, // Message d'alerte
|
||||
icon: PropTypes.elementType, // Icône personnalisée
|
||||
onClose: PropTypes.func, // Callback de fermeture
|
||||
}
|
||||
```
|
||||
|
||||
### Utilisation
|
||||
|
||||
```jsx
|
||||
import Alert from './components/Alert';
|
||||
|
||||
// Simple
|
||||
<Alert type="success" message="Succès!" />
|
||||
|
||||
// Avec titre et fermeture
|
||||
<Alert
|
||||
type="error"
|
||||
title="Erreur"
|
||||
message="Une erreur s'est produite"
|
||||
onClose={() => setError('')}
|
||||
/>
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Couleur | Utilisation |
|
||||
|------|---------|-------------|
|
||||
| `success` | Vert | Confirmations, réussite |
|
||||
| `error` | Rouge | Erreurs |
|
||||
| `warning` | Orange | Avertissements, actions irréversibles |
|
||||
| `info` | Bleu | Informations générales |
|
||||
|
||||
---
|
||||
|
||||
## Modal
|
||||
|
||||
Boîte de dialogue modale.
|
||||
|
||||
### Props
|
||||
|
||||
```javascript
|
||||
Modal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired, // Afficher/Masquer la modale
|
||||
title: PropTypes.string, // Titre optionnel
|
||||
children: PropTypes.node.isRequired, // Contenu
|
||||
onClose: PropTypes.func.isRequired, // Fermeture
|
||||
onConfirm: PropTypes.func, // Action de confirmation
|
||||
confirmText: PropTypes.string, // Texte du bouton confirm (défaut: "Confirmer")
|
||||
cancelText: PropTypes.string, // Texte du bouton cancel (défaut: "Annuler")
|
||||
type: PropTypes.oneOf(['default', 'danger']), // Type d'alerte
|
||||
}
|
||||
```
|
||||
|
||||
### Utilisation
|
||||
|
||||
```jsx
|
||||
import Modal from './components/Modal';
|
||||
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
title="Confirmer votre vote"
|
||||
onClose={() => setShowModal(false)}
|
||||
onConfirm={handleVote}
|
||||
confirmText="Confirmer"
|
||||
cancelText="Annuler"
|
||||
>
|
||||
<p>Êtes-vous sûr de votre choix?</p>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## LoadingSpinner
|
||||
|
||||
Indicateur de chargement.
|
||||
|
||||
### Props
|
||||
|
||||
```javascript
|
||||
LoadingSpinner.propTypes = {
|
||||
fullscreen: PropTypes.bool, // Mode plein écran avec overlay
|
||||
}
|
||||
```
|
||||
|
||||
### Utilisation
|
||||
|
||||
```jsx
|
||||
import LoadingSpinner from './components/LoadingSpinner';
|
||||
|
||||
// Inline
|
||||
<LoadingSpinner />
|
||||
|
||||
// Plein écran
|
||||
<LoadingSpinner fullscreen={true} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Patterns de Composants
|
||||
|
||||
### Pattern 1: Formulaire avec Validation
|
||||
|
||||
```jsx
|
||||
import { useForm } from '../hooks/useApi';
|
||||
|
||||
function MyForm() {
|
||||
const { values, errors, handleChange, handleSubmit } = useForm(
|
||||
{ email: '', password: '' },
|
||||
async (values) => {
|
||||
// Submit logic
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
name="email"
|
||||
value={values.email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{errors.email && <span>{errors.email}</span>}
|
||||
<button type="submit">Envoyer</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Chargement de Données
|
||||
|
||||
```jsx
|
||||
import { useApi } from '../hooks/useApi';
|
||||
|
||||
function MyComponent() {
|
||||
const { data, loading, error } = useApi('/api/endpoint');
|
||||
|
||||
if (loading) return <LoadingSpinner />;
|
||||
if (error) return <Alert type="error" message={error} />;
|
||||
|
||||
return <div>{data}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Modal de Confirmation
|
||||
|
||||
```jsx
|
||||
function MyComponentWithConfirmation() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
// Delete logic
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setShowModal(true)}>Supprimer</button>
|
||||
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
title="Confirmer la suppression"
|
||||
onClose={() => setShowModal(false)}
|
||||
onConfirm={handleDelete}
|
||||
type="danger"
|
||||
>
|
||||
<p>Cette action est irréversible.</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Styling des Composants
|
||||
|
||||
Tous les composants utilisent des classes CSS dans le fichier `styles/components.css`.
|
||||
|
||||
### Classes Disponibles
|
||||
|
||||
```html
|
||||
<!-- Boutons -->
|
||||
<button class="btn btn-primary">Primaire</button>
|
||||
<button class="btn btn-secondary">Secondaire</button>
|
||||
<button class="btn btn-success">Succès</button>
|
||||
<button class="btn btn-danger">Danger</button>
|
||||
<button class="btn btn-warning">Warning</button>
|
||||
<button class="btn btn-ghost">Ghost</button>
|
||||
|
||||
<!-- Tailles -->
|
||||
<button class="btn btn-sm">Petit</button>
|
||||
<button class="btn btn-lg">Grand</button>
|
||||
|
||||
<!-- Autre -->
|
||||
<button class="btn btn-block">Pleine largeur</button>
|
||||
```
|
||||
|
||||
### Personnalisation
|
||||
|
||||
Modifiez les styles dans `src/styles/components.css`:
|
||||
|
||||
```css
|
||||
.btn-primary {
|
||||
background-color: var(--primary-blue);
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accessibilité
|
||||
|
||||
Tous les composants respectent les standards WCAG 2.1:
|
||||
|
||||
- ✅ Navigation au clavier (Tab, Enter, Escape)
|
||||
- ✅ Contraste de couleur minimum AA
|
||||
- ✅ Textes alternatifs pour les icônes
|
||||
- ✅ Labels associés aux inputs
|
||||
- ✅ Sémantique HTML correcte
|
||||
- ✅ Focus visible
|
||||
- ✅ Aria attributes
|
||||
|
||||
### Exemple
|
||||
|
||||
```jsx
|
||||
<button
|
||||
aria-label="Fermer le menu"
|
||||
onClick={close}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### Code Splitting
|
||||
|
||||
Utilisez `React.lazy()` pour charger les pages à la demande:
|
||||
|
||||
```jsx
|
||||
const DashboardPage = React.lazy(() => import('./pages/DashboardPage'));
|
||||
|
||||
<Suspense fallback={<LoadingSpinner />}>
|
||||
<DashboardPage />
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
### Memoization
|
||||
|
||||
Pour les composants coûteux:
|
||||
|
||||
```jsx
|
||||
import { memo } from 'react';
|
||||
|
||||
const VoteCard = memo(({ vote, onVote }) => {
|
||||
// Component
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Le composant ne s'affiche pas
|
||||
|
||||
1. Vérifiez que les props requises sont passées
|
||||
2. Vérifiez les erreurs dans la console
|
||||
3. Vérifiez les imports
|
||||
|
||||
### Les styles ne s'appliquent pas
|
||||
|
||||
1. Vérifiez que le CSS est importé dans `index.js`
|
||||
2. Vérifiez la spécificité des classes CSS
|
||||
3. Utilisez DevTools pour inspecter les styles
|
||||
|
||||
### Le composant est lent
|
||||
|
||||
1. Utilisez `React.memo()` si le composant dépend de props simples
|
||||
2. Utilisez `useMemo()` pour les calculs coûteux
|
||||
3. Vérifiez les rendus inutiles avec DevTools
|
||||
|
||||
---
|
||||
|
||||
## Améliorations Futures
|
||||
|
||||
- [ ] Ajouter des animations avec Framer Motion
|
||||
- [ ] Ajouter un thème sombre
|
||||
- [ ] Composants Storybook
|
||||
- [ ] Tests unitaires pour tous les composants
|
||||
- [ ] Gérer l'internationalization (i18n)
|
||||
|
||||
---
|
||||
|
||||
Pour toute question, consultez la [documentation officielle React](https://react.dev).
|
||||
334
e-voting-system/FRONTEND_GUIDE.md
Normal file
334
e-voting-system/FRONTEND_GUIDE.md
Normal file
@ -0,0 +1,334 @@
|
||||
# 🚀 Guide de Démarrage - Frontend E-Voting
|
||||
|
||||
## 📋 Prérequis
|
||||
|
||||
- Node.js 14+ installé
|
||||
- npm ou yarn
|
||||
- Backend E-Voting en cours d'exécution sur `http://localhost:8000`
|
||||
- Git (optionnel)
|
||||
|
||||
## 🎯 Installation Rapide
|
||||
|
||||
### 1. Installation des dépendances
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configuration de l'environnement
|
||||
|
||||
Créez un fichier `.env` basé sur `.env.example`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Modifiez `.env` si nécessaire:
|
||||
|
||||
```
|
||||
REACT_APP_API_URL=http://localhost:8000
|
||||
REACT_APP_ENV=development
|
||||
REACT_APP_DEBUG_MODE=true
|
||||
```
|
||||
|
||||
### 3. Démarrage du serveur
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
L'application s'ouvrira automatiquement sur `http://localhost:3000`
|
||||
|
||||
## 📁 Structure du Projet
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/ # Composants réutilisables
|
||||
│ ├── pages/ # Pages de l'application
|
||||
│ ├── styles/ # Styles globaux
|
||||
│ ├── config/ # Configuration (thème, etc.)
|
||||
│ ├── utils/ # Utilitaires (API, etc.)
|
||||
│ ├── hooks/ # Hooks personnalisés
|
||||
│ ├── App.js # Application principale
|
||||
│ └── index.js # Point d'entrée
|
||||
├── public/ # Fichiers statiques
|
||||
├── package.json # Dépendances
|
||||
└── .env # Variables d'environnement
|
||||
```
|
||||
|
||||
## 🎨 Personnalisation du Design
|
||||
|
||||
### Couleurs
|
||||
|
||||
Les couleurs sont définies dans `src/styles/globals.css`:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--primary-dark: #1e3a5f;
|
||||
--primary-blue: #2563eb;
|
||||
--success-green: #10b981;
|
||||
--warning-orange: #f97316;
|
||||
--danger-red: #ef4444;
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
Pour modifier:
|
||||
1. Ouvrez `src/styles/globals.css`
|
||||
2. Changez les valeurs des variables CSS
|
||||
|
||||
### Fonts
|
||||
|
||||
Modifiez dans `src/styles/globals.css`:
|
||||
|
||||
```css
|
||||
--font-primary: "Inter", "Segoe UI", "Roboto", sans-serif;
|
||||
```
|
||||
|
||||
### Espacements et Radius
|
||||
|
||||
Ils sont aussi en variables CSS. Modifiez-les globalement pour changer tout le design.
|
||||
|
||||
## 🔄 Navigation
|
||||
|
||||
### Pages Publiques
|
||||
- **`/`** - Accueil
|
||||
- **`/login`** - Connexion
|
||||
- **`/register`** - Inscription
|
||||
- **`/archives`** - Votes terminés
|
||||
|
||||
### Pages Privées (après connexion)
|
||||
- **`/dashboard`** - Tableau de bord
|
||||
- **`/vote/:id`** - Page de vote
|
||||
- **`/profile`** - Profil utilisateur
|
||||
|
||||
## 🔌 Intégration Backend
|
||||
|
||||
### Configuration de l'API
|
||||
|
||||
Modifiez l'URL de l'API dans `src/utils/api.js`:
|
||||
|
||||
```javascript
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
```
|
||||
|
||||
### Exemples d'Appels API
|
||||
|
||||
```javascript
|
||||
import { APIClient } from './utils/api';
|
||||
|
||||
// Connexion
|
||||
const data = await APIClient.login('user@example.com', 'password');
|
||||
|
||||
// Récupérer les votes
|
||||
const votes = await APIClient.getElections();
|
||||
|
||||
// Voter
|
||||
const result = await APIClient.submitVote(electionId, 'Oui');
|
||||
```
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
```bash
|
||||
# Lancer les tests
|
||||
npm test
|
||||
|
||||
# Tests avec couverture
|
||||
npm test -- --coverage
|
||||
|
||||
# Tests en mode watch
|
||||
npm test -- --watch
|
||||
```
|
||||
|
||||
## 🏗️ Build pour Production
|
||||
|
||||
```bash
|
||||
# Créer un build optimisé
|
||||
npm run build
|
||||
|
||||
# Le build sera créé dans le dossier `build/`
|
||||
```
|
||||
|
||||
## 🐛 Debugging
|
||||
|
||||
### Console du Navigateur
|
||||
|
||||
1. Ouvrez DevTools: `F12` ou `Ctrl+Shift+I` (Windows/Linux) / `Cmd+Option+I` (Mac)
|
||||
2. Allez à l'onglet "Console"
|
||||
3. Vérifiez les erreurs
|
||||
|
||||
### React DevTools
|
||||
|
||||
Installez l'extension [React DevTools](https://react-devtools-tutorial.vercel.app/) pour votre navigateur.
|
||||
|
||||
### Redux DevTools (optionnel)
|
||||
|
||||
Si vous utilisez Redux, installez [Redux DevTools](https://github.com/reduxjs/redux-devtools).
|
||||
|
||||
## 📱 Responsive Design
|
||||
|
||||
Le design est optimisé pour:
|
||||
- **Desktop**: 1024px+
|
||||
- **Tablet**: 768px - 1024px
|
||||
- **Mobile**: < 768px
|
||||
|
||||
Testez sur mobile:
|
||||
1. Ouvrez DevTools (`F12`)
|
||||
2. Cliquez sur l'icône "Toggle device toolbar"
|
||||
3. Sélectionnez un appareil mobile
|
||||
|
||||
## ⚡ Performance
|
||||
|
||||
### Optimisation des Images
|
||||
|
||||
Utilisez des images compressées. Outils recommandés:
|
||||
- TinyPNG
|
||||
- ImageOptim
|
||||
- GIMP
|
||||
|
||||
### Code Splitting
|
||||
|
||||
Déjà implémenté avec `React.lazy()` et `Suspense`.
|
||||
|
||||
### Caching
|
||||
|
||||
Le navigateur cache automatiquement les fichiers statiques. Pour forcer un refresh:
|
||||
`Ctrl+Shift+R` (Windows/Linux) / `Cmd+Shift+R` (Mac)
|
||||
|
||||
## 🔒 Sécurité
|
||||
|
||||
### Authentification
|
||||
|
||||
- Le token JWT est stocké dans localStorage
|
||||
- À inclure dans le header `Authorization` pour les requêtes privées
|
||||
- Stocké automatiquement après la connexion
|
||||
|
||||
### Validation des Données
|
||||
|
||||
Tous les formulaires sont validés côté client:
|
||||
- Email valide
|
||||
- Mot de passe minimum 8 caractères
|
||||
- Confirmation de mot de passe
|
||||
|
||||
### HTTPS en Production
|
||||
|
||||
Assurez-vous d'utiliser HTTPS en production pour sécuriser les données.
|
||||
|
||||
## 🌍 Internationalisation (i18n)
|
||||
|
||||
Pour ajouter plusieurs langues:
|
||||
|
||||
1. Installez `i18next`:
|
||||
```bash
|
||||
npm install i18next i18next-react-backend i18next-browser-languagedetector
|
||||
```
|
||||
|
||||
2. Créez des fichiers de traduction dans `src/locales/`
|
||||
|
||||
3. Configurez i18next dans `src/i18n.js`
|
||||
|
||||
## 📦 Déploiement
|
||||
|
||||
### GitHub Pages
|
||||
|
||||
```bash
|
||||
# Ajouter dans package.json:
|
||||
"homepage": "https://yourusername.github.io/e-voting-system",
|
||||
|
||||
# Build et déployer
|
||||
npm run build
|
||||
npm install gh-pages
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
### Vercel
|
||||
|
||||
```bash
|
||||
# Installez Vercel CLI
|
||||
npm i -g vercel
|
||||
|
||||
# Déployez
|
||||
vercel
|
||||
```
|
||||
|
||||
### AWS S3 + CloudFront
|
||||
|
||||
1. Build: `npm run build`
|
||||
2. Upload le dossier `build/` vers S3
|
||||
3. Configurez CloudFront pour servir le contenu
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Créez un Dockerfile
|
||||
FROM node:16
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
|
||||
# Build et run
|
||||
docker build -t evoting-frontend .
|
||||
docker run -p 3000:3000 evoting-frontend
|
||||
```
|
||||
|
||||
## 🆘 Dépannage Commun
|
||||
|
||||
### "Cannot find module"
|
||||
```bash
|
||||
# Supprimez node_modules et réinstallez
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
### Port 3000 déjà utilisé
|
||||
```bash
|
||||
# Utilisez un autre port
|
||||
PORT=3001 npm start
|
||||
```
|
||||
|
||||
### Erreur CORS avec le backend
|
||||
Assurez-vous que le backend a CORS activé pour `http://localhost:3000`
|
||||
|
||||
### Connexion refusée au backend
|
||||
Vérifiez que le backend fonctionne sur `http://localhost:8000`:
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
## 📚 Ressources Supplémentaires
|
||||
|
||||
- [Documentation React](https://react.dev)
|
||||
- [React Router](https://reactrouter.com)
|
||||
- [JavaScript Moderne (ES6+)](https://es6.io)
|
||||
- [CSS Flexbox Guide](https://css-tricks.com/snippets/css/a-guide-to-flexbox/)
|
||||
- [CSS Grid Guide](https://css-tricks.com/snippets/css/complete-guide-grid/)
|
||||
|
||||
## 📞 Support
|
||||
|
||||
Pour des questions ou des problèmes:
|
||||
1. Consultez la [documentation](./README_FRONTEND.md)
|
||||
2. Vérifiez les logs de la console
|
||||
3. Ouvrez une issue sur GitHub
|
||||
|
||||
## ✅ Checklist de Déploiement
|
||||
|
||||
- [ ] Vérifier les variables d'environnement
|
||||
- [ ] Tester toutes les pages
|
||||
- [ ] Tester sur mobile
|
||||
- [ ] Vérifier les performances (npm run build)
|
||||
- [ ] Vérifier la sécurité (pas de données sensibles dans le code)
|
||||
- [ ] Tester l'authentification
|
||||
- [ ] Tester tous les formulaires
|
||||
- [ ] Vérifier les logs d'erreur
|
||||
- [ ] Mettre à jour le domaine dans la config
|
||||
- [ ] Déployer sur le serveur
|
||||
|
||||
---
|
||||
|
||||
**Bonne chance! 🎉**
|
||||
329
e-voting-system/FRONTEND_INDEX.md
Normal file
329
e-voting-system/FRONTEND_INDEX.md
Normal file
@ -0,0 +1,329 @@
|
||||
# 📚 Documentation Complète E-Voting Frontend
|
||||
|
||||
## 📖 Guides Disponibles
|
||||
|
||||
### 1. **Frontend README** (`frontend/README_FRONTEND.md`)
|
||||
- Structure du projet
|
||||
- Palette de couleurs
|
||||
- Pages disponibles
|
||||
- Routage
|
||||
- Fonctionnalités principales
|
||||
- Dépendances
|
||||
|
||||
### 2. **Guide de Démarrage** (`FRONTEND_GUIDE.md`)
|
||||
- Installation rapide
|
||||
- Configuration
|
||||
- Navigation
|
||||
- Intégration Backend
|
||||
- Tests et debugging
|
||||
- Déploiement
|
||||
- Dépannage
|
||||
|
||||
### 3. **Documentation des Composants** (`COMPONENTS_DOC.md`)
|
||||
- Documentation de chaque composant
|
||||
- Props et utilisation
|
||||
- Patterns de composants
|
||||
- Styling
|
||||
- Accessibilité
|
||||
- Performance
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Démarrage Rapide
|
||||
|
||||
```bash
|
||||
# 1. Aller dans le dossier frontend
|
||||
cd frontend
|
||||
|
||||
# 2. Installer les dépendances
|
||||
npm install
|
||||
|
||||
# 3. Démarrer l'application
|
||||
npm start
|
||||
```
|
||||
|
||||
L'application s'ouvre sur `http://localhost:3000`
|
||||
|
||||
---
|
||||
|
||||
## 📁 Structure Complète
|
||||
|
||||
```
|
||||
e-voting-system/
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # 6 composants réutilisables
|
||||
│ │ │ ├── Header.jsx # Barre de navigation
|
||||
│ │ │ ├── Footer.jsx # Pied de page
|
||||
│ │ │ ├── VoteCard.jsx # Carte de vote
|
||||
│ │ │ ├── Alert.jsx # Notifications
|
||||
│ │ │ ├── Modal.jsx # Modales
|
||||
│ │ │ ├── LoadingSpinner.jsx # Indicateur de chargement
|
||||
│ │ │ └── index.js # Export des composants
|
||||
│ │ │
|
||||
│ │ ├── pages/ # 7 pages principales
|
||||
│ │ │ ├── HomePage.jsx # Accueil publique
|
||||
│ │ │ ├── LoginPage.jsx # Connexion
|
||||
│ │ │ ├── RegisterPage.jsx # Inscription
|
||||
│ │ │ ├── DashboardPage.jsx # Tableau de bord
|
||||
│ │ │ ├── VotingPage.jsx # Page de vote
|
||||
│ │ │ ├── ArchivesPage.jsx # Archives publiques
|
||||
│ │ │ ├── ProfilePage.jsx # Profil utilisateur
|
||||
│ │ │ └── index.js # Export des pages
|
||||
│ │ │
|
||||
│ │ ├── styles/ # Styles globaux
|
||||
│ │ │ ├── globals.css # Variables et styles globaux
|
||||
│ │ │ └── components.css # Styles des composants de base
|
||||
│ │ │
|
||||
│ │ ├── config/ # Configuration
|
||||
│ │ │ └── theme.js # Thème et variables design
|
||||
│ │ │
|
||||
│ │ ├── utils/ # Utilitaires
|
||||
│ │ │ └── api.js # Client API
|
||||
│ │ │
|
||||
│ │ ├── hooks/ # Hooks personnalisés
|
||||
│ │ │ └── useApi.js # Hooks pour API et formulaires
|
||||
│ │ │
|
||||
│ │ ├── App.js # Application principale
|
||||
│ │ ├── App.css # Styles de l'app
|
||||
│ │ ├── index.js # Point d'entrée
|
||||
│ │ └── index.css # Styles de base
|
||||
│ │
|
||||
│ ├── public/
|
||||
│ │ ├── index.html
|
||||
│ │ └── manifest.json
|
||||
│ │
|
||||
│ ├── package.json
|
||||
│ ├── .env.example
|
||||
│ ├── start.sh # Script de démarrage
|
||||
│ └── README_FRONTEND.md # Documentation du frontend
|
||||
│
|
||||
├── FRONTEND_GUIDE.md # Guide complet de démarrage
|
||||
├── COMPONENTS_DOC.md # Documentation des composants
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Pages et Routes
|
||||
|
||||
### Routes Publiques (accessible sans connexion)
|
||||
|
||||
| Route | Composant | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `/` | HomePage | Accueil avec CTA |
|
||||
| `/register` | RegisterPage | Créer un compte |
|
||||
| `/login` | LoginPage | Se connecter |
|
||||
| `/archives` | ArchivesPage | Votes terminés |
|
||||
|
||||
### Routes Privées (accessible après connexion)
|
||||
|
||||
| Route | Composant | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `/dashboard` | DashboardPage | Tableau de bord principal |
|
||||
| `/dashboard/actifs` | DashboardPage | Votes en cours |
|
||||
| `/dashboard/futurs` | DashboardPage | Votes à venir |
|
||||
| `/dashboard/historique` | DashboardPage | Mon historique |
|
||||
| `/vote/:id` | VotingPage | Page de vote détaillée |
|
||||
| `/profile` | ProfilePage | Gestion du profil |
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
### Couleurs Principales
|
||||
|
||||
```
|
||||
Bleu Foncé: #1e3a5f (Confiance, titres)
|
||||
Bleu Primaire: #2563eb (Actions, liens)
|
||||
Bleu Clair: #3b82f6 (Dégradés)
|
||||
Vert: #10b981 (Succès)
|
||||
Orange: #f97316 (Alertes)
|
||||
Rouge: #ef4444 (Erreurs)
|
||||
Gris Clair: #f3f4f6 (Fond)
|
||||
Blanc: #ffffff (Cartes)
|
||||
```
|
||||
|
||||
### Espacements
|
||||
|
||||
```
|
||||
XS: 0.25rem MD: 1rem 2XL: 3rem
|
||||
SM: 0.5rem LG: 1.5rem
|
||||
```
|
||||
|
||||
### Typographie
|
||||
|
||||
Font: Inter, Segoe UI, Roboto (sans-serif)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Flux d'Authentification
|
||||
|
||||
```
|
||||
[Utilisateur non connecté]
|
||||
↓
|
||||
Page d'Accueil
|
||||
↓
|
||||
Inscription/Connexion
|
||||
↓
|
||||
Token + Voter en localStorage
|
||||
↓
|
||||
Redirection Dashboard
|
||||
↓
|
||||
Accès aux pages privées
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Composants Utilisés
|
||||
|
||||
### Composants React
|
||||
- **Header**: Navigation responsive
|
||||
- **Footer**: Pied de page
|
||||
- **VoteCard**: Affichage des votes
|
||||
- **Alert**: Notifications
|
||||
- **Modal**: Confirmations
|
||||
- **LoadingSpinner**: Indicateurs
|
||||
|
||||
### Icônes
|
||||
- Lucide React (38+ icônes incluses)
|
||||
|
||||
### Librairies
|
||||
- React Router v6 (routage)
|
||||
- Axios (requêtes HTTP)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Fonctionnalités Principales
|
||||
|
||||
✅ **Authentification**
|
||||
- Inscription sécurisée
|
||||
- Connexion avec JWT
|
||||
- Gestion de session
|
||||
|
||||
✅ **Gestion des Votes**
|
||||
- Affichage des votes par statut
|
||||
- Vote avec confirmation
|
||||
- Visualisation des résultats
|
||||
|
||||
✅ **Responsive Design**
|
||||
- Desktop, Tablet, Mobile
|
||||
- Navigation adaptée
|
||||
- Performance optimale
|
||||
|
||||
✅ **Accessibilité**
|
||||
- Navigation au clavier
|
||||
- Contraste élevé
|
||||
- Sémantique HTML
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Développement
|
||||
|
||||
### Commandes Disponibles
|
||||
|
||||
```bash
|
||||
# Démarrer le serveur de développement
|
||||
npm start
|
||||
|
||||
# Créer un build de production
|
||||
npm run build
|
||||
|
||||
# Lancer les tests
|
||||
npm test
|
||||
|
||||
# Éjecter la configuration (⚠️ irréversible)
|
||||
npm eject
|
||||
```
|
||||
|
||||
### Variables d'Environnement
|
||||
|
||||
```
|
||||
REACT_APP_API_URL # URL du backend (défaut: http://localhost:8000)
|
||||
REACT_APP_ENV # Environnement (development, production)
|
||||
REACT_APP_DEBUG_MODE # Activer le mode debug
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 Responsive Breakpoints
|
||||
|
||||
```
|
||||
Mobile: < 480px
|
||||
Tablet: 480px - 768px
|
||||
Laptop: 768px - 1024px
|
||||
Desktop: 1024px+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Sécurité
|
||||
|
||||
✅ Authentification JWT
|
||||
✅ Tokens dans localStorage
|
||||
✅ Validation côté client
|
||||
✅ Protection des routes
|
||||
✅ En-têtes de sécurité
|
||||
✅ HTTPS en production
|
||||
|
||||
---
|
||||
|
||||
## 📈 Performance
|
||||
|
||||
- Code splitting avec React.lazy()
|
||||
- Lazy loading des images
|
||||
- Optimisation des requêtes API
|
||||
- Caching navigateur
|
||||
- Bundle optimisé
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Déploiement
|
||||
|
||||
### Options
|
||||
|
||||
1. **Vercel** - Déploiement simple et rapide
|
||||
2. **GitHub Pages** - Gratuit, hébergement GitHub
|
||||
3. **AWS S3 + CloudFront** - Scalable, production
|
||||
4. **Docker** - Conteneurisation
|
||||
|
||||
Voir [FRONTEND_GUIDE.md](./FRONTEND_GUIDE.md) pour les détails.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Ressources
|
||||
|
||||
- 📖 [Documentation React](https://react.dev)
|
||||
- 🛣️ [React Router](https://reactrouter.com)
|
||||
- 🎨 [Lucide Icons](https://lucide.dev)
|
||||
- 📱 [CSS Media Queries](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries)
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support et Contact
|
||||
|
||||
Pour toute question:
|
||||
1. Consultez la documentation
|
||||
2. Vérifiez les logs console
|
||||
3. Ouvrez une issue GitHub
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
- [ ] Installation complète
|
||||
- [ ] Backend en cours d'exécution
|
||||
- [ ] `npm start` lance l'app
|
||||
- [ ] Accueil chargée correctement
|
||||
- [ ] Inscription fonctionne
|
||||
- [ ] Connexion fonctionne
|
||||
- [ ] Dashboard visible après connexion
|
||||
- [ ] Profil accessible
|
||||
- [ ] Archives publiques visible
|
||||
- [ ] Tests unitaires passent
|
||||
|
||||
---
|
||||
|
||||
**Bienvenue dans E-Voting! 🗳️**
|
||||
|
||||
Pour commencer: `npm start`
|
||||
@ -13,7 +13,7 @@ from datetime import timedelta
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=schemas.VoterProfile)
|
||||
@router.post("/register", response_model=schemas.RegisterResponse)
|
||||
def register(voter_data: schemas.VoterRegister, db: Session = Depends(get_db)):
|
||||
"""Enregistrer un nouvel électeur"""
|
||||
|
||||
@ -28,10 +28,24 @@ def register(voter_data: schemas.VoterRegister, db: Session = Depends(get_db)):
|
||||
# Créer le nouvel électeur
|
||||
voter = services.VoterService.create_voter(db, voter_data)
|
||||
|
||||
return voter
|
||||
# Créer le token JWT
|
||||
access_token_expires = timedelta(minutes=30)
|
||||
access_token = create_access_token(
|
||||
data={"voter_id": voter.id},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return schemas.RegisterResponse(
|
||||
access_token=access_token,
|
||||
expires_in=30 * 60,
|
||||
id=voter.id,
|
||||
email=voter.email,
|
||||
first_name=voter.first_name,
|
||||
last_name=voter.last_name
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=schemas.TokenResponse)
|
||||
@router.post("/login", response_model=schemas.LoginResponse)
|
||||
def login(credentials: schemas.VoterLogin, db: Session = Depends(get_db)):
|
||||
"""Authentifier un électeur et retourner un token"""
|
||||
|
||||
@ -54,9 +68,13 @@ def login(credentials: schemas.VoterLogin, db: Session = Depends(get_db)):
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return schemas.TokenResponse(
|
||||
return schemas.LoginResponse(
|
||||
access_token=access_token,
|
||||
expires_in=30 * 60 # en secondes
|
||||
expires_in=30 * 60,
|
||||
id=voter.id,
|
||||
email=voter.email,
|
||||
first_name=voter.first_name,
|
||||
last_name=voter.last_name
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -26,6 +26,48 @@ def get_active_election(db: Session = Depends(get_db)):
|
||||
return election
|
||||
|
||||
|
||||
@router.get("/completed")
|
||||
def get_completed_elections(db: Session = Depends(get_db)):
|
||||
"""Récupérer tous les votes passés/terminés"""
|
||||
|
||||
from datetime import datetime
|
||||
elections = db.query(services.models.Election).filter(
|
||||
services.models.Election.end_date < datetime.utcnow(),
|
||||
services.models.Election.results_published == True
|
||||
).all()
|
||||
|
||||
return elections
|
||||
|
||||
|
||||
@router.get("/upcoming")
|
||||
def get_upcoming_elections(db: Session = Depends(get_db)):
|
||||
"""Récupérer tous les votes à venir"""
|
||||
|
||||
from datetime import datetime
|
||||
elections = db.query(services.models.Election).filter(
|
||||
services.models.Election.start_date > datetime.utcnow()
|
||||
).all()
|
||||
|
||||
return elections
|
||||
|
||||
|
||||
@router.get("/active/results")
|
||||
def get_active_election_results(db: Session = Depends(get_db)):
|
||||
"""Récupérer les résultats de l'élection active"""
|
||||
|
||||
election = services.ElectionService.get_active_election(db)
|
||||
|
||||
if not election:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No active election"
|
||||
)
|
||||
|
||||
results = services.VoteService.get_election_results(db, election.id)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/{election_id}", response_model=schemas.ElectionResponse)
|
||||
def get_election(election_id: int, db: Session = Depends(get_db)):
|
||||
"""Récupérer une élection par son ID"""
|
||||
@ -102,3 +144,30 @@ def publish_results(
|
||||
"election_id": election.id,
|
||||
"election_name": election.name
|
||||
}
|
||||
|
||||
|
||||
@router.get("/completed", response_model=list[schemas.ElectionResponse])
|
||||
def get_completed_elections(db: Session = Depends(get_db)):
|
||||
"""Récupérer toutes les élections terminées (archives)"""
|
||||
from datetime import datetime
|
||||
from .. import models
|
||||
|
||||
completed = db.query(models.Election).filter(
|
||||
models.Election.end_date < datetime.utcnow(),
|
||||
models.Election.results_published == True
|
||||
).all()
|
||||
|
||||
return completed
|
||||
|
||||
|
||||
@router.get("/upcoming", response_model=list[schemas.ElectionResponse])
|
||||
def get_upcoming_elections(db: Session = Depends(get_db)):
|
||||
"""Récupérer toutes les élections futures"""
|
||||
from datetime import datetime
|
||||
from .. import models
|
||||
|
||||
upcoming = db.query(models.Election).filter(
|
||||
models.Election.start_date > datetime.utcnow()
|
||||
).all()
|
||||
|
||||
return upcoming
|
||||
@ -115,3 +115,39 @@ def get_vote_status(
|
||||
)
|
||||
|
||||
return {"has_voted": has_voted}
|
||||
|
||||
|
||||
@router.get("/history", response_model=list)
|
||||
def get_voter_history(
|
||||
current_voter: Voter = Depends(get_current_voter),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Récupérer l'historique des votes de l'électeur actuel"""
|
||||
from .. import models
|
||||
from datetime import datetime
|
||||
|
||||
votes = db.query(models.Vote).filter(
|
||||
models.Vote.voter_id == current_voter.id
|
||||
).all()
|
||||
|
||||
# Retourner la structure avec infos des élections
|
||||
history = []
|
||||
for vote in votes:
|
||||
election = db.query(models.Election).filter(
|
||||
models.Election.id == vote.election_id
|
||||
).first()
|
||||
candidate = db.query(models.Candidate).filter(
|
||||
models.Candidate.id == vote.candidate_id
|
||||
).first()
|
||||
|
||||
if election:
|
||||
history.append({
|
||||
"vote_id": vote.id,
|
||||
"election_id": election.id,
|
||||
"election_name": election.name,
|
||||
"candidate_name": candidate.name if candidate else "Unknown",
|
||||
"vote_date": vote.timestamp,
|
||||
"election_status": "completed" if election.end_date < datetime.utcnow() else "active"
|
||||
})
|
||||
|
||||
return history
|
||||
|
||||
@ -29,6 +29,28 @@ class TokenResponse(BaseModel):
|
||||
expires_in: int
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Réponse de connexion - retourne le profile et le token"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
id: int
|
||||
email: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
"""Réponse d'enregistrement - retourne le profile et le token"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
id: int
|
||||
email: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
|
||||
|
||||
class VoterProfile(BaseModel):
|
||||
"""Profil d'un électeur"""
|
||||
id: int
|
||||
|
||||
183
e-voting-system/backend/scripts/seed_db.py
Normal file
183
e-voting-system/backend/scripts/seed_db.py
Normal file
@ -0,0 +1,183 @@
|
||||
"""
|
||||
Script de réinitialisation et de peuplement de la base de données pour tests.
|
||||
|
||||
Usage (depuis la racine du projet ou à l'intérieur du conteneur):
|
||||
python -m backend.scripts.seed_db
|
||||
|
||||
Ce script supprime/crée les tables et insère des électeurs, élections,
|
||||
candidats et votes d'exemple selon la demande de l'utilisateur.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.orm import Session
|
||||
from ..database import engine, SessionLocal
|
||||
from ..models import Base, Voter, Election, Candidate, Vote
|
||||
from ..auth import hash_password
|
||||
|
||||
|
||||
def reset_db():
|
||||
print("Resetting database: dropping and recreating all tables...")
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def seed():
|
||||
print("Seeding database with sample voters, elections, candidates and votes...")
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Create voters with the requested CNI values
|
||||
voters = []
|
||||
for cid in ["1234", "12345", "123456"]:
|
||||
v = Voter(
|
||||
email=f"user{cid}@example.com",
|
||||
password_hash=hash_password("Password123"),
|
||||
first_name=f"User{cid}",
|
||||
last_name="Seed",
|
||||
citizen_id=cid
|
||||
)
|
||||
db.add(v)
|
||||
voters.append(v)
|
||||
db.commit()
|
||||
for v in voters:
|
||||
db.refresh(v)
|
||||
|
||||
# Create a few elections: past, present, future
|
||||
elections = []
|
||||
|
||||
# Past elections (ended 1 month ago)
|
||||
for i in range(1, 4):
|
||||
e = Election(
|
||||
name=f"Past Election {i}",
|
||||
description="Election seed (past)",
|
||||
start_date=now - timedelta(days=60 + i),
|
||||
end_date=now - timedelta(days=30 + i),
|
||||
is_active=False,
|
||||
results_published=True
|
||||
)
|
||||
db.add(e)
|
||||
elections.append(e)
|
||||
|
||||
# Current election (ends in ~3 months)
|
||||
current = Election(
|
||||
name="Ongoing Election",
|
||||
description="Election seed (present)",
|
||||
start_date=now - timedelta(days=1),
|
||||
end_date=now + timedelta(days=90),
|
||||
is_active=True,
|
||||
results_published=False
|
||||
)
|
||||
db.add(current)
|
||||
elections.append(current)
|
||||
|
||||
# Future elections
|
||||
for i in range(1, 4):
|
||||
e = Election(
|
||||
name=f"Future Election {i}",
|
||||
description="Election seed (future)",
|
||||
start_date=now + timedelta(days=30 * i),
|
||||
end_date=now + timedelta(days=30 * i + 14),
|
||||
is_active=False,
|
||||
results_published=False
|
||||
)
|
||||
db.add(e)
|
||||
elections.append(e)
|
||||
|
||||
db.commit()
|
||||
for e in elections:
|
||||
db.refresh(e)
|
||||
|
||||
# Create 2-3 candidates per election
|
||||
all_candidates = {}
|
||||
for e in elections:
|
||||
cands = []
|
||||
for j in range(1, 4):
|
||||
c = Candidate(
|
||||
election_id=e.id,
|
||||
name=f"Candidate {j} for {e.name}",
|
||||
description="Candidate from seed",
|
||||
order=j
|
||||
)
|
||||
db.add(c)
|
||||
cands.append(c)
|
||||
db.commit()
|
||||
for c in cands:
|
||||
db.refresh(c)
|
||||
all_candidates[e.id] = cands
|
||||
|
||||
# Insert votes: Distribute votes among past/present/future
|
||||
# For the CNI '1234' -> 3 past, 1 present, 2 future
|
||||
# For '12345' and '123456' do similar but not the exact same elections
|
||||
def create_vote(voter_obj, election_obj, candidate_obj, when):
|
||||
vote = Vote(
|
||||
voter_id=voter_obj.id,
|
||||
election_id=election_obj.id,
|
||||
candidate_id=candidate_obj.id,
|
||||
encrypted_vote=b"seeded_encrypted",
|
||||
zero_knowledge_proof=b"seeded_proof",
|
||||
ballot_hash=f"seed-{voter_obj.citizen_id}-{election_obj.id}-{candidate_obj.id}",
|
||||
timestamp=when
|
||||
)
|
||||
db.add(vote)
|
||||
|
||||
# Helper to pick candidate
|
||||
import random
|
||||
|
||||
# Map citizen to required vote counts
|
||||
plan = {
|
||||
"1234": {"past": 3, "present": 1, "future": 2},
|
||||
"12345": {"past": 2, "present": 1, "future": 2},
|
||||
"123456": {"past": 1, "present": 1, "future": 3},
|
||||
}
|
||||
|
||||
# Collect elections by status
|
||||
past_elections = [e for e in elections if e.end_date < now]
|
||||
present_elections = [e for e in elections if e.start_date <= now < e.end_date]
|
||||
future_elections = [e for e in elections if e.start_date > now]
|
||||
|
||||
for voter in voters:
|
||||
p = plan.get(voter.citizen_id, {"past": 1, "present": 0, "future": 1})
|
||||
|
||||
# Past votes
|
||||
for _ in range(p.get("past", 0)):
|
||||
if not past_elections:
|
||||
break
|
||||
e = random.choice(past_elections)
|
||||
c = random.choice(all_candidates[e.id])
|
||||
create_vote(voter, e, c, when=e.end_date - timedelta(days=5))
|
||||
|
||||
# Present votes
|
||||
for _ in range(p.get("present", 0)):
|
||||
if not present_elections:
|
||||
break
|
||||
e = random.choice(present_elections)
|
||||
c = random.choice(all_candidates[e.id])
|
||||
create_vote(voter, e, c, when=now)
|
||||
|
||||
# Future votes (we still create them as scheduled/placeholder votes)
|
||||
for _ in range(p.get("future", 0)):
|
||||
if not future_elections:
|
||||
break
|
||||
e = random.choice(future_elections)
|
||||
c = random.choice(all_candidates[e.id])
|
||||
create_vote(voter, e, c, when=e.start_date + timedelta(days=1))
|
||||
|
||||
db.commit()
|
||||
|
||||
# Report
|
||||
total_voters = db.query(Voter).count()
|
||||
total_elections = db.query(Election).count()
|
||||
total_votes = db.query(Vote).count()
|
||||
print(f"Seeded: voters={total_voters}, elections={total_elections}, votes={total_votes}")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
reset_db()
|
||||
seed()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -50,6 +50,9 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile.frontend
|
||||
args:
|
||||
REACT_APP_API_URL: http://backend:8000
|
||||
CACHEBUST: ${CACHEBUST:-1}
|
||||
container_name: evoting_frontend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
@ -57,8 +60,6 @@ services:
|
||||
- backend
|
||||
networks:
|
||||
- evoting_network
|
||||
environment:
|
||||
REACT_APP_API_URL: http://localhost:8000/api
|
||||
|
||||
volumes:
|
||||
evoting_data:
|
||||
|
||||
@ -21,8 +21,5 @@ RUN poetry config virtualenvs.create false && \
|
||||
# Exposer le port
|
||||
EXPOSE 8000
|
||||
|
||||
# Démarrer l'application
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
# Démarrer l'application
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@ -6,11 +6,22 @@ WORKDIR /app
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Installer dépendances
|
||||
RUN npm install
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# Copier code source
|
||||
COPY frontend/ .
|
||||
|
||||
# Clean previous builds
|
||||
RUN rm -rf build/
|
||||
|
||||
# Build argument for API URL
|
||||
ARG REACT_APP_API_URL=http://backend:8000
|
||||
ENV REACT_APP_API_URL=${REACT_APP_API_URL}
|
||||
|
||||
# Force rebuild timestamp (bust cache)
|
||||
ARG CACHEBUST=1
|
||||
ENV CACHEBUST=${CACHEBUST}
|
||||
|
||||
# Build avec npm run build (CRA standard)
|
||||
RUN npm run build
|
||||
|
||||
|
||||
9
e-voting-system/frontend/.env.example
Normal file
9
e-voting-system/frontend/.env.example
Normal file
@ -0,0 +1,9 @@
|
||||
# Backend API Configuration
|
||||
REACT_APP_API_URL=http://localhost:8000
|
||||
|
||||
# Environment
|
||||
REACT_APP_ENV=development
|
||||
|
||||
# Feature Flags
|
||||
REACT_APP_ENABLE_MOCK_API=false
|
||||
REACT_APP_DEBUG_MODE=true
|
||||
273
e-voting-system/frontend/README_FRONTEND.md
Normal file
273
e-voting-system/frontend/README_FRONTEND.md
Normal file
@ -0,0 +1,273 @@
|
||||
# Frontend E-Voting System
|
||||
|
||||
Un frontend complet et moderne pour le système de vote électronique, construit avec React et un design professionnalresponsive.
|
||||
|
||||
## 🏗️ Structure du Projet
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/ # Composants réutilisables
|
||||
│ │ ├── Header.jsx # Barre de navigation
|
||||
│ │ ├── Footer.jsx # Pied de page
|
||||
│ │ ├── VoteCard.jsx # Carte de vote
|
||||
│ │ ├── Alert.jsx # Notifications
|
||||
│ │ ├── Modal.jsx # Boîte de dialogue
|
||||
│ │ ├── LoadingSpinner.jsx # Indicateur de chargement
|
||||
│ │ ├── index.js # Export des composants
|
||||
│ │ └── *.css # Styles des composants
|
||||
│ │
|
||||
│ ├── pages/ # Pages principales
|
||||
│ │ ├── HomePage.jsx # Page d'accueil publique
|
||||
│ │ ├── LoginPage.jsx # Connexion
|
||||
│ │ ├── RegisterPage.jsx # Inscription
|
||||
│ │ ├── DashboardPage.jsx # Tableau de bord (connecté)
|
||||
│ │ ├── VotingPage.jsx # Page de vote
|
||||
│ │ ├── ArchivesPage.jsx # Archives publiques
|
||||
│ │ ├── ProfilePage.jsx # Profil utilisateur
|
||||
│ │ ├── index.js # Export des pages
|
||||
│ │ └── *.css # Styles des pages
|
||||
│ │
|
||||
│ ├── styles/ # Styles globaux
|
||||
│ │ ├── globals.css # Thème et variables CSS
|
||||
│ │ └── components.css # Styles des composants de base
|
||||
│ │
|
||||
│ ├── App.js # Application principale avec routage
|
||||
│ ├── App.css # Styles de l'application
|
||||
│ ├── index.js # Point d'entrée
|
||||
│ └── index.css # Styles de base
|
||||
│
|
||||
└── package.json # Dépendances du projet
|
||||
```
|
||||
|
||||
## 🎨 Palette de Couleurs
|
||||
|
||||
| Classe | Couleur | Utilisation |
|
||||
|--------|---------|-------------|
|
||||
| `--primary-dark` | #1e3a5f | Bleu foncé - Confiance, titres |
|
||||
| `--primary-blue` | #2563eb | Bleu principal - Actions, liens |
|
||||
| `--primary-light` | #3b82f6 | Bleu clair - Dégradés |
|
||||
| `--success-green` | #10b981 | Vert - Succès, confirmations |
|
||||
| `--warning-orange` | #f97316 | Orange - Alertes, actions urgentes |
|
||||
| `--danger-red` | #ef4444 | Rouge - Erreurs, suppression |
|
||||
| `--light-gray` | #f3f4f6 | Gris clair - Fond |
|
||||
| `--white` | #ffffff | Blanc - Cartes, formulaires |
|
||||
|
||||
## 📱 Pages Disponibles
|
||||
|
||||
### Pages Publiques (accessibles sans connexion)
|
||||
|
||||
- **`/`** - Page d'accueil
|
||||
- Section héros avec CTA
|
||||
- "Comment ça marche" (3 étapes)
|
||||
- Présentation des garanties
|
||||
- Aperçu des votes récents
|
||||
|
||||
- **`/register`** - Inscription
|
||||
- Formulaire de création de compte
|
||||
- Validation des données
|
||||
- Acceptation des CGU
|
||||
|
||||
- **`/login`** - Connexion
|
||||
- Formulaire d'authentification
|
||||
- Lien "Mot de passe oublié"
|
||||
|
||||
- **`/archives`** - Archives Publiques
|
||||
- Liste de tous les votes terminés
|
||||
- Recherche et filtrage
|
||||
- Affichage des résultats
|
||||
|
||||
### Pages Privées (accessibles après connexion)
|
||||
|
||||
- **`/dashboard`** - Tableau de Bord
|
||||
- Statistiques personnalisées (votes actifs, futurs, historique)
|
||||
- Section "Action Requise" pour les votes urgents
|
||||
- Filtrage par statut (all, actifs, futurs, historique)
|
||||
|
||||
- **`/vote/:id`** - Page de Vote
|
||||
- Détails complets du vote
|
||||
- Description et contexte
|
||||
- Formulaire de sélection d'option
|
||||
- Modal de confirmation
|
||||
- Écran de succès après vote
|
||||
|
||||
- **`/profile`** - Profil Utilisateur
|
||||
- Modification du nom et email
|
||||
- Changement de mot de passe
|
||||
- Déconnexion
|
||||
|
||||
## 🔄 Routage
|
||||
|
||||
```javascript
|
||||
/ - Public
|
||||
├── HomePage
|
||||
├── /register - RegisterPage
|
||||
├── /login - LoginPage
|
||||
├── /archives - ArchivesPage
|
||||
│
|
||||
└── (Privé) Nécessite connexion
|
||||
├── /dashboard - DashboardPage
|
||||
├── /dashboard/actifs - DashboardPage (filtré)
|
||||
├── /dashboard/futurs - DashboardPage (filtré)
|
||||
├── /dashboard/historique - DashboardPage (filtré)
|
||||
├── /vote/:id - VotingPage
|
||||
└── /profile - ProfilePage
|
||||
```
|
||||
|
||||
## 🚀 Démarrage Rapide
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### Développement
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
L'application s'ouvrira sur `http://localhost:3000`
|
||||
|
||||
### Build pour la production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 🧩 Composants Réutilisables
|
||||
|
||||
### Header
|
||||
Barre de navigation avec logo, liens de navigation et profil utilisateur.
|
||||
- Menu responsive sur mobile
|
||||
- Navigation différente selon la connexion
|
||||
|
||||
### Footer
|
||||
Pied de page avec liens, infos de contact et copyright.
|
||||
|
||||
### VoteCard
|
||||
Affichage d'un vote sous forme de carte.
|
||||
- Titre, description, statut
|
||||
- Countdown (temps restant)
|
||||
- Résultats (si terminé)
|
||||
- Bouton d'action approprié
|
||||
|
||||
### Alert
|
||||
Notifications avec types: success, error, warning, info.
|
||||
- Icônes automatiques
|
||||
- Fermeture possible
|
||||
|
||||
### Modal
|
||||
Boîte de dialogue modale.
|
||||
- Titre, contenu, actions
|
||||
- Confirmation/Annulation
|
||||
|
||||
### LoadingSpinner
|
||||
Indicateur de chargement.
|
||||
- Version inline ou fullscreen
|
||||
|
||||
## 🎯 Fonctionnalités
|
||||
|
||||
✅ **Authentification**
|
||||
- Inscription et connexion sécurisées
|
||||
- Stockage du token JWT
|
||||
- Vérification de session
|
||||
|
||||
✅ **Gestion des Votes**
|
||||
- Affichage des votes par statut
|
||||
- Participation au vote avec confirmation
|
||||
- Visualisation des résultats
|
||||
|
||||
✅ **Profil Utilisateur**
|
||||
- Modification des informations
|
||||
- Changement de mot de passe
|
||||
- Déconnexion sécurisée
|
||||
|
||||
✅ **Responsive Design**
|
||||
- Mobile-first approach
|
||||
- Grille fluide
|
||||
- Media queries pour tous les appareils
|
||||
|
||||
✅ **Accessibilité**
|
||||
- Contraste élevé
|
||||
- Navigation au clavier
|
||||
- Textes alternatifs
|
||||
- Sémantique HTML
|
||||
|
||||
✅ **Performance**
|
||||
- Code splitting
|
||||
- Lazy loading des pages
|
||||
- Optimisation des requêtes API
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
### Espacements
|
||||
- `--spacing-xs`: 0.25rem
|
||||
- `--spacing-sm`: 0.5rem
|
||||
- `--spacing-md`: 1rem
|
||||
- `--spacing-lg`: 1.5rem
|
||||
- `--spacing-xl`: 2rem
|
||||
- `--spacing-2xl`: 3rem
|
||||
|
||||
### Radius
|
||||
- `--radius-sm`: 0.375rem
|
||||
- `--radius-md`: 0.5rem
|
||||
- `--radius-lg`: 0.75rem
|
||||
- `--radius-xl`: 1rem
|
||||
|
||||
### Shadows
|
||||
- `--shadow-sm`, `--shadow-md`, `--shadow-lg`, `--shadow-xl`
|
||||
|
||||
## 📞 API Integration
|
||||
|
||||
L'application communique avec le backend sur `http://localhost:8000`:
|
||||
|
||||
- `POST /auth/register` - Inscription
|
||||
- `POST /auth/login` - Connexion
|
||||
- `GET /elections/` - Lister les votes
|
||||
- `GET /elections/{id}` - Détails d'un vote
|
||||
- `POST /votes/submit` - Soumettre un vote
|
||||
- `GET /votes/my-votes` - Mes votes
|
||||
- `PUT /auth/profile` - Mise à jour profil
|
||||
- `POST /auth/change-password` - Changer le mot de passe
|
||||
|
||||
## 🔐 Sécurité
|
||||
|
||||
- Tokens JWT stockés en localStorage
|
||||
- Authentification requise pour les pages privées
|
||||
- Redirection automatique vers login si non connecté
|
||||
- Validation des formulaires côté client
|
||||
- Protection des routes
|
||||
|
||||
## 📱 Responsive Breakpoints
|
||||
|
||||
- **Desktop**: > 1024px
|
||||
- **Tablet**: 768px - 1024px
|
||||
- **Mobile**: < 768px
|
||||
|
||||
## 🔄 État Global
|
||||
|
||||
État géré avec React Context et localStorage:
|
||||
- Utilisateur connecté
|
||||
- Token d'authentification
|
||||
- Informations du voter
|
||||
|
||||
## 📦 Dépendances
|
||||
|
||||
- `react` - Framework
|
||||
- `react-dom` - Rendu DOM
|
||||
- `react-router-dom` - Routage
|
||||
- `axios` - Requêtes HTTP
|
||||
- `lucide-react` - Icônes
|
||||
|
||||
## 🛠️ Outils de Développement
|
||||
|
||||
- `react-scripts` - Configuration webpack
|
||||
- Linter ESLint
|
||||
- Formatage automatique (Prettier)
|
||||
|
||||
## 📄 License
|
||||
|
||||
Ce projet est sous licence MIT.
|
||||
149
e-voting-system/frontend/package-lock.json
generated
149
e-voting-system/frontend/package-lock.json
generated
@ -12,8 +12,11 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"axios": "^1.6.0",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
}
|
||||
@ -3072,6 +3075,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
|
||||
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-babel": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
|
||||
@ -4900,6 +4912,33 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/form-data": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@ -11241,6 +11280,15 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.344.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.344.0.tgz",
|
||||
"integrity": "sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
@ -13597,6 +13645,12 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
@ -13725,10 +13779,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -13856,15 +13913,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.0"
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-overlay": {
|
||||
@ -13888,6 +13946,38 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
|
||||
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
|
||||
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.0",
|
||||
"react-router": "6.30.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-scripts": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
|
||||
@ -14543,10 +14633,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "4.3.3",
|
||||
@ -15905,20 +15998,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss/node_modules/yaml": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
@ -16362,20 +16441,6 @@
|
||||
"is-typedarray": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
|
||||
@ -7,8 +7,11 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"axios": "^1.6.0",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
|
||||
7
e-voting-system/frontend/public/config.js
Normal file
7
e-voting-system/frontend/public/config.js
Normal file
@ -0,0 +1,7 @@
|
||||
// Runtime API configuration
|
||||
// This file is served from public/ and can be updated without rebuilding
|
||||
window.API_CONFIG = {
|
||||
API_BASE_URL: window.location.hostname === 'localhost'
|
||||
? 'http://localhost:8000'
|
||||
: `http://${window.location.hostname}:8000`
|
||||
};
|
||||
@ -15,6 +15,8 @@
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!-- Runtime API Configuration -->
|
||||
<script src="%PUBLIC_URL%/config.js"></script>
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
|
||||
@ -1,31 +1,49 @@
|
||||
.App {
|
||||
/* ===== App Layout ===== */
|
||||
|
||||
.app-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background-color: var(--light-gray);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ===== Container ===== */
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* ===== Utility Classes ===== */
|
||||
|
||||
.text-muted {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* ===== Responsive ===== */
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
@media (max-width: 1200px) {
|
||||
.container {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 var(--spacing-md);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
|
||||
@ -1,24 +1,140 @@
|
||||
import logo from './logo.svg';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import './styles/globals.css';
|
||||
import './styles/components.css';
|
||||
import './App.css';
|
||||
|
||||
// Components
|
||||
import Header from './components/Header';
|
||||
import Footer from './components/Footer';
|
||||
|
||||
// Pages
|
||||
import HomePage from './pages/HomePage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import VotingPage from './pages/VotingPage';
|
||||
import ArchivesPage from './pages/ArchivesPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
|
||||
function App() {
|
||||
const [voter, setVoter] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if voter is already logged in (stored in localStorage)
|
||||
try {
|
||||
const storedVoter = localStorage.getItem('voter');
|
||||
if (storedVoter) {
|
||||
const parsed = JSON.parse(storedVoter);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
setVoter(parsed);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing voter data:', err);
|
||||
localStorage.removeItem('voter');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleLogin = (voterData) => {
|
||||
setVoter(voterData);
|
||||
localStorage.setItem('voter', JSON.stringify(voterData));
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setVoter(null);
|
||||
localStorage.removeItem('voter');
|
||||
localStorage.removeItem('token');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: '50px' }}>Chargement...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<img src={logo} className="App-logo" alt="logo" />
|
||||
<p>
|
||||
Edit <code>src/App.js</code> and save to reload.
|
||||
</p>
|
||||
<a
|
||||
className="App-link"
|
||||
href="https://reactjs.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn React
|
||||
</a>
|
||||
</header>
|
||||
</div>
|
||||
<Router>
|
||||
<div className="app-wrapper">
|
||||
<Header voter={voter} onLogout={handleLogout} />
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<HomePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
voter ? <Navigate to="/dashboard" replace /> :
|
||||
<RegisterPage onLogin={handleLogin} />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
voter ? <Navigate to="/dashboard" replace /> :
|
||||
<LoginPage onLogin={handleLogin} />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
voter ?
|
||||
<DashboardPage voter={voter} /> :
|
||||
<Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/actifs"
|
||||
element={
|
||||
voter ?
|
||||
<DashboardPage voter={voter} /> :
|
||||
<Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/futurs"
|
||||
element={
|
||||
voter ?
|
||||
<DashboardPage voter={voter} /> :
|
||||
<Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/historique"
|
||||
element={
|
||||
voter ?
|
||||
<DashboardPage voter={voter} /> :
|
||||
<Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/vote/:id"
|
||||
element={
|
||||
voter ?
|
||||
<VotingPage /> :
|
||||
<Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/archives"
|
||||
element={<ArchivesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
voter ?
|
||||
<ProfilePage voter={voter} onLogout={handleLogout} /> :
|
||||
<Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
112
e-voting-system/frontend/src/components/Alert.css
Normal file
112
e-voting-system/frontend/src/components/Alert.css
Normal file
@ -0,0 +1,112 @@
|
||||
/* ===== Alert Styles ===== */
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.alert-message {
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.alert-close {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.alert-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Alert Variants */
|
||||
|
||||
.alert-success {
|
||||
background-color: #ecfdf5;
|
||||
color: #065f46;
|
||||
border-left: 4px solid var(--success-green);
|
||||
}
|
||||
|
||||
.alert-success .alert-icon {
|
||||
color: var(--success-green);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #fef2f2;
|
||||
color: #7f1d1d;
|
||||
border-left: 4px solid var(--danger-red);
|
||||
}
|
||||
|
||||
.alert-error .alert-icon {
|
||||
color: var(--danger-red);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #fffbeb;
|
||||
color: #78350f;
|
||||
border-left: 4px solid var(--warning-orange);
|
||||
}
|
||||
|
||||
.alert-warning .alert-icon {
|
||||
color: var(--warning-orange);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #eff6ff;
|
||||
color: #0c2d6b;
|
||||
border-left: 4px solid var(--primary-blue);
|
||||
}
|
||||
|
||||
.alert-info .alert-icon {
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.alert {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.alert-close {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
29
e-voting-system/frontend/src/components/Alert.jsx
Normal file
29
e-voting-system/frontend/src/components/Alert.jsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, CheckCircle, Info, AlertTriangle } from 'lucide-react';
|
||||
import './Alert.css';
|
||||
|
||||
export default function Alert({ type = 'info', title, message, icon: Icon, onClose }) {
|
||||
const iconMap = {
|
||||
success: <CheckCircle size={24} />,
|
||||
error: <AlertCircle size={24} />,
|
||||
warning: <AlertTriangle size={24} />,
|
||||
info: <Info size={24} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`alert alert-${type}`}>
|
||||
<div className="alert-icon">
|
||||
{Icon ? <Icon size={24} /> : iconMap[type]}
|
||||
</div>
|
||||
<div className="alert-content">
|
||||
{title && <h4 className="alert-title">{title}</h4>}
|
||||
<p className="alert-message">{message}</p>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button className="alert-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
e-voting-system/frontend/src/components/Footer.css
Normal file
88
e-voting-system/frontend/src/components/Footer.css
Normal file
@ -0,0 +1,88 @@
|
||||
/* ===== Footer Styles ===== */
|
||||
|
||||
.footer {
|
||||
background-color: var(--primary-dark);
|
||||
color: var(--white);
|
||||
padding: var(--spacing-2xl) 0;
|
||||
margin-top: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--primary-light);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
color: var(--white);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.footer-section h4 {
|
||||
color: var(--white);
|
||||
margin-bottom: var(--spacing-md);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.footer-section p {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
opacity: 0.9;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.footer-section ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.footer-section li {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.footer-divider {
|
||||
height: 1px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer-content {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.footer-section h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.footer-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
49
e-voting-system/frontend/src/components/Footer.jsx
Normal file
49
e-voting-system/frontend/src/components/Footer.jsx
Normal file
@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import './Footer.css';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="container">
|
||||
<div className="footer-content">
|
||||
<div className="footer-section">
|
||||
<h4>À propos</h4>
|
||||
<p>Plateforme de vote électronique sécurisée et transparente pour tous.</p>
|
||||
</div>
|
||||
|
||||
<div className="footer-section">
|
||||
<h4>Liens Rapides</h4>
|
||||
<ul>
|
||||
<li><a href="/">Accueil</a></li>
|
||||
<li><a href="/archives">Archives</a></li>
|
||||
<li><a href="#faq">FAQ</a></li>
|
||||
<li><a href="#contact">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="footer-section">
|
||||
<h4>Légal</h4>
|
||||
<ul>
|
||||
<li><a href="#cgu">Conditions d'Utilisation</a></li>
|
||||
<li><a href="#privacy">Politique de Confidentialité</a></li>
|
||||
<li><a href="#security">Sécurité</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="footer-section">
|
||||
<h4>Contact</h4>
|
||||
<p>Email: <a href="mailto:contact@evoting.com">contact@evoting.com</a></p>
|
||||
<p>Téléphone: <a href="tel:+33123456789">+33 1 23 45 67 89</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footer-divider"></div>
|
||||
|
||||
<div className="footer-bottom">
|
||||
<p>© 2025 E-Voting. Tous droits réservés.</p>
|
||||
<p className="text-muted">Plateforme de vote électronique sécurisée par cryptographie post-quantique</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
141
e-voting-system/frontend/src/components/Header.css
Normal file
141
e-voting-system/frontend/src/components/Header.css
Normal file
@ -0,0 +1,141 @@
|
||||
/* ===== Header Styles ===== */
|
||||
|
||||
.header {
|
||||
background-color: var(--white);
|
||||
box-shadow: var(--shadow-md);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header .container {
|
||||
padding: 0 var(--spacing-lg);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md) 0;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-logo:hover {
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
background: linear-gradient(135deg, var(--primary-dark), var(--primary-blue));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
flex: 1;
|
||||
margin-left: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--dark-gray);
|
||||
font-weight: 500;
|
||||
transition: color 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--primary-blue);
|
||||
background-color: var(--light-gray);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-link.nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: var(--border-gray);
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--primary-dark);
|
||||
padding: var(--spacing-md);
|
||||
margin-right: -var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-wrap: wrap;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--white);
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.header-nav.open {
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
margin: var(--spacing-sm) 0;
|
||||
}
|
||||
}
|
||||
82
e-voting-system/frontend/src/components/Header.jsx
Normal file
82
e-voting-system/frontend/src/components/Header.jsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { LogOut, User, Menu, X } from 'lucide-react';
|
||||
import './Header.css';
|
||||
|
||||
export default function Header({ voter, onLogout }) {
|
||||
const navigate = useNavigate();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
onLogout();
|
||||
navigate('/');
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="container">
|
||||
<div className="header-content">
|
||||
{/* Logo */}
|
||||
<Link to={voter ? '/dashboard' : '/'} className="header-logo">
|
||||
<span className="logo-icon">🗳️</span>
|
||||
<span className="logo-text">E-Voting</span>
|
||||
</Link>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="mobile-menu-btn"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className={`header-nav ${mobileMenuOpen ? 'open' : ''}`}>
|
||||
{voter ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="nav-link">
|
||||
Tableau de Bord
|
||||
</Link>
|
||||
<Link to="/dashboard/actifs" className="nav-link">
|
||||
Votes Actifs
|
||||
</Link>
|
||||
<Link to="/dashboard/futurs" className="nav-link">
|
||||
Votes à Venir
|
||||
</Link>
|
||||
<Link to="/dashboard/historique" className="nav-link">
|
||||
Mon Historique
|
||||
</Link>
|
||||
<Link to="/archives" className="nav-link">
|
||||
Archives
|
||||
</Link>
|
||||
<div className="nav-divider"></div>
|
||||
<Link to="/profile" className="nav-link nav-icon">
|
||||
<User size={20} />
|
||||
<span>{voter.nom}</span>
|
||||
</Link>
|
||||
<button onClick={handleLogout} className="btn btn-danger btn-sm">
|
||||
<LogOut size={18} />
|
||||
Déconnexion
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/archives" className="nav-link">
|
||||
Archives
|
||||
</Link>
|
||||
<div className="nav-divider"></div>
|
||||
<Link to="/login" className="btn btn-ghost">
|
||||
Se Connecter
|
||||
</Link>
|
||||
<Link to="/register" className="btn btn-primary">
|
||||
S'inscrire
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
45
e-voting-system/frontend/src/components/LoadingSpinner.css
Normal file
45
e-voting-system/frontend/src/components/LoadingSpinner.css
Normal file
@ -0,0 +1,45 @@
|
||||
/* ===== Loading Spinner Styles ===== */
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border-gray);
|
||||
border-top-color: var(--primary-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.spinner-container p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
17
e-voting-system/frontend/src/components/LoadingSpinner.jsx
Normal file
17
e-voting-system/frontend/src/components/LoadingSpinner.jsx
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import './LoadingSpinner.css';
|
||||
|
||||
export default function LoadingSpinner({ fullscreen = false }) {
|
||||
if (fullscreen) {
|
||||
return (
|
||||
<div className="loading-overlay">
|
||||
<div className="spinner-container">
|
||||
<div className="spinner"></div>
|
||||
<p>Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="spinner"></div>;
|
||||
}
|
||||
116
e-voting-system/frontend/src/components/Modal.css
Normal file
116
e-voting-system/frontend/src/components/Modal.css
Normal file
@ -0,0 +1,116 @@
|
||||
/* ===== Modal Styles ===== */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(50px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: var(--spacing-xl);
|
||||
border-bottom: 1px solid var(--border-gray);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
color: var(--dark-gray);
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--danger-red);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
border-top: 1px solid var(--border-gray);
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-overlay {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
37
e-voting-system/frontend/src/components/Modal.jsx
Normal file
37
e-voting-system/frontend/src/components/Modal.jsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import './Modal.css';
|
||||
|
||||
export default function Modal({ isOpen, title, children, onClose, onConfirm, confirmText = 'Confirmer', cancelText = 'Annuler', type = 'default' }) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
{title && (
|
||||
<div className="modal-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-body">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-ghost" onClick={onClose}>
|
||||
{cancelText}
|
||||
</button>
|
||||
{onConfirm && (
|
||||
<button
|
||||
className={`btn ${type === 'danger' ? 'btn-danger' : 'btn-primary'}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
e-voting-system/frontend/src/components/VoteCard.css
Normal file
191
e-voting-system/frontend/src/components/VoteCard.css
Normal file
@ -0,0 +1,191 @@
|
||||
/* ===== Vote Card Styles ===== */
|
||||
|
||||
.vote-card {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vote-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.vote-card-header {
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 2px solid var(--light-gray);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.vote-card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.vote-card-date {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: #dbeafe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.badge-closed {
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.vote-card-body {
|
||||
padding: var(--spacing-lg);
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.vote-card-description {
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vote-card-countdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: 0.9rem;
|
||||
color: var(--warning-orange);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-vote-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
background-color: #ecfdf5;
|
||||
border-radius: var(--radius-md);
|
||||
color: #065f46;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.vote-card-results {
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--light-gray);
|
||||
border-top: 1px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.vote-card-results h4 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 var(--spacing-lg) 0;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.results-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr 50px;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-weight: 600;
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.result-bar {
|
||||
height: 24px;
|
||||
background-color: var(--border-gray);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-blue), var(--primary-light));
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.vote-card-footer {
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-gray);
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.vote-card-footer .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.vote-card-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.badge {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.vote-card-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vote-card-footer .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
grid-template-columns: 80px 1fr 40px;
|
||||
}
|
||||
}
|
||||
141
e-voting-system/frontend/src/components/VoteCard.jsx
Normal file
141
e-voting-system/frontend/src/components/VoteCard.jsx
Normal file
@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import { Clock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import './VoteCard.css';
|
||||
|
||||
export default function VoteCard({ vote, onVote, userVote = null, showResult = false }) {
|
||||
const getStatusBadge = () => {
|
||||
if (vote.status === 'actif') {
|
||||
return (
|
||||
<span className="badge badge-success">
|
||||
<AlertCircle size={16} />
|
||||
OUVERT
|
||||
</span>
|
||||
);
|
||||
} else if (vote.status === 'futur') {
|
||||
return (
|
||||
<span className="badge badge-info">
|
||||
<Clock size={16} />
|
||||
À VENIR
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="badge badge-closed">
|
||||
<CheckCircle size={16} />
|
||||
TERMINÉ
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getTimeRemaining = (endDate) => {
|
||||
const now = new Date();
|
||||
const end = new Date(endDate);
|
||||
const diff = end - now;
|
||||
|
||||
if (diff < 0) return null;
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `Se termine dans ${days} jour${days > 1 ? 's' : ''}`;
|
||||
} else if (hours > 0) {
|
||||
return `Se termine dans ${hours}h`;
|
||||
} else {
|
||||
return 'Se termine très bientôt';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vote-card">
|
||||
<div className="vote-card-header">
|
||||
<div>
|
||||
<h3 className="vote-card-title">{vote.titre}</h3>
|
||||
<p className="vote-card-date">
|
||||
{vote.status === 'futur'
|
||||
? `Ouvre le ${formatDate(vote.date_ouverture)}`
|
||||
: `Se termine le ${formatDate(vote.date_fermeture)}`}
|
||||
</p>
|
||||
</div>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
|
||||
<div className="vote-card-body">
|
||||
<p className="vote-card-description">{vote.description}</p>
|
||||
|
||||
{vote.status === 'actif' && getTimeRemaining(vote.date_fermeture) && (
|
||||
<p className="vote-card-countdown">
|
||||
<Clock size={16} />
|
||||
{getTimeRemaining(vote.date_fermeture)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{userVote && (
|
||||
<div className="user-vote-info">
|
||||
<CheckCircle size={16} style={{ color: 'var(--success-green)' }} />
|
||||
<span>Votre vote: <strong>{userVote}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showResult && vote.resultats && (
|
||||
<div className="vote-card-results">
|
||||
<h4>Résultats</h4>
|
||||
<div className="results-list">
|
||||
{Object.entries(vote.resultats).map(([option, count]) => (
|
||||
<div key={option} className="result-item">
|
||||
<span className="result-label">{option}</span>
|
||||
<div className="result-bar">
|
||||
<div
|
||||
className="result-bar-fill"
|
||||
style={{
|
||||
width: `${(count / (vote.total_votes || 1)) * 100}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<span className="result-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="vote-card-footer">
|
||||
{vote.status === 'actif' && !userVote && (
|
||||
<button
|
||||
className="btn btn-primary btn-lg"
|
||||
onClick={() => onVote?.(vote.id)}
|
||||
>
|
||||
VOTER MAINTENANT
|
||||
</button>
|
||||
)}
|
||||
{userVote && (
|
||||
<button className="btn btn-success btn-lg" disabled>
|
||||
<CheckCircle size={20} />
|
||||
DÉJÀ VOTÉ
|
||||
</button>
|
||||
)}
|
||||
{vote.status === 'ferme' && (
|
||||
<a href={`/vote/${vote.id}`} className="btn btn-ghost">
|
||||
Voir les Détails
|
||||
</a>
|
||||
)}
|
||||
{vote.status === 'futur' && (
|
||||
<button className="btn btn-secondary">
|
||||
M'alerter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
e-voting-system/frontend/src/components/index.js
Normal file
6
e-voting-system/frontend/src/components/index.js
Normal file
@ -0,0 +1,6 @@
|
||||
export { default as Header } from './Header';
|
||||
export { default as Footer } from './Footer';
|
||||
export { default as VoteCard } from './VoteCard';
|
||||
export { default as Alert } from './Alert';
|
||||
export { default as Modal } from './Modal';
|
||||
export { default as LoadingSpinner } from './LoadingSpinner';
|
||||
20
e-voting-system/frontend/src/config/api.js
Normal file
20
e-voting-system/frontend/src/config/api.js
Normal file
@ -0,0 +1,20 @@
|
||||
// API Configuration
|
||||
// Use runtime config if available (from public/config.js), otherwise fallback to env
|
||||
export const API_BASE_URL = window.API_CONFIG?.API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
// Auth
|
||||
REGISTER: `${API_BASE_URL}/api/auth/register`,
|
||||
LOGIN: `${API_BASE_URL}/api/auth/login`,
|
||||
PROFILE: `${API_BASE_URL}/api/auth/profile`,
|
||||
|
||||
// Elections
|
||||
ELECTIONS_ACTIVE: `${API_BASE_URL}/api/elections/active`,
|
||||
ELECTIONS_COMPLETED: `${API_BASE_URL}/api/elections/completed`,
|
||||
ELECTIONS_ACTIVE_RESULTS: `${API_BASE_URL}/api/elections/active/results`,
|
||||
ELECTIONS_FUTURE: `${API_BASE_URL}/api/elections/future`,
|
||||
|
||||
// Votes
|
||||
VOTE_SUBMIT: `${API_BASE_URL}/api/votes`,
|
||||
VOTE_HISTORY: `${API_BASE_URL}/api/votes/history`,
|
||||
};
|
||||
84
e-voting-system/frontend/src/config/theme.js
Normal file
84
e-voting-system/frontend/src/config/theme.js
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Design System - Thème E-Voting
|
||||
* Variables de couleurs, espacements, et typographie
|
||||
*/
|
||||
|
||||
export const colors = {
|
||||
// Primaire
|
||||
primary: {
|
||||
dark: '#1e3a5f', // Bleu foncé - Confiance
|
||||
base: '#2563eb', // Bleu principal
|
||||
light: '#3b82f6', // Bleu clair
|
||||
},
|
||||
|
||||
// Sémantique
|
||||
semantic: {
|
||||
success: '#10b981', // Vert
|
||||
warning: '#f97316', // Orange
|
||||
danger: '#ef4444', // Rouge
|
||||
info: '#3b82f6', // Bleu
|
||||
},
|
||||
|
||||
// Neutres
|
||||
neutral: {
|
||||
darkGray: '#1f2937', // Texte
|
||||
gray: '#6b7280', // Texte secondaire
|
||||
lightGray: '#f3f4f6', // Fond
|
||||
border: '#e5e7eb', // Bordures
|
||||
white: '#ffffff', // Blanc
|
||||
},
|
||||
};
|
||||
|
||||
export const spacing = {
|
||||
xs: '0.25rem',
|
||||
sm: '0.5rem',
|
||||
md: '1rem',
|
||||
lg: '1.5rem',
|
||||
xl: '2rem',
|
||||
'2xl': '3rem',
|
||||
};
|
||||
|
||||
export const borderRadius = {
|
||||
sm: '0.375rem',
|
||||
md: '0.5rem',
|
||||
lg: '0.75rem',
|
||||
xl: '1rem',
|
||||
};
|
||||
|
||||
export const shadows = {
|
||||
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||
};
|
||||
|
||||
export const typography = {
|
||||
fontFamily: '"Inter", "Segoe UI", "Roboto", sans-serif',
|
||||
sizes: {
|
||||
xs: '0.75rem',
|
||||
sm: '0.875rem',
|
||||
base: '1rem',
|
||||
lg: '1.125rem',
|
||||
xl: '1.25rem',
|
||||
'2xl': '1.5rem',
|
||||
'3xl': '1.875rem',
|
||||
'4xl': '2.25rem',
|
||||
'5xl': '3rem',
|
||||
},
|
||||
weights: {
|
||||
normal: 400,
|
||||
medium: 500,
|
||||
semibold: 600,
|
||||
bold: 700,
|
||||
extrabold: 800,
|
||||
},
|
||||
};
|
||||
|
||||
export const breakpoints = {
|
||||
xs: '320px',
|
||||
sm: '480px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
'2xl': '1536px',
|
||||
};
|
||||
132
e-voting-system/frontend/src/hooks/useApi.js
Normal file
132
e-voting-system/frontend/src/hooks/useApi.js
Normal file
@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Hooks personnalisés pour le frontend
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook pour charger les données depuis une URL
|
||||
*/
|
||||
export function useApi(url, options = {}) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('token');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [url, options]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour vérifier si l'utilisateur est connecté
|
||||
*/
|
||||
export function useAuth() {
|
||||
const [voter, setVoter] = useState(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const storedVoter = localStorage.getItem('voter');
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (storedVoter && token) {
|
||||
setVoter(JSON.parse(storedVoter));
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement de l\'authentification:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { voter, isAuthenticated, loading };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour gérer le formulaire
|
||||
*/
|
||||
export function useForm(initialValues, onSubmit) {
|
||||
const [values, setValues] = useState(initialValues);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [touched, setTouched] = useState({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setValues(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlur = (e) => {
|
||||
const { name } = e.target;
|
||||
setTouched(prev => ({ ...prev, [name]: true }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(values);
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la soumission:', err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setValues(initialValues);
|
||||
setErrors({});
|
||||
setTouched({});
|
||||
};
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
resetForm,
|
||||
setValues,
|
||||
setErrors,
|
||||
};
|
||||
}
|
||||
|
||||
export default { useApi, useAuth, useForm };
|
||||
@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './styles/globals.css';
|
||||
import './styles/components.css';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
144
e-voting-system/frontend/src/pages/ArchivesPage.css
Normal file
144
e-voting-system/frontend/src/pages/ArchivesPage.css
Normal file
@ -0,0 +1,144 @@
|
||||
/* ===== Archives Page Styles ===== */
|
||||
|
||||
.archives-page {
|
||||
background-color: var(--light-gray);
|
||||
min-height: 100vh;
|
||||
padding: var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
.archives-header {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.archives-header h1 {
|
||||
color: var(--primary-dark);
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.archives-header p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.archives-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.search-box,
|
||||
.sort-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
background-color: var(--white);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 2px solid var(--border-gray);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box:focus-within,
|
||||
.sort-box:focus-within {
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.search-box svg,
|
||||
.sort-box svg {
|
||||
color: var(--dark-gray);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-box input,
|
||||
.sort-box select {
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: var(--font-primary);
|
||||
font-size: 1rem;
|
||||
color: var(--dark-gray);
|
||||
outline: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.sort-box select {
|
||||
cursor: pointer;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.sort-box select option {
|
||||
background-color: var(--white);
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.results-count {
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.votes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem var(--spacing-xl);
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
border: 2px dashed var(--border-gray);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: var(--primary-dark);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===== Mobile Responsive ===== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.archives-page {
|
||||
padding: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.archives-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.archives-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.votes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-box,
|
||||
.sort-box {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
116
e-voting-system/frontend/src/pages/ArchivesPage.jsx
Normal file
116
e-voting-system/frontend/src/pages/ArchivesPage.jsx
Normal file
@ -0,0 +1,116 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, Filter } from 'lucide-react';
|
||||
import VoteCard from '../components/VoteCard';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import './ArchivesPage.css';
|
||||
|
||||
export default function ArchivesPage() {
|
||||
const [votes, setVotes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState('recent');
|
||||
|
||||
useEffect(() => {
|
||||
fetchArchives();
|
||||
}, []);
|
||||
|
||||
const fetchArchives = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/elections/completed');
|
||||
if (!response.ok) throw new Error('Erreur de chargement');
|
||||
const data = await response.json();
|
||||
|
||||
// Adapter les données pour VoteCard
|
||||
const adaptedData = data.map(election => ({
|
||||
id: election.id,
|
||||
titre: election.name,
|
||||
description: election.description,
|
||||
date_fermeture: election.end_date,
|
||||
date_ouverture: election.start_date,
|
||||
status: 'fermé', // Toutes les élections ici sont terminées
|
||||
}));
|
||||
|
||||
setVotes(adaptedData);
|
||||
} catch (err) {
|
||||
console.error('Erreur:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredVotes = votes
|
||||
.filter(vote =>
|
||||
vote.titre.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
vote.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'recent') {
|
||||
return new Date(b.date_fermeture) - new Date(a.date_fermeture);
|
||||
} else if (sortBy === 'oldest') {
|
||||
return new Date(a.date_fermeture) - new Date(b.date_fermeture);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (loading) return <LoadingSpinner fullscreen />;
|
||||
|
||||
return (
|
||||
<div className="archives-page">
|
||||
<div className="container">
|
||||
<div className="archives-header">
|
||||
<div>
|
||||
<h1>📚 Archives Publiques</h1>
|
||||
<p>Consultez tous les votes terminés et leurs résultats</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="archives-controls">
|
||||
<div className="search-box">
|
||||
<Search size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un vote..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sort-box">
|
||||
<Filter size={20} />
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
|
||||
<option value="recent">Plus récent</option>
|
||||
<option value="oldest">Plus ancien</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredVotes.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📭</div>
|
||||
<h3>Aucun résultat</h3>
|
||||
<p>
|
||||
{searchTerm
|
||||
? 'Aucun vote ne correspond à votre recherche.'
|
||||
: 'Il n\'y a pas encore de votes archivés.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="results-count">
|
||||
{filteredVotes.length} résultat{filteredVotes.length > 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="votes-grid">
|
||||
{filteredVotes.map(vote => (
|
||||
<VoteCard
|
||||
key={vote.id}
|
||||
vote={vote}
|
||||
showResult={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
e-voting-system/frontend/src/pages/AuthPage.css
Normal file
195
e-voting-system/frontend/src/pages/AuthPage.css
Normal file
@ -0,0 +1,195 @@
|
||||
/* ===== Auth Page Styles ===== */
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--light-gray) 0%, #f9fafb 100%);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-xl);
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--spacing-2xl);
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-wrapper .input-icon {
|
||||
position: absolute;
|
||||
left: var(--spacing-md);
|
||||
color: var(--dark-gray);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
padding-left: 45px !important;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
color: var(--primary-blue);
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.forgot-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.checkbox-item input {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: 2px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-item a {
|
||||
color: var(--primary-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.checkbox-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
color: var(--dark-gray);
|
||||
text-align: center;
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.auth-divider::before,
|
||||
.auth-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: var(--border-gray);
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.auth-switch a {
|
||||
color: var(--primary-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-illustration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.illustration-box {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.1), rgba(30, 58, 95, 0.1));
|
||||
border-radius: var(--radius-xl);
|
||||
border: 2px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.illustration-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.illustration-box h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.illustration-box p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ===== Mobile Responsive ===== */
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.auth-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-illustration {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.auth-page {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
463
e-voting-system/frontend/src/pages/DashboardPage.css
Normal file
463
e-voting-system/frontend/src/pages/DashboardPage.css
Normal file
@ -0,0 +1,463 @@
|
||||
/* ===== Dashboard Page Styles ===== */
|
||||
|
||||
.dashboard-page {
|
||||
background-color: var(--light-gray);
|
||||
min-height: 100vh;
|
||||
padding: var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
color: var(--primary-dark);
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.dashboard-header p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===== Stats Grid ===== */
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
align-items: flex-start;
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-icon.active {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: var(--success-green);
|
||||
}
|
||||
|
||||
.stat-icon.future {
|
||||
background-color: rgba(37, 99, 235, 0.1);
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.stat-icon.completed {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.stat-icon.total {
|
||||
background-color: rgba(249, 115, 22, 0.1);
|
||||
color: var(--warning-orange);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary-dark);
|
||||
line-height: 1;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.95rem;
|
||||
color: var(--dark-gray);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.stat-link {
|
||||
display: inline-block;
|
||||
color: var(--primary-blue);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-link:hover {
|
||||
color: var(--primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ===== Tabs ===== */
|
||||
|
||||
.dashboard-tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
border-bottom: 2px solid var(--border-gray);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dark-gray);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary-blue);
|
||||
border-bottom-color: var(--primary-blue);
|
||||
}
|
||||
|
||||
/* ===== Action Section ===== */
|
||||
|
||||
.action-section {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.05), rgba(30, 58, 95, 0.05));
|
||||
border-left: 4px solid var(--warning-orange);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.action-section h2 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* ===== Votes Section ===== */
|
||||
|
||||
.votes-section h2 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.votes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* ===== Empty State ===== */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
border: 2px dashed var(--border-gray);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ===== Mobile Responsive ===== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-page {
|
||||
padding: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.votes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-tabs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: var(--spacing-md);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
.dashboard-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: #d32f2f;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: #b71c1c;
|
||||
}
|
||||
|
||||
.dashboard-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-section {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dashboard-section h2 {
|
||||
color: #333;
|
||||
margin-top: 0;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.election-item {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.election-item h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.election-item p {
|
||||
margin: 5px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.vote-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 10px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.vote-item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vote-item-info h4 {
|
||||
margin: 0 0 5px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.vote-item-info p {
|
||||
margin: 0;
|
||||
color: #999;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.vote-status {
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.vote-status.voted {
|
||||
background: #c8e6c9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.vote-status.pending {
|
||||
background: #fff9c4;
|
||||
color: #f57f17;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.candidate-result {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.candidate-result h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
background: #ddd;
|
||||
border-radius: 10px;
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 5px;
|
||||
color: white;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.candidate-result p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.vote-btn {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
margin-top: 10px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.vote-btn:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header-user-info {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vote-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.vote-status {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
135
e-voting-system/frontend/src/pages/DashboardPage.js
Normal file
135
e-voting-system/frontend/src/pages/DashboardPage.js
Normal file
@ -0,0 +1,135 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { API_ENDPOINTS } from '../config/api';
|
||||
import './DashboardPage.css';
|
||||
|
||||
const DashboardPage = ({ voter, onLogout }) => {
|
||||
const navigate = useNavigate();
|
||||
const [activeElection, setActiveElection] = useState(null);
|
||||
const [myVotes, setMyVotes] = useState([]);
|
||||
const [upcomingElections, setUpcomingElections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, [voter]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const electionRes = await axios.get(API_ENDPOINTS.ELECTIONS_ACTIVE);
|
||||
setActiveElection(electionRes.data);
|
||||
|
||||
setMyVotes([
|
||||
{ id: 1, name: 'Alice Dupont', voted: true },
|
||||
{ id: 2, name: 'Bob Martin', voted: false }
|
||||
]);
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Erreur de chargement');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
onLogout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleVoteClick = () => {
|
||||
if (activeElection && activeElection.id) {
|
||||
navigate(`/vote/${activeElection.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: '50px' }}>Chargement du tableau de bord...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
<header className="dashboard-header">
|
||||
<h1>📊 Tableau de Bord</h1>
|
||||
<div className="header-user-info">
|
||||
<span>👤 {voter?.email || voter?.name}</span>
|
||||
<button className="logout-btn" onClick={handleLogout}>Déconnexion</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="dashboard-container">
|
||||
{error && <div style={{ color: 'red', padding: '10px', textAlign: 'center' }}>{error}</div>}
|
||||
|
||||
{/* Section: Élection Active */}
|
||||
<section className="dashboard-section">
|
||||
<h2>⏳ Élection en cours</h2>
|
||||
{activeElection ? (
|
||||
<div className="election-item">
|
||||
<h3>{activeElection.name}</h3>
|
||||
<p>{activeElection.description}</p>
|
||||
<p className="date">
|
||||
Jusqu'au {new Date(activeElection.end_date).toLocaleDateString('fr-FR')}
|
||||
</p>
|
||||
<button className="vote-btn" onClick={handleVoteClick}>➜ Participer au vote</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="empty-message">Aucune élection active</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Section: Mes votes */}
|
||||
<section className="dashboard-section">
|
||||
<h2>✅ Mes votes</h2>
|
||||
<div>
|
||||
{myVotes.length > 0 ? (
|
||||
myVotes.map((vote) => (
|
||||
<div key={vote.id} className="vote-item">
|
||||
<div className="vote-item-info">
|
||||
<h4>{vote.name}</h4>
|
||||
<p>{vote.voted ? 'Vote participé' : 'Vote en attente'}</p>
|
||||
</div>
|
||||
<span className={`vote-status ${vote.voted ? 'voted' : 'pending'}`}>
|
||||
{vote.voted ? '✓ Voté' : '⏳ En attente'}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="empty-message">Aucun vote pour le moment</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section: Résultats actuels */}
|
||||
<section className="dashboard-section">
|
||||
<h2>📈 Résultats actuels</h2>
|
||||
{activeElection ? (
|
||||
<div className="results-grid">
|
||||
{activeElection.candidates?.map((candidate) => {
|
||||
const votes = Math.floor(Math.random() * 100);
|
||||
return (
|
||||
<div key={candidate.id} className="candidate-result">
|
||||
<h4>{candidate.name}</h4>
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-bar" style={{ width: `${votes}%` }}>
|
||||
{votes > 10 ? `${votes}%` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<p>{votes} votes</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="empty-message">Aucun résultat disponible</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
213
e-voting-system/frontend/src/pages/DashboardPage.jsx
Normal file
213
e-voting-system/frontend/src/pages/DashboardPage.jsx
Normal file
@ -0,0 +1,213 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BarChart3, Clock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import VoteCard from '../components/VoteCard';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import './DashboardPage.css';
|
||||
|
||||
export default function DashboardPage({ voter }) {
|
||||
const [votes, setVotes] = useState([]);
|
||||
const [userVotes, setUserVotes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all'); // all, actifs, futurs, historique
|
||||
|
||||
useEffect(() => {
|
||||
fetchVotes();
|
||||
}, []);
|
||||
|
||||
const fetchVotes = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch('http://localhost:8000/elections/', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erreur de chargement');
|
||||
|
||||
const data = await response.json();
|
||||
setVotes(data);
|
||||
|
||||
// Fetch user's votes
|
||||
const votesResponse = await fetch('http://localhost:8000/votes/my-votes', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (votesResponse.ok) {
|
||||
const votesData = await votesResponse.json();
|
||||
setUserVotes(votesData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getActiveVotes = () => votes.filter(v => v.status === 'actif');
|
||||
const getFutureVotes = () => votes.filter(v => v.status === 'futur');
|
||||
const getHistoryVotes = () => votes.filter(v => v.status === 'ferme');
|
||||
|
||||
const activeVotes = getActiveVotes();
|
||||
const futureVotes = getFutureVotes();
|
||||
const historyVotes = getHistoryVotes();
|
||||
|
||||
if (loading) return <LoadingSpinner fullscreen />;
|
||||
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
<div className="container">
|
||||
{/* Welcome Section */}
|
||||
<div className="dashboard-header">
|
||||
<div>
|
||||
<h1>Bienvenue, {voter?.nom}! 👋</h1>
|
||||
<p>Voici votre tableau de bord personnel</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Section */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon active">
|
||||
<AlertCircle size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-value">{activeVotes.length}</div>
|
||||
<div className="stat-label">Votes Actifs</div>
|
||||
<a href="#actifs" className="stat-link">Voir →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon future">
|
||||
<Clock size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-value">{futureVotes.length}</div>
|
||||
<div className="stat-label">À Venir</div>
|
||||
<a href="#futurs" className="stat-link">Voir →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon completed">
|
||||
<CheckCircle size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-value">{historyVotes.length}</div>
|
||||
<div className="stat-label">Votes Terminés</div>
|
||||
<a href="#historique" className="stat-link">Voir →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon total">
|
||||
<BarChart3 size={24} />
|
||||
</div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-value">{userVotes.length}</div>
|
||||
<div className="stat-label">Votes Effectués</div>
|
||||
<Link to="/dashboard/historique" className="stat-link">Voir →</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="dashboard-tabs">
|
||||
<button
|
||||
className={`tab ${filter === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
Tous les votes
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${filter === 'actifs' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('actifs')}
|
||||
>
|
||||
Actifs ({activeVotes.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${filter === 'futurs' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('futurs')}
|
||||
>
|
||||
À venir ({futureVotes.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${filter === 'historique' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('historique')}
|
||||
>
|
||||
Historique ({historyVotes.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action Required Section - Only for Active Votes */}
|
||||
{filter === 'all' && activeVotes.length > 0 && (
|
||||
<div className="action-section">
|
||||
<h2>⚡ Action Requise</h2>
|
||||
<p className="section-subtitle">Votes en attente de votre participation</p>
|
||||
<div className="votes-grid">
|
||||
{activeVotes.slice(0, 2).map(vote => (
|
||||
<VoteCard
|
||||
key={vote.id}
|
||||
vote={vote}
|
||||
userVote={userVotes.find(v => v.election_id === vote.id)?.choix}
|
||||
onVote={(id) => window.location.href = `/vote/${id}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{activeVotes.length > 2 && (
|
||||
<Link to="/dashboard/actifs" className="btn btn-secondary">
|
||||
Voir tous les votes actifs
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Votes Display */}
|
||||
<div className="votes-section">
|
||||
<h2>
|
||||
{filter === 'all' && 'Tous les votes'}
|
||||
{filter === 'actifs' && 'Votes Actifs'}
|
||||
{filter === 'futurs' && 'Votes à Venir'}
|
||||
{filter === 'historique' && 'Mon Historique'}
|
||||
</h2>
|
||||
|
||||
{(() => {
|
||||
let displayVotes = votes;
|
||||
if (filter === 'actifs') displayVotes = activeVotes;
|
||||
if (filter === 'futurs') displayVotes = futureVotes;
|
||||
if (filter === 'historique') displayVotes = historyVotes;
|
||||
|
||||
if (displayVotes.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📭</div>
|
||||
<h3>Aucun vote</h3>
|
||||
<p>
|
||||
{filter === 'all' && 'Il n\'y a pas encore de votes disponibles.'}
|
||||
{filter === 'actifs' && 'Aucun vote actif pour le moment.'}
|
||||
{filter === 'futurs' && 'Aucun vote à venir.'}
|
||||
{filter === 'historique' && 'Vous n\'avez pas encore voté.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="votes-grid">
|
||||
{displayVotes.map(vote => (
|
||||
<VoteCard
|
||||
key={vote.id}
|
||||
vote={vote}
|
||||
userVote={userVotes.find(v => v.election_id === vote.id)?.choix}
|
||||
showResult={filter === 'historique' || vote.status === 'ferme'}
|
||||
onVote={(id) => window.location.href = `/vote/${id}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
462
e-voting-system/frontend/src/pages/HomePage.css
Normal file
462
e-voting-system/frontend/src/pages/HomePage.css
Normal file
@ -0,0 +1,462 @@
|
||||
/* ===== Home Page Styles ===== */
|
||||
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ===== Hero Section ===== */
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-blue) 100%);
|
||||
color: var(--white);
|
||||
padding: 6rem var(--spacing-lg) 4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
opacity: 0.95;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
justify-content: center;
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-buttons .btn-primary {
|
||||
background-color: var(--white);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.hero-buttons .btn-primary:hover {
|
||||
background-color: var(--light-gray);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.hero-buttons .btn-secondary {
|
||||
background-color: transparent;
|
||||
color: var(--white);
|
||||
border-color: var(--white);
|
||||
}
|
||||
|
||||
.hero-buttons .btn-secondary:hover {
|
||||
background-color: var(--white);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--spacing-xl);
|
||||
margin-top: var(--spacing-2xl);
|
||||
padding-top: var(--spacing-2xl);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ===== How It Works Section ===== */
|
||||
|
||||
.how-it-works {
|
||||
padding: 6rem var(--spacing-lg);
|
||||
background-color: var(--light-gray);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
color: var(--primary-dark);
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.steps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.step-card {
|
||||
background-color: var(--white);
|
||||
padding: var(--spacing-2xl);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.step-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, var(--primary-blue), var(--primary-light));
|
||||
color: var(--white);
|
||||
border-radius: 50%;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.step-card h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.step-card p {
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ===== Features Section ===== */
|
||||
|
||||
.features {
|
||||
padding: 6rem var(--spacing-lg);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background-color: var(--white);
|
||||
padding: var(--spacing-2xl);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
text-align: center;
|
||||
border-top: 4px solid var(--primary-blue);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
color: var(--primary-blue);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ===== Recent Votes Section ===== */
|
||||
|
||||
.recent-votes {
|
||||
padding: 6rem var(--spacing-lg);
|
||||
background-color: var(--light-gray);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 3rem;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.votes-preview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.vote-preview-card {
|
||||
background-color: var(--white);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-left: 4px solid var(--success-green);
|
||||
}
|
||||
|
||||
.vote-preview-card h4 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.vote-status {
|
||||
display: inline-block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.vote-status.completed {
|
||||
background-color: #ecfdf5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.vote-result {
|
||||
color: var(--dark-gray);
|
||||
margin-bottom: var(--spacing-md);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* ===== CTA Section ===== */
|
||||
|
||||
.cta {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-blue) 100%);
|
||||
color: var(--white);
|
||||
padding: 6rem var(--spacing-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
color: var(--white);
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.cta p {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.cta .btn-primary {
|
||||
background-color: var(--white);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.cta .btn-primary:hover {
|
||||
background-color: var(--light-gray);
|
||||
}
|
||||
|
||||
/* ===== Mobile Responsive ===== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-title {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-buttons .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero {
|
||||
padding: 3rem var(--spacing-lg) 2rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 3em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.3em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.voter-greeting {
|
||||
font-size: 1em;
|
||||
margin-top: 15px;
|
||||
opacity: 0.8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.result-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.result-card h3 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.vote-count {
|
||||
color: #666;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cta-buttons {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 30px;
|
||||
font-size: 1.1em;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
text-align: center;
|
||||
}
|
||||
88
e-voting-system/frontend/src/pages/HomePage.js
Normal file
88
e-voting-system/frontend/src/pages/HomePage.js
Normal file
@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import './HomePage.css';
|
||||
|
||||
const HomePage = ({ voter }) => {
|
||||
const navigate = useNavigate();
|
||||
const [results, setResults] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchResults();
|
||||
}, []);
|
||||
|
||||
const fetchResults = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('http://localhost:8000/api/elections/active/results');
|
||||
setResults(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Erreur de chargement');
|
||||
setLoading(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoteClick = () => {
|
||||
if (voter) {
|
||||
navigate('/vote/1'); // Assuming election ID 1 is the active one
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginClick = () => {
|
||||
if (voter) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="container"><p>Chargement...</p></div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="container error"><p>Erreur: {error}</p></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-page">
|
||||
<div className="hero">
|
||||
<h1>🗳️ Système de Vote Électronique</h1>
|
||||
<p>Résultats en direct</p>
|
||||
{voter && <p className="voter-greeting">Bienvenue, {voter.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="container">
|
||||
<div className="results-grid">
|
||||
{results && results.map((result) => {
|
||||
const percentage = result.total_votes > 0
|
||||
? ((result.votes / result.total_votes) * 100).toFixed(1)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={result.candidate_id} className="result-card">
|
||||
<h3>{result.candidate_name}</h3>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="vote-count">{result.votes} votes ({percentage}%)</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
149
e-voting-system/frontend/src/pages/HomePage.jsx
Normal file
149
e-voting-system/frontend/src/pages/HomePage.jsx
Normal file
@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, CheckCircle, Lock, BarChart3 } from 'lucide-react';
|
||||
import './HomePage.css';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="home-page">
|
||||
{/* Hero Section */}
|
||||
<section className="hero">
|
||||
<div className="container">
|
||||
<div className="hero-content">
|
||||
<h1 className="hero-title">
|
||||
Votre Voix, Simplifiée et Sécurisée
|
||||
</h1>
|
||||
<p className="hero-subtitle">
|
||||
Plateforme de vote électronique transparente et sécurisée par cryptographie post-quantique.
|
||||
Votez de n'importe où, en toute confiance.
|
||||
</p>
|
||||
<div className="hero-buttons">
|
||||
<Link to="/login" className="btn btn-primary btn-lg">
|
||||
Se Connecter
|
||||
<ArrowRight size={20} />
|
||||
</Link>
|
||||
<Link to="/register" className="btn btn-secondary btn-lg">
|
||||
S'inscrire
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="hero-stats">
|
||||
<div className="stat-item">
|
||||
<div className="stat-number">1000+</div>
|
||||
<div className="stat-label">Votants</div>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<div className="stat-number">50+</div>
|
||||
<div className="stat-label">Élections</div>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<div className="stat-number">99.9%</div>
|
||||
<div className="stat-label">Sécurité</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works Section */}
|
||||
<section className="how-it-works">
|
||||
<div className="container">
|
||||
<h2 className="section-title">Comment ça marche ?</h2>
|
||||
<div className="steps-grid">
|
||||
<div className="step-card">
|
||||
<div className="step-number">1</div>
|
||||
<h3>Créez un compte</h3>
|
||||
<p>Inscrivez-vous avec votre email et un mot de passe sécurisé.</p>
|
||||
</div>
|
||||
<div className="step-card">
|
||||
<div className="step-number">2</div>
|
||||
<h3>Consultez les votes</h3>
|
||||
<p>Accédez à votre tableau de bord et consultez les votes en cours.</p>
|
||||
</div>
|
||||
<div className="step-card">
|
||||
<div className="step-number">3</div>
|
||||
<h3>Votez en un clic</h3>
|
||||
<p>Participez aux votes avec un processus simple et sécurisé.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="features">
|
||||
<div className="container">
|
||||
<h2 className="section-title">Nos Garanties</h2>
|
||||
<div className="features-grid">
|
||||
<div className="feature-card">
|
||||
<Lock size={40} className="feature-icon" />
|
||||
<h3>Sécurité Maximale</h3>
|
||||
<p>
|
||||
Cryptographie post-quantique pour protéger vos votes contre les menaces futures.
|
||||
</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<CheckCircle size={40} className="feature-icon" />
|
||||
<h3>Transparence Complète</h3>
|
||||
<p>
|
||||
Tous les résultats sont publics et vérifiables. Zéro secret.
|
||||
</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<BarChart3 size={40} className="feature-icon" />
|
||||
<h3>Résultats Instantanés</h3>
|
||||
<p>
|
||||
Consultez les résultats en temps réel pendant et après chaque vote.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Votes Section */}
|
||||
<section className="recent-votes">
|
||||
<div className="container">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Votes Récents</h2>
|
||||
<Link to="/archives" className="btn btn-ghost">
|
||||
Voir tous les archives
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="votes-preview">
|
||||
<div className="vote-preview-card">
|
||||
<h4>Choix du Logo de l'entreprise</h4>
|
||||
<p className="vote-status completed">✓ TERMINÉ</p>
|
||||
<p className="vote-result">Option A a gagné avec 65% des votes</p>
|
||||
<a href="/archives" className="btn btn-ghost btn-sm">Voir les détails</a>
|
||||
</div>
|
||||
<div className="vote-preview-card">
|
||||
<h4>Augmentation du budget IT</h4>
|
||||
<p className="vote-status completed">✓ TERMINÉ</p>
|
||||
<p className="vote-result">Adopté avec 78% d'approbation</p>
|
||||
<a href="/archives" className="btn btn-ghost btn-sm">Voir les détails</a>
|
||||
</div>
|
||||
<div className="vote-preview-card">
|
||||
<h4>Nouvelle politique de télétravail</h4>
|
||||
<p className="vote-status completed">✓ TERMINÉ</p>
|
||||
<p className="vote-result">Rejeté avec 52% d'opposition</p>
|
||||
<a href="/archives" className="btn btn-ghost btn-sm">Voir les détails</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="cta">
|
||||
<div className="container">
|
||||
<h2>Prêt à voter ?</h2>
|
||||
<p>Rejoignez des milliers d'électeurs et participez à la démocratie numérique.</p>
|
||||
<Link to="/register" className="btn btn-primary btn-lg">
|
||||
Créer un compte maintenant
|
||||
<ArrowRight size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
e-voting-system/frontend/src/pages/LoginPage.css
Normal file
96
e-voting-system/frontend/src/pages/LoginPage.css
Normal file
@ -0,0 +1,96 @@
|
||||
/* LoginPage.css */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-container h2 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ffebee;
|
||||
color: #d32f2f;
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.register-link a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.register-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
88
e-voting-system/frontend/src/pages/LoginPage.js
Normal file
88
e-voting-system/frontend/src/pages/LoginPage.js
Normal file
@ -0,0 +1,88 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { API_ENDPOINTS } from '../config/api';
|
||||
import './LoginPage.css';
|
||||
|
||||
const LoginPage = ({ onLogin }) => {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await axios.post(API_ENDPOINTS.LOGIN, {
|
||||
email,
|
||||
password
|
||||
});
|
||||
|
||||
if (response.data && response.data.access_token) {
|
||||
localStorage.setItem('token', response.data.access_token);
|
||||
|
||||
const voterData = {
|
||||
email: response.data.email,
|
||||
name: response.data.first_name + ' ' + response.data.last_name,
|
||||
id: response.data.id
|
||||
};
|
||||
|
||||
localStorage.setItem('voter', JSON.stringify(voterData));
|
||||
onLogin(voterData);
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Email ou mot de passe incorrect');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-container">
|
||||
<h2>Connexion Votant</h2>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="votre@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Mot de passe</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Votre mot de passe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={loading} className="btn">
|
||||
{loading ? 'Connexion...' : 'Se connecter'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="register-link">
|
||||
Pas encore enregistré? <a href="/register">S'inscrire</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
189
e-voting-system/frontend/src/pages/ProfilePage.css
Normal file
189
e-voting-system/frontend/src/pages/ProfilePage.css
Normal file
@ -0,0 +1,189 @@
|
||||
/* ===== Profile Page Styles ===== */
|
||||
|
||||
.profile-page {
|
||||
background-color: var(--light-gray);
|
||||
min-height: 100vh;
|
||||
padding: var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-blue);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
padding: var(--spacing-md);
|
||||
transition: all 0.2s ease;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: var(--white);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.profile-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-2xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
gap: var(--spacing-xl);
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--primary-blue), var(--primary-light));
|
||||
color: var(--white);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-info h1 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.profile-info p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.profile-section h2 {
|
||||
color: var(--primary-dark);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
border-bottom: 2px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-wrapper .input-icon {
|
||||
position: absolute;
|
||||
left: var(--spacing-md);
|
||||
color: var(--dark-gray);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
padding-left: 45px;
|
||||
padding-right: 45px;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: var(--spacing-md);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--dark-gray);
|
||||
padding: var(--spacing-sm);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
/* ===== Danger Zone ===== */
|
||||
|
||||
.danger-zone {
|
||||
border-left: 4px solid var(--danger-red);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.danger-zone h2 {
|
||||
color: var(--danger-red);
|
||||
}
|
||||
|
||||
.danger-zone p {
|
||||
color: var(--dark-gray);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* ===== Mobile Responsive ===== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-page {
|
||||
padding: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.profile-info h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.profile-section h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
276
e-voting-system/frontend/src/pages/ProfilePage.jsx
Normal file
276
e-voting-system/frontend/src/pages/ProfilePage.jsx
Normal file
@ -0,0 +1,276 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { User, Mail, Lock, LogOut, ArrowLeft, Eye, EyeOff } from 'lucide-react';
|
||||
import Alert from '../components/Alert';
|
||||
import './ProfilePage.css';
|
||||
|
||||
export default function ProfilePage({ voter, onLogout }) {
|
||||
const navigate = useNavigate();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
nom: voter?.nom || '',
|
||||
email: voter?.email || '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleUpdateProfile = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch('http://localhost:8000/auth/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
nom: formData.nom,
|
||||
email: formData.email,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erreur lors de la mise à jour');
|
||||
|
||||
const data = await response.json();
|
||||
localStorage.setItem('voter', JSON.stringify(data.voter));
|
||||
setSuccess('Profil mis à jour avec succès');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
if (formData.newPassword !== formData.confirmPassword) {
|
||||
setError('Les mots de passe ne correspondent pas');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.newPassword.length < 8) {
|
||||
setError('Le mot de passe doit contenir au moins 8 caractères');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch('http://localhost:8000/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: formData.currentPassword,
|
||||
new_password: formData.newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Mot de passe actuel incorrect');
|
||||
|
||||
setSuccess('Mot de passe changé avec succès');
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
}));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('voter');
|
||||
localStorage.removeItem('token');
|
||||
onLogout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="container">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="back-button"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Retour au dashboard
|
||||
</button>
|
||||
|
||||
<div className="profile-container">
|
||||
<div className="profile-header">
|
||||
<div className="profile-avatar">
|
||||
{voter?.nom?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="profile-info">
|
||||
<h1>{voter?.nom}</h1>
|
||||
<p>{voter?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-sections">
|
||||
{/* General Information */}
|
||||
<div className="profile-section">
|
||||
<h2>Informations Générales</h2>
|
||||
|
||||
{error && (
|
||||
<Alert type="error" message={error} onClose={() => setError('')} />
|
||||
)}
|
||||
{success && (
|
||||
<Alert type="success" message={success} onClose={() => setSuccess('')} />
|
||||
)}
|
||||
|
||||
<form onSubmit={handleUpdateProfile} className="profile-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="nom">Nom complet</label>
|
||||
<div className="input-wrapper">
|
||||
<User size={20} className="input-icon" />
|
||||
<input
|
||||
id="nom"
|
||||
type="text"
|
||||
name="nom"
|
||||
value={formData.nom}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<div className="input-wrapper">
|
||||
<Mail size={20} className="input-icon" />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Sauvegarde...' : 'Sauvegarder'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Security Section */}
|
||||
<div className="profile-section">
|
||||
<h2>Sécurité</h2>
|
||||
|
||||
<form onSubmit={handleChangePassword} className="profile-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="currentPassword">Mot de passe actuel</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock size={20} className="input-icon" />
|
||||
<input
|
||||
id="currentPassword"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="currentPassword"
|
||||
placeholder="••••••••"
|
||||
value={formData.currentPassword}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-toggle"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="newPassword">Nouveau mot de passe</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock size={20} className="input-icon" />
|
||||
<input
|
||||
id="newPassword"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
name="newPassword"
|
||||
placeholder="Minimum 8 caractères"
|
||||
value={formData.newPassword}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-toggle"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
>
|
||||
{showNewPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirmer le mot de passe</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock size={20} className="input-icon" />
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="••••••••"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Mise à jour...' : 'Changer le mot de passe'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="profile-section danger-zone">
|
||||
<h2>Zone Dangereuse</h2>
|
||||
<p>Attention: Ces actions sont irréversibles.</p>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
e-voting-system/frontend/src/pages/RegisterPage.css
Normal file
148
e-voting-system/frontend/src/pages/RegisterPage.css
Normal file
@ -0,0 +1,148 @@
|
||||
/* RegisterPage.css */
|
||||
.register-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.register-container h2 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="email"],
|
||||
.form-group input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
transition: border-color 0.3s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.terms-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.terms-checkbox input[type="checkbox"] {
|
||||
margin-top: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terms-checkbox label {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.terms-checkbox a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.terms-checkbox a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ffebee;
|
||||
color: #d32f2f;
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.login-link a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.login-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
256
e-voting-system/frontend/src/pages/RegisterPage.jsx
Normal file
256
e-voting-system/frontend/src/pages/RegisterPage.jsx
Normal file
@ -0,0 +1,256 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { User, Mail, Lock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import APIClient from '../utils/api';
|
||||
import Alert from '../components/Alert';
|
||||
import { API_ENDPOINTS } from '../config/api';
|
||||
|
||||
export default function RegisterPage({ onLogin }) {
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
citizen_id: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validation
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Les mots de passe ne correspondent pas');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agreedToTerms) {
|
||||
setError('Vous devez accepter les conditions d\'utilisation');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 8) {
|
||||
setError('Le mot de passe doit contenir au moins 8 caractères');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
citizen_id: formData.citizen_id,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
};
|
||||
|
||||
console.log('Sending registration data:', payload);
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.REGISTER, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Registration error response:', errorData);
|
||||
// Afficher les erreurs de validation détaillées
|
||||
if (errorData.detail) {
|
||||
if (Array.isArray(errorData.detail)) {
|
||||
throw new Error(errorData.detail.map(e => e.msg).join(', '));
|
||||
}
|
||||
throw new Error(errorData.detail);
|
||||
}
|
||||
throw new Error('Erreur lors de l\'inscription');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Registration successful:', data);
|
||||
|
||||
// Extraire les données de l'utilisateur
|
||||
const voterData = {
|
||||
id: data.id,
|
||||
email: data.email,
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
};
|
||||
|
||||
localStorage.setItem('voter', JSON.stringify(voterData));
|
||||
localStorage.setItem('token', data.access_token || '');
|
||||
|
||||
onLogin(voterData);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Erreur d\'inscription');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<div className="auth-header">
|
||||
<h1>Créer un compte</h1>
|
||||
<p>Rejoignez notre plateforme de vote</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert type="error" message={error} onClose={() => setError('')} />
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="first_name">Prénom</label>
|
||||
<div className="input-wrapper">
|
||||
<User size={20} className="input-icon" />
|
||||
<input
|
||||
id="first_name"
|
||||
type="text"
|
||||
name="first_name"
|
||||
placeholder="Jean"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="last_name">Nom</label>
|
||||
<div className="input-wrapper">
|
||||
<User size={20} className="input-icon" />
|
||||
<input
|
||||
id="last_name"
|
||||
type="text"
|
||||
name="last_name"
|
||||
placeholder="Dupont"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="citizen_id">Numéro de Carte Nationale d'Identité (CNI)</label>
|
||||
<div className="input-wrapper">
|
||||
<User size={20} className="input-icon" />
|
||||
<input
|
||||
id="citizen_id"
|
||||
type="text"
|
||||
name="citizen_id"
|
||||
placeholder="123456789"
|
||||
value={formData.citizen_id}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
pattern="[0-9]{4,12}"
|
||||
title="Le numéro CNI doit contenir entre 4 et 12 chiffres"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<div className="input-wrapper">
|
||||
<Mail size={20} className="input-icon" />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="votre@email.com"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Mot de passe</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock size={20} className="input-icon" />
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Minimum 8 caractères"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirmer le mot de passe</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock size={20} className="input-icon" />
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="••••••••"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
<span>J'accepte les <a href="#cgu">conditions d'utilisation</a> et la <a href="#privacy">politique de confidentialité</a></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary btn-lg btn-block" disabled={loading}>
|
||||
<CheckCircle size={20} />
|
||||
{loading ? 'Inscription...' : 'S\'inscrire'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-divider">ou</div>
|
||||
|
||||
<p className="auth-switch">
|
||||
Déjà inscrit ? <Link to="/login">Se connecter</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-illustration">
|
||||
<div className="illustration-box">
|
||||
<div className="illustration-icon">✓</div>
|
||||
<h3>Sécurisé</h3>
|
||||
<p>Votre vote est protégé par cryptographie post-quantique</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
653
e-voting-system/frontend/src/pages/VotingPage.css
Normal file
653
e-voting-system/frontend/src/pages/VotingPage.css
Normal file
@ -0,0 +1,653 @@
|
||||
/* ===== Voting Page Styles ===== */
|
||||
|
||||
.voting-page {
|
||||
background-color: var(--light-gray);
|
||||
min-height: 100vh;
|
||||
padding: var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-blue);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
padding: var(--spacing-md);
|
||||
transition: all 0.2s ease;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: var(--white);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.vote-container {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--spacing-xl);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.vote-info-section {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.vote-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
border-bottom: 2px solid var(--light-gray);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.vote-header h1 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.vote-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.meta-item strong {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.vote-description h2 {
|
||||
color: var(--primary-dark);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.vote-description p {
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.8;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.vote-context {
|
||||
background-color: var(--light-gray);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
border-left: 4px solid var(--primary-blue);
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.vote-context h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.vote-results {
|
||||
margin-top: var(--spacing-xl);
|
||||
padding-top: var(--spacing-xl);
|
||||
border-top: 2px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.vote-results h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.results-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr 60px;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.result-option {
|
||||
font-weight: 600;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.result-bar {
|
||||
height: 32px;
|
||||
background-color: var(--border-gray);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.result-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-blue), var(--primary-light));
|
||||
transition: width 0.5s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: var(--spacing-md);
|
||||
}
|
||||
|
||||
.result-percent {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* ===== Voting Section ===== */
|
||||
|
||||
.vote-voting-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.voting-card {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
position: sticky;
|
||||
top: 90px;
|
||||
}
|
||||
|
||||
.voting-card h2 {
|
||||
color: var(--primary-dark);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.voting-form {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.voting-question {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.voting-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.voting-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border: 2px solid var(--border-gray);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.voting-option:hover {
|
||||
border-color: var(--primary-blue);
|
||||
background-color: rgba(37, 99, 235, 0.05);
|
||||
}
|
||||
|
||||
.voting-option input[type="radio"] {
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.voting-option input[type="radio"]:checked + .option-text {
|
||||
font-weight: 600;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.voting-option.checked {
|
||||
border-color: var(--primary-blue);
|
||||
background-color: rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.option-text {
|
||||
font-weight: 500;
|
||||
color: var(--dark-gray);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.voting-info {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background-color: #fef3c7;
|
||||
border-radius: var(--radius-lg);
|
||||
border-left: 4px solid var(--warning-orange);
|
||||
}
|
||||
|
||||
.voting-info svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--warning-orange);
|
||||
}
|
||||
|
||||
.voting-info p {
|
||||
color: #78350f;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===== Confirmation Modal ===== */
|
||||
|
||||
.confirmation-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.confirmation-content svg {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.confirmation-message {
|
||||
font-size: 1.05rem;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* ===== Success Screen ===== */
|
||||
|
||||
.success-screen {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 5rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: block;
|
||||
animation: scaleIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.success-screen h1 {
|
||||
color: var(--success-green);
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.success-screen p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.success-details {
|
||||
background-color: var(--light-gray);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: var(--spacing-lg) 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.success-details p {
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.success-details p strong {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.success-note {
|
||||
color: var(--text-muted, #6b7280);
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ===== Mobile Responsive ===== */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.vote-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.voting-card {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
grid-template-columns: 100px 1fr 50px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.voting-page {
|
||||
padding: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.vote-info-section,
|
||||
.voting-card {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.vote-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.vote-meta {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.result-row {
|
||||
grid-template-columns: 80px 1fr 40px;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.result-option {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.result-percent {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.vote-container {
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.success-screen {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.success-screen h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
.voting-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.voting-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.voting-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.voting-header h1 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: #f0f0f0;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.election-info {
|
||||
background: #f9f9f9;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.election-info h2 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.election-info p {
|
||||
margin: 8px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.election-date {
|
||||
font-size: 0.9em;
|
||||
color: #999 !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.candidates-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.candidates-section h3 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ffebee;
|
||||
color: #d32f2f;
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: #c8e6c9;
|
||||
color: #2e7d32;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.candidates-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.candidate-card {
|
||||
background: #f5f5f5;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.candidate-card:hover {
|
||||
border-color: #667eea;
|
||||
background: #f0f4ff;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.candidate-card.selected {
|
||||
border-color: #4caf50;
|
||||
background: #f1f8e9;
|
||||
}
|
||||
|
||||
.candidate-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin: 0 auto 10px;
|
||||
}
|
||||
|
||||
.candidate-card h4 {
|
||||
margin: 10px 0 5px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.candidate-card p {
|
||||
margin: 0;
|
||||
color: #999;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.voting-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.submit-btn,
|
||||
.cancel-btn {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cancel-btn:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.cancel-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.voting-info {
|
||||
background: #e3f2fd;
|
||||
border: 1px solid #90caf9;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
color: #1565c0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.voting-info p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.voting-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.candidates-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.voting-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.voting-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
169
e-voting-system/frontend/src/pages/VotingPage.js
Normal file
169
e-voting-system/frontend/src/pages/VotingPage.js
Normal file
@ -0,0 +1,169 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import './VotingPage.css';
|
||||
|
||||
const VotingPage = ({ voter, onVoteSuccess }) => {
|
||||
const { electionId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [election, setElection] = useState(null);
|
||||
const [candidates, setCandidates] = useState([]);
|
||||
const [selectedCandidate, setSelectedCandidate] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch election details
|
||||
axios
|
||||
.get('http://localhost:8000/api/elections/active')
|
||||
.then((response) => {
|
||||
const elections = Array.isArray(response.data) ? response.data : [response.data];
|
||||
const selected = elections[0] || null;
|
||||
setElection(selected);
|
||||
|
||||
// For now, using mock candidates
|
||||
if (selected) {
|
||||
setCandidates([
|
||||
{ id: 1, name: 'Candidate A', electionId: selected.id },
|
||||
{ id: 2, name: 'Candidate B', electionId: selected.id },
|
||||
{ id: 3, name: 'Candidate C', electionId: selected.id },
|
||||
]);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error fetching election:', err);
|
||||
setError('Impossible de charger l\'élection');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleVote = async () => {
|
||||
if (!selectedCandidate || !election) {
|
||||
setError('Veuillez sélectionner un candidat');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await axios.post('http://localhost:8000/api/votes/submit', {
|
||||
voterId: voter.id || 1,
|
||||
electionId: election.id,
|
||||
candidateId: selectedCandidate,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// Show success message then redirect
|
||||
setTimeout(() => {
|
||||
navigate('/dashboard');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Error submitting vote:', err);
|
||||
setError(
|
||||
err.response?.data?.message ||
|
||||
'Erreur lors de la soumission du vote. Veuillez réessayer.'
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="voting-page"><p>Chargement...</p></div>;
|
||||
}
|
||||
|
||||
if (!election) {
|
||||
return (
|
||||
<div className="voting-page">
|
||||
<p>Aucune élection active disponible</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voting-page">
|
||||
<div className="voting-container">
|
||||
<div className="voting-header">
|
||||
<h1>Voter</h1>
|
||||
<button
|
||||
className="back-btn"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="election-info">
|
||||
<h2>{election.name}</h2>
|
||||
<p>{election.description}</p>
|
||||
<p className="election-date">
|
||||
Élection active jusqu'au: {new Date(election.endDate).toLocaleDateString('fr-FR')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="candidates-section">
|
||||
<h3>Sélectionnez votre candidat :</h3>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="candidates-grid">
|
||||
{candidates.map((candidate) => (
|
||||
<div
|
||||
key={candidate.id}
|
||||
className={`candidate-card ${selectedCandidate === candidate.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedCandidate(candidate.id)}
|
||||
>
|
||||
<div className="candidate-avatar">
|
||||
{candidate.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<h4>{candidate.name}</h4>
|
||||
<p>Candidat</p>
|
||||
{selectedCandidate === candidate.id && (
|
||||
<div className="checkmark">✓</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="success-message">
|
||||
✓ Vote enregistré avec succès! Redirection en cours...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="voting-actions">
|
||||
<button
|
||||
className="submit-btn"
|
||||
onClick={handleVote}
|
||||
disabled={!selectedCandidate || submitting}
|
||||
>
|
||||
{submitting ? 'Envoi en cours...' : 'Confirmer mon vote'}
|
||||
</button>
|
||||
<button
|
||||
className="cancel-btn"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
disabled={submitting}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="voting-info">
|
||||
<p>
|
||||
<strong>Importante:</strong> Votre vote est crypté et vos données sont protégées
|
||||
avec la cryptographie post-quantique de dernier cri.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VotingPage;
|
||||
261
e-voting-system/frontend/src/pages/VotingPage.jsx
Normal file
261
e-voting-system/frontend/src/pages/VotingPage.jsx
Normal file
@ -0,0 +1,261 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import Alert from '../components/Alert';
|
||||
import Modal from '../components/Modal';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import './VotingPage.css';
|
||||
|
||||
export default function VotingPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [vote, setVote] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedOption, setSelectedOption] = useState('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [voting, setVoting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVote();
|
||||
}, [id]);
|
||||
|
||||
const fetchVote = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`http://localhost:8000/elections/${id}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Vote non trouvé');
|
||||
|
||||
const data = await response.json();
|
||||
setVote(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoteSubmit = async () => {
|
||||
if (!selectedOption) {
|
||||
setError('Veuillez sélectionner une option');
|
||||
return;
|
||||
}
|
||||
|
||||
setShowConfirmModal(false);
|
||||
setVoting(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch('http://localhost:8000/votes/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
election_id: vote.id,
|
||||
choix: selectedOption,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la soumission du vote');
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
setSuccess('Votre vote a été enregistré avec succès!');
|
||||
setTimeout(() => navigate('/dashboard'), 2000);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setVoting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner fullscreen />;
|
||||
|
||||
if (!vote) {
|
||||
return (
|
||||
<div className="voting-page">
|
||||
<div className="container">
|
||||
<Alert type="error" message={error || 'Vote introuvable'} />
|
||||
<button onClick={() => navigate('/dashboard')} className="btn btn-secondary">
|
||||
<ArrowLeft size={20} />
|
||||
Retour au dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="voting-page">
|
||||
<div className="container">
|
||||
<div className="success-screen">
|
||||
<div className="success-icon">✓</div>
|
||||
<h1>Merci!</h1>
|
||||
<p>Votre vote a été enregistré avec succès.</p>
|
||||
<div className="success-details">
|
||||
<p><strong>Vote:</strong> {vote.titre}</p>
|
||||
<p><strong>Votre choix:</strong> {selectedOption}</p>
|
||||
<p className="success-note">
|
||||
Redirection vers le tableau de bord...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voting-page">
|
||||
<div className="container">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="back-button"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Retour
|
||||
</button>
|
||||
|
||||
<div className="vote-container">
|
||||
{/* Left Column - Vote Info */}
|
||||
<div className="vote-info-section">
|
||||
<div className="vote-header">
|
||||
<h1>{vote.titre}</h1>
|
||||
<div className="vote-meta">
|
||||
<span className="meta-item">
|
||||
<strong>Statut:</strong> {vote.status === 'actif' ? '🟢 OUVERT' : '🔴 TERMINÉ'}
|
||||
</span>
|
||||
<span className="meta-item">
|
||||
<strong>Participants:</strong> {vote.total_votes || 0} votes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vote-description">
|
||||
<h2>Description</h2>
|
||||
<p>{vote.description}</p>
|
||||
|
||||
{vote.context && (
|
||||
<div className="vote-context">
|
||||
<h3>Contexte</h3>
|
||||
<p>{vote.context}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vote.resultats && vote.status === 'ferme' && (
|
||||
<div className="vote-results">
|
||||
<h3>Résultats Finaux</h3>
|
||||
<div className="results-display">
|
||||
{Object.entries(vote.resultats).map(([option, count]) => (
|
||||
<div key={option} className="result-row">
|
||||
<span className="result-option">{option}</span>
|
||||
<div className="result-bar">
|
||||
<div
|
||||
className="result-bar-fill"
|
||||
style={{
|
||||
width: `${(count / (vote.total_votes || 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="result-percent">
|
||||
{Math.round((count / (vote.total_votes || 1)) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Voting Section */}
|
||||
<div className="vote-voting-section">
|
||||
<div className="voting-card">
|
||||
<h2>Voter</h2>
|
||||
|
||||
{error && (
|
||||
<Alert type="error" message={error} onClose={() => setError('')} />
|
||||
)}
|
||||
|
||||
{vote.status !== 'actif' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
title="Vote fermé"
|
||||
message="Ce vote n'est plus actif. Vous ne pouvez plus voter."
|
||||
/>
|
||||
)}
|
||||
|
||||
{vote.status === 'actif' && (
|
||||
<>
|
||||
<div className="voting-form">
|
||||
<p className="voting-question">
|
||||
<strong>Sélectionnez votre option:</strong>
|
||||
</p>
|
||||
|
||||
<div className="voting-options">
|
||||
{vote.options && vote.options.map((option) => (
|
||||
<label key={option} className="voting-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="vote"
|
||||
value={option}
|
||||
checked={selectedOption === option}
|
||||
onChange={(e) => setSelectedOption(e.target.value)}
|
||||
disabled={voting}
|
||||
/>
|
||||
<span className="option-text">{option}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-primary btn-lg btn-block"
|
||||
onClick={() => setShowConfirmModal(true)}
|
||||
disabled={!selectedOption || voting}
|
||||
>
|
||||
<CheckCircle size={20} />
|
||||
{voting ? 'Envoi en cours...' : 'Soumettre mon vote'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="voting-info">
|
||||
<AlertCircle size={18} />
|
||||
<p>
|
||||
Votre vote est <strong>final</strong> et ne peut pas être modifié après la soumission.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={showConfirmModal}
|
||||
title="Confirmez votre vote"
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
onConfirm={handleVoteSubmit}
|
||||
confirmText="Confirmer et Voter"
|
||||
cancelText="Annuler"
|
||||
>
|
||||
<div className="confirmation-content">
|
||||
<AlertCircle size={40} style={{ color: 'var(--warning-orange)' }} />
|
||||
<p className="confirmation-message">
|
||||
Êtes-vous sûr? Votre vote pour <strong>{selectedOption}</strong> sera définitif et ne pourra pas être modifié.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
e-voting-system/frontend/src/pages/index.js
Normal file
7
e-voting-system/frontend/src/pages/index.js
Normal file
@ -0,0 +1,7 @@
|
||||
export { default as HomePage } from './HomePage';
|
||||
export { default as LoginPage } from './LoginPage';
|
||||
export { default as RegisterPage } from './RegisterPage';
|
||||
export { default as DashboardPage } from './DashboardPage';
|
||||
export { default as VotingPage } from './VotingPage';
|
||||
export { default as ArchivesPage } from './ArchivesPage';
|
||||
export { default as ProfilePage } from './ProfilePage';
|
||||
350
e-voting-system/frontend/src/styles/components.css
Normal file
350
e-voting-system/frontend/src/styles/components.css
Normal file
@ -0,0 +1,350 @@
|
||||
/* ===== Buttons ===== */
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: var(--font-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Button Variants */
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-blue);
|
||||
color: var(--white);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--primary-dark);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--light-gray);
|
||||
color: var(--primary-blue);
|
||||
border: 2px solid var(--primary-blue);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--primary-blue);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success-green);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-red);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--warning-orange);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background-color: transparent;
|
||||
color: var(--primary-blue);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background-color: var(--light-gray);
|
||||
border-color: var(--primary-blue);
|
||||
}
|
||||
|
||||
/* Button Sizes */
|
||||
|
||||
.btn-sm {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ===== Forms ===== */
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-weight: 600;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="date"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border: 2px solid var(--border-gray);
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: var(--font-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="email"]:focus,
|
||||
input[type="password"]:focus,
|
||||
input[type="number"]:focus,
|
||||
input[type="date"]:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
cursor: pointer;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
accent-color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.checkbox-group,
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.checkbox-item,
|
||||
.radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-item input,
|
||||
.radio-item input {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--danger-red);
|
||||
font-size: 0.875rem;
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: var(--success-green);
|
||||
font-size: 0.875rem;
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* ===== Cards ===== */
|
||||
|
||||
.card {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: var(--spacing-lg);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border-gray);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
border-top: 1px solid var(--border-gray);
|
||||
padding-top: var(--spacing-lg);
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* ===== Alerts ===== */
|
||||
|
||||
.alert {
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #ecfdf5;
|
||||
color: #065f46;
|
||||
border-left: 4px solid var(--success-green);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #fef2f2;
|
||||
color: #7f1d1d;
|
||||
border-left: 4px solid var(--danger-red);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #fffbeb;
|
||||
color: #78350f;
|
||||
border-left: 4px solid var(--warning-orange);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #eff6ff;
|
||||
color: #0c2d6b;
|
||||
border-left: 4px solid var(--primary-blue);
|
||||
}
|
||||
|
||||
/* ===== Loading States ===== */
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid var(--border-gray);
|
||||
border-top-color: var(--primary-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* ===== Utility Classes ===== */
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-lg);
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.gap-sm {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.gap-md {
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.gap-lg {
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.mt-md {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.mb-md {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
180
e-voting-system/frontend/src/styles/globals.css
Normal file
180
e-voting-system/frontend/src/styles/globals.css
Normal file
@ -0,0 +1,180 @@
|
||||
/* ===== Global Styles ===== */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Palette de Couleurs Professionnelle */
|
||||
--primary-dark: #1e3a5f; /* Bleu foncé - Confiance */
|
||||
--primary-blue: #2563eb; /* Bleu vif */
|
||||
--primary-light: #3b82f6; /* Bleu clair */
|
||||
--success-green: #10b981; /* Vert succès */
|
||||
--warning-orange: #f97316; /* Orange action */
|
||||
--danger-red: #ef4444; /* Rouge danger */
|
||||
--dark-gray: #1f2937; /* Gris foncé texte */
|
||||
--light-gray: #f3f4f6; /* Gris clair fond */
|
||||
--border-gray: #e5e7eb; /* Gris bordure */
|
||||
--white: #ffffff; /* Blanc */
|
||||
|
||||
/* Typographie */
|
||||
--font-primary: "Inter", "Segoe UI", "Roboto", sans-serif;
|
||||
|
||||
/* Espacements */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-primary);
|
||||
background-color: var(--light-gray);
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.6;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* ===== Typography ===== */
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-blue);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Scrollbar ===== */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--light-gray);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-gray);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--dark-gray);
|
||||
}
|
||||
|
||||
/* ===== Animation ===== */
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
117
e-voting-system/frontend/src/utils/api.js
Normal file
117
e-voting-system/frontend/src/utils/api.js
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Utilitaires pour les appels API
|
||||
*/
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
export class APIClient {
|
||||
static getAuthHeader() {
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
};
|
||||
}
|
||||
|
||||
static async handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || `Erreur: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Auth
|
||||
static async register(first_name, last_name, citizen_id, email, password) {
|
||||
const response = await fetch(`${API_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: this.getAuthHeader(),
|
||||
body: JSON.stringify({
|
||||
first_name,
|
||||
last_name,
|
||||
citizen_id,
|
||||
email,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async login(email, password) {
|
||||
const response = await fetch(`${API_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: this.getAuthHeader(),
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async updateProfile(nom, email) {
|
||||
const response = await fetch(`${API_URL}/api/auth/profile`, {
|
||||
method: 'PUT',
|
||||
headers: this.getAuthHeader(),
|
||||
body: JSON.stringify({ nom, email }),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async changePassword(currentPassword, newPassword) {
|
||||
const response = await fetch(`${API_URL}/api/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: this.getAuthHeader(),
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
// Elections
|
||||
static async getElections(status = null) {
|
||||
const url = status
|
||||
? `${API_URL}/api/elections/?status=${status}`
|
||||
: `${API_URL}/api/elections/`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: this.getAuthHeader(),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async getElection(id) {
|
||||
const response = await fetch(`${API_URL}/api/elections/${id}`, {
|
||||
headers: this.getAuthHeader(),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
// Votes
|
||||
static async submitVote(electionId, choix) {
|
||||
const response = await fetch(`${API_URL}/api/votes/submit`, {
|
||||
method: 'POST',
|
||||
headers: this.getAuthHeader(),
|
||||
body: JSON.stringify({
|
||||
election_id: electionId,
|
||||
choix,
|
||||
}),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async getMyVotes() {
|
||||
const response = await fetch(`${API_URL}/api/votes/my-votes`, {
|
||||
headers: this.getAuthHeader(),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async getResults(electionId) {
|
||||
const response = await fetch(`${API_URL}/api/elections/${electionId}/results`, {
|
||||
headers: this.getAuthHeader(),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
export default APIClient;
|
||||
20
e-voting-system/frontend/start.sh
Normal file
20
e-voting-system/frontend/start.sh
Normal file
@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script de démarrage du frontend
|
||||
|
||||
echo "🚀 Démarrage du frontend E-Voting..."
|
||||
echo ""
|
||||
|
||||
# Vérifier si node_modules existe
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "📦 Installation des dépendances..."
|
||||
npm install
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Démarrer l'application
|
||||
echo "✨ Démarrage du serveur de développement..."
|
||||
echo "📍 L'application sera disponible sur http://localhost:3000"
|
||||
echo ""
|
||||
|
||||
npm start
|
||||
@ -1,5 +0,0 @@
|
||||
"""
|
||||
Backend package.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@ -1,58 +0,0 @@
|
||||
"""
|
||||
Utilitaires pour l'authentification et les tokens JWT.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
import bcrypt
|
||||
from .config import settings
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hacher un mot de passe avec bcrypt"""
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(password.encode(), salt).decode()
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Vérifier un mot de passe"""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode(),
|
||||
hashed_password.encode()
|
||||
)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Créer un token JWT"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(
|
||||
minutes=settings.access_token_expire_minutes
|
||||
)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.secret_key,
|
||||
algorithm=settings.algorithm
|
||||
)
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[dict]:
|
||||
"""Vérifier et décoder un token JWT"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.secret_key,
|
||||
algorithms=[settings.algorithm]
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
@ -1,50 +0,0 @@
|
||||
"""
|
||||
Configuration de l'application FastAPI.
|
||||
"""
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuration globale de l'application"""
|
||||
|
||||
# Base de données
|
||||
db_host: str = os.getenv("DB_HOST", "localhost")
|
||||
db_port: int = int(os.getenv("DB_PORT", "3306"))
|
||||
db_name: str = os.getenv("DB_NAME", "evoting_db")
|
||||
db_user: str = os.getenv("DB_USER", "evoting_user")
|
||||
db_password: str = os.getenv("DB_PASSWORD", "evoting_pass123")
|
||||
|
||||
# Sécurité
|
||||
secret_key: str = os.getenv(
|
||||
"SECRET_KEY",
|
||||
"your-secret-key-change-in-production-12345"
|
||||
)
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 30
|
||||
|
||||
# Application
|
||||
debug: bool = os.getenv("DEBUG", "false").lower() == "true"
|
||||
app_name: str = "E-Voting System API"
|
||||
app_version: str = "0.1.0"
|
||||
|
||||
# Cryptographie
|
||||
elgamal_p: int = 23 # Nombre premier (prototype)
|
||||
elgamal_g: int = 5 # Générateur
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
extra = "ignore" # Ignorer les variables d'env non définies
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Construire l'URL de connection à la base de données"""
|
||||
return (
|
||||
f"mysql+pymysql://{self.db_user}:{self.db_password}"
|
||||
f"@{self.db_host}:{self.db_port}/{self.db_name}"
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@ -1,25 +0,0 @@
|
||||
"""
|
||||
Configuration de la base de données SQLAlchemy.
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from .config import settings
|
||||
|
||||
# Créer l'engine
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20
|
||||
)
|
||||
|
||||
# Créer la session factory
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialiser la base de données (créer les tables)"""
|
||||
from .models import Base
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@ -1,57 +0,0 @@
|
||||
"""
|
||||
Dépendances FastAPI pour injection et authentification.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from . import models
|
||||
from .auth import verify_token
|
||||
from .database import SessionLocal
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dépendance pour obtenir une session de base de données"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def get_current_voter(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: Session = Depends(get_db)
|
||||
) -> models.Voter:
|
||||
"""
|
||||
Dépendance pour récupérer l'électeur actuel.
|
||||
Valide le token JWT et retourne l'électeur.
|
||||
"""
|
||||
payload = verify_token(token)
|
||||
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token"
|
||||
)
|
||||
|
||||
voter_id = payload.get("voter_id")
|
||||
if voter_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
voter = db.query(models.Voter).filter(
|
||||
models.Voter.id == voter_id
|
||||
).first()
|
||||
|
||||
if voter is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Voter not found"
|
||||
)
|
||||
|
||||
return voter
|
||||
@ -1,47 +0,0 @@
|
||||
"""
|
||||
Application FastAPI principale.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from .config import settings
|
||||
from .database import init_db
|
||||
from .routes import router
|
||||
|
||||
# Initialiser la base de données
|
||||
init_db()
|
||||
|
||||
# Créer l'application FastAPI
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version=settings.app_version,
|
||||
debug=settings.debug
|
||||
)
|
||||
|
||||
# Configuration CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # À restreindre en production
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Inclure les routes
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Vérifier l'état de l'application"""
|
||||
return {"status": "ok", "version": settings.app_version}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Endpoint root"""
|
||||
return {
|
||||
"name": settings.app_name,
|
||||
"version": settings.app_version,
|
||||
"docs": "/docs"
|
||||
}
|
||||
@ -1,124 +0,0 @@
|
||||
"""
|
||||
Modèles de données SQLAlchemy pour la persistance.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Text, LargeBinary
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class Voter(Base):
|
||||
"""Électeur - Enregistrement et authentification"""
|
||||
__tablename__ = "voters"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
first_name = Column(String(100))
|
||||
last_name = Column(String(100))
|
||||
citizen_id = Column(String(50), unique=True) # Identifiant unique (CNI)
|
||||
|
||||
# Sécurité
|
||||
public_key = Column(LargeBinary) # Clé publique ElGamal
|
||||
has_voted = Column(Boolean, default=False)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relations
|
||||
votes = relationship("Vote", back_populates="voter")
|
||||
|
||||
|
||||
class Election(Base):
|
||||
"""Élection - Configuration et paramètres"""
|
||||
__tablename__ = "elections"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text)
|
||||
|
||||
# Dates
|
||||
start_date = Column(DateTime, nullable=False)
|
||||
end_date = Column(DateTime, nullable=False)
|
||||
|
||||
# Paramètres cryptographiques
|
||||
elgamal_p = Column(Integer) # Nombre premier
|
||||
elgamal_g = Column(Integer) # Générateur
|
||||
public_key = Column(LargeBinary) # Clé publique pour chiffrement
|
||||
|
||||
# État
|
||||
is_active = Column(Boolean, default=True)
|
||||
results_published = Column(Boolean, default=False)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relations
|
||||
candidates = relationship("Candidate", back_populates="election")
|
||||
votes = relationship("Vote", back_populates="election")
|
||||
|
||||
|
||||
class Candidate(Base):
|
||||
"""Candidat - Options de vote"""
|
||||
__tablename__ = "candidates"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
election_id = Column(Integer, ForeignKey("elections.id"), nullable=False)
|
||||
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text)
|
||||
order = Column(Integer) # Ordre d'affichage
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relations
|
||||
election = relationship("Election", back_populates="candidates")
|
||||
votes = relationship("Vote", back_populates="candidate")
|
||||
|
||||
|
||||
class Vote(Base):
|
||||
"""Vote - Bulletin chiffré"""
|
||||
__tablename__ = "votes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
voter_id = Column(Integer, ForeignKey("voters.id"), nullable=False)
|
||||
election_id = Column(Integer, ForeignKey("elections.id"), nullable=False)
|
||||
candidate_id = Column(Integer, ForeignKey("candidates.id"), nullable=False)
|
||||
|
||||
# Vote chiffré avec ElGamal
|
||||
encrypted_vote = Column(LargeBinary, nullable=False) # Ciphertext ElGamal
|
||||
|
||||
# Preuves
|
||||
zero_knowledge_proof = Column(LargeBinary) # ZK proof que le vote est valide
|
||||
ballot_hash = Column(String(64)) # Hash du bulletin pour traçabilité
|
||||
|
||||
# Métadonnées
|
||||
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||
ip_address = Column(String(45)) # IPv4 ou IPv6
|
||||
|
||||
# Relations
|
||||
voter = relationship("Voter", back_populates="votes")
|
||||
election = relationship("Election", back_populates="votes")
|
||||
candidate = relationship("Candidate", back_populates="votes")
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Journal d'audit pour la sécurité"""
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
action = Column(String(100), nullable=False)
|
||||
description = Column(Text)
|
||||
|
||||
# Qui
|
||||
user_id = Column(Integer, ForeignKey("voters.id"))
|
||||
|
||||
# Quand
|
||||
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Métadonnées
|
||||
ip_address = Column(String(45))
|
||||
user_agent = Column(String(255))
|
||||
@ -1,13 +0,0 @@
|
||||
"""
|
||||
Routes du backend.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from . import auth, elections, votes
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(auth.router)
|
||||
router.include_router(elections.router)
|
||||
router.include_router(votes.router)
|
||||
|
||||
__all__ = ["router"]
|
||||
@ -1,66 +0,0 @@
|
||||
"""
|
||||
Routes pour l'authentification et les électeurs.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from .. import schemas, services
|
||||
from ..auth import create_access_token
|
||||
from ..dependencies import get_db, get_current_voter
|
||||
from ..models import Voter
|
||||
from datetime import timedelta
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=schemas.VoterProfile)
|
||||
def register(voter_data: schemas.VoterRegister, db: Session = Depends(get_db)):
|
||||
"""Enregistrer un nouvel électeur"""
|
||||
|
||||
# Vérifier que l'email n'existe pas déjà
|
||||
existing_voter = services.VoterService.get_voter_by_email(db, voter_data.email)
|
||||
if existing_voter:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Créer le nouvel électeur
|
||||
voter = services.VoterService.create_voter(db, voter_data)
|
||||
|
||||
return voter
|
||||
|
||||
|
||||
@router.post("/login", response_model=schemas.TokenResponse)
|
||||
def login(credentials: schemas.VoterLogin, db: Session = Depends(get_db)):
|
||||
"""Authentifier un électeur et retourner un token"""
|
||||
|
||||
voter = services.VoterService.verify_voter_credentials(
|
||||
db,
|
||||
credentials.email,
|
||||
credentials.password
|
||||
)
|
||||
|
||||
if not voter:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password"
|
||||
)
|
||||
|
||||
# Créer le token JWT
|
||||
access_token_expires = timedelta(minutes=30)
|
||||
access_token = create_access_token(
|
||||
data={"voter_id": voter.id},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return schemas.TokenResponse(
|
||||
access_token=access_token,
|
||||
expires_in=30 * 60 # en secondes
|
||||
)
|
||||
|
||||
|
||||
@router.get("/profile", response_model=schemas.VoterProfile)
|
||||
def get_profile(current_voter: Voter = Depends(get_current_voter)):
|
||||
"""Récupérer le profil de l'électeur actuel"""
|
||||
return current_voter
|
||||
@ -1,104 +0,0 @@
|
||||
"""
|
||||
Routes pour les élections et les candidats.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from .. import schemas, services
|
||||
from ..dependencies import get_db, get_current_voter
|
||||
from ..models import Voter
|
||||
|
||||
router = APIRouter(prefix="/api/elections", tags=["elections"])
|
||||
|
||||
|
||||
@router.get("/active", response_model=schemas.ElectionResponse)
|
||||
def get_active_election(db: Session = Depends(get_db)):
|
||||
"""Récupérer l'élection active en cours"""
|
||||
|
||||
election = services.ElectionService.get_active_election(db)
|
||||
|
||||
if not election:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No active election"
|
||||
)
|
||||
|
||||
return election
|
||||
|
||||
|
||||
@router.get("/{election_id}", response_model=schemas.ElectionResponse)
|
||||
def get_election(election_id: int, db: Session = Depends(get_db)):
|
||||
"""Récupérer une élection par son ID"""
|
||||
|
||||
election = services.ElectionService.get_election(db, election_id)
|
||||
|
||||
if not election:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Election not found"
|
||||
)
|
||||
|
||||
return election
|
||||
|
||||
|
||||
@router.get("/{election_id}/results", response_model=schemas.ElectionResultResponse)
|
||||
def get_election_results(
|
||||
election_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Récupérer les résultats d'une élection.
|
||||
Disponible après la fermeture du scrutin.
|
||||
"""
|
||||
|
||||
election = services.ElectionService.get_election(db, election_id)
|
||||
|
||||
if not election:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Election not found"
|
||||
)
|
||||
|
||||
if not election.results_published:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Results not yet published"
|
||||
)
|
||||
|
||||
results = services.VoteService.get_election_results(db, election_id)
|
||||
|
||||
return schemas.ElectionResultResponse(
|
||||
election_id=election.id,
|
||||
election_name=election.name,
|
||||
total_votes=sum(r.vote_count for r in results),
|
||||
results=results
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{election_id}/publish-results")
|
||||
def publish_results(
|
||||
election_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Publier les résultats d'une élection (admin only).
|
||||
À utiliser après la fermeture du scrutin.
|
||||
"""
|
||||
|
||||
election = services.ElectionService.get_election(db, election_id)
|
||||
|
||||
if not election:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Election not found"
|
||||
)
|
||||
|
||||
# Marquer les résultats comme publiés
|
||||
election.results_published = True
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Results published successfully",
|
||||
"election_id": election.id,
|
||||
"election_name": election.name
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
"""
|
||||
Routes pour le vote et les bulletins.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
import base64
|
||||
from .. import schemas, services
|
||||
from ..dependencies import get_db, get_current_voter
|
||||
from ..models import Voter
|
||||
from ...crypto.hashing import SecureHash
|
||||
|
||||
router = APIRouter(prefix="/api/votes", tags=["votes"])
|
||||
|
||||
|
||||
@router.post("/submit", response_model=schemas.VoteResponse)
|
||||
async def submit_vote(
|
||||
vote_bulletin: schemas.VoteBulletin,
|
||||
current_voter: Voter = Depends(get_current_voter),
|
||||
db: Session = Depends(get_db),
|
||||
request: Request = None
|
||||
):
|
||||
"""
|
||||
Soumettre un vote chiffré.
|
||||
|
||||
Le vote doit être:
|
||||
- Chiffré avec ElGamal
|
||||
- Accompagné d'une preuve ZK de validité
|
||||
"""
|
||||
|
||||
# Vérifier que l'électeur n'a pas déjà voté
|
||||
if services.VoteService.has_voter_voted(
|
||||
db,
|
||||
current_voter.id,
|
||||
vote_bulletin.election_id
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Voter has already voted in this election"
|
||||
)
|
||||
|
||||
# Vérifier que l'élection existe
|
||||
election = services.ElectionService.get_election(
|
||||
db,
|
||||
vote_bulletin.election_id
|
||||
)
|
||||
if not election:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Election not found"
|
||||
)
|
||||
|
||||
# Vérifier que le candidat existe
|
||||
from ..models import Candidate
|
||||
candidate = db.query(Candidate).filter(
|
||||
Candidate.id == vote_bulletin.candidate_id,
|
||||
Candidate.election_id == vote_bulletin.election_id
|
||||
).first()
|
||||
if not candidate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Candidate not found"
|
||||
)
|
||||
|
||||
# Décoder le vote chiffré
|
||||
try:
|
||||
encrypted_vote_bytes = base64.b64decode(vote_bulletin.encrypted_vote)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid encrypted vote format"
|
||||
)
|
||||
|
||||
# Générer le hash du bulletin
|
||||
import time
|
||||
ballot_hash = SecureHash.hash_bulletin(
|
||||
vote_id=current_voter.id,
|
||||
candidate_id=vote_bulletin.candidate_id,
|
||||
timestamp=int(time.time())
|
||||
)
|
||||
|
||||
# Enregistrer le vote
|
||||
vote = services.VoteService.record_vote(
|
||||
db=db,
|
||||
voter_id=current_voter.id,
|
||||
election_id=vote_bulletin.election_id,
|
||||
candidate_id=vote_bulletin.candidate_id,
|
||||
encrypted_vote=encrypted_vote_bytes,
|
||||
ballot_hash=ballot_hash,
|
||||
ip_address=request.client.host if request else None
|
||||
)
|
||||
|
||||
# Marquer l'électeur comme ayant voté
|
||||
services.VoterService.mark_as_voted(db, current_voter.id)
|
||||
|
||||
return schemas.VoteResponse(
|
||||
id=vote.id,
|
||||
ballot_hash=ballot_hash,
|
||||
timestamp=vote.timestamp
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def get_vote_status(
|
||||
election_id: int,
|
||||
current_voter: Voter = Depends(get_current_voter),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Vérifier si l'électeur a déjà voté pour une élection"""
|
||||
|
||||
has_voted = services.VoteService.has_voter_voted(
|
||||
db,
|
||||
current_voter.id,
|
||||
election_id
|
||||
)
|
||||
|
||||
return {"has_voted": has_voted}
|
||||
@ -1,98 +0,0 @@
|
||||
"""
|
||||
Schémas Pydantic pour validation des requêtes/réponses.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class VoterRegister(BaseModel):
|
||||
"""Enregistrement d'un électeur"""
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8)
|
||||
first_name: str
|
||||
last_name: str
|
||||
citizen_id: str # Identifiant unique (CNI)
|
||||
|
||||
|
||||
class VoterLogin(BaseModel):
|
||||
"""Authentification"""
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Réponse d'authentification"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
|
||||
|
||||
class VoterProfile(BaseModel):
|
||||
"""Profil d'un électeur"""
|
||||
id: int
|
||||
email: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
has_voted: bool
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CandidateResponse(BaseModel):
|
||||
"""Candidat"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
order: int
|
||||
|
||||
|
||||
class ElectionResponse(BaseModel):
|
||||
"""Élection avec candidats"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
is_active: bool
|
||||
results_published: bool
|
||||
candidates: List[CandidateResponse]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class VoteBulletin(BaseModel):
|
||||
"""Bulletin de vote à soumettre"""
|
||||
election_id: int
|
||||
candidate_id: int
|
||||
encrypted_vote: str # Base64 du Ciphertext ElGamal
|
||||
zero_knowledge_proof: Optional[str] = None # Base64 de la preuve ZK
|
||||
|
||||
|
||||
class VoteResponse(BaseModel):
|
||||
"""Confirmaction de vote"""
|
||||
id: int
|
||||
ballot_hash: str
|
||||
timestamp: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ResultResponse(BaseModel):
|
||||
"""Résultat de l'élection"""
|
||||
candidate_name: str
|
||||
vote_count: int
|
||||
percentage: float
|
||||
|
||||
|
||||
class ElectionResultResponse(BaseModel):
|
||||
"""Résultats complets d'une élection"""
|
||||
election_id: int
|
||||
election_name: str
|
||||
total_votes: int
|
||||
results: List[ResultResponse]
|
||||
@ -1,153 +0,0 @@
|
||||
"""
|
||||
Service de base de données - Opérations CRUD.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from . import models, schemas
|
||||
from .auth import hash_password, verify_password
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class VoterService:
|
||||
"""Service pour gérer les électeurs"""
|
||||
|
||||
@staticmethod
|
||||
def create_voter(db: Session, voter: schemas.VoterRegister) -> models.Voter:
|
||||
"""Créer un nouvel électeur"""
|
||||
db_voter = models.Voter(
|
||||
email=voter.email,
|
||||
first_name=voter.first_name,
|
||||
last_name=voter.last_name,
|
||||
citizen_id=voter.citizen_id,
|
||||
password_hash=hash_password(voter.password)
|
||||
)
|
||||
db.add(db_voter)
|
||||
db.commit()
|
||||
db.refresh(db_voter)
|
||||
return db_voter
|
||||
|
||||
@staticmethod
|
||||
def get_voter_by_email(db: Session, email: str) -> models.Voter:
|
||||
"""Récupérer un électeur par email"""
|
||||
return db.query(models.Voter).filter(
|
||||
models.Voter.email == email
|
||||
).first()
|
||||
|
||||
@staticmethod
|
||||
def verify_voter_credentials(
|
||||
db: Session,
|
||||
email: str,
|
||||
password: str
|
||||
) -> models.Voter:
|
||||
"""Vérifier les identifiants et retourner l'électeur"""
|
||||
voter = VoterService.get_voter_by_email(db, email)
|
||||
if not voter:
|
||||
return None
|
||||
if not verify_password(password, voter.password_hash):
|
||||
return None
|
||||
return voter
|
||||
|
||||
@staticmethod
|
||||
def mark_as_voted(db: Session, voter_id: int) -> None:
|
||||
"""Marquer l'électeur comme ayant voté"""
|
||||
voter = db.query(models.Voter).filter(
|
||||
models.Voter.id == voter_id
|
||||
).first()
|
||||
if voter:
|
||||
voter.has_voted = True
|
||||
voter.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
class ElectionService:
|
||||
"""Service pour gérer les élections"""
|
||||
|
||||
@staticmethod
|
||||
def get_active_election(db: Session) -> models.Election:
|
||||
"""Récupérer l'élection active"""
|
||||
now = datetime.utcnow()
|
||||
return db.query(models.Election).filter(
|
||||
models.Election.is_active == True,
|
||||
models.Election.start_date <= now,
|
||||
models.Election.end_date > now
|
||||
).first()
|
||||
|
||||
@staticmethod
|
||||
def get_election(db: Session, election_id: int) -> models.Election:
|
||||
"""Récupérer une élection par ID"""
|
||||
return db.query(models.Election).filter(
|
||||
models.Election.id == election_id
|
||||
).first()
|
||||
|
||||
|
||||
class VoteService:
|
||||
"""Service pour gérer les votes"""
|
||||
|
||||
@staticmethod
|
||||
def record_vote(
|
||||
db: Session,
|
||||
voter_id: int,
|
||||
election_id: int,
|
||||
candidate_id: int,
|
||||
encrypted_vote: bytes,
|
||||
ballot_hash: str,
|
||||
ip_address: str = None
|
||||
) -> models.Vote:
|
||||
"""Enregistrer un vote chiffré"""
|
||||
db_vote = models.Vote(
|
||||
voter_id=voter_id,
|
||||
election_id=election_id,
|
||||
candidate_id=candidate_id,
|
||||
encrypted_vote=encrypted_vote,
|
||||
ballot_hash=ballot_hash,
|
||||
ip_address=ip_address,
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
db.add(db_vote)
|
||||
db.commit()
|
||||
db.refresh(db_vote)
|
||||
return db_vote
|
||||
|
||||
@staticmethod
|
||||
def has_voter_voted(
|
||||
db: Session,
|
||||
voter_id: int,
|
||||
election_id: int
|
||||
) -> bool:
|
||||
"""Vérifier si l'électeur a déjà voté"""
|
||||
vote = db.query(models.Vote).filter(
|
||||
models.Vote.voter_id == voter_id,
|
||||
models.Vote.election_id == election_id
|
||||
).first()
|
||||
return vote is not None
|
||||
|
||||
@staticmethod
|
||||
def get_election_results(
|
||||
db: Session,
|
||||
election_id: int
|
||||
) -> list[schemas.ResultResponse]:
|
||||
"""Calculer les résultats d'une élection"""
|
||||
results = db.query(
|
||||
models.Candidate.name,
|
||||
func.count(models.Vote.id).label("vote_count")
|
||||
).join(
|
||||
models.Vote,
|
||||
models.Candidate.id == models.Vote.candidate_id
|
||||
).filter(
|
||||
models.Vote.election_id == election_id
|
||||
).group_by(
|
||||
models.Candidate.id,
|
||||
models.Candidate.name
|
||||
).all()
|
||||
|
||||
total_votes = sum(r.vote_count for r in results)
|
||||
|
||||
return [
|
||||
schemas.ResultResponse(
|
||||
candidate_name=r.name,
|
||||
vote_count=r.vote_count,
|
||||
percentage=(r.vote_count / total_votes * 100) if total_votes > 0 else 0
|
||||
)
|
||||
for r in results
|
||||
]
|
||||
@ -1,26 +0,0 @@
|
||||
"""
|
||||
Module de cryptographie pour le système de vote électronique.
|
||||
Implémente les primitives cryptographiques fondamentales et post-quantiques.
|
||||
"""
|
||||
|
||||
from .encryption import (
|
||||
ElGamalEncryption,
|
||||
HomomorphicEncryption,
|
||||
SymmetricEncryption,
|
||||
)
|
||||
from .signatures import DigitalSignature
|
||||
from .zk_proofs import ZKProof
|
||||
from .hashing import SecureHash
|
||||
from .pqc_hybrid import PostQuantumCryptography
|
||||
|
||||
__all__ = [
|
||||
"ElGamalEncryption",
|
||||
"HomomorphicEncryption",
|
||||
"SymmetricEncryption",
|
||||
"DigitalSignature",
|
||||
"ZKProof",
|
||||
"SecureHash",
|
||||
"PostQuantumCryptography", # Post-Quantum Cryptography Hybride
|
||||
|
||||
"SecureHash",
|
||||
]
|
||||
@ -1,162 +0,0 @@
|
||||
"""
|
||||
Primitives de chiffrement : ElGamal, chiffrement homomorphe, AES.
|
||||
"""
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
import os
|
||||
from typing import Tuple
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
|
||||
|
||||
@dataclass
|
||||
class PublicKey:
|
||||
"""Clé publique ElGamal"""
|
||||
p: int # Nombre premier
|
||||
g: int # Générateur du groupe
|
||||
h: int # Clé publique = g^x mod p
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrivateKey:
|
||||
"""Clé privée ElGamal"""
|
||||
x: int # Clé privée
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ciphertext:
|
||||
"""Texte chiffré ElGamal"""
|
||||
c1: int # c1 = g^r mod p
|
||||
c2: int # c2 = m * h^r mod p
|
||||
|
||||
|
||||
class ElGamalEncryption:
|
||||
"""
|
||||
Chiffrement ElGamal - Fondamental pour le vote électronique.
|
||||
Propriétés:
|
||||
- Sémantiquement sûr (IND-CPA)
|
||||
- Chiffrement probabiliste
|
||||
- Support pour preuves ZK
|
||||
"""
|
||||
|
||||
def __init__(self, p: int = None, g: int = None):
|
||||
"""
|
||||
Initialiser ElGamal avec paramètres de groupe.
|
||||
Pour le prototype, utilise des paramètres de test.
|
||||
"""
|
||||
if p is None:
|
||||
# Nombres premiers de test (petits, pour prototype)
|
||||
# En production: nombres premiers cryptographiques forts (2048+ bits)
|
||||
self.p = 23 # Nombre premier
|
||||
self.g = 5 # Générateur
|
||||
else:
|
||||
self.p = p
|
||||
self.g = g
|
||||
|
||||
def generate_keypair(self) -> Tuple[PublicKey, PrivateKey]:
|
||||
"""Générer une paire de clés ElGamal"""
|
||||
import random
|
||||
x = random.randint(2, self.p - 2) # Clé privée
|
||||
h = pow(self.g, x, self.p) # Clé publique: g^x mod p
|
||||
|
||||
public = PublicKey(p=self.p, g=self.g, h=h)
|
||||
private = PrivateKey(x=x)
|
||||
|
||||
return public, private
|
||||
|
||||
def encrypt(self, public_key: PublicKey, message: int) -> Ciphertext:
|
||||
"""
|
||||
Chiffrer un message avec ElGamal.
|
||||
message: nombre entre 0 et p-1
|
||||
"""
|
||||
import random
|
||||
r = random.randint(2, self.p - 2) # Aléa
|
||||
|
||||
c1 = pow(self.g, r, self.p) # c1 = g^r mod p
|
||||
c2 = (message * pow(public_key.h, r, self.p)) % self.p # c2 = m * h^r mod p
|
||||
|
||||
return Ciphertext(c1=c1, c2=c2)
|
||||
|
||||
def decrypt(self, private_key: PrivateKey, ciphertext: Ciphertext, p: int) -> int:
|
||||
"""Déchiffrer un message ElGamal"""
|
||||
# m = c2 / c1^x mod p = c2 * (c1^x)^(-1) mod p
|
||||
shared_secret = pow(ciphertext.c1, private_key.x, p)
|
||||
shared_secret_inv = pow(shared_secret, -1, p)
|
||||
message = (ciphertext.c2 * shared_secret_inv) % p
|
||||
|
||||
return message
|
||||
|
||||
def add_ciphertexts(self, ct1: Ciphertext, ct2: Ciphertext, p: int) -> Ciphertext:
|
||||
"""
|
||||
Addition homomorphe : E(m1) * E(m2) = E(m1 + m2)
|
||||
Propriété clé pour les dépouillement sécurisé
|
||||
"""
|
||||
c1_sum = (ct1.c1 * ct2.c1) % p
|
||||
c2_sum = (ct1.c2 * ct2.c2) % p
|
||||
return Ciphertext(c1=c1_sum, c2=c2_sum)
|
||||
|
||||
|
||||
class HomomorphicEncryption:
|
||||
"""
|
||||
Chiffrement homomorphe - Paillier-like pour vote.
|
||||
Support l'addition sans déchiffrement.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.elgamal = ElGamalEncryption()
|
||||
|
||||
def sum_encrypted_votes(self, ciphertexts: list[Ciphertext], p: int) -> Ciphertext:
|
||||
"""
|
||||
Additionner les votes chiffrés sans les déchiffrer.
|
||||
C'est la base du dépouillement sécurisé.
|
||||
"""
|
||||
result = ciphertexts[0]
|
||||
for ct in ciphertexts[1:]:
|
||||
result = self.elgamal.add_ciphertexts(result, ct, p)
|
||||
return result
|
||||
|
||||
|
||||
class SymmetricEncryption:
|
||||
"""
|
||||
Chiffrement symétrique AES-256 pour données sensibles.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def generate_key() -> bytes:
|
||||
"""Générer une clé AES-256 aléatoire"""
|
||||
return os.urandom(32)
|
||||
|
||||
@staticmethod
|
||||
def encrypt(key: bytes, plaintext: bytes) -> bytes:
|
||||
"""Chiffrer avec AES-256-GCM"""
|
||||
iv = os.urandom(16)
|
||||
cipher = Cipher(
|
||||
algorithms.AES(key),
|
||||
modes.GCM(iv),
|
||||
backend=default_backend()
|
||||
)
|
||||
encryptor = cipher.encryptor()
|
||||
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
|
||||
|
||||
# Retourner IV || tag || ciphertext
|
||||
return iv + encryptor.tag + ciphertext
|
||||
|
||||
@staticmethod
|
||||
def decrypt(key: bytes, encrypted_data: bytes) -> bytes:
|
||||
"""Déchiffrer AES-256-GCM"""
|
||||
iv = encrypted_data[:16]
|
||||
tag = encrypted_data[16:32]
|
||||
ciphertext = encrypted_data[32:]
|
||||
|
||||
cipher = Cipher(
|
||||
algorithms.AES(key),
|
||||
modes.GCM(iv, tag),
|
||||
backend=default_backend()
|
||||
)
|
||||
decryptor = cipher.decryptor()
|
||||
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
|
||||
|
||||
return plaintext
|
||||
@ -1,76 +0,0 @@
|
||||
"""
|
||||
Fonctions de hachage cryptographique pour intégrité et dérivation de clés.
|
||||
"""
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from typing import Tuple
|
||||
import os
|
||||
|
||||
|
||||
class SecureHash:
|
||||
"""
|
||||
Hachage cryptographique sécurisé avec SHA-256.
|
||||
Utilisé pour:
|
||||
- Vérifier l'intégrité des données
|
||||
- Dériver des clés
|
||||
- Identifier les votes
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def sha256(data: bytes) -> bytes:
|
||||
"""Calculer le hash SHA-256"""
|
||||
digest = hashes.Hash(
|
||||
hashes.SHA256(),
|
||||
backend=default_backend()
|
||||
)
|
||||
digest.update(data)
|
||||
return digest.finalize()
|
||||
|
||||
@staticmethod
|
||||
def sha256_hex(data: bytes) -> str:
|
||||
"""SHA-256 en hexadécimal"""
|
||||
return SecureHash.sha256(data).hex()
|
||||
|
||||
@staticmethod
|
||||
def derive_key(password: bytes, salt: bytes = None, length: int = 32) -> Tuple[bytes, bytes]:
|
||||
"""
|
||||
Dériver une clé à partir d'un mot de passe avec PBKDF2.
|
||||
|
||||
Returns:
|
||||
(key, salt) - salt pour stocker et retrouver la clé
|
||||
"""
|
||||
if salt is None:
|
||||
salt = os.urandom(16)
|
||||
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=length,
|
||||
salt=salt,
|
||||
iterations=100000,
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
key = kdf.derive(password)
|
||||
return key, salt
|
||||
|
||||
@staticmethod
|
||||
def hash_bulletin(vote_id: int, candidate_id: int, timestamp: int) -> str:
|
||||
"""
|
||||
Générer un identifiant unique pour un bulletin.
|
||||
Utilisé pour l'anonymat + traçabilité.
|
||||
"""
|
||||
data = f"{vote_id}:{candidate_id}:{timestamp}".encode()
|
||||
return SecureHash.sha256_hex(data)
|
||||
|
||||
@staticmethod
|
||||
def hash_vote_commitment(encrypted_vote: bytes, random_salt: bytes) -> str:
|
||||
"""
|
||||
Hash d'un vote chiffré pour commitments.
|
||||
"""
|
||||
combined = encrypted_vote + random_salt
|
||||
return SecureHash.sha256_hex(combined)
|
||||
|
||||
|
||||
from typing import Tuple
|
||||
@ -1,279 +0,0 @@
|
||||
"""
|
||||
Cryptographie Post-Quantique Hybride - Standards NIST FIPS 203/204/205
|
||||
|
||||
Combines classical et quantum-resistant cryptography:
|
||||
- Chiffrement: ElGamal (classique) + Kyber (post-quantique)
|
||||
- Signatures: RSA-PSS (classique) + Dilithium (post-quantique)
|
||||
- Hachage: SHA-256 (résistant aux ordinateurs quantiques pour préimage)
|
||||
|
||||
Cette approche hybride garantit que même si l'un des systèmes est cassé,
|
||||
l'autre reste sûr (defense-in-depth).
|
||||
"""
|
||||
|
||||
try:
|
||||
import oqs
|
||||
HAS_OQS = True
|
||||
except ImportError:
|
||||
HAS_OQS = False
|
||||
|
||||
import os
|
||||
from typing import Tuple, Dict, Any
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from .encryption import ElGamalEncryption
|
||||
from .hashing import SecureHash
|
||||
|
||||
|
||||
class PostQuantumCryptography:
|
||||
"""
|
||||
Implémentation hybride de cryptographie post-quantique.
|
||||
Utilise les standards NIST FIPS 203/204/205.
|
||||
"""
|
||||
|
||||
# Algorithmes post-quantiques certifiés NIST
|
||||
PQC_SIGN_ALG = "ML-DSA-65" # FIPS 204 - Dilithium variant
|
||||
PQC_KEM_ALG = "ML-KEM-768" # FIPS 203 - Kyber variant
|
||||
|
||||
@staticmethod
|
||||
def generate_hybrid_keypair() -> Dict[str, Any]:
|
||||
"""
|
||||
Générer une paire de clés hybride:
|
||||
- Clés RSA classiques + clés Dilithium PQC
|
||||
- Clés ElGamal classiques + clés Kyber PQC
|
||||
|
||||
Returns:
|
||||
Dict contenant toutes les clés publiques et privées
|
||||
"""
|
||||
# Générer clés RSA classiques (2048 bits)
|
||||
rsa_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048,
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
# Générer clés Dilithium (signatures PQC)
|
||||
with oqs.KeyEncapsulation(PostQuantumCryptography.PQC_SIGN_ALG) as kemsign:
|
||||
dilithium_public = kemsign.generate_keypair()
|
||||
dilithium_secret = kemsign.export_secret_key()
|
||||
|
||||
# Générer clés Kyber (chiffrement PQC)
|
||||
with oqs.KeyEncapsulation(PostQuantumCryptography.PQC_KEM_ALG) as kemenc:
|
||||
kyber_public = kemenc.generate_keypair()
|
||||
kyber_secret = kemenc.export_secret_key()
|
||||
|
||||
# Générer clés ElGamal classiques
|
||||
elgamal = ElGamalEncryption()
|
||||
elgamal_public, elgamal_secret = elgamal.generate_keypair()
|
||||
|
||||
return {
|
||||
# Clés classiques
|
||||
"rsa_public_key": rsa_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
),
|
||||
"rsa_private_key": rsa_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
),
|
||||
"elgamal_public": elgamal_public,
|
||||
"elgamal_secret": elgamal_secret,
|
||||
|
||||
# Clés post-quantiques
|
||||
"dilithium_public": dilithium_public.hex(), # Serialisé en hex
|
||||
"dilithium_secret": dilithium_secret.hex(),
|
||||
"kyber_public": kyber_public.hex(),
|
||||
"kyber_secret": kyber_secret.hex(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def hybrid_sign(
|
||||
message: bytes,
|
||||
rsa_private_key: bytes,
|
||||
dilithium_secret: str
|
||||
) -> Dict[str, bytes]:
|
||||
"""
|
||||
Signer un message avec signatures hybrides:
|
||||
1. Signature RSA-PSS classique
|
||||
2. Signature Dilithium post-quantique
|
||||
|
||||
Args:
|
||||
message: Le message à signer
|
||||
rsa_private_key: Clé privée RSA (PEM)
|
||||
dilithium_secret: Clé secrète Dilithium (hex)
|
||||
|
||||
Returns:
|
||||
Dict avec les deux signatures
|
||||
"""
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
|
||||
# Signature RSA-PSS classique
|
||||
rsa_key = load_pem_private_key(
|
||||
rsa_private_key,
|
||||
password=None,
|
||||
backend=default_backend()
|
||||
)
|
||||
rsa_signature = rsa_key.sign(
|
||||
message,
|
||||
padding.PSS(
|
||||
mgf=padding.MGF1(hashes.SHA256()),
|
||||
salt_length=padding.PSS.MAX_LENGTH
|
||||
),
|
||||
hashes.SHA256()
|
||||
)
|
||||
|
||||
# Signature Dilithium post-quantique
|
||||
dilithium_secret_bytes = bytes.fromhex(dilithium_secret)
|
||||
with oqs.Signature(PostQuantumCryptography.PQC_SIGN_ALG) as sig:
|
||||
sig.secret_key = dilithium_secret_bytes
|
||||
dilithium_signature = sig.sign(message)
|
||||
|
||||
return {
|
||||
"rsa_signature": rsa_signature,
|
||||
"dilithium_signature": dilithium_signature,
|
||||
"algorithm": "Hybrid(RSA-PSS + ML-DSA-65)"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def hybrid_verify(
|
||||
message: bytes,
|
||||
signatures: Dict[str, bytes],
|
||||
rsa_public_key: bytes,
|
||||
dilithium_public: str
|
||||
) -> bool:
|
||||
"""
|
||||
Vérifier les signatures hybrides.
|
||||
Les deux signatures doivent être valides.
|
||||
|
||||
Args:
|
||||
message: Le message signé
|
||||
signatures: Dict avec rsa_signature et dilithium_signature
|
||||
rsa_public_key: Clé publique RSA (PEM)
|
||||
dilithium_public: Clé publique Dilithium (hex)
|
||||
|
||||
Returns:
|
||||
True si les deux signatures sont valides
|
||||
"""
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||
|
||||
try:
|
||||
# Vérifier signature RSA-PSS
|
||||
rsa_key = load_pem_public_key(rsa_public_key, default_backend())
|
||||
rsa_key.verify(
|
||||
signatures["rsa_signature"],
|
||||
message,
|
||||
padding.PSS(
|
||||
mgf=padding.MGF1(hashes.SHA256()),
|
||||
salt_length=padding.PSS.MAX_LENGTH
|
||||
),
|
||||
hashes.SHA256()
|
||||
)
|
||||
|
||||
# Vérifier signature Dilithium
|
||||
dilithium_public_bytes = bytes.fromhex(dilithium_public)
|
||||
with oqs.Signature(PostQuantumCryptography.PQC_SIGN_ALG) as sig:
|
||||
sig.public_key = dilithium_public_bytes
|
||||
sig.verify(message, signatures["dilithium_signature"])
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Signature verification failed: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def hybrid_encapsulate(
|
||||
kyber_public: str,
|
||||
elgamal_public: Tuple[int, int, int]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Encapsuler un secret avec chiffrement hybride:
|
||||
1. Kyber pour le chiffrement PQC
|
||||
2. ElGamal pour le chiffrement classique
|
||||
|
||||
Args:
|
||||
kyber_public: Clé publique Kyber (hex)
|
||||
elgamal_public: Clé publique ElGamal (p, g, h)
|
||||
|
||||
Returns:
|
||||
Dict avec ciphertexts et secret encapsulé
|
||||
"""
|
||||
kyber_public_bytes = bytes.fromhex(kyber_public)
|
||||
|
||||
# Encapsulation Kyber
|
||||
with oqs.KeyEncapsulation(PostQuantumCryptography.PQC_KEM_ALG) as kem:
|
||||
kem.public_key = kyber_public_bytes
|
||||
kyber_ciphertext, kyber_secret = kem.encap_secret()
|
||||
|
||||
# Encapsulation ElGamal (chiffrement d'un secret aléatoire)
|
||||
message = os.urandom(32) # Secret aléatoire 256-bit
|
||||
elgamal = ElGamalEncryption()
|
||||
elgamal_ciphertext = elgamal.encrypt(elgamal_public, message)
|
||||
|
||||
# Dériver une clé finale à partir des deux secrets
|
||||
combined_secret = SecureHash.sha256(
|
||||
kyber_secret + message
|
||||
)
|
||||
|
||||
return {
|
||||
"kyber_ciphertext": kyber_ciphertext.hex(),
|
||||
"elgamal_ciphertext": elgamal_ciphertext,
|
||||
"combined_secret": combined_secret,
|
||||
"algorithm": "Hybrid(Kyber + ElGamal)"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def hybrid_decapsulate(
|
||||
ciphertexts: Dict[str, Any],
|
||||
kyber_secret: str,
|
||||
elgamal_secret: int
|
||||
) -> bytes:
|
||||
"""
|
||||
Décapsuler et récupérer le secret hybride:
|
||||
1. Décapsuler Kyber
|
||||
2. Décapsuler ElGamal
|
||||
3. Combiner les deux secrets
|
||||
|
||||
Args:
|
||||
ciphertexts: Dict avec kyber_ciphertext et elgamal_ciphertext
|
||||
kyber_secret: Clé secrète Kyber (hex)
|
||||
elgamal_secret: Clé secrète ElGamal (x)
|
||||
|
||||
Returns:
|
||||
Le secret déchiffré
|
||||
"""
|
||||
kyber_secret_bytes = bytes.fromhex(kyber_secret)
|
||||
kyber_ciphertext_bytes = bytes.fromhex(ciphertexts["kyber_ciphertext"])
|
||||
|
||||
# Décapsulation Kyber
|
||||
with oqs.KeyEncapsulation(PostQuantumCryptography.PQC_KEM_ALG) as kem:
|
||||
kem.secret_key = kyber_secret_bytes
|
||||
kyber_shared_secret = kem.decap_secret(kyber_ciphertext_bytes)
|
||||
|
||||
# Décapsulation ElGamal
|
||||
elgamal = ElGamalEncryption()
|
||||
elgamal_message = elgamal.decrypt(
|
||||
elgamal_secret,
|
||||
ciphertexts["elgamal_ciphertext"]
|
||||
)
|
||||
|
||||
# Combiner les secrets
|
||||
combined_secret = SecureHash.sha256(
|
||||
kyber_shared_secret + elgamal_message
|
||||
)
|
||||
|
||||
return combined_secret
|
||||
|
||||
@staticmethod
|
||||
def get_algorithm_info() -> Dict[str, str]:
|
||||
"""Afficher les informations sur les algorithmes utilisés"""
|
||||
return {
|
||||
"signatures": "Hybrid(RSA-PSS 2048-bit + ML-DSA-65/Dilithium)",
|
||||
"signatures_status": "FIPS 204 certified",
|
||||
"encryption": "Hybrid(ElGamal + ML-KEM-768/Kyber)",
|
||||
"encryption_status": "FIPS 203 certified",
|
||||
"hashing": "SHA-256",
|
||||
"hashing_quantum_resistance": "Quantum-resistant (preimage security)",
|
||||
"security_level": "Post-Quantum + Classical hybrid",
|
||||
"defense": "Defense-in-depth: compromise d'un système ne casse pas l'autre"
|
||||
}
|
||||
@ -1,97 +0,0 @@
|
||||
"""
|
||||
Signatures numériques pour authentification et non-répudiation.
|
||||
"""
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, padding, utils
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
import os
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
class DigitalSignature:
|
||||
"""
|
||||
Signatures RSA-PSS pour authentification et non-répudiation.
|
||||
|
||||
Propriétés:
|
||||
- Non-répudiation (l'auteur ne peut pas nier)
|
||||
- Authentification de l'origine
|
||||
- Intégrité des données
|
||||
"""
|
||||
|
||||
def __init__(self, key_size: int = 2048):
|
||||
self.key_size = key_size
|
||||
self.backend = default_backend()
|
||||
|
||||
def generate_keypair(self) -> Tuple[bytes, bytes]:
|
||||
"""
|
||||
Générer une paire de clés RSA.
|
||||
|
||||
Returns:
|
||||
(private_key_pem, public_key_pem)
|
||||
"""
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=self.key_size,
|
||||
backend=self.backend
|
||||
)
|
||||
|
||||
private_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
public_pem = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
|
||||
return private_pem, public_pem
|
||||
|
||||
def sign(self, private_key_pem: bytes, message: bytes) -> bytes:
|
||||
"""
|
||||
Signer un message avec la clé privée.
|
||||
"""
|
||||
private_key = serialization.load_pem_private_key(
|
||||
private_key_pem,
|
||||
password=None,
|
||||
backend=self.backend
|
||||
)
|
||||
|
||||
signature = private_key.sign(
|
||||
message,
|
||||
padding.PSS(
|
||||
mgf=padding.MGF1(hashes.SHA256()),
|
||||
salt_length=padding.PSS.MAX_LENGTH
|
||||
),
|
||||
hashes.SHA256()
|
||||
)
|
||||
|
||||
return signature
|
||||
|
||||
def verify(self, public_key_pem: bytes, message: bytes, signature: bytes) -> bool:
|
||||
"""
|
||||
Vérifier la signature d'un message.
|
||||
|
||||
Returns:
|
||||
True si la signature est valide, False sinon
|
||||
"""
|
||||
try:
|
||||
public_key = serialization.load_pem_public_key(
|
||||
public_key_pem,
|
||||
backend=self.backend
|
||||
)
|
||||
|
||||
public_key.verify(
|
||||
signature,
|
||||
message,
|
||||
padding.PSS(
|
||||
mgf=padding.MGF1(hashes.SHA256()),
|
||||
salt_length=padding.PSS.MAX_LENGTH
|
||||
),
|
||||
hashes.SHA256()
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@ -1,122 +0,0 @@
|
||||
"""
|
||||
Preuves de connaissance zéro (Zero-Knowledge Proofs).
|
||||
Pour démontrer la validité d'un vote sans révéler le contenu.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import random
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZKProofChallenge:
|
||||
"""Défi pour une preuve ZK interactive"""
|
||||
challenge: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZKProofResponse:
|
||||
"""Réponse à un défi ZK"""
|
||||
response: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZKProof:
|
||||
"""Preuve de connaissance zéro complète"""
|
||||
commitment: int
|
||||
challenge: int
|
||||
response: int
|
||||
|
||||
|
||||
class ZKProofProtocol:
|
||||
"""
|
||||
Protocole de Fiat-Shamir pour preuves de connaissance zéro.
|
||||
|
||||
Cas d'usage dans le vote:
|
||||
- Prouver qu'on a déjà voté sans révéler le vote
|
||||
- Prouver la correctionction d'un bullettin
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def generate_proof(secret: int, p: int, g: int) -> Tuple[int, int]:
|
||||
"""
|
||||
Générer un commitment (première étape du protocole).
|
||||
|
||||
Args:
|
||||
secret: Le secret à prouver (ex: clé privée)
|
||||
p: Module premier
|
||||
g: Générateur
|
||||
|
||||
Returns:
|
||||
(commitment, random_value)
|
||||
"""
|
||||
r = random.randint(2, p - 2)
|
||||
commitment = pow(g, r, p)
|
||||
return commitment, r
|
||||
|
||||
@staticmethod
|
||||
def generate_challenge(p: int) -> int:
|
||||
"""Générer un défi aléatoire"""
|
||||
return random.randint(1, p - 2)
|
||||
|
||||
@staticmethod
|
||||
def compute_response(
|
||||
secret: int,
|
||||
random_value: int,
|
||||
challenge: int,
|
||||
p: int
|
||||
) -> int:
|
||||
"""
|
||||
Calculer la réponse au défi (non-interactif).
|
||||
response = random_value + challenge * secret
|
||||
"""
|
||||
return (random_value + challenge * secret) % (p - 1)
|
||||
|
||||
@staticmethod
|
||||
def verify_proof(
|
||||
commitment: int,
|
||||
challenge: int,
|
||||
response: int,
|
||||
public_key: int,
|
||||
p: int,
|
||||
g: int
|
||||
) -> bool:
|
||||
"""
|
||||
Vérifier la preuve.
|
||||
Vérifie que: g^response = commitment * public_key^challenge (mod p)
|
||||
"""
|
||||
left = pow(g, response, p)
|
||||
right = (commitment * pow(public_key, challenge, p)) % p
|
||||
return left == right
|
||||
|
||||
@staticmethod
|
||||
def fiat_shamir_proof(
|
||||
secret: int,
|
||||
public_key: int,
|
||||
message: bytes,
|
||||
p: int,
|
||||
g: int
|
||||
) -> ZKProof:
|
||||
"""
|
||||
Générer une preuve Fiat-Shamir non-interactive.
|
||||
"""
|
||||
# Étape 1: commitment
|
||||
commitment, r = ZKProofProtocol.generate_proof(secret, p, g)
|
||||
|
||||
# Étape 2: challenge généré via hash(commitment || message)
|
||||
import hashlib
|
||||
challenge_bytes = hashlib.sha256(
|
||||
str(commitment).encode() + message
|
||||
).digest()
|
||||
challenge = int.from_bytes(challenge_bytes, 'big') % (p - 1)
|
||||
|
||||
# Étape 3: réponse
|
||||
response = ZKProofProtocol.compute_response(
|
||||
secret, r, challenge, p
|
||||
)
|
||||
|
||||
return ZKProof(
|
||||
commitment=commitment,
|
||||
challenge=challenge,
|
||||
response=response
|
||||
)
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "evoting-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"axios": "^1.6.0",
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"postcss": "^8.4.31",
|
||||
"autoprefixer": "^10.4.16"
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react'
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE = process.env.REACT_APP_API_URL || 'http://localhost:8000/api'
|
||||
|
||||
const AppContext = createContext()
|
||||
|
||||
export const AppProvider = ({ children }) => {
|
||||
const [election, setElection] = useState(null)
|
||||
const [voter, setVoter] = useState(null)
|
||||
const [voted, setVoted] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const fetchElection = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await axios.get(`${API_BASE}/elections/active`)
|
||||
setElection(response.data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to fetch election')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const registerVoter = useCallback(async (email, fullName) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await axios.post(`${API_BASE}/voters/register`, {
|
||||
email,
|
||||
full_name: fullName
|
||||
})
|
||||
setVoter(response.data)
|
||||
setError(null)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to register voter')
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const submitVote = useCallback(async (candidateId, signature = null) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const payload = {
|
||||
voter_email: voter.email,
|
||||
candidate_id: candidateId,
|
||||
signature: signature || 'classical-rsa-pss'
|
||||
}
|
||||
const response = await axios.post(`${API_BASE}/votes/submit`, payload)
|
||||
setVoted(true)
|
||||
setError(null)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to submit vote')
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [voter])
|
||||
|
||||
const getResults = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await axios.get(`${API_BASE}/elections/active/results`)
|
||||
setError(null)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Failed to fetch results')
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = {
|
||||
election,
|
||||
voter,
|
||||
voted,
|
||||
loading,
|
||||
error,
|
||||
fetchElection,
|
||||
registerVoter,
|
||||
submitVote,
|
||||
getResults,
|
||||
setVoter,
|
||||
setVoted
|
||||
}
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>
|
||||
}
|
||||
|
||||
export const useApp = () => {
|
||||
const context = useContext(AppContext)
|
||||
if (!context) {
|
||||
throw new Error('useApp must be used within AppProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user