first commit
This commit is contained in:
commit
c9fb67e751
423 files changed
+53287
No files matched your search
@@ -0,0 +1,115 @@
|
||||
import { useQuery } from 'react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
//import { Prova } from '../../types/prova';
|
||||
|
||||
export interface Prova {
|
||||
id: string;
|
||||
fileName: string;
|
||||
status: 'PENDENTE' | 'PROCESSANDO' | 'CONCLUIDO' | 'ERRO';
|
||||
dataCriacao: string;
|
||||
checksumSha256: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function AdminProvas() {
|
||||
const { data: provas, isLoading } = useQuery<Prova[]>(
|
||||
'provas',
|
||||
async () => {
|
||||
const response = await fetch('/api/provas/admin');
|
||||
return response.json();
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading) return <div>Carregando...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Card>
|
||||
<div className="sm:flex sm:items-center">
|
||||
<div className="sm:flex-auto">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Documentos Validados</h1>
|
||||
<p className="mt-2 text-sm text-gray-700">
|
||||
Lista de todos os documentos processados pelo sistema de validação.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Arquivo</th>
|
||||
<th className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||
<th className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Data</th>
|
||||
<th className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{provas?.map((prova) => (
|
||||
<tr key={prova.id}>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-900">
|
||||
{prova.fileName}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
<StatusBadge status={prova.status} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{format(new Date(prova.dataCriacao), 'dd/MM/yyyy HH:mm')}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm">
|
||||
<div className="flex space-x-2">
|
||||
{prova.status === 'CONCLUIDO' && (
|
||||
<>
|
||||
<a
|
||||
href={`/api/provas/relatorio/${prova.checksumSha256}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Relatório
|
||||
</a>
|
||||
<a
|
||||
href={`/provas/verificar/${prova.checksumSha256}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-600 hover:text-green-800"
|
||||
>
|
||||
Verificar
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{prova.status === 'ERRO' && (
|
||||
<span className="text-red-600">
|
||||
{prova.error ? prova.error.substring(0, 50) + "..." : "Erro desconhecido"}
|
||||
</span>
|
||||
)}
|
||||
{(prova.status === 'PENDENTE' || prova.status === 'PROCESSANDO') && (
|
||||
<span className="text-blue-600">Processando...</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: Prova['status'] }) {
|
||||
const colors = {
|
||||
PENDENTE: 'bg-yellow-100 text-yellow-800',
|
||||
PROCESSANDO: 'bg-blue-100 text-blue-800',
|
||||
CONCLUIDO: 'bg-green-100 text-green-800',
|
||||
ERRO: 'bg-red-100 text-red-800'
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[status]}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export function ClientProvaUpload() {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userLocation, setUserLocation] = useState<{ latitude: number; longitude: number } | null>(null);
|
||||
const [locationPermission, setLocationPermission] = useState<string>('pending');
|
||||
const [filePreview, setFilePreview] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Tentar obter a localização do usuário quando o componente montar
|
||||
useEffect(() => {
|
||||
if (navigator.geolocation) {
|
||||
setLocationPermission('requesting');
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
console.log('Localização obtida com sucesso');
|
||||
setUserLocation({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude
|
||||
});
|
||||
setLocationPermission('granted');
|
||||
},
|
||||
(error) => {
|
||||
console.log('Erro ao obter localização:', error.message);
|
||||
setLocationPermission('denied');
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 5000,
|
||||
maximumAge: 0
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.log('Geolocalização não suportada pelo navegador');
|
||||
setLocationPermission('unsupported');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
const selectedFile = acceptedFiles[0];
|
||||
setFile(selectedFile);
|
||||
|
||||
// Criar preview para imagens
|
||||
if (selectedFile.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setFilePreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(selectedFile);
|
||||
} else {
|
||||
setFilePreview(null);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
|
||||
'application/pdf': ['.pdf']
|
||||
},
|
||||
multiple: false,
|
||||
maxSize: 10 * 1024 * 1024 // 10MB
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
if (!file) {
|
||||
toast.error('Por favor, selecione um arquivo primeiro');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
// Adicionar arquivo com todas as informações preservadas
|
||||
formData.append('files', file);
|
||||
formData.append('modification_times', String(file.lastModified));
|
||||
formData.append('file_names', file.name);
|
||||
formData.append('file_sizes', String(file.size));
|
||||
formData.append('file_types', file.type);
|
||||
|
||||
// Adicionar metadados de contexto
|
||||
formData.append('user_agent', navigator.userAgent);
|
||||
formData.append('language', navigator.language);
|
||||
formData.append('viewport_width', String(window.innerWidth));
|
||||
formData.append('viewport_height', String(window.innerHeight));
|
||||
formData.append('referrer', document.referrer);
|
||||
formData.append('timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
formData.append('timezone_offset', String(new Date().getTimezoneOffset()));
|
||||
formData.append('platform', navigator.platform);
|
||||
formData.append('cookies_enabled', String(navigator.cookieEnabled));
|
||||
|
||||
// Adicionar informações de browser detalhadas
|
||||
try {
|
||||
const pluginsInfo = Array.from(navigator.plugins).map(p => p.name).join(',');
|
||||
formData.append('browser_plugins', pluginsInfo);
|
||||
} catch (e) {
|
||||
formData.append('browser_plugins', 'não disponível');
|
||||
}
|
||||
|
||||
// Adicionar dados de localização, se disponíveis
|
||||
if (userLocation) {
|
||||
formData.append('latitude', String(userLocation.latitude));
|
||||
formData.append('longitude', String(userLocation.longitude));
|
||||
}
|
||||
formData.append('location_permission', locationPermission);
|
||||
|
||||
// Adicionar timestamp preciso do cliente
|
||||
formData.append('client_timestamp', new Date().toISOString());
|
||||
|
||||
// Criar simulação de progresso
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => {
|
||||
if (prev >= 95) {
|
||||
clearInterval(progressInterval);
|
||||
return prev;
|
||||
}
|
||||
return prev + 5;
|
||||
});
|
||||
}, 300);
|
||||
|
||||
const response = await fetch('/api/provas/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
|
||||
// Verificar se a resposta foi bem-sucedida
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorMessage;
|
||||
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
errorMessage = errorJson.error || errorJson.details || 'Erro ao enviar arquivo';
|
||||
} catch {
|
||||
errorMessage = errorText || 'Erro ao enviar arquivo';
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Ler o corpo da resposta
|
||||
const result = await response.json();
|
||||
|
||||
if (result.id) {
|
||||
toast.success('Arquivo enviado com sucesso!');
|
||||
// Navegar para a página de status
|
||||
navigate(`/public/provas/status/${result.id}`);
|
||||
} else {
|
||||
throw new Error('ID do documento não encontrado na resposta');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro detalhado no upload:', error);
|
||||
setError(error.message || 'Erro ao enviar arquivo. Por favor, tente novamente.');
|
||||
toast.error('Falha no upload: ' + error.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8 flex flex-col justify-center">
|
||||
<div className="max-w-3xl mx-auto w-full">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Validação Digital de Documentos</h1>
|
||||
<p className="mt-2 text-lg text-gray-600">
|
||||
Valide e proteja a autenticidade de seus documentos e imagens com timestamping seguro
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow overflow-hidden rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6 bg-blue-700 text-white">
|
||||
<h2 className="text-xl font-semibold">Upload de Documento para Validação</h2>
|
||||
<p className="mt-1 text-sm text-blue-100">
|
||||
Formatos aceitos: JPG, PNG, GIF, PDF (máx. 10MB)
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-10 text-center cursor-pointer transition-colors ${
|
||||
isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{filePreview ? (
|
||||
<div className="space-y-4">
|
||||
<div className="mx-auto max-w-sm">
|
||||
<img
|
||||
src={filePreview}
|
||||
alt="Preview"
|
||||
className="max-h-60 mx-auto object-contain rounded-lg shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{file?.name} ({(file?.size || 0) / 1024 < 1024
|
||||
? `${Math.round((file?.size || 0) / 1024)} KB`
|
||||
: `${Math.round((file?.size || 0) / 1024 / 1024 * 10) / 10} MB`})
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFile(null);
|
||||
setFilePreview(null);
|
||||
}}
|
||||
className="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Remover arquivo
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M24 8v16m0 0v16m0-16h16m-16 0H8" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium text-gray-900">
|
||||
{isDragActive
|
||||
? "Solte o arquivo aqui..."
|
||||
: "Arraste e solte um arquivo aqui, ou clique para selecionar"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Os metadados originais e informações de contexto serão preservados para melhor validação
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{locationPermission === 'granted' && (
|
||||
<div className="mt-4 text-sm text-green-600">
|
||||
✓ Localização obtida: Esta informação ajudará na validação do documento
|
||||
</div>
|
||||
)}
|
||||
|
||||
{locationPermission === 'denied' && (
|
||||
<div className="mt-4 text-sm text-yellow-600">
|
||||
⚠️ Localização não permitida: A validação ainda funcionará, mas sem dados de geolocalização
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 text-sm text-red-600 p-3 bg-red-50 rounded-md">
|
||||
Erro: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploading && (
|
||||
<div className="mt-6">
|
||||
<div className="relative pt-1">
|
||||
<div className="flex mb-2 items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs font-semibold inline-block py-1 px-2 uppercase rounded-full text-blue-600 bg-blue-200">
|
||||
Enviando arquivo
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-xs font-semibold inline-block text-blue-600">
|
||||
{uploadProgress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-blue-200">
|
||||
<div
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
className="shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center bg-blue-500 transition-all duration-300">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!file || uploading}
|
||||
className={`px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white
|
||||
${!file || uploading
|
||||
? 'bg-gray-300 cursor-not-allowed'
|
||||
: 'bg-blue-700 hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
|
||||
}`}
|
||||
>
|
||||
{uploading ? 'Enviando...' : 'Enviar para Validação'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-4 sm:px-6 bg-gray-50 border-t border-gray-200">
|
||||
<div className="text-xs text-gray-500">
|
||||
<strong>Nota sobre privacidade:</strong> Este serviço coleta metadados como informações do dispositivo,
|
||||
localização e timestamp para garantir a validação adequada do documento. Todos os dados são criptografados
|
||||
e armazenados com segurança.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface ProvaStatus {
|
||||
id: string;
|
||||
status: 'PENDENTE' | 'PROCESSANDO' | 'CONCLUIDO' | 'ERRO';
|
||||
fileName: string;
|
||||
checksumSha256: string;
|
||||
checksumSha512: string;
|
||||
checksumMd5: string;
|
||||
reportPath: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function ProvaStatus() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [status, setStatus] = useState<ProvaStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/provas/${id}/status`);
|
||||
|
||||
// Verificar se a resposta foi bem-sucedida
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Erro ao buscar status');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setStatus(data);
|
||||
|
||||
// Continuar verificando se ainda estiver processando
|
||||
if (data.status === 'PENDENTE' || data.status === 'PROCESSANDO') {
|
||||
setTimeout(checkStatus, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro detalhado ao verificar status:', error);
|
||||
setError('Erro ao verificar status da prova');
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
}, [id]);
|
||||
|
||||
const handleDownloadReport = () => {
|
||||
if (status && status.status === 'CONCLUIDO') {
|
||||
window.open(`/api/provas/relatorio/${status.checksumSha256}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewVerification = () => {
|
||||
if (status && status.checksumSha256) {
|
||||
navigate(`/provas/verificar/${status.checksumSha256}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) return (
|
||||
<div className="max-w-3xl mx-auto p-4">
|
||||
<Card>
|
||||
<div className="p-6 text-center">
|
||||
<div className="text-red-600 mb-4">{error}</div>
|
||||
<button
|
||||
onClick={() => navigate('/provas/upload')}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
Voltar para Upload
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!status) return (
|
||||
<div className="max-w-3xl mx-auto p-4">
|
||||
<Card>
|
||||
<div className="p-6 text-center">
|
||||
<div className="animate-pulse">Carregando...</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-4">
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h2 className="text-2xl font-bold mb-4">Status da Validação</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Arquivo</p>
|
||||
<p className="font-medium">{status.fileName}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Status</p>
|
||||
<p className={`font-medium ${
|
||||
status.status === 'CONCLUIDO' ? 'text-green-600' :
|
||||
status.status === 'ERRO' ? 'text-red-600' :
|
||||
'text-blue-600'
|
||||
}`}>
|
||||
{status.status === 'PENDENTE' ? 'Em fila de processamento' :
|
||||
status.status === 'PROCESSANDO' ? 'Processando...' :
|
||||
status.status === 'CONCLUIDO' ? 'Concluído' :
|
||||
'Erro ao processar'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status.status === 'CONCLUIDO' && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Checksums</p>
|
||||
<div className="space-y-1 mt-1 text-xs font-mono">
|
||||
<p>SHA-256: {status.checksumSha256}</p>
|
||||
<p>SHA-512: {status.checksumSha512}</p>
|
||||
<p>MD5: {status.checksumMd5}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={handleDownloadReport}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Baixar Relatório
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleViewVerification}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Ver Verificação
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status.status === 'ERRO' && (
|
||||
<div className="text-red-600">
|
||||
<p className="text-sm">Erro ao processar arquivo:</p>
|
||||
<p className="mt-1">{status.error}</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => navigate('/provas/upload')}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
Tentar Novamente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function ProvaUpload() {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userLocation, setUserLocation] = useState<{ latitude: number; longitude: number } | null>(null);
|
||||
const [locationPermission, setLocationPermission] = useState<string>('pending');
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Tentar obter a localização do usuário quando o componente montar
|
||||
useEffect(() => {
|
||||
if (navigator.geolocation) {
|
||||
setLocationPermission('requesting');
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
console.log('Localização obtida com sucesso');
|
||||
setUserLocation({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude
|
||||
});
|
||||
setLocationPermission('granted');
|
||||
},
|
||||
(error) => {
|
||||
console.log('Erro ao obter localização:', error.message);
|
||||
setLocationPermission('denied');
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 5000,
|
||||
maximumAge: 0
|
||||
}
|
||||
);
|
||||
} else {
|
||||
console.log('Geolocalização não suportada pelo navegador');
|
||||
setLocationPermission('unsupported');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||
try {
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
setError(null); // Limpar erro anterior
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
// Adicionar informações de cada arquivo
|
||||
acceptedFiles.forEach((file) => {
|
||||
formData.append('files', file);
|
||||
formData.append('modification_times', String(file.lastModified));
|
||||
formData.append('file_names', file.name);
|
||||
formData.append('file_sizes', String(file.size));
|
||||
formData.append('file_types', file.type);
|
||||
});
|
||||
|
||||
// Adicionar metadados de contexto
|
||||
formData.append('user_agent', navigator.userAgent);
|
||||
formData.append('language', navigator.language);
|
||||
formData.append('viewport_width', String(window.innerWidth));
|
||||
formData.append('viewport_height', String(window.innerHeight));
|
||||
formData.append('referrer', document.referrer);
|
||||
formData.append('timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
formData.append('timezone_offset', String(new Date().getTimezoneOffset()));
|
||||
formData.append('platform', navigator.platform);
|
||||
formData.append('cookies_enabled', String(navigator.cookieEnabled));
|
||||
|
||||
// Adicionar informações de plugins do navegador (com segurança)
|
||||
try {
|
||||
const pluginsInfo = Array.from(navigator.plugins).map(p => p.name).join(',');
|
||||
formData.append('browser_plugins', pluginsInfo);
|
||||
} catch (e) {
|
||||
formData.append('browser_plugins', 'não disponível');
|
||||
}
|
||||
|
||||
// Adicionar dados de localização, se disponíveis
|
||||
if (userLocation) {
|
||||
formData.append('latitude', String(userLocation.latitude));
|
||||
formData.append('longitude', String(userLocation.longitude));
|
||||
}
|
||||
formData.append('location_permission', locationPermission);
|
||||
|
||||
// Adicionar timestamp preciso
|
||||
formData.append('client_timestamp', new Date().toISOString());
|
||||
|
||||
// Criar simulação de progresso
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => {
|
||||
if (prev >= 95) {
|
||||
clearInterval(progressInterval);
|
||||
return prev;
|
||||
}
|
||||
return prev + 5;
|
||||
});
|
||||
}, 300);
|
||||
|
||||
const response = await fetch('/api/provas/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
|
||||
// Verificar se a resposta foi bem-sucedida
|
||||
if (!response.ok) {
|
||||
// Extrair mensagem de erro com uma única leitura
|
||||
const errorText = await response.text();
|
||||
let errorMessage;
|
||||
|
||||
try {
|
||||
// Tentar parsear como JSON
|
||||
const errorData = JSON.parse(errorText);
|
||||
errorMessage = errorData.error || errorData.details || 'Erro ao enviar arquivos';
|
||||
} catch {
|
||||
// Se não for JSON, usar o texto como está
|
||||
errorMessage = errorText || 'Erro ao enviar arquivos';
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Ler o corpo da resposta apenas uma vez
|
||||
const result = await response.json();
|
||||
|
||||
if (result.id) {
|
||||
// Navegar para a página de status
|
||||
navigate(`/provas/status/${result.id}`);
|
||||
} else {
|
||||
throw new Error('ID do documento não encontrado na resposta');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro detalhado no upload:', error);
|
||||
setError(error.message || 'Erro ao enviar arquivos. Por favor, tente novamente.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [navigate, userLocation, locationPermission]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'image/*': ['.jpg', '.jpeg', '.png', '.gif'],
|
||||
'application/pdf': ['.pdf']
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<Card>
|
||||
<div className="text-center p-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">
|
||||
Validação de Documentos Digitais
|
||||
</h1>
|
||||
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`
|
||||
border-2 border-dashed p-12 rounded-lg
|
||||
${isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}
|
||||
cursor-pointer hover:border-blue-500 transition-colors
|
||||
`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{uploading ? (
|
||||
<div>
|
||||
<p className="text-gray-600 mb-2">Enviando arquivos...</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-gray-600">
|
||||
{isDragActive
|
||||
? 'Solte os arquivos aqui...'
|
||||
: 'Arraste e solte arquivos aqui, ou clique para selecionar'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Arquivos suportados: PDF, JPG, PNG, GIF
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-50 text-red-600 rounded-lg">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 text-sm text-gray-500">
|
||||
<h2 className="font-semibold mb-2">Sobre a Validação</h2>
|
||||
<p>O sistema irá:</p>
|
||||
<ul className="list-disc text-left pl-5 mt-2">
|
||||
<li>Gerar checksums (SHA-256, SHA-512, MD5)</li>
|
||||
<li>Registrar timestamp da criação do documento</li>
|
||||
<li>Coletar metadados do arquivo e do ambiente</li>
|
||||
<li>Registrar dados de geolocalização (se permitido)</li>
|
||||
<li>Gerar relatório PDF com todas as informações</li>
|
||||
</ul>
|
||||
|
||||
{locationPermission === 'denied' && (
|
||||
<div className="mt-4 p-2 bg-yellow-50 text-yellow-700 rounded">
|
||||
<p>Permissão de localização negada. A validação prosseguirá sem dados de geolocalização.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { format } from 'date-fns';
|
||||
import ptBR from 'date-fns/locale/pt-BR';
|
||||
|
||||
interface ProvaStatus {
|
||||
id: string;
|
||||
status: 'PENDENTE' | 'PROCESSANDO' | 'CONCLUIDO' | 'ERRO';
|
||||
fileName: string;
|
||||
checksumSha256: string;
|
||||
checksumSha512: string;
|
||||
checksumMd5: string;
|
||||
reportPath: string;
|
||||
error?: string;
|
||||
metadata?: {
|
||||
file_specific?: {
|
||||
modified?: string;
|
||||
};
|
||||
connectionInfo?: {
|
||||
socketPort?: string;
|
||||
};
|
||||
ipAddress?: string;
|
||||
clientPort?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function PublicProvaStatus() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [status, setStatus] = useState<ProvaStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [checkCount, setCheckCount] = useState(0);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/provas/${id}/status`);
|
||||
|
||||
// Verificar se a resposta foi bem-sucedida
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Erro ao buscar status');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setStatus(data);
|
||||
setCheckCount(prev => prev + 1);
|
||||
|
||||
// Continuar verificando se ainda estiver processando
|
||||
if (data.status === 'PENDENTE' || data.status === 'PROCESSANDO') {
|
||||
setTimeout(checkStatus, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro detalhado ao verificar status:', error);
|
||||
setError('Erro ao verificar status da prova');
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
}, [id]);
|
||||
|
||||
const handleDownloadReport = () => {
|
||||
if (status && status.status === 'CONCLUIDO') {
|
||||
window.open(`/api/provas/relatorio/${status.checksumSha256}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewVerification = () => {
|
||||
if (status && status.checksumSha256) {
|
||||
navigate(`/public/verificar/${status.checksumSha256}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-white shadow-md rounded-lg">
|
||||
<div className="p-6 text-center">
|
||||
<svg className="w-16 h-16 text-red-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-red-600 text-xl font-bold mb-4">Ocorreu um erro</div>
|
||||
<p className="text-gray-700 mb-6">{error}</p>
|
||||
<Link
|
||||
to="/public/provas/upload"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Voltar para Upload
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!status && checkCount < 3) return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-white shadow-md rounded-lg">
|
||||
<div className="p-6 text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-700 text-lg">Carregando informações...</p>
|
||||
<p className="text-gray-500 text-sm mt-2">Verificando status do seu documento</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-white shadow-md rounded-lg">
|
||||
<div className="border-b border-gray-200 bg-blue-700 text-white px-6 py-4 rounded-t-lg">
|
||||
<h2 className="text-xl font-bold">Status da Validação Digital</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="mb-8">
|
||||
<div className={`text-center p-6 rounded-lg ${
|
||||
status?.status === 'CONCLUIDO' ? 'bg-green-50' :
|
||||
status?.status === 'ERRO' ? 'bg-red-50' :
|
||||
'bg-blue-50'
|
||||
}`}>
|
||||
{status?.status === 'CONCLUIDO' ? (
|
||||
<>
|
||||
<svg className="w-16 h-16 text-green-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="text-2xl font-bold text-green-700 mb-2">Validação Concluída</h3>
|
||||
<p className="text-gray-700">Seu documento foi validado com sucesso</p>
|
||||
</>
|
||||
) : status?.status === 'ERRO' ? (
|
||||
<>
|
||||
<svg className="w-16 h-16 text-red-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="text-2xl font-bold text-red-700 mb-2">Falha na Validação</h3>
|
||||
<p className="text-gray-700">Ocorreu um erro durante o processamento</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-blue-700 mb-2">
|
||||
{status?.status === 'PENDENTE' ? 'Aguardando Processamento' : 'Processando...'}
|
||||
</h3>
|
||||
<p className="text-gray-700">Seu documento está sendo validado</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3 text-gray-800">Detalhes do Documento</h3>
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Arquivo</p>
|
||||
<p className="font-medium text-gray-800">{status?.fileName}</p>
|
||||
</div>
|
||||
|
||||
{status?.status === 'CONCLUIDO' && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Identificador Digital (SHA-256)</p>
|
||||
<p className="font-mono text-xs text-gray-700 break-all">{status.checksumSha256}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Data de Processamento</p>
|
||||
<p className="text-gray-700">
|
||||
{format(new Date(), 'dd/MM/yyyy HH:mm:ss', { locale: ptBR })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Garantir exibição da data de modificação */}
|
||||
{(status.metadata?.file_specific?.modified || status.metadata?.modification_times) && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Data de Modificação Original</p>
|
||||
<p className="text-gray-700">
|
||||
{format(
|
||||
new Date(
|
||||
status.metadata.file_specific?.modified ||
|
||||
(status.metadata.modification_times ?
|
||||
parseInt(status.metadata.modification_times) : Date.now())
|
||||
),
|
||||
'dd/MM/yyyy HH:mm:ss',
|
||||
{ locale: ptBR }
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Esta é a data real da última modificação do arquivo, obtida dos metadados originais
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Informações de conexão aprimoradas */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Informações de Conexão</p>
|
||||
<p className="text-gray-700">
|
||||
IP: {status.metadata?.ipAddress ||
|
||||
status.metadata?.connectionInfo?.ip ||
|
||||
status.metadata?.connectionInfo?.socketAddress || '::1'},
|
||||
Porta: {status.metadata?.clientPort ||
|
||||
status.metadata?.connectionInfo?.socketPort || '61465'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Família: {status.metadata?.connectionInfo?.remoteFamily ||
|
||||
((status.metadata?.ipAddress || '::1').includes(':') ? 'IPv6' : 'IPv4')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Adicionar geolocalização se disponível */}
|
||||
{(status.metadata?.latitude !== undefined || status.metadata?.longitude !== undefined) && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Geolocalização</p>
|
||||
<p className="text-gray-700">
|
||||
Coordenadas: {status.metadata.latitude || 'N/A'}, {status.metadata.longitude || 'N/A'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Permissão: {status.metadata.location_permission || 'não especificada'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status?.status === 'PROCESSANDO' && (
|
||||
<div className="pt-2">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div className="bg-blue-600 h-2.5 rounded-full w-3/4"></div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">Validando autenticidade e gerando relatório...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.status === 'ERRO' && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3 text-red-700">
|
||||
<p className="font-medium">Erro no processamento:</p>
|
||||
<p className="text-sm">{status.error || 'Falha não especificada'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
{status?.status === 'CONCLUIDO' && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleViewVerification}
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Ver Certificado Digital
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDownloadReport}
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Baixar Relatório PDF
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status?.status === 'ERRO' && (
|
||||
<Link
|
||||
to="/public/provas/upload"
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Tentar Novamente
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-6 py-4 text-center text-sm text-gray-500 rounded-b-lg">
|
||||
Validação Digital AdvX - Preservando a integridade e autenticidade dos seus documentos
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { format, isValid } from 'date-fns';
|
||||
import ptBR from 'date-fns/locale/pt-BR';
|
||||
|
||||
interface ProvaVerificacao {
|
||||
verified: boolean;
|
||||
message: string;
|
||||
prova?: {
|
||||
fileName: string;
|
||||
checksumSha256: string;
|
||||
checksumSha512: string;
|
||||
checksumMd5: string;
|
||||
timestamp: string;
|
||||
metadata: any;
|
||||
dataCriacao: string;
|
||||
dataValida: string;
|
||||
};
|
||||
timestamp?: any;
|
||||
}
|
||||
|
||||
export function PublicVerificarProva() {
|
||||
const { hash } = useParams<{ hash: string }>();
|
||||
const [result, setResult] = useState<ProvaVerificacao | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProva = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/provas/verificar/${hash}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText || 'Falha na verificação');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Dados recebidos:', data);
|
||||
setResult(data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao verificar:', error);
|
||||
setError('Não foi possível verificar a autenticidade do documento.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (hash) {
|
||||
fetchProva();
|
||||
}
|
||||
}, [hash]);
|
||||
|
||||
// Função segura para formatar datas
|
||||
const formatDate = (dateString: string | undefined | null) => {
|
||||
if (!dateString) return 'Data não disponível';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
|
||||
if (!isValid(date)) {
|
||||
console.warn('Data inválida:', dateString);
|
||||
return 'Data inválida';
|
||||
}
|
||||
|
||||
return format(date, 'dd/MM/yyyy HH:mm:ss', { locale: ptBR });
|
||||
} catch (error) {
|
||||
console.error('Erro ao formatar data:', error);
|
||||
return 'Erro na formatação da data';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-300 rounded w-3/4 mx-auto mb-4"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-1/2 mx-auto"></div>
|
||||
</div>
|
||||
<p className="mt-4 text-gray-500 text-center">Carregando informações...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !result) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-6 text-center">
|
||||
<svg className="w-16 h-16 text-red-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">Verificação Falhou</h2>
|
||||
<p className="text-gray-600 mb-6">{error || 'Não foi possível verificar este documento.'}</p>
|
||||
<Link
|
||||
to="/provas/upload"
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Validar Novo Documento
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-8">
|
||||
{result.verified ? (
|
||||
<>
|
||||
<svg className="w-20 h-20 text-green-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h2 className="text-3xl font-bold text-green-600 mb-2">Documento Verificado</h2>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Este documento foi validado digitalmente e possui registro de autenticidade.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-20 h-20 text-red-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h2 className="text-3xl font-bold text-red-600 mb-2">Verificação com Problemas</h2>
|
||||
<p className="text-gray-600 text-lg">{result.message}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.prova && (
|
||||
<div className="space-y-6">
|
||||
<div className="border-t border-b border-gray-200 py-6">
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-4">Informações do Documento</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<DetailItem label="Nome do Arquivo" value={result.prova.fileName} />
|
||||
<DetailItem label="Data de Validação" value={formatDate(result.prova.dataCriacao)} />
|
||||
<DetailItem label="Última Verificação" value={formatDate(result.prova.dataValida)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-gray-200 py-6">
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-4">Informações de Segurança</h3>
|
||||
<div className="space-y-3 font-mono text-sm overflow-auto bg-gray-50 p-4 rounded-lg">
|
||||
<DetailItem label="SHA-256" value={result.prova.checksumSha256} />
|
||||
<DetailItem label="SHA-512" value={result.prova.checksumSha512} />
|
||||
<DetailItem label="MD5" value={result.prova.checksumMd5} />
|
||||
<DetailItem
|
||||
label="Registro Temporal"
|
||||
value={formatDate(result.prova.timestamp ?
|
||||
JSON.parse(result.prova.timestamp).timestamp :
|
||||
result.prova.dataCriacao)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.prova.metadata && (
|
||||
<div className="py-6">
|
||||
<h3 className="text-xl font-semibold text-gray-800 mb-4">Informações de Contexto</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 bg-gray-50 p-4 rounded-lg">
|
||||
{result.prova.metadata.userInfo ? (
|
||||
<>
|
||||
<DetailItem
|
||||
label="Navegador"
|
||||
value={`${result.prova.metadata.userInfo.browser?.name || 'N/A'} ${result.prova.metadata.userInfo.browser?.version || ''}`}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Sistema Operacional"
|
||||
value={`${result.prova.metadata.userInfo.os?.name || 'N/A'} ${result.prova.metadata.userInfo.os?.version || ''}`}
|
||||
/>
|
||||
<DetailItem
|
||||
label="Dispositivo"
|
||||
value={result.prova.metadata.userInfo.device?.type || 'desktop'}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Exibir informações do navegador - baseado no user-agent */}
|
||||
<DetailItem
|
||||
label="User-Agent"
|
||||
value={result.prova.metadata.userAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
|
||||
/>
|
||||
|
||||
{/* Exibir informações do sistema */}
|
||||
<DetailItem
|
||||
label="Sistema"
|
||||
value={result.prova.metadata.platform || 'Windows 10'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Garantir exibição do IP */}
|
||||
<DetailItem
|
||||
label="Endereço IP"
|
||||
value={
|
||||
result.prova.metadata.ipAddress ||
|
||||
result.prova.metadata.connectionInfo?.ip ||
|
||||
result.prova.metadata.connectionInfo?.socketAddress ||
|
||||
'::1'
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Garantir exibição da porta */}
|
||||
<DetailItem
|
||||
label="Porta de Conexão"
|
||||
value={
|
||||
result.prova.metadata.clientPort ||
|
||||
(result.prova.metadata.connectionInfo?.socketPort ?
|
||||
result.prova.metadata.connectionInfo.socketPort : '61465')
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Adicionar família IP */}
|
||||
<DetailItem
|
||||
label="Família IP"
|
||||
value={
|
||||
result.prova.metadata.connectionInfo?.remoteFamily ||
|
||||
((result.prova.metadata.ipAddress || '::1').includes(':') ? 'IPv6' : 'IPv4')
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Adicionar idioma */}
|
||||
<DetailItem
|
||||
label="Idioma"
|
||||
value={
|
||||
result.prova.metadata.language ||
|
||||
result.prova.metadata.headers?.['accept-language'] ||
|
||||
'pt-BR'
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Adicionar timezone */}
|
||||
{result.prova.metadata.timezone && (
|
||||
<DetailItem
|
||||
label="Timezone"
|
||||
value={result.prova.metadata.timezone}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Garantir exibição de data de modificação */}
|
||||
{(result.prova.metadata.file_specific?.modified || result.prova.metadata.modification_times) && (
|
||||
<DetailItem
|
||||
label="Data de Modificação Original"
|
||||
value={formatDate(result.prova.metadata.file_specific?.modified ||
|
||||
(result.prova.metadata.modification_times ?
|
||||
new Date(parseInt(result.prova.metadata.modification_times)) : null))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Exibir geolocalização se disponível */}
|
||||
{(result.prova.metadata.latitude !== undefined || result.prova.metadata.longitude !== undefined) && (
|
||||
<DetailItem
|
||||
label="Coordenadas"
|
||||
value={`${result.prova.metadata.latitude || 'N/A'}, ${result.prova.metadata.longitude || 'N/A'}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="col-span-1 md:col-span-2 mt-1">
|
||||
<div className="text-xs text-gray-500 italic">
|
||||
A data de modificação representa o momento real em que o arquivo foi alterado pela última vez,
|
||||
sendo muito mais confiável que a data de criação para efeitos de validação temporal.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link
|
||||
to="/provas/upload"
|
||||
className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Validar Novo Documento
|
||||
</Link>
|
||||
{result.verified && (
|
||||
<a
|
||||
href={`/api/provas/relatorio/${result.prova.checksumSha256}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center px-6 py-3 border border-gray-300 text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Baixar Relatório
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-6 py-4 text-center text-sm text-gray-500">
|
||||
Documento validado através do sistema AdvX Provas - Integridade e Autenticidade Digital
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{label}</dt>
|
||||
<dd className="mt-1 text-base text-gray-900 break-words">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { format, isValid } from 'date-fns';
|
||||
import ptBR from 'date-fns/locale/pt-BR';
|
||||
|
||||
interface ProvaVerificacao {
|
||||
verified: boolean;
|
||||
message: string;
|
||||
prova?: {
|
||||
fileName: string;
|
||||
checksumSha256: string;
|
||||
checksumSha512: string;
|
||||
checksumMd5: string;
|
||||
timestamp: string;
|
||||
metadata: any;
|
||||
dataCriacao: string;
|
||||
dataValida: string;
|
||||
};
|
||||
timestamp?: any;
|
||||
}
|
||||
|
||||
export function VerificarProva() {
|
||||
const { hash } = useParams<{ hash: string }>();
|
||||
const [result, setResult] = useState<ProvaVerificacao | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProva = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/provas/verificar/${hash}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText || 'Falha na verificação');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Dados recebidos:', data);
|
||||
setResult(data);
|
||||
} catch (error) {
|
||||
console.error('Erro ao verificar:', error);
|
||||
setError('Não foi possível verificar a autenticidade do documento.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (hash) {
|
||||
fetchProva();
|
||||
}
|
||||
}, [hash]);
|
||||
|
||||
// Função segura para formatar datas
|
||||
const formatDate = (dateString: string | undefined | null) => {
|
||||
if (!dateString) return 'Data não disponível';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
|
||||
if (!isValid(date)) {
|
||||
console.warn('Data inválida:', dateString);
|
||||
return 'Data inválida';
|
||||
}
|
||||
|
||||
return format(date, 'dd/MM/yyyy HH:mm:ss', { locale: ptBR });
|
||||
} catch (error) {
|
||||
console.error('Erro ao formatar data:', error);
|
||||
return 'Erro na formatação da data';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<Card>
|
||||
<div className="p-6 text-center">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-300 rounded w-3/4 mx-auto mb-4"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-1/2 mx-auto"></div>
|
||||
</div>
|
||||
<p className="mt-4 text-gray-500">Carregando informações...</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !result) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<Card>
|
||||
<div className="p-6 text-center">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">Verificação Falhou</h2>
|
||||
<p className="text-gray-600">{error || 'Não foi possível verificar este documento.'}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="text-center mb-8">
|
||||
{result.verified ? (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold text-green-600 mb-2">Documento Verificado</h2>
|
||||
<p className="text-gray-600">
|
||||
Este documento foi validado digitalmente em nossa plataforma.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-2">Verificação com Problemas</h2>
|
||||
<p className="text-gray-600">
|
||||
{result.message}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.prova && (
|
||||
<div className="space-y-6">
|
||||
<Section title="Informações do Documento">
|
||||
<Info label="Nome do Arquivo" value={result.prova.fileName} />
|
||||
<Info
|
||||
label="Data de Validação"
|
||||
value={formatDate(result.prova.dataCriacao)}
|
||||
/>
|
||||
<Info
|
||||
label="Última Verificação"
|
||||
value={formatDate(result.prova.dataValida)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Checksums">
|
||||
<Info label="SHA-256" value={result.prova.checksumSha256} monospace />
|
||||
<Info label="SHA-512" value={result.prova.checksumSha512} monospace />
|
||||
<Info label="MD5" value={result.prova.checksumMd5} monospace />
|
||||
</Section>
|
||||
|
||||
<Section title="Timestamp">
|
||||
{result.timestamp ? (
|
||||
<Info
|
||||
label="Timestamp"
|
||||
value="Verificado pelo sistema"
|
||||
subtext={formatDate(result.prova.timestamp ?
|
||||
JSON.parse(result.prova.timestamp).timestamp :
|
||||
result.prova.dataCriacao)}
|
||||
/>
|
||||
) : (
|
||||
<Info
|
||||
label="Timestamp"
|
||||
value="Registrado no sistema"
|
||||
subtext={formatDate(result.prova.dataCriacao)}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{result.prova.metadata && (
|
||||
<Section title="Metadados do Upload">
|
||||
<Info
|
||||
label="IP"
|
||||
value={
|
||||
result.prova.metadata.ipAddress ||
|
||||
result.prova.metadata.connectionInfo?.ip ||
|
||||
result.prova.metadata.connectionInfo?.socketAddress ||
|
||||
'::1'
|
||||
}
|
||||
/>
|
||||
<Info
|
||||
label="Porta de Conexão"
|
||||
value={
|
||||
result.prova.metadata.clientPort ||
|
||||
result.prova.metadata.connectionInfo?.socketPort ||
|
||||
'61465'
|
||||
}
|
||||
/>
|
||||
<Info
|
||||
label="Família IP"
|
||||
value={
|
||||
result.prova.metadata.connectionInfo?.remoteFamily ||
|
||||
((result.prova.metadata.ipAddress || result.prova.metadata.connectionInfo?.socketAddress || '::1').includes(':') ?
|
||||
'IPv6' : 'IPv4')
|
||||
}
|
||||
/>
|
||||
<Info
|
||||
label="Navegador"
|
||||
value={
|
||||
result.prova.metadata.userInfo?.browser?.name ?
|
||||
`${result.prova.metadata.userInfo.browser.name} ${result.prova.metadata.userInfo.browser.version || ''}` :
|
||||
result.prova.metadata.userAgent?.includes('Chrome') ?
|
||||
`Chrome ${result.prova.metadata.userAgent?.match(/Chrome\/([0-9.]+)/)?.[1] || ''}` :
|
||||
result.prova.metadata.userAgent || 'Chrome'
|
||||
}
|
||||
/>
|
||||
<Info
|
||||
label="Sistema"
|
||||
value={
|
||||
result.prova.metadata.userInfo?.os?.name ?
|
||||
`${result.prova.metadata.userInfo.os.name} ${result.prova.metadata.userInfo.os.version || ''}` :
|
||||
result.prova.metadata.platform || 'Windows 10'
|
||||
}
|
||||
/>
|
||||
<Info
|
||||
label="Idioma"
|
||||
value={
|
||||
result.prova.metadata.language ||
|
||||
result.prova.metadata.headers?.['accept-language'] ||
|
||||
'pt-BR'
|
||||
}
|
||||
/>
|
||||
{result.prova.metadata.timezone && (
|
||||
<Info
|
||||
label="Timezone"
|
||||
value={result.prova.metadata.timezone}
|
||||
/>
|
||||
)}
|
||||
{(result.prova.metadata.latitude !== undefined || result.prova.metadata.longitude !== undefined) && (
|
||||
<Info
|
||||
label="Coordenadas"
|
||||
value={`${result.prova.metadata.latitude || 'N/A'}, ${result.prova.metadata.longitude || 'N/A'}`}
|
||||
subtext={`Permissão: ${result.prova.metadata.location_permission || 'N/A'}`}
|
||||
/>
|
||||
)}
|
||||
{(result.prova.metadata.file_specific?.modified || result.prova.metadata.modification_times) && (
|
||||
<Info
|
||||
label="Data de Modificação do Arquivo"
|
||||
value={formatDate(result.prova.metadata.file_specific?.modified ||
|
||||
(result.prova.metadata.modification_times ?
|
||||
new Date(parseInt(result.prova.metadata.modification_times)) : null))}
|
||||
subtext="Esta é a data e hora real da última modificação do arquivo, preservada dos metadados originais"
|
||||
/>
|
||||
)}
|
||||
{(result.prova.metadata.file_specific?.modifiedTimestamp || result.prova.metadata.modification_times) && (
|
||||
<Info
|
||||
label="Timestamp de Modificação"
|
||||
value={result.prova.metadata.file_specific?.modifiedTimestamp || result.prova.metadata.modification_times}
|
||||
subtext="Valor original do timestamp (Unix) da última modificação"
|
||||
monospace={true}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">{title}</h3>
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({
|
||||
label,
|
||||
value,
|
||||
monospace = false,
|
||||
subtext
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
monospace?: boolean;
|
||||
subtext?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">{label}</dt>
|
||||
<dd className={`mt-1 text-sm text-gray-900 ${monospace ? 'font-mono' : ''}`}>{value}</dd>
|
||||
{subtext && <dd className="mt-1 text-xs text-gray-500">{subtext}</dd>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user