diff --git a/.env.coolify.example b/.env.coolify.example index 763fa61..d9b536f 100644 --- a/.env.coolify.example +++ b/.env.coolify.example @@ -6,4 +6,4 @@ POSTGRES_PORT=5432 POSTGRES_DB=encuesta_e3 POSTGRES_USER=postgres POSTGRES_PASSWORD=your_coolify_postgres_password -ADMIN_SECRET=RESETEOE3 +ADMIN_SECRET=REMOVERVOTOE3 diff --git a/.env.example b/.env.example index 323410d..83f0e30 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,5 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=your_secure_password_here DATABASE_SSL=false -# Clave secreta para panel de administración (Restablecer votos) -ADMIN_SECRET=RESETEOE3 +# Clave secreta para panel de administración y remoción de votos (Restablecer votos) +ADMIN_SECRET=REMOVERVOTOE3 diff --git a/server/db.ts b/server/db.ts index 3667427..dcce98f 100644 --- a/server/db.ts +++ b/server/db.ts @@ -175,6 +175,25 @@ export async function deleteVote(voter: string): Promise { return existed; } +/** + * Elimina todos los votos (para vaciado/reset total de pruebas) + */ +export async function deleteAllVotes(): Promise { + if (pool && isPostgresConnected) { + try { + const res = await pool.query('DELETE FROM votes'); + inMemoryVotes = {}; + return res.rowCount ?? 0; + } catch (err) { + console.error('Error al vaciar votos en PostgreSQL:', (err as Error).message); + throw err; + } + } + const count = Object.keys(inMemoryVotes).length; + inMemoryVotes = {}; + return count; +} + /** * Diagnóstico de salud de la base de datos */ diff --git a/server/index.ts b/server/index.ts index 34b4525..b2f12df 100644 --- a/server/index.ts +++ b/server/index.ts @@ -3,13 +3,19 @@ import cors from 'cors'; import path from 'path'; import fs from 'fs'; import dotenv from 'dotenv'; -import { initDatabase, getAllVotes, castVote, deleteVote, getDatabaseHealth } from './db'; +import { initDatabase, getAllVotes, castVote, deleteVote, deleteAllVotes, getDatabaseHealth } from './db'; dotenv.config(); const app = express(); const PORT = parseInt(process.env.PORT || '3000', 10); -const ADMIN_SECRET = process.env.ADMIN_SECRET || 'RESETEOE3'; +const ADMIN_SECRET = process.env.ADMIN_SECRET || 'REMOVERVOTOE3'; +const VALID_ADMIN_KEYS = new Set([ADMIN_SECRET, 'REMOVERVOTOE3', 'RESETEOE3']); + +function isValidAdminKey(key: unknown): boolean { + if (!key || typeof key !== 'string') return false; + return VALID_ADMIN_KEYS.has(key.trim()); +} app.use(cors()); app.use(express.json()); @@ -177,15 +183,15 @@ app.post('/api/vote', async (req: Request, res: Response): Promise => { }); /** - * Restablecer / eliminar un voto (Administrador) + * Restablecer / eliminar un voto individual (Administrador) */ app.delete('/api/vote/:voter', async (req: Request, res: Response): Promise => { try { const voter = req.params.voter; const providedKey = req.headers['x-admin-key'] || req.body?.password || req.query?.key; - if (providedKey !== ADMIN_SECRET) { - res.status(401).json({ success: false, error: 'Clave de administración incorrecta.' }); + if (!isValidAdminKey(providedKey)) { + res.status(401).json({ success: false, error: 'Contraseña de administración incorrecta. Use REMOVERVOTOE3.' }); return; } @@ -213,6 +219,37 @@ app.delete('/api/vote/:voter', async (req: Request, res: Response): Promise => { + try { + const providedKey = req.headers['x-admin-key'] || req.body?.password || req.query?.key; + + if (!isValidAdminKey(providedKey)) { + res.status(401).json({ success: false, error: 'Contraseña de administración incorrecta. Use REMOVERVOTOE3.' }); + return; + } + + const count = await deleteAllVotes(); + + // Disparar actualización en vivo + broadcastUpdate().catch(console.error); + + res.json({ + success: true, + message: `Se han eliminado todos los votos (${count} votos restablecidos).`, + count, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: 'Error al vaciar los votos.', + detail: (error as Error).message, + }); + } +}); + // ========================================== // SERVIR FRONTEND ESTÁTICO EN PRODUCCIÓN // ========================================== diff --git a/src/App.tsx b/src/App.tsx index 173fe36..11ed30f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -202,6 +202,7 @@ export default function App() { voterName={voterName} existingVoteFor={existingVoteFor} onCastVote={handleCastVote} + onVoteRemoved={() => fetchVotes(true)} isLoading={isRefreshing} /> diff --git a/src/components/AdminPanel.tsx b/src/components/AdminPanel.tsx index b7a8174..00f7f43 100644 --- a/src/components/AdminPanel.tsx +++ b/src/components/AdminPanel.tsx @@ -40,8 +40,9 @@ export const AdminPanel: React.FC = ({ return; } - if (password.trim() !== 'RESETEOE3') { - setAdminMsg({ text: 'Contraseña de administración incorrecta.', type: 'error' }); + const trimmedPass = password.trim(); + if (trimmedPass !== 'REMOVERVOTOE3' && trimmedPass !== 'RESETEOE3') { + setAdminMsg({ text: 'Contraseña incorrecta. Se requiere "REMOVERVOTOE3".', type: 'error' }); return; } @@ -49,17 +50,50 @@ export const AdminPanel: React.FC = ({ setAdminMsg(null); try { - await ApiService.deleteVote(selectedVoter, password.trim()); + await ApiService.deleteVote(selectedVoter, trimmedPass); await onVoteResetSuccess(); setAdminMsg({ - text: `Voto de "${selectedVoter}" restablecido y eliminado de PostgreSQL.`, + text: `Voto de "${selectedVoter}" removido exitosamente del sistema.`, type: 'success', }); setSelectedVoter(''); setPassword(''); } catch (err) { setAdminMsg({ - text: `Error al restablecer voto: ${err instanceof Error ? err.message : 'Error desconocido'}`, + text: `Error al remover voto: ${err instanceof Error ? err.message : 'Error desconocido'}`, + type: 'error', + }); + } finally { + setIsDeleting(false); + } + }; + + const handleResetAllVotes = async () => { + const trimmedPass = password.trim(); + if (trimmedPass !== 'REMOVERVOTOE3' && trimmedPass !== 'RESETEOE3') { + setAdminMsg({ text: 'Ingresa la contraseña "REMOVERVOTOE3" para vaciar todos los votos.', type: 'error' }); + return; + } + + if (!window.confirm('¿Confirmas que deseas eliminar TODOS los votos para reiniciar pruebas?')) { + return; + } + + setIsDeleting(true); + setAdminMsg(null); + + try { + const res = await ApiService.deleteAllVotes(trimmedPass); + await onVoteResetSuccess(); + setAdminMsg({ + text: `Todos los votos han sido removidos (${res.count} votos eliminados).`, + type: 'success', + }); + setSelectedVoter(''); + setPassword(''); + } catch (err) { + setAdminMsg({ + text: `Error al vaciar votos: ${err instanceof Error ? err.message : 'Error desconocido'}`, type: 'error', }); } finally { @@ -339,41 +373,62 @@ const VOTOS_EMBEBIDOS = ${JSON.stringify(rawVotes, null, 2)};`; {/* Reset section */}
- +
+ + + Clave requerida: REMOVERVOTOE3 + +
-
- +
+
+ +
- setPassword(e.target.value)} - placeholder="Contraseña: RESETEOE3" - className="w-full px-3 py-2.5 bg-white/[0.04] border border-white/10 rounded-xl text-white text-xs focus:border-[#00B7D4] focus:outline-none" - /> +
+ setPassword(e.target.value)} + placeholder="Clave: REMOVERVOTOE3" + className="w-full px-3 py-2.5 bg-white/[0.04] border border-white/10 rounded-xl text-white text-xs focus:border-[#00B7D4] focus:outline-none" + /> +
- +
+ + + +
{adminMsg && ( diff --git a/src/components/VotingStep.tsx b/src/components/VotingStep.tsx index 2bd9686..14cb216 100644 --- a/src/components/VotingStep.tsx +++ b/src/components/VotingStep.tsx @@ -1,12 +1,14 @@ import React, { useState } from 'react'; import confetti from 'canvas-confetti'; -import { CheckCircle2, Sparkles, RefreshCw, Zap } from 'lucide-react'; +import { CheckCircle2, Sparkles, RefreshCw, Zap, Trash2, KeyRound } from 'lucide-react'; import { CANDIDATOS_DEFAULT } from '../types'; +import { ApiService } from '../services/api'; interface VotingStepProps { voterName: string; existingVoteFor: string | null; onCastVote: (candidate: string) => Promise; + onVoteRemoved?: () => void; isLoading: boolean; } @@ -14,13 +16,43 @@ export const VotingStep: React.FC = ({ voterName, existingVoteFor, onCastVote, + onVoteRemoved, isLoading, }) => { const [selectedCandidate, setSelectedCandidate] = useState(null); const [submitting, setSubmitting] = useState(false); + const [showRemovePrompt, setShowRemovePrompt] = useState(false); + const [removePassword, setRemovePassword] = useState(''); + const [isRemoving, setIsRemoving] = useState(false); + const [removeError, setRemoveError] = useState(null); const normalizedVoter = voterName.trim().toUpperCase(); + const handleRemoveExistingVote = async () => { + if (!normalizedVoter) return; + const trimmedPass = removePassword.trim(); + if (trimmedPass !== 'REMOVERVOTOE3' && trimmedPass !== 'RESETEOE3') { + setRemoveError('Contraseña incorrecta. Se requiere "REMOVERVOTOE3".'); + return; + } + + setIsRemoving(true); + setRemoveError(null); + try { + await ApiService.deleteVote(normalizedVoter, trimmedPass); + setShowRemovePrompt(false); + setRemovePassword(''); + setSelectedCandidate(null); + if (onVoteRemoved) { + onVoteRemoved(); + } + } catch (err) { + setRemoveError(err instanceof Error ? err.message : 'Error al remover voto'); + } finally { + setIsRemoving(false); + } + }; + // Función anti-autovoto const isSelfVote = (candidateName: string): boolean => { if (!normalizedVoter) return false; @@ -135,20 +167,78 @@ export const VotingStep: React.FC = ({ {/* Confirmation and Atomic Action Button */}
{existingVoteFor && ( -
-
- - - Voto emitido para:{' '} - - {existingVoteFor} - + <> +
+
+ + + Voto emitido para:{' '} + + {existingVoteFor} + + +
+ + Atómico
- - Atómico - -
+ + {/* Opción de Remover Voto */} + {showRemovePrompt ? ( +
+
+ + + Remover voto de {normalizedVoter} + + +
+ +
+ setRemovePassword(e.target.value)} + placeholder="Clave: REMOVERVOTOE3" + className="flex-1 px-3 py-2 bg-black/60 border border-red-500/30 rounded-lg text-white text-xs placeholder:text-gray-500 focus:outline-none focus:border-red-400" + /> + +
+ + {removeError && ( +

{removeError}

+ )} +
+ ) : ( +
+ +
+ )} + )}