From 546785ef67b46270b186b0d3ad05e28d7f786390 Mon Sep 17 00:00:00 2001 From: Alexis Bruneteau Date: Thu, 6 Nov 2025 17:15:34 +0100 Subject: [PATCH] feat: Integrate backend API with frontend - Authentication & Elections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core Integration: - Create API client with TypeScript types for all endpoints - Implement authentication context provider for user state management - Add protected route component for dashboard access control - Connect login/register pages to backend authentication endpoints - Implement user session persistence with localStorage tokens Authentication: - Login page now connects to /api/auth/login endpoint - Register page connects to /api/auth/register with validation - Password strength requirements (min 8 chars) - Form validation and error handling - Automatic redirect to dashboard on successful auth - Logout functionality with session cleanup Protected Routes: - Dashboard pages require authentication - Non-authenticated users redirected to login - Loading spinner during auth verification - User name displayed in dashboard header - Proper session management Election/Vote APIs: - Dashboard fetches active elections from /api/elections/active - Display real election data with candidates count - Handle loading and error states - Skeleton loaders for better UX Type Safety: - Full TypeScript interfaces for all API responses - Proper error handling with try-catch blocks - API response types: AuthToken, VoterProfile, Election, Candidate, Vote, VoteHistory Environment: - API URL configurable via NEXT_PUBLIC_API_URL env variable - Default to http://localhost:8000 for local development 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../frontend/app/auth/login/page.tsx | 29 ++- .../frontend/app/auth/register/page.tsx | 42 +-- .../frontend/app/dashboard/layout.tsx | 26 +- .../frontend/app/dashboard/page.tsx | 91 ++++--- e-voting-system/frontend/app/layout.tsx | 5 +- .../frontend/components/protected-route.tsx | 38 +++ e-voting-system/frontend/lib/api.ts | 243 ++++++++++++++++++ e-voting-system/frontend/lib/auth-context.tsx | 149 +++++++++++ 8 files changed, 544 insertions(+), 79 deletions(-) create mode 100644 e-voting-system/frontend/components/protected-route.tsx create mode 100644 e-voting-system/frontend/lib/api.ts create mode 100644 e-voting-system/frontend/lib/auth-context.tsx diff --git a/e-voting-system/frontend/app/auth/login/page.tsx b/e-voting-system/frontend/app/auth/login/page.tsx index 993ec65..6961a63 100644 --- a/e-voting-system/frontend/app/auth/login/page.tsx +++ b/e-voting-system/frontend/app/auth/login/page.tsx @@ -2,34 +2,35 @@ import Link from "next/link" import { useState } from "react" +import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Mail, Lock, LogIn, AlertCircle } from "lucide-react" +import { useAuth } from "@/lib/auth-context" export default function LoginPage() { + const router = useRouter() + const { login, isLoading } = useAuth() const [email, setEmail] = useState("") const [password, setPassword] = useState("") - const [loading, setLoading] = useState(false) const [error, setError] = useState("") const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError("") - setLoading(true) + + if (!email || !password) { + setError("Email et mot de passe requis") + return + } try { - // API call would go here - console.log("Login attempt:", { email, password }) - // Simulate API delay - await new Promise(resolve => setTimeout(resolve, 1000)) - // Redirect to dashboard - // router.push('/dashboard') + await login(email, password) + router.push("/dashboard") } catch (err) { setError("Email ou mot de passe incorrect") - } finally { - setLoading(false) } } @@ -67,7 +68,7 @@ export default function LoginPage() { value={email} onChange={(e) => setEmail(e.target.value)} required - disabled={loading} + disabled={isLoading} className="pl-10" /> @@ -84,7 +85,7 @@ export default function LoginPage() { value={password} onChange={(e) => setPassword(e.target.value)} required - disabled={loading} + disabled={isLoading} className="pl-10" /> @@ -99,9 +100,9 @@ export default function LoginPage() { diff --git a/e-voting-system/frontend/app/dashboard/layout.tsx b/e-voting-system/frontend/app/dashboard/layout.tsx index 8ec6fed..cd32bad 100644 --- a/e-voting-system/frontend/app/dashboard/layout.tsx +++ b/e-voting-system/frontend/app/dashboard/layout.tsx @@ -1,17 +1,27 @@ "use client" import Link from "next/link" +import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" import { Menu, LogOut, User as UserIcon } from "lucide-react" import { useState } from "react" +import { useAuth } from "@/lib/auth-context" +import { ProtectedRoute } from "@/components/protected-route" export default function DashboardLayout({ children, }: { children: React.ReactNode }) { + const router = useRouter() + const { user, logout } = useAuth() const [sidebarOpen, setSidebarOpen] = useState(false) + const handleLogout = () => { + logout() + router.push("/auth/login") + } + const navItems = [ { href: "/dashboard", label: "Tableau de Bord", icon: "📊" }, { href: "/dashboard/votes/active", label: "Votes Actifs", icon: "🗳️" }, @@ -59,7 +69,11 @@ export default function DashboardLayout({ Mon Profil - @@ -79,16 +93,18 @@ export default function DashboardLayout({
- - Bienvenue, Utilisateur - + {user && ( + + Bienvenue, {user.first_name} {user.last_name} + + )}
{/* Content Area */}
- {children} + {children}
diff --git a/e-voting-system/frontend/app/dashboard/page.tsx b/e-voting-system/frontend/app/dashboard/page.tsx index 15df622..b9ef595 100644 --- a/e-voting-system/frontend/app/dashboard/page.tsx +++ b/e-voting-system/frontend/app/dashboard/page.tsx @@ -1,12 +1,38 @@ "use client" import Link from "next/link" +import { useState, useEffect } from "react" import { Button } from "@/components/ui/button" import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { BarChart3, CheckCircle, Clock, Archive } from "lucide-react" +import { electionsApi, Election } from "@/lib/api" export default function DashboardPage() { - // Mock data - would come from backend + const [activeVotes, setActiveVotes] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true) + const response = await electionsApi.getActive() + if (response.data) { + setActiveVotes(response.data) + } else if (response.error) { + setError(response.error) + } + } catch (err) { + setError("Erreur lors du chargement des données") + } finally { + setLoading(false) + } + } + + fetchData() + }, []) + + // Mock data for stats const stats = [ { title: "Votes Actifs", @@ -38,29 +64,6 @@ export default function DashboardPage() { }, ] - const activeVotes = [ - { - id: 1, - title: "Election Présidentielle 2025", - description: "Première manche - Scrutin dimanche", - progress: 65, - endDate: "6 Nov 2025 à 20:00", - }, - { - id: 2, - title: "Référendum : Réforme Constitutionnelle", - description: "Consultez la population", - progress: 45, - endDate: "8 Nov 2025 à 18:00", - }, - { - id: 3, - title: "Election Municipale - Île-de-France", - description: "Élection locale régionale", - progress: 78, - endDate: "10 Nov 2025 à 17:00", - }, - ] return (
@@ -103,37 +106,41 @@ export default function DashboardPage() {
+ {error && ( +
+

{error}

+
+ )} + + {loading && ( +
+
+
+ )} + + {!loading && activeVotes.length === 0 && ( +
+

Aucun vote actif en ce moment

+
+ )} +
- {activeVotes.map((vote) => ( + {activeVotes.slice(0, 3).map((vote) => (
- {vote.title} + {vote.name} {vote.description}
- {/* Progress Bar */} -
-
- Participation - {vote.progress}% -
-
-
-
-
- {/* Footer Info */}
- Ferme le {vote.endDate} + Candidates: {vote.candidates?.length || 0} - +
diff --git a/e-voting-system/frontend/app/layout.tsx b/e-voting-system/frontend/app/layout.tsx index ca898f3..e377ff4 100644 --- a/e-voting-system/frontend/app/layout.tsx +++ b/e-voting-system/frontend/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next" import "./globals.css" +import { AuthProvider } from "@/lib/auth-context" export const metadata: Metadata = { title: "E-Voting - Plateforme de Vote Électronique Sécurisée", @@ -13,7 +14,9 @@ export default function RootLayout({ }) { return ( - {children} + + {children} + ) } diff --git a/e-voting-system/frontend/components/protected-route.tsx b/e-voting-system/frontend/components/protected-route.tsx new file mode 100644 index 0000000..aa6089c --- /dev/null +++ b/e-voting-system/frontend/components/protected-route.tsx @@ -0,0 +1,38 @@ +/** + * Protected Route Component + * Redirects to login if user is not authenticated + */ + +"use client" + +import { useAuth } from "@/lib/auth-context" +import { useRouter } from "next/navigation" +import { useEffect } from "react" + +export function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isLoading } = useAuth() + const router = useRouter() + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.replace("/auth/login") + } + }, [isAuthenticated, isLoading, router]) + + if (isLoading) { + return ( +
+
+
+

Vérification de l'authentification...

+
+
+ ) + } + + if (!isAuthenticated) { + return null + } + + return <>{children} +} diff --git a/e-voting-system/frontend/lib/api.ts b/e-voting-system/frontend/lib/api.ts new file mode 100644 index 0000000..5fbae08 --- /dev/null +++ b/e-voting-system/frontend/lib/api.ts @@ -0,0 +1,243 @@ +/** + * API Client for E-Voting Backend + * Handles all communication with the FastAPI backend + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000" + +export interface ApiResponse { + data?: T + error?: string + status: number +} + +export interface AuthToken { + access_token: string + expires_in: number + id: number + email: string + first_name: string + last_name: string +} + +export interface VoterProfile { + id: number + email: string + first_name: string + last_name: string + created_at: string +} + +export interface Election { + id: number + name: string + description: string + start_date: string + end_date: string + is_active: boolean + results_published: boolean + candidates: Candidate[] +} + +export interface Candidate { + id: number + name: string + description: string + election_id: number +} + +export interface Vote { + id: number + election_id: number + candidate_id: number + voter_id: number + ballot_hash: string + timestamp: string +} + +export interface VoteHistory { + vote_id: number + election_id: number + election_name: string + candidate_name: string + vote_date: string + election_status: "active" | "closed" | "upcoming" +} + +/** + * Get stored authentication token from localStorage + */ +export function getAuthToken(): string | null { + if (typeof window === "undefined") return null + return localStorage.getItem("auth_token") +} + +/** + * Store authentication token in localStorage + */ +export function setAuthToken(token: string): void { + if (typeof window !== "undefined") { + localStorage.setItem("auth_token", token) + } +} + +/** + * Remove authentication token from localStorage + */ +export function clearAuthToken(): void { + if (typeof window !== "undefined") { + localStorage.removeItem("auth_token") + } +} + +/** + * Make API request with authentication + */ +async function apiRequest( + endpoint: string, + options: RequestInit & { skipAuth?: boolean } = {} +): Promise> { + const { skipAuth = false, ...fetchOptions } = options + + try { + const headers: Record = { + "Content-Type": "application/json", + ...((fetchOptions.headers as Record) || {}), + } + + // Add authentication token if available + if (!skipAuth) { + const token = getAuthToken() + if (token) { + headers["Authorization"] = `Bearer ${token}` + } + } + + const response = await fetch(`${API_URL}${endpoint}`, { + ...fetchOptions, + headers, + }) + + if (response.status === 401) { + // Token expired or invalid + clearAuthToken() + throw new Error("Unauthorized - please login again") + } + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error.detail || `HTTP ${response.status}`) + } + + const data = await response.json() + return { data, status: response.status } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error" + return { error: message, status: 500 } + } +} + +/** + * Authentication APIs + */ +export const authApi = { + async register(email: string, password: string, firstName: string, lastName: string) { + return apiRequest("/api/auth/register", { + method: "POST", + skipAuth: true, + body: JSON.stringify({ + email, + password, + first_name: firstName, + last_name: lastName, + }), + }) + }, + + async login(email: string, password: string) { + return apiRequest("/api/auth/login", { + method: "POST", + skipAuth: true, + body: JSON.stringify({ email, password }), + }) + }, + + async getProfile() { + return apiRequest("/api/auth/profile") + }, + + logout() { + clearAuthToken() + return { data: null, status: 200 } + }, +} + +/** + * Elections APIs + */ +export const electionsApi = { + async getActive() { + return apiRequest("/api/elections/active") + }, + + async getUpcoming() { + return apiRequest("/api/elections/upcoming") + }, + + async getCompleted() { + return apiRequest("/api/elections/completed") + }, + + async getById(id: number) { + return apiRequest(`/api/elections/${id}`) + }, + + async getCandidates(electionId: number) { + return apiRequest(`/api/elections/${electionId}/candidates`) + }, + + async getResults(electionId: number) { + return apiRequest(`/api/elections/${electionId}/results`) + }, + + async publishResults(electionId: number) { + return apiRequest(`/api/elections/${electionId}/publish-results`, { + method: "POST", + }) + }, +} + +/** + * Votes APIs + */ +export const votesApi = { + async submitVote(electionId: number, choix: string) { + return apiRequest("/api/votes", { + method: "POST", + body: JSON.stringify({ + election_id: electionId, + choix: choix, + }), + }) + }, + + async getStatus(electionId: number) { + return apiRequest<{ has_voted: boolean }>(`/api/votes/status?election_id=${electionId}`) + }, + + async getHistory() { + return apiRequest("/api/votes/history") + }, +} + +/** + * Health check + */ +export async function healthCheck() { + try { + const response = await fetch(`${API_URL}/health`) + return response.ok + } catch { + return false + } +} diff --git a/e-voting-system/frontend/lib/auth-context.tsx b/e-voting-system/frontend/lib/auth-context.tsx new file mode 100644 index 0000000..d6bb5fd --- /dev/null +++ b/e-voting-system/frontend/lib/auth-context.tsx @@ -0,0 +1,149 @@ +/** + * Authentication Context + * Manages user authentication state globally + */ + +"use client" + +import React, { createContext, useContext, useState, useEffect, ReactNode } from "react" +import { authApi, getAuthToken, setAuthToken, clearAuthToken, VoterProfile } from "./api" + +interface AuthContextType { + user: VoterProfile | null + isLoading: boolean + isAuthenticated: boolean + error: string | null + login: (email: string, password: string) => Promise + register: (email: string, password: string, firstName: string, lastName: string) => Promise + logout: () => void + refreshProfile: () => Promise +} + +const AuthContext = createContext(undefined) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + // Check if user is already logged in on mount + useEffect(() => { + const checkAuth = async () => { + const token = getAuthToken() + if (token) { + try { + const response = await authApi.getProfile() + if (response.data) { + setUser(response.data) + } else { + clearAuthToken() + } + } catch (err) { + clearAuthToken() + } + } + setIsLoading(false) + } + + checkAuth() + }, []) + + const login = async (email: string, password: string) => { + setIsLoading(true) + setError(null) + try { + const response = await authApi.login(email, password) + if (response.error) { + throw new Error(response.error) + } + if (response.data) { + setAuthToken(response.data.access_token) + setUser({ + id: response.data.id, + email: response.data.email, + first_name: response.data.first_name, + last_name: response.data.last_name, + created_at: new Date().toISOString(), + }) + } + } catch (err) { + const message = err instanceof Error ? err.message : "Login failed" + setError(message) + throw err + } finally { + setIsLoading(false) + } + } + + const register = async (email: string, password: string, firstName: string, lastName: string) => { + setIsLoading(true) + setError(null) + try { + const response = await authApi.register(email, password, firstName, lastName) + if (response.error) { + throw new Error(response.error) + } + if (response.data) { + setAuthToken(response.data.access_token) + setUser({ + id: response.data.id, + email: response.data.email, + first_name: response.data.first_name, + last_name: response.data.last_name, + created_at: new Date().toISOString(), + }) + } + } catch (err) { + const message = err instanceof Error ? err.message : "Registration failed" + setError(message) + throw err + } finally { + setIsLoading(false) + } + } + + const logout = () => { + authApi.logout() + setUser(null) + setError(null) + } + + const refreshProfile = async () => { + try { + const response = await authApi.getProfile() + if (response.data) { + setUser(response.data) + } else { + clearAuthToken() + setUser(null) + } + } catch (err) { + clearAuthToken() + setUser(null) + } + } + + const value: AuthContextType = { + user, + isLoading, + isAuthenticated: user !== null, + error, + login, + register, + logout, + refreshProfile, + } + + return {children} +} + +/** + * Hook to use authentication context + */ +export function useAuth() { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider") + } + return context +}