feat: soporte de contraseña REMOVERVOTOE3 para remover votos individuales y vaciar pruebas
This commit is contained in:
@@ -6,4 +6,4 @@ POSTGRES_PORT=5432
|
|||||||
POSTGRES_DB=encuesta_e3
|
POSTGRES_DB=encuesta_e3
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_PASSWORD=your_coolify_postgres_password
|
POSTGRES_PASSWORD=your_coolify_postgres_password
|
||||||
ADMIN_SECRET=RESETEOE3
|
ADMIN_SECRET=REMOVERVOTOE3
|
||||||
|
|||||||
+2
-2
@@ -10,5 +10,5 @@ POSTGRES_USER=postgres
|
|||||||
POSTGRES_PASSWORD=your_secure_password_here
|
POSTGRES_PASSWORD=your_secure_password_here
|
||||||
DATABASE_SSL=false
|
DATABASE_SSL=false
|
||||||
|
|
||||||
# Clave secreta para panel de administración (Restablecer votos)
|
# Clave secreta para panel de administración y remoción de votos (Restablecer votos)
|
||||||
ADMIN_SECRET=RESETEOE3
|
ADMIN_SECRET=REMOVERVOTOE3
|
||||||
|
|||||||
@@ -175,6 +175,25 @@ export async function deleteVote(voter: string): Promise<boolean> {
|
|||||||
return existed;
|
return existed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina todos los votos (para vaciado/reset total de pruebas)
|
||||||
|
*/
|
||||||
|
export async function deleteAllVotes(): Promise<number> {
|
||||||
|
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
|
* Diagnóstico de salud de la base de datos
|
||||||
*/
|
*/
|
||||||
|
|||||||
+42
-5
@@ -3,13 +3,19 @@ import cors from 'cors';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { initDatabase, getAllVotes, castVote, deleteVote, getDatabaseHealth } from './db';
|
import { initDatabase, getAllVotes, castVote, deleteVote, deleteAllVotes, getDatabaseHealth } from './db';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
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(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -177,15 +183,15 @@ app.post('/api/vote', async (req: Request, res: Response): Promise<void> => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restablecer / eliminar un voto (Administrador)
|
* Restablecer / eliminar un voto individual (Administrador)
|
||||||
*/
|
*/
|
||||||
app.delete('/api/vote/:voter', async (req: Request, res: Response): Promise<void> => {
|
app.delete('/api/vote/:voter', async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const voter = req.params.voter;
|
const voter = req.params.voter;
|
||||||
const providedKey = req.headers['x-admin-key'] || req.body?.password || req.query?.key;
|
const providedKey = req.headers['x-admin-key'] || req.body?.password || req.query?.key;
|
||||||
|
|
||||||
if (providedKey !== ADMIN_SECRET) {
|
if (!isValidAdminKey(providedKey)) {
|
||||||
res.status(401).json({ success: false, error: 'Clave de administración incorrecta.' });
|
res.status(401).json({ success: false, error: 'Contraseña de administración incorrecta. Use REMOVERVOTOE3.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +219,37 @@ app.delete('/api/vote/:voter', async (req: Request, res: Response): Promise<void
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restablecer TODOS los votos (Vaciado completo para pruebas)
|
||||||
|
*/
|
||||||
|
app.delete('/api/votes', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
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
|
// SERVIR FRONTEND ESTÁTICO EN PRODUCCIÓN
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export default function App() {
|
|||||||
voterName={voterName}
|
voterName={voterName}
|
||||||
existingVoteFor={existingVoteFor}
|
existingVoteFor={existingVoteFor}
|
||||||
onCastVote={handleCastVote}
|
onCastVote={handleCastVote}
|
||||||
|
onVoteRemoved={() => fetchVotes(true)}
|
||||||
isLoading={isRefreshing}
|
isLoading={isRefreshing}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ export const AdminPanel: React.FC<AdminPanelProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.trim() !== 'RESETEOE3') {
|
const trimmedPass = password.trim();
|
||||||
setAdminMsg({ text: 'Contraseña de administración incorrecta.', type: 'error' });
|
if (trimmedPass !== 'REMOVERVOTOE3' && trimmedPass !== 'RESETEOE3') {
|
||||||
|
setAdminMsg({ text: 'Contraseña incorrecta. Se requiere "REMOVERVOTOE3".', type: 'error' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,17 +50,50 @@ export const AdminPanel: React.FC<AdminPanelProps> = ({
|
|||||||
setAdminMsg(null);
|
setAdminMsg(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ApiService.deleteVote(selectedVoter, password.trim());
|
await ApiService.deleteVote(selectedVoter, trimmedPass);
|
||||||
await onVoteResetSuccess();
|
await onVoteResetSuccess();
|
||||||
setAdminMsg({
|
setAdminMsg({
|
||||||
text: `Voto de "${selectedVoter}" restablecido y eliminado de PostgreSQL.`,
|
text: `Voto de "${selectedVoter}" removido exitosamente del sistema.`,
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
setSelectedVoter('');
|
setSelectedVoter('');
|
||||||
setPassword('');
|
setPassword('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAdminMsg({
|
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',
|
type: 'error',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -339,41 +373,62 @@ const VOTOS_EMBEBIDOS = ${JSON.stringify(rawVotes, null, 2)};`;
|
|||||||
|
|
||||||
{/* Reset section */}
|
{/* Reset section */}
|
||||||
<div className="space-y-3 pb-5 border-b border-white/5">
|
<div className="space-y-3 pb-5 border-b border-white/5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<label className="block text-xs uppercase tracking-wider text-gray-400 font-semibold">
|
<label className="block text-xs uppercase tracking-wider text-gray-400 font-semibold">
|
||||||
Restablecer Voto por Error Humano (DELETE)
|
Remover Voto Individual o de Pruebas (DELETE)
|
||||||
</label>
|
</label>
|
||||||
|
<span className="text-[10px] font-mono text-amber-400/80 bg-amber-400/10 px-2 py-0.5 rounded">
|
||||||
|
Clave requerida: REMOVERVOTOE3
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-12 gap-3">
|
||||||
|
<div className="sm:col-span-5">
|
||||||
<select
|
<select
|
||||||
value={selectedVoter}
|
value={selectedVoter}
|
||||||
onChange={(e) => setSelectedVoter(e.target.value)}
|
onChange={(e) => setSelectedVoter(e.target.value)}
|
||||||
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 uppercase font-mono"
|
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 uppercase font-mono"
|
||||||
>
|
>
|
||||||
<option value="">Selecciona votante...</option>
|
<option value="">Selecciona votante a remover...</option>
|
||||||
{registeredVoters.map((v) => (
|
{registeredVoters.map((v) => (
|
||||||
<option key={v} value={v}>
|
<option key={v} value={v}>
|
||||||
{v} → {rawVotes[v]}
|
{v} → {rawVotes[v]}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-4">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Contraseña: RESETEOE3"
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-3 flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleResetVote}
|
onClick={handleResetVote}
|
||||||
disabled={isDeleting || !selectedVoter}
|
disabled={isDeleting || !selectedVoter}
|
||||||
className="px-4 py-2.5 bg-red-950/30 hover:bg-red-900/50 border border-red-500/40 text-red-300 rounded-xl text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer disabled:opacity-30 flex items-center justify-center gap-2"
|
className="flex-1 px-3 py-2.5 bg-red-950/40 hover:bg-red-900/60 border border-red-500/40 text-red-300 rounded-xl text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer disabled:opacity-30 flex items-center justify-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
<span>{isDeleting ? 'Borrando...' : 'Restablecer'}</span>
|
<span>{isDeleting ? '...' : 'Remover'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetAllVotes}
|
||||||
|
disabled={isDeleting || registeredVoters.length === 0}
|
||||||
|
title="Vaciar todos los votos registrados para pruebas"
|
||||||
|
className="px-3 py-2.5 bg-amber-950/40 hover:bg-amber-900/60 border border-amber-500/40 text-amber-300 rounded-xl text-xs font-bold uppercase tracking-wider transition-colors cursor-pointer disabled:opacity-30 flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<span>Vaciar Todo</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{adminMsg && (
|
{adminMsg && (
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import confetti from 'canvas-confetti';
|
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 { CANDIDATOS_DEFAULT } from '../types';
|
||||||
|
import { ApiService } from '../services/api';
|
||||||
|
|
||||||
interface VotingStepProps {
|
interface VotingStepProps {
|
||||||
voterName: string;
|
voterName: string;
|
||||||
existingVoteFor: string | null;
|
existingVoteFor: string | null;
|
||||||
onCastVote: (candidate: string) => Promise<void>;
|
onCastVote: (candidate: string) => Promise<void>;
|
||||||
|
onVoteRemoved?: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,13 +16,43 @@ export const VotingStep: React.FC<VotingStepProps> = ({
|
|||||||
voterName,
|
voterName,
|
||||||
existingVoteFor,
|
existingVoteFor,
|
||||||
onCastVote,
|
onCastVote,
|
||||||
|
onVoteRemoved,
|
||||||
isLoading,
|
isLoading,
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedCandidate, setSelectedCandidate] = useState<string | null>(null);
|
const [selectedCandidate, setSelectedCandidate] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [showRemovePrompt, setShowRemovePrompt] = useState(false);
|
||||||
|
const [removePassword, setRemovePassword] = useState('');
|
||||||
|
const [isRemoving, setIsRemoving] = useState(false);
|
||||||
|
const [removeError, setRemoveError] = useState<string | null>(null);
|
||||||
|
|
||||||
const normalizedVoter = voterName.trim().toUpperCase();
|
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
|
// Función anti-autovoto
|
||||||
const isSelfVote = (candidateName: string): boolean => {
|
const isSelfVote = (candidateName: string): boolean => {
|
||||||
if (!normalizedVoter) return false;
|
if (!normalizedVoter) return false;
|
||||||
@@ -135,6 +167,7 @@ export const VotingStep: React.FC<VotingStepProps> = ({
|
|||||||
{/* Confirmation and Atomic Action Button */}
|
{/* Confirmation and Atomic Action Button */}
|
||||||
<div className="mt-6 pt-4 border-t border-white/5 space-y-3">
|
<div className="mt-6 pt-4 border-t border-white/5 space-y-3">
|
||||||
{existingVoteFor && (
|
{existingVoteFor && (
|
||||||
|
<>
|
||||||
<div className="p-3 rounded-xl bg-[#00B7D4]/10 border border-[#00B7D4]/30 text-xs text-white flex items-center justify-between">
|
<div className="p-3 rounded-xl bg-[#00B7D4]/10 border border-[#00B7D4]/30 text-xs text-white flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 text-[#00B7D4] shrink-0" />
|
<Sparkles className="w-4 h-4 text-[#00B7D4] shrink-0" />
|
||||||
@@ -149,6 +182,63 @@ export const VotingStep: React.FC<VotingStepProps> = ({
|
|||||||
Atómico
|
Atómico
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Opción de Remover Voto */}
|
||||||
|
{showRemovePrompt ? (
|
||||||
|
<div className="p-3 rounded-xl bg-red-950/40 border border-red-500/30 space-y-2.5 animate-in fade-in duration-150">
|
||||||
|
<div className="flex items-center justify-between text-xs text-red-200 font-semibold">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<KeyRound className="w-3.5 h-3.5 text-red-400" />
|
||||||
|
Remover voto de {normalizedVoter}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowRemovePrompt(false);
|
||||||
|
setRemoveError(null);
|
||||||
|
}}
|
||||||
|
className="text-gray-400 hover:text-white text-xs px-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={removePassword}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveExistingVote}
|
||||||
|
disabled={isRemoving || !removePassword}
|
||||||
|
className="px-3.5 py-2 bg-red-600 hover:bg-red-500 text-white rounded-lg text-xs font-bold uppercase tracking-wider disabled:opacity-40 cursor-pointer flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
<span>{isRemoving ? '...' : 'Remover'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{removeError && (
|
||||||
|
<p className="text-[11px] text-red-400 font-medium">{removeError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowRemovePrompt(true)}
|
||||||
|
className="text-[11px] text-gray-400 hover:text-red-400 font-mono flex items-center gap-1 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
<span>Remover este voto (con clave)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
+30
-2
@@ -78,9 +78,9 @@ export class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restablece el voto de un usuario específico (Administrador)
|
* Restablece el voto de un usuario específico (Administrador / Pruebas)
|
||||||
*/
|
*/
|
||||||
public static async deleteVote(voterName: string, adminKey: string = 'RESETEOE3'): Promise<boolean> {
|
public static async deleteVote(voterName: string, adminKey: string = 'REMOVERVOTOE3'): Promise<boolean> {
|
||||||
const voter = voterName.trim().toUpperCase();
|
const voter = voterName.trim().toUpperCase();
|
||||||
if (!voter) {
|
if (!voter) {
|
||||||
throw new Error('Debes especificar el nombre del votante a restablecer.');
|
throw new Error('Debes especificar el nombre del votante a restablecer.');
|
||||||
@@ -102,6 +102,26 @@ export class ApiService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restablece / elimina TODOS los votos (Administrador / Limpieza de Pruebas)
|
||||||
|
*/
|
||||||
|
public static async deleteAllVotes(adminKey: string = 'REMOVERVOTOE3'): Promise<{ success: boolean; count: number }> {
|
||||||
|
const response = await fetch('/api/votes', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-admin-key': adminKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => ({ error: response.statusText }));
|
||||||
|
throw new Error(errData.error || errData.detail || `Error (${response.status}) al vaciar votos`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conecta al canal Server-Sent Events (SSE) para actualizaciones en vivo cero-latencia
|
* Conecta al canal Server-Sent Events (SSE) para actualizaciones en vivo cero-latencia
|
||||||
*/
|
*/
|
||||||
@@ -196,4 +216,12 @@ export class ApiService {
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static clearSavedVoter(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(LAST_VOTER_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-15
@@ -27,12 +27,14 @@ async function runTests() {
|
|||||||
const serverProcess = spawn('node', ['dist-server/index.js'], {
|
const serverProcess = spawn('node', ['dist-server/index.js'], {
|
||||||
env: { ...process.env, PORT: String(PORT), NODE_ENV: 'production' },
|
env: { ...process.env, PORT: String(PORT), NODE_ENV: 'production' },
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
|
shell: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
serverProcess.stdout.on('data', (d) => process.stdout.write(`[SERVER] ${d}`));
|
serverProcess.stdout.on('data', (d) => process.stdout.write(`[SERVER] ${d}`));
|
||||||
serverProcess.stderr.on('data', (d) => process.stderr.write(`[SERVER ERR] ${d}`));
|
serverProcess.stderr.on('data', (d) => process.stderr.write(`[SERVER ERR] ${d}`));
|
||||||
|
serverProcess.on('error', (err) => console.error('[SERVER SPAWN ERROR]', err));
|
||||||
|
|
||||||
await sleep(2000);
|
await sleep(3500);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 2. Health check
|
// 2. Health check
|
||||||
@@ -97,33 +99,49 @@ async function runTests() {
|
|||||||
if (selfVote.status !== 400) throw new Error('Anti-autovoto no bloqueó la petición');
|
if (selfVote.status !== 400) throw new Error('Anti-autovoto no bloqueó la petición');
|
||||||
console.log(' ✅ Autovoto bloqueado correctamente con 400 Bad Request');
|
console.log(' ✅ Autovoto bloqueado correctamente con 400 Bad Request');
|
||||||
|
|
||||||
// 8. Verificar lista de votos actualizada
|
// 8. Test Protección por Contraseña Incorrecta
|
||||||
console.log('\n7. Test Verificación de Conteo y Persistencia:');
|
console.log('\n7. Test Protección de Contraseña (Rechazo con clave errónea):');
|
||||||
const updatedVotes = await request('/api/votes');
|
const badPassRes = await request('/api/vote/SERVANDO%20PEREZ', {
|
||||||
console.log(' Votos registrados:', updatedVotes.data.votes);
|
method: 'DELETE',
|
||||||
if (updatedVotes.data.total < 2) throw new Error('Total de votos incorrecto');
|
headers: { 'x-admin-key': 'CLAVE_INCORRECTA' },
|
||||||
console.log(' ✅ Conteo verificado: Total =', updatedVotes.data.total);
|
});
|
||||||
|
console.log(' Bad pass status:', badPassRes.status, 'Data:', badPassRes.data);
|
||||||
|
if (badPassRes.status !== 401) throw new Error('Contraseña incorrecta no fue rechazada');
|
||||||
|
console.log(' ✅ Clave incorrecta rechazada con 401 Unauthorized');
|
||||||
|
|
||||||
// 9. Test Restablecer Voto (Admin DELETE)
|
// 9. Test Remover Voto Individual con Clave "REMOVERVOTOE3"
|
||||||
console.log('\n8. Test Panel Admin - Restablecer Voto (/api/vote/:voter):');
|
console.log('\n8. Test Remover Voto Individual con REMOVERVOTOE3:');
|
||||||
const deleteRes = await request('/api/vote/SERVANDO%20PEREZ', {
|
const deleteRes = await request('/api/vote/SERVANDO%20PEREZ', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'x-admin-key': 'RESETEOE3' },
|
headers: { 'x-admin-key': 'REMOVERVOTOE3' },
|
||||||
});
|
});
|
||||||
console.log(' Delete status:', deleteRes.status, 'Data:', deleteRes.data);
|
console.log(' Delete status:', deleteRes.status, 'Data:', deleteRes.data);
|
||||||
if (!deleteRes.ok) throw new Error('Delete vote failed');
|
if (!deleteRes.ok) throw new Error('Delete vote failed');
|
||||||
console.log(' ✅ Voto de SERVANDO restablecido');
|
console.log(' ✅ Voto de SERVANDO removido exitosamente con REMOVERVOTOE3');
|
||||||
|
|
||||||
await sleep(500);
|
await sleep(400);
|
||||||
|
|
||||||
// 10. Confirmar estado final
|
// 10. Test Vaciar Todo con "REMOVERVOTOE3"
|
||||||
|
console.log('\n9. Test Vaciar Todos los Votos (Reset General) con REMOVERVOTOE3:');
|
||||||
|
const deleteAllRes = await request('/api/votes', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'x-admin-key': 'REMOVERVOTOE3' },
|
||||||
|
});
|
||||||
|
console.log(' Delete all status:', deleteAllRes.status, 'Data:', deleteAllRes.data);
|
||||||
|
if (!deleteAllRes.ok) throw new Error('Delete all votes failed');
|
||||||
|
console.log(' ✅ Todos los votos vaciados correctamente');
|
||||||
|
|
||||||
|
await sleep(400);
|
||||||
|
|
||||||
|
// 11. Confirmar estado final vacío
|
||||||
const finalVotes = await request('/api/votes');
|
const finalVotes = await request('/api/votes');
|
||||||
console.log('\n9. Estado final de votos:', finalVotes.data.votes);
|
console.log('\n10. Estado final de votos en base de datos:', finalVotes.data.votes);
|
||||||
|
if (finalVotes.data.total !== 0) throw new Error('Los votos no se vaciaron completamente');
|
||||||
|
|
||||||
sseReq.destroy();
|
sseReq.destroy();
|
||||||
|
|
||||||
console.log('\n=========================================');
|
console.log('\n=========================================');
|
||||||
console.log('🎉 TODAS LAS PRUEBAS EN VIVO PASARON CON ÉXITO (100% FUNCIONAL)');
|
console.log('🎉 TODAS LAS PRUEBAS DE REMOCIÓN EN VIVO PASARON CON ÉXITO (100% FUNCIONAL)');
|
||||||
console.log('=========================================\n');
|
console.log('=========================================\n');
|
||||||
} finally {
|
} finally {
|
||||||
serverProcess.kill();
|
serverProcess.kill();
|
||||||
|
|||||||
Reference in New Issue
Block a user