Initial LinkedSign platform
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,98 @@
|
||||
# LinkedSign Frontend
|
||||
|
||||
Aplicación web React + Vite para LinkedSign. Se complementa con el backend REST y con la app Android.
|
||||
|
||||
## Módulos visibles
|
||||
|
||||
- autenticación
|
||||
- documentos
|
||||
- certificados
|
||||
- estilo de firma
|
||||
- firmar
|
||||
- verificación pública
|
||||
|
||||
## Flujo actual
|
||||
|
||||
### Autenticación
|
||||
|
||||
- registro con correo y contraseña segura
|
||||
- validación por enlace enviado al correo
|
||||
- reenvío automático del enlace si el usuario intenta iniciar sesión sin validar
|
||||
- cambio de contraseña desde el bloque del usuario del menú lateral
|
||||
- confirmación de cambio de contraseña por correo
|
||||
|
||||
### Documentos
|
||||
|
||||
En el menú **Documentos** existen dos tablas:
|
||||
|
||||
1. **Documentos originales para firmar**
|
||||
- subir PDF
|
||||
- ver original
|
||||
- descargar original
|
||||
- enviar a firmar
|
||||
- eliminar
|
||||
|
||||
2. **Documentos firmados generados**
|
||||
- ver el PDF firmado antes de descargar
|
||||
- descargar el PDF firmado
|
||||
|
||||
### Certificados
|
||||
|
||||
- subir `.p12` / `.pfx`
|
||||
- ver sujeto, emisor y vigencia
|
||||
- ver si el archivo incluye cadena embebida
|
||||
|
||||
### Estilo Firma
|
||||
|
||||
El menú **Estilo Firma** centraliza la configuración visual del usuario:
|
||||
|
||||
- clásico
|
||||
- QR
|
||||
- profesional
|
||||
- imagen
|
||||
|
||||
Para QR:
|
||||
|
||||
- izquierda / derecha
|
||||
- fondo transparente o suave
|
||||
- sin borde o con borde
|
||||
|
||||
La preferencia se reutiliza en futuras firmas.
|
||||
|
||||
### Firmar
|
||||
|
||||
El menú **Firmar** usa:
|
||||
|
||||
- documento original
|
||||
- certificado activo
|
||||
- razón
|
||||
- ubicación
|
||||
- posición visible elegida sobre el PDF
|
||||
- configuración visual guardada en **Estilo Firma**
|
||||
|
||||
### Verificación pública
|
||||
|
||||
- las firmas con QR apuntan a una URL pública de verificación
|
||||
- la pantalla muestra si la firma sigue siendo válida dentro del registro de LinkedSign
|
||||
|
||||
## Desarrollo
|
||||
|
||||
```bash
|
||||
cd /home/mgonzalez/desarrollo/react/linkedsign/frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Relación con Android
|
||||
|
||||
La app móvil en [android/README.md](/home/mgonzalez/desarrollo/react/linkedsign/android/README.md):
|
||||
|
||||
- usa la misma autenticación
|
||||
- consume el mismo backend
|
||||
- sincroniza documentos, certificados y firmados bajo petición
|
||||
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3352
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-pdf": "^10.4.1",
|
||||
"react-router-dom": "^7.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import PrivateRoute from './components/ui/PrivateRoute';
|
||||
import Layout from './components/layout/Layout';
|
||||
import LoginPage from './pages/auth/LoginPage';
|
||||
import RegisterPage from './pages/auth/RegisterPage';
|
||||
import VerifyEmailPage from './pages/auth/VerifyEmailPage';
|
||||
import ConfirmPasswordChangePage from './pages/auth/ConfirmPasswordChangePage';
|
||||
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||
import DocumentsPage from './pages/documents/DocumentsPage';
|
||||
import CertificatesPage from './pages/certificates/CertificatesPage';
|
||||
import SignatureStylePage from './pages/signature-style/SignatureStylePage';
|
||||
import SignaturesPage from './pages/signatures/SignaturesPage';
|
||||
import VerifySignaturePage from './pages/verify/VerifySignaturePage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Toaster position="top-right" toastOptions={{ duration: 3500 }} />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/auth/verify-email/:token" element={<VerifyEmailPage />} />
|
||||
<Route path="/auth/confirm-password-change/:token" element={<ConfirmPasswordChangePage />} />
|
||||
<Route path="/verify/:code" element={<VerifySignaturePage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={<PrivateRoute><Layout><DashboardPage /></Layout></PrivateRoute>}
|
||||
/>
|
||||
<Route
|
||||
path="/documents"
|
||||
element={<PrivateRoute><Layout><DocumentsPage /></Layout></PrivateRoute>}
|
||||
/>
|
||||
<Route
|
||||
path="/certificates"
|
||||
element={<PrivateRoute><Layout><CertificatesPage /></Layout></PrivateRoute>}
|
||||
/>
|
||||
<Route
|
||||
path="/signature-style"
|
||||
element={<PrivateRoute><Layout><SignatureStylePage /></Layout></PrivateRoute>}
|
||||
/>
|
||||
<Route
|
||||
path="/signatures"
|
||||
element={<PrivateRoute><Layout><SignaturesPage /></Layout></PrivateRoute>}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import client from './client';
|
||||
|
||||
export const register = (data) => client.post('/auth/register', data);
|
||||
export const login = (data) => client.post('/auth/login', data);
|
||||
export const logout = (refreshToken) => client.post('/auth/logout', { refreshToken });
|
||||
export const getMe = () => client.get('/auth/me');
|
||||
export const verifyEmail = (token) => client.get(`/auth/verify/${token}`);
|
||||
export const changePassword = (data) => client.patch('/auth/change-password', data);
|
||||
export const confirmPasswordChange = (token) => client.get(`/auth/confirm-password-change/${token}`);
|
||||
@@ -0,0 +1,12 @@
|
||||
import client from './client';
|
||||
|
||||
export const uploadCertificate = (file, password, alias) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (password) form.append('password', password);
|
||||
if (alias) form.append('alias', alias);
|
||||
return client.post('/certificates', form, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
};
|
||||
|
||||
export const listCertificates = () => client.get('/certificates');
|
||||
export const deleteCertificate = (id) => client.delete(`/certificates/${id}`);
|
||||
@@ -0,0 +1,42 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: API_URL,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (error) => {
|
||||
const original = error.config;
|
||||
if (error.response?.status === 401 && !original._retry) {
|
||||
original._retry = true;
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const { data } = await axios.post(`${API_URL}/auth/refresh`, { refreshToken });
|
||||
const { accessToken, refreshToken: newRefresh } = data.data;
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('refreshToken', newRefresh);
|
||||
original.headers.Authorization = `Bearer ${accessToken}`;
|
||||
return client(original);
|
||||
} catch {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default client;
|
||||
@@ -0,0 +1,12 @@
|
||||
import client from './client';
|
||||
|
||||
export const uploadDocument = (file) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return client.post('/documents', form, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
};
|
||||
|
||||
export const listDocuments = (params) => client.get('/documents', { params });
|
||||
export const getDocument = (id) => client.get(`/documents/${id}`);
|
||||
export const deleteDocument = (id) => client.delete(`/documents/${id}`);
|
||||
export const downloadDocument = (id) => client.get(`/documents/${id}/download`, { responseType: 'blob' });
|
||||
@@ -0,0 +1,5 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
|
||||
|
||||
export const verifySignature = (code) => axios.get(`${API_URL}/signatures/verify/${code}`);
|
||||
@@ -0,0 +1,6 @@
|
||||
import client from './client';
|
||||
|
||||
export const signDocument = (data) => client.post('/signatures/sign', data);
|
||||
export const listSignedDocuments = () => client.get('/signatures/mine');
|
||||
export const getSignatures = (documentId) => client.get(`/signatures/document/${documentId}`);
|
||||
export const downloadSigned = (sigId) => client.get(`/signatures/${sigId}/download`, { responseType: 'blob' });
|
||||
@@ -0,0 +1,4 @@
|
||||
import client from './client';
|
||||
|
||||
export const updateSignatureAppearancePreference = (signaturePreferences) =>
|
||||
client.patch('/users/me/preferences/signature-appearance', signaturePreferences);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { FileText, Shield, PenTool, LayoutDashboard, LogOut, Palette, Menu, X, KeyRound, Search, Plus, Users, Settings, ScrollText } from 'lucide-react';
|
||||
import ChangePasswordModal from '../ui/ChangePasswordModal';
|
||||
|
||||
const navSections = [
|
||||
{
|
||||
label: 'Principal',
|
||||
items: [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/documents', icon: FileText, label: 'Mis documentos' },
|
||||
{ to: '/signatures', icon: PenTool, label: 'Firmar ahora' },
|
||||
{ to: '/certificates', icon: Users, label: 'Firmantes' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Sistema',
|
||||
items: [
|
||||
{ to: '/signature-style', icon: Settings, label: 'Configuración' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const topbarTitles = {
|
||||
'/dashboard': 'Dashboard',
|
||||
'/documents': 'Mis documentos',
|
||||
'/certificates': 'Firmantes',
|
||||
'/signature-style': 'Configuración',
|
||||
'/signatures': 'Firmar ahora',
|
||||
};
|
||||
|
||||
export default function Layout({ children }) {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const currentTitle = topbarTitles[location.pathname] || 'FirmaDoc';
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
{showChangePassword && (
|
||||
<ChangePasswordModal onClose={() => setShowChangePassword(false)} />
|
||||
)}
|
||||
<header className="mobile-topbar">
|
||||
<button
|
||||
className="mobile-menu-toggle"
|
||||
type="button"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
aria-label="Abrir menú"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="mobile-topbar-brand">
|
||||
<FileText size={18} />
|
||||
<span>FirmaDoc</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{mobileMenuOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-sidebar-backdrop"
|
||||
aria-label="Cerrar menú"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside className={`sidebar ${mobileMenuOpen ? 'sidebar--mobile-open' : ''}`}>
|
||||
<div className="sidebar-brand">
|
||||
<div className="sidebar-brand-mark">
|
||||
<FileText size={18} />
|
||||
</div>
|
||||
<div className="sidebar-brand-copy">
|
||||
<span>FirmaDoc</span>
|
||||
<small>Plataforma web</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-sidebar-close"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
aria-label="Cerrar menú"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navSections.map((section) => (
|
||||
<div key={section.label} className="sidebar-group">
|
||||
<div className="nav-group-label">{section.label}</div>
|
||||
{section.items.map(({ to, icon: Icon, label }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className={`nav-item ${location.pathname.startsWith(to) ? 'active' : ''}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<Icon size={15} />
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="sidebar-group sidebar-group--disabled">
|
||||
<div className="nav-group-label">Extra</div>
|
||||
<span className="nav-item nav-item--static">
|
||||
<ScrollText size={15} />
|
||||
<span>Auditoría</span>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="user-info-button"
|
||||
onClick={() => setShowChangePassword(true)}
|
||||
title="Cambiar contraseña"
|
||||
>
|
||||
<div className="user-info">
|
||||
<div className="user-avatar">{user?.name?.[0]?.toUpperCase()}</div>
|
||||
<div className="user-details">
|
||||
<span className="user-name">{user?.name}</span>
|
||||
<span className="user-email">{user?.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
<KeyRound size={16} />
|
||||
</button>
|
||||
<button className="btn-logout" onClick={handleLogout} title="Cerrar sesión">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main-content">
|
||||
<div className="topbar">
|
||||
<div className="topbar-title">{currentTitle}</div>
|
||||
<div className="search-box">
|
||||
<Search size={13} />
|
||||
<input placeholder="Buscar documentos..." aria-label="Buscar documentos" />
|
||||
</div>
|
||||
<button className="btn btn-primary topbar-cta" type="button" onClick={() => navigate('/documents')}>
|
||||
<Plus size={14} />
|
||||
Nuevo documento
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { changePassword } from '../../api/auth';
|
||||
|
||||
const getPasswordChecks = (password = '') => ([
|
||||
{ label: 'Al menos 8 caracteres', valid: password.length >= 8 },
|
||||
{ label: 'Una mayúscula', valid: /[A-Z]/.test(password) },
|
||||
{ label: 'Una minúscula', valid: /[a-z]/.test(password) },
|
||||
{ label: 'Un número', valid: /\d/.test(password) },
|
||||
{ label: 'Un carácter especial', valid: /[^A-Za-z0-9]/.test(password) },
|
||||
]);
|
||||
|
||||
export default function ChangePasswordModal({ onClose }) {
|
||||
const [form, setForm] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const passwordChecks = useMemo(() => getPasswordChecks(form.newPassword), [form.newPassword]);
|
||||
const isPasswordStrong = passwordChecks.every((check) => check.valid);
|
||||
|
||||
const handleChange = (event) => {
|
||||
const { name, value } = event.target;
|
||||
setForm((currentForm) => ({ ...currentForm, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!isPasswordStrong) {
|
||||
toast.error('La nueva contraseña no cumple con los requisitos de seguridad');
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.newPassword !== form.confirmPassword) {
|
||||
toast.error('Las contraseñas nuevas no coinciden');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await changePassword({
|
||||
currentPassword: form.currentPassword,
|
||||
newPassword: form.newPassword,
|
||||
});
|
||||
toast.success('Te enviamos un correo para confirmar el cambio de contraseña');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.error || 'No se pudo cambiar la contraseña');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">
|
||||
<KeyRound size={18} />
|
||||
<span>Cambiar contraseña</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="modal-desc">
|
||||
Usa una contraseña segura. Debe incluir mayúscula, minúscula, número, carácter especial y mínimo 8 caracteres.
|
||||
</p>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label>Contraseña actual</label>
|
||||
<input type="password" name="currentPassword" value={form.currentPassword} onChange={handleChange} required autoFocus />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Nueva contraseña</label>
|
||||
<input type="password" name="newPassword" value={form.newPassword} onChange={handleChange} required />
|
||||
<div className="password-rules">
|
||||
{passwordChecks.map((check) => (
|
||||
<div
|
||||
key={check.label}
|
||||
className={`password-rule ${check.valid ? 'password-rule--ok' : 'password-rule--pending'}`}
|
||||
>
|
||||
<span>{check.valid ? '✓' : '•'}</span>
|
||||
<span>{check.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Confirmar nueva contraseña</label>
|
||||
<input type="password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required />
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{saving ? 'Guardando...' : 'Guardar contraseña'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { KeyRound, X } from 'lucide-react';
|
||||
|
||||
export default function PasswordModal({ title, description, onConfirm, onCancel }) {
|
||||
const [password, setPassword] = useState('');
|
||||
const inputRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
onConfirm(password);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal-title">
|
||||
<KeyRound size={18} />
|
||||
<span>{title || 'Contraseña requerida'}</span>
|
||||
</div>
|
||||
<button className="btn-icon" onClick={onCancel}><X size={16} /></button>
|
||||
</div>
|
||||
{description && <p className="modal-desc">{description}</p>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label>Contraseña del certificado</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Ingresa la contraseña..."
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={onCancel}>Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary">Confirmar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
const SIG_W = 200;
|
||||
const SIG_H = 60;
|
||||
|
||||
const getSignatureWidth = (style, signerName = '') => {
|
||||
if (style !== 'qr') return SIG_W;
|
||||
const normalizedName = signerName.trim();
|
||||
const calculatedWidth = 78 + (normalizedName.length * 4.9);
|
||||
return Math.max(170, Math.min(270, Math.round(calculatedWidth)));
|
||||
};
|
||||
|
||||
export default function PdfSignaturePlacer({
|
||||
pdfBlob,
|
||||
signerName,
|
||||
signatureStyle = 'classic',
|
||||
qrPosition = 'left',
|
||||
qrBackground = 'transparent',
|
||||
qrBorder = 'none',
|
||||
signatureImage = null,
|
||||
onPlaced,
|
||||
}) {
|
||||
const [numPages, setNumPages] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sigBox, setSigBox] = useState(null); // { x, y } in container px
|
||||
const containerRef = useRef(null);
|
||||
const [pageSize, setPageSize] = useState(null); // rendered px size
|
||||
const signatureWidth = getSignatureWidth(signatureStyle, signerName);
|
||||
|
||||
const getCanvasMetrics = () => {
|
||||
const container = containerRef.current;
|
||||
const canvas = container?.querySelector('.react-pdf__Page__canvas');
|
||||
if (!container || !canvas) return null;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
containerRect,
|
||||
canvasRect,
|
||||
offsetX: canvasRect.left - containerRect.left,
|
||||
offsetY: canvasRect.top - containerRect.top,
|
||||
width: canvasRect.width,
|
||||
height: canvasRect.height,
|
||||
};
|
||||
};
|
||||
|
||||
const handleDocLoad = ({ numPages }) => setNumPages(numPages);
|
||||
|
||||
const handlePageLoad = (page) => {
|
||||
setPageSize({ width: page.width, height: page.height });
|
||||
};
|
||||
|
||||
const handleContainerClick = (e) => {
|
||||
const metrics = getCanvasMetrics();
|
||||
if (!metrics) return;
|
||||
|
||||
const x = e.clientX - metrics.canvasRect.left;
|
||||
const y = e.clientY - metrics.canvasRect.top;
|
||||
const clampedX = Math.min(Math.max(x, 0), metrics.width - signatureWidth);
|
||||
const clampedY = Math.min(Math.max(y, 0), metrics.height - SIG_H);
|
||||
|
||||
setSigBox({
|
||||
x: metrics.offsetX + clampedX,
|
||||
y: metrics.offsetY + clampedY,
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!sigBox || !pageSize) return;
|
||||
const metrics = getCanvasMetrics();
|
||||
if (!metrics) return;
|
||||
|
||||
const relativeX = sigBox.x - metrics.offsetX;
|
||||
const relativeY = sigBox.y - metrics.offsetY;
|
||||
// PDF coordinates: origin bottom-left, Y inverted
|
||||
const scaleX = pageSize.pdfWidth / metrics.width;
|
||||
const scaleY = pageSize.pdfHeight / metrics.height;
|
||||
const pdfX = relativeX * scaleX;
|
||||
const pdfY = pageSize.pdfHeight - (relativeY + SIG_H) * scaleY;
|
||||
onPlaced({
|
||||
page: currentPage - 1,
|
||||
rect: {
|
||||
x: Math.round(pdfX),
|
||||
y: Math.round(pdfY),
|
||||
width: Math.round(signatureWidth * scaleX),
|
||||
height: Math.round(SIG_H * scaleY),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const previewDate = new Date().toLocaleDateString('es-EC');
|
||||
|
||||
const renderPreview = () => {
|
||||
if (signatureStyle === 'qr') {
|
||||
return (
|
||||
<div className="sig-preview-body sig-preview-body--qr">
|
||||
{qrPosition === 'left' && (
|
||||
<div className="sig-preview-qr-box" data-background={qrBackground} data-border={qrBorder}>
|
||||
<div className="sig-preview-qr-pattern" />
|
||||
<span>QR</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="sig-preview-text">
|
||||
<strong>{signerName}</strong>
|
||||
<small>Verificación digital</small>
|
||||
</div>
|
||||
{qrPosition === 'right' && (
|
||||
<div className="sig-preview-qr-box" data-background={qrBackground} data-border={qrBorder}>
|
||||
<div className="sig-preview-qr-pattern" />
|
||||
<span>QR</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (signatureStyle === 'professional') {
|
||||
return (
|
||||
<div className="sig-preview-body sig-preview-body--professional">
|
||||
<div className="sig-preview-pro-header">FIRMA DIGITAL</div>
|
||||
<div className="sig-preview-pro-content">
|
||||
<strong>{signerName}</strong>
|
||||
<small>{previewDate}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (signatureStyle === 'image') {
|
||||
return (
|
||||
<div className="sig-preview-body sig-preview-body--image">
|
||||
<div className="sig-preview-image-area">
|
||||
{signatureImage
|
||||
? <img src={signatureImage} alt="firma" className="sig-preview-image" />
|
||||
: <span className="sig-preview-image-placeholder">Firma</span>}
|
||||
</div>
|
||||
<div className="sig-preview-text">
|
||||
<strong>{signerName}</strong>
|
||||
<small>Firma digital · {previewDate}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sig-preview-body">
|
||||
<span className="sig-preview-badge">DS</span>
|
||||
<div className="sig-preview-text">
|
||||
<strong>{signerName}</strong>
|
||||
<small>Firma digital · {previewDate}</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pdf-placer">
|
||||
<div className="pdf-placer-toolbar">
|
||||
<span className="pdf-placer-hint">
|
||||
Haz clic en el documento para colocar la firma
|
||||
</span>
|
||||
<div className="pdf-placer-pages">
|
||||
<button className="btn btn-ghost" disabled={currentPage <= 1}
|
||||
onClick={() => { setCurrentPage(p => p - 1); setSigBox(null); }}>‹</button>
|
||||
<span>Pág. {currentPage} / {numPages || '…'}</span>
|
||||
<button className="btn btn-ghost" disabled={currentPage >= numPages}
|
||||
onClick={() => { setCurrentPage(p => p + 1); setSigBox(null); }}>›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pdf-placer-canvas"
|
||||
onClick={handleContainerClick}
|
||||
style={{ cursor: 'crosshair', position: 'relative', display: 'inline-block' }}
|
||||
>
|
||||
<Document file={pdfBlob} onLoadSuccess={handleDocLoad}>
|
||||
<Page
|
||||
pageNumber={currentPage}
|
||||
width={600}
|
||||
onLoadSuccess={(p) => setPageSize({
|
||||
width: p.width, height: p.height,
|
||||
pdfWidth: p.originalWidth, pdfHeight: p.originalHeight,
|
||||
})}
|
||||
/>
|
||||
</Document>
|
||||
|
||||
{sigBox && (
|
||||
<div
|
||||
className={`sig-preview-box sig-preview-box--${signatureStyle}`}
|
||||
data-background={signatureStyle === 'qr' ? qrBackground : undefined}
|
||||
data-border={signatureStyle === 'qr' ? qrBorder : undefined}
|
||||
style={{ left: sigBox.x, top: sigBox.y, width: signatureWidth, height: SIG_H }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{signatureStyle === 'classic' && <div className="sig-preview-bar" />}
|
||||
{renderPreview()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sigBox && (
|
||||
<div className="pdf-placer-actions">
|
||||
<button className="btn btn-ghost" onClick={() => setSigBox(null)}>Limpiar</button>
|
||||
<button className="btn btn-primary" onClick={handleConfirm}>
|
||||
Confirmar posición
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
export default function PrivateRoute({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <div className="loading-screen"><div className="spinner" /></div>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useRef } from 'react';
|
||||
import { QrCode, Briefcase, ImageIcon, Stamp, PanelsLeftBottom, PanelsRightBottom, CircleOff, Square } from 'lucide-react';
|
||||
|
||||
const getQrPreviewWidth = (name = '') => {
|
||||
const extraWidth = Math.max(0, name.trim().length - 18) * 4;
|
||||
return Math.min(320, 220 + extraWidth);
|
||||
};
|
||||
|
||||
const STYLES = [
|
||||
{
|
||||
id: 'classic',
|
||||
label: 'Clásico',
|
||||
icon: Stamp,
|
||||
desc: 'Barra azul, insignia DS y campos de texto',
|
||||
preview: ({ name, date }) => (
|
||||
<div className="sig-preview sig-preview--classic">
|
||||
<div className="sp-bar" />
|
||||
<div className="sp-body">
|
||||
<div className="sp-badge">DS</div>
|
||||
<div className="sp-info">
|
||||
<strong>{name}</strong>
|
||||
<span>Razon: Firmado digitalmente</span>
|
||||
<span>Fecha: {date}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sp-watermark">Firma digital</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'qr',
|
||||
label: 'Código QR',
|
||||
icon: QrCode,
|
||||
desc: 'QR de verificación con datos de la firma',
|
||||
preview: ({ name, date, qrPosition = 'left', qrBackground = 'transparent', qrBorder = 'none' }) => (
|
||||
<div
|
||||
className="sig-preview sig-preview--qr"
|
||||
data-background={qrBackground}
|
||||
data-border={qrBorder}
|
||||
style={{ width: `${getQrPreviewWidth(name)}px`, maxWidth: '100%' }}
|
||||
>
|
||||
{qrPosition === 'left' && (
|
||||
<>
|
||||
<div className="sp-qr-box" data-background={qrBackground} data-border={qrBorder}>
|
||||
<svg viewBox="0 0 10 10" className="sp-qr-svg" xmlns="http://www.w3.org/2000/svg">
|
||||
{[0,1,2,3,4,5,6,7,8,9].map(r =>
|
||||
[0,1,2,3,4,5,6,7,8,9].map(c => {
|
||||
const v = ((r * 3 + c * 7 + r ^ c) % 3 === 0);
|
||||
return v ? <rect key={`${r}-${c}`} x={c} y={r} width={1} height={1} fill="#1f1b6e" /> : null;
|
||||
})
|
||||
)}
|
||||
<rect x={0} y={0} width={3} height={3} fill="none" stroke="#1f1b6e" strokeWidth={0.3} />
|
||||
<rect x={7} y={0} width={3} height={3} fill="none" stroke="#1f1b6e" strokeWidth={0.3} />
|
||||
<rect x={0} y={7} width={3} height={3} fill="none" stroke="#1f1b6e" strokeWidth={0.3} />
|
||||
</svg>
|
||||
<span className="sp-qr-hint">Escanear</span>
|
||||
</div>
|
||||
<div className="sp-divider-v" />
|
||||
</>
|
||||
)}
|
||||
<div className="sp-info sp-info--qr">
|
||||
<strong>{name}</strong>
|
||||
<span>Firmado digitalmente</span>
|
||||
<span>{date}</span>
|
||||
</div>
|
||||
{qrPosition === 'right' && (
|
||||
<>
|
||||
<div className="sp-divider-v" />
|
||||
<div className="sp-qr-box" data-background={qrBackground} data-border={qrBorder}>
|
||||
<svg viewBox="0 0 10 10" className="sp-qr-svg" xmlns="http://www.w3.org/2000/svg">
|
||||
{[0,1,2,3,4,5,6,7,8,9].map(r =>
|
||||
[0,1,2,3,4,5,6,7,8,9].map(c => {
|
||||
const v = ((r * 3 + c * 7 + r ^ c) % 3 === 0);
|
||||
return v ? <rect key={`${r}-${c}`} x={c} y={r} width={1} height={1} fill="#1f1b6e" /> : null;
|
||||
})
|
||||
)}
|
||||
<rect x={0} y={0} width={3} height={3} fill="none" stroke="#1f1b6e" strokeWidth={0.3} />
|
||||
<rect x={7} y={0} width={3} height={3} fill="none" stroke="#1f1b6e" strokeWidth={0.3} />
|
||||
<rect x={0} y={7} width={3} height={3} fill="none" stroke="#1f1b6e" strokeWidth={0.3} />
|
||||
</svg>
|
||||
<span className="sp-qr-hint">Escanear</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'professional',
|
||||
label: 'Profesional',
|
||||
icon: Briefcase,
|
||||
desc: 'Cabecera oscura formal con campos organizados',
|
||||
preview: ({ name, date }) => (
|
||||
<div className="sig-preview sig-preview--professional">
|
||||
<div className="sp-pro-header">FIRMA DIGITAL <span>Firmado electrónicamente</span></div>
|
||||
<div className="sp-pro-name">{name}</div>
|
||||
<div className="sp-pro-line" />
|
||||
<div className="sp-pro-fields">
|
||||
<div><b>Razon:</b><span>Firmado digitalmente</span></div>
|
||||
<div><b>Fecha:</b><span>{date}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
label: 'Imagen',
|
||||
icon: ImageIcon,
|
||||
desc: 'Sube tu firma manuscrita o imagen personalizada',
|
||||
preview: ({ name, date, image }) => (
|
||||
<div className="sig-preview sig-preview--image">
|
||||
<div className="sp-img-bar" />
|
||||
<div className="sp-img-area">
|
||||
{image
|
||||
? <img src={image} alt="firma" className="sp-sig-img" />
|
||||
: <span className="sp-img-placeholder">Tu firma aquí</span>
|
||||
}
|
||||
</div>
|
||||
<div className="sp-divider-v" />
|
||||
<div className="sp-info">
|
||||
<strong>{name}</strong>
|
||||
<span>Firmado digitalmente</span>
|
||||
<span>{date}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function SignatureStylePicker({
|
||||
value,
|
||||
onChange,
|
||||
signatureImage,
|
||||
onImageChange,
|
||||
signerName,
|
||||
qrPosition = 'left',
|
||||
onQrPositionChange,
|
||||
qrBackground = 'transparent',
|
||||
onQrBackgroundChange,
|
||||
qrBorder = 'none',
|
||||
onQrBorderChange,
|
||||
}) {
|
||||
const fileRef = useRef();
|
||||
const today = new Date().toLocaleDateString('es-EC');
|
||||
|
||||
const handleImageFile = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => onImageChange(ev.target.result);
|
||||
reader.readAsDataURL(file);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="style-picker">
|
||||
<label className="field-label-standalone">Estilo de firma visual</label>
|
||||
<div className="style-picker-grid">
|
||||
{STYLES.map(s => {
|
||||
const Icon = s.icon;
|
||||
const isActive = value === s.id;
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className={`style-card ${isActive ? 'style-card--active' : ''}`}
|
||||
onClick={() => onChange(s.id)}
|
||||
>
|
||||
<div className="style-card-header">
|
||||
<Icon size={14} />
|
||||
<span>{s.label}</span>
|
||||
{isActive && <div className="style-card-check">✓</div>}
|
||||
</div>
|
||||
<div className="style-card-preview">
|
||||
{s.preview({
|
||||
name: signerName || 'Milton González H.',
|
||||
date: today,
|
||||
image: signatureImage,
|
||||
qrPosition,
|
||||
qrBackground,
|
||||
qrBorder,
|
||||
})}
|
||||
</div>
|
||||
<p className="style-card-desc">{s.desc}</p>
|
||||
{s.id === 'qr' && isActive && (
|
||||
<div className="style-card-inline-tools">
|
||||
<div className="style-inline-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`style-icon-toggle ${qrBackground === 'transparent' ? 'style-icon-toggle--active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onQrBackgroundChange?.('transparent');
|
||||
}}
|
||||
title="Fondo transparente"
|
||||
aria-label="Fondo transparente"
|
||||
>
|
||||
<CircleOff size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`style-icon-toggle ${qrBackground === 'soft' ? 'style-icon-toggle--active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onQrBackgroundChange?.('soft');
|
||||
}}
|
||||
title="Fondo suave"
|
||||
aria-label="Fondo suave"
|
||||
>
|
||||
<Square size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="style-inline-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`style-icon-toggle ${qrBorder === 'none' ? 'style-icon-toggle--active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onQrBorderChange?.('none');
|
||||
}}
|
||||
title="Sin borde"
|
||||
aria-label="Sin borde"
|
||||
>
|
||||
<CircleOff size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`style-icon-toggle ${qrBorder === 'line' ? 'style-icon-toggle--active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onQrBorderChange?.('line');
|
||||
}}
|
||||
title="Con borde"
|
||||
aria-label="Con borde"
|
||||
>
|
||||
<Square size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`style-icon-toggle ${qrPosition === 'left' ? 'style-icon-toggle--active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onQrPositionChange?.('left');
|
||||
}}
|
||||
title="QR a la izquierda"
|
||||
aria-label="QR a la izquierda"
|
||||
>
|
||||
<PanelsLeftBottom size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`style-icon-toggle ${qrPosition === 'right' ? 'style-icon-toggle--active' : ''}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onQrPositionChange?.('right');
|
||||
}}
|
||||
title="QR a la derecha"
|
||||
aria-label="QR a la derecha"
|
||||
>
|
||||
<PanelsRightBottom size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{value === 'image' && (
|
||||
<div className="image-upload-area">
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => fileRef.current.click()}>
|
||||
<ImageIcon size={14} /> {signatureImage ? 'Cambiar imagen' : 'Subir firma (PNG/JPG)'}
|
||||
</button>
|
||||
{signatureImage && (
|
||||
<button type="button" className="btn btn-ghost btn-sm danger" onClick={() => onImageChange(null)}>
|
||||
Quitar imagen
|
||||
</button>
|
||||
)}
|
||||
<small className="field-hint">Fondo transparente (PNG) recomendado para mejor resultado</small>
|
||||
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" style={{ display: 'none' }} onChange={handleImageFile} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { login as apiLogin, logout as apiLogout, getMe } from '../api/auth';
|
||||
import { updateSignatureAppearancePreference as apiUpdateSignatureAppearancePreference } from '../api/users';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) {
|
||||
getMe()
|
||||
.then(({ data }) => setUser(data.data.user))
|
||||
.catch(() => {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const { data } = await apiLogin({ email, password });
|
||||
const { accessToken, refreshToken, user: userData } = data.data;
|
||||
localStorage.setItem('accessToken', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
setUser(userData);
|
||||
return userData;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken');
|
||||
try { await apiLogout(refreshToken); } catch { /* ignore */ }
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const updateSignatureAppearancePreference = useCallback(async (signaturePreferences) => {
|
||||
const { data } = await apiUpdateSignatureAppearancePreference(signaturePreferences);
|
||||
setUser((currentUser) => currentUser
|
||||
? { ...currentUser, signaturePreferences: data.data.signaturePreferences }
|
||||
: currentUser);
|
||||
return data.data.signaturePreferences;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, updateSignatureAppearancePreference }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { CheckCircle2, MailWarning, LoaderCircle } from 'lucide-react';
|
||||
import { confirmPasswordChange } from '../../api/auth';
|
||||
|
||||
export default function ConfirmPasswordChangePage() {
|
||||
const { token } = useParams();
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [message, setMessage] = useState('Confirmando cambio de contraseña...');
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
confirmPasswordChange(token)
|
||||
.then(({ data }) => {
|
||||
if (!active) return;
|
||||
setStatus('success');
|
||||
setMessage(data.data.message || 'Contraseña cambiada correctamente.');
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return;
|
||||
setStatus('error');
|
||||
setMessage(error.response?.data?.error || 'El enlace no es válido o ya expiró.');
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<div className="auth-logo">
|
||||
{status === 'loading' && <LoaderCircle size={32} className="spin-soft" />}
|
||||
{status === 'success' && <CheckCircle2 size={32} />}
|
||||
{status === 'error' && <MailWarning size={32} />}
|
||||
<h1>Cambio de contraseña</h1>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-form">
|
||||
{status === 'success' && (
|
||||
<Link to="/login" className="btn btn-primary btn-full">
|
||||
Ir a iniciar sesión
|
||||
</Link>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<Link to="/dashboard" className="btn btn-secondary btn-full">
|
||||
Volver
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { PenTool } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({ email: '', password: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(form.email, form.password);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
const code = err.response?.data?.code;
|
||||
const message = err.response?.data?.error || 'Error al iniciar sesión';
|
||||
|
||||
if (code === 'EMAIL_NOT_VERIFIED') {
|
||||
toast.success('Tu cuenta aún no está validada. Te enviamos un nuevo correo de confirmación.');
|
||||
} else if (code === 'MAIL_SEND_FAILED') {
|
||||
toast.error(message);
|
||||
} else {
|
||||
toast.error(message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<div className="auth-logo">
|
||||
<PenTool size={32} />
|
||||
<h1>LinkedSign</h1>
|
||||
<p>Firma digital segura</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="field">
|
||||
<label>Correo electrónico</label>
|
||||
<input type="email" name="email" value={form.email} onChange={handleChange} required autoFocus />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Contraseña</label>
|
||||
<input type="password" name="password" value={form.password} onChange={handleChange} required />
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
|
||||
{loading ? 'Ingresando...' : 'Iniciar sesión'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-link">
|
||||
¿No tienes cuenta? <Link to="/register">Registrarse</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { register } from '../../api/auth';
|
||||
import { PenTool } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const getPasswordChecks = (password = '') => ([
|
||||
{ label: 'Al menos 8 caracteres', valid: password.length >= 8 },
|
||||
{ label: 'Una mayúscula', valid: /[A-Z]/.test(password) },
|
||||
{ label: 'Una minúscula', valid: /[a-z]/.test(password) },
|
||||
{ label: 'Un número', valid: /\d/.test(password) },
|
||||
{ label: 'Un carácter especial', valid: /[^A-Za-z0-9]/.test(password) },
|
||||
]);
|
||||
|
||||
export default function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({ email: '', password: '', confirmPassword: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const passwordChecks = getPasswordChecks(form.password);
|
||||
const isPasswordStrong = passwordChecks.every((check) => check.valid);
|
||||
|
||||
const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!isPasswordStrong) {
|
||||
toast.error('La contraseña no cumple con los requisitos de seguridad');
|
||||
return;
|
||||
}
|
||||
if (form.password !== form.confirmPassword) {
|
||||
toast.error('Las contraseñas no coinciden');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await register({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
});
|
||||
toast.success('Cuenta creada. Revisa tu correo para confirmar tu usuario.');
|
||||
navigate('/login');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || 'Error al registrarse');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<div className="auth-logo">
|
||||
<PenTool size={32} />
|
||||
<h1>LinkedSign</h1>
|
||||
<p>Crear nueva cuenta</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="field">
|
||||
<label>Correo electrónico</label>
|
||||
<input type="email" name="email" value={form.email} onChange={handleChange} required autoFocus />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Contraseña <small>(mínimo 8 caracteres)</small></label>
|
||||
<input type="password" name="password" value={form.password} onChange={handleChange} required />
|
||||
<div className="password-rules">
|
||||
{passwordChecks.map((check) => (
|
||||
<div
|
||||
key={check.label}
|
||||
className={`password-rule ${check.valid ? 'password-rule--ok' : 'password-rule--pending'}`}
|
||||
>
|
||||
<span>{check.valid ? '✓' : '•'}</span>
|
||||
<span>{check.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Confirmar contraseña</label>
|
||||
<input type="password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required />
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
|
||||
{loading ? 'Registrando...' : 'Crear cuenta'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-link">
|
||||
¿Ya tienes cuenta? <Link to="/login">Iniciar sesión</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { CheckCircle2, MailWarning, LoaderCircle } from 'lucide-react';
|
||||
import { verifyEmail } from '../../api/auth';
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
const { token } = useParams();
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [message, setMessage] = useState('Validando enlace de confirmación...');
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
verifyEmail(token)
|
||||
.then(({ data }) => {
|
||||
if (!active) return;
|
||||
setStatus('success');
|
||||
setMessage(data.data.message || 'Correo confirmado correctamente.');
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return;
|
||||
setStatus('error');
|
||||
setMessage(error.response?.data?.error || 'El enlace no es válido o ya expiró.');
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-card">
|
||||
<div className="auth-logo">
|
||||
{status === 'loading' && <LoaderCircle size={32} className="spin-soft" />}
|
||||
{status === 'success' && <CheckCircle2 size={32} />}
|
||||
{status === 'error' && <MailWarning size={32} />}
|
||||
<h1>Confirmación de cuenta</h1>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-form">
|
||||
{status === 'success' && (
|
||||
<Link to="/login" className="btn btn-primary btn-full">
|
||||
Ir a iniciar sesión
|
||||
</Link>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<Link to="/register" className="btn btn-secondary btn-full">
|
||||
Volver a registro
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { listCertificates, uploadCertificate, deleteCertificate } from '../../api/certificates';
|
||||
import { Shield, Trash2, Upload, RefreshCw, Plus, AlertTriangle, KeyRound } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import PasswordModal from '../../components/ui/PasswordModal';
|
||||
|
||||
const formatDate = (d) => d ? new Date(d).toLocaleDateString('es-EC') : '—';
|
||||
|
||||
const certStatus = (validTo) => {
|
||||
if (!validTo) return null;
|
||||
const now = new Date();
|
||||
const exp = new Date(validTo);
|
||||
const daysLeft = Math.floor((exp - now) / (1000 * 60 * 60 * 24));
|
||||
if (daysLeft < 0) return { label: 'Vencido', cls: 'badge-rejected' };
|
||||
if (daysLeft <= 30) return { label: `Vence en ${daysLeft}d`, cls: 'badge-pending' };
|
||||
return { label: 'Vigente', cls: 'badge-signed' };
|
||||
};
|
||||
|
||||
const chainStatusMeta = (chainStatus, certificateCount) => {
|
||||
if (chainStatus === 'complete') {
|
||||
return {
|
||||
label: `Cadena incluida (${certificateCount || 0})`,
|
||||
text: 'El archivo P12 incluye más de un certificado y aparenta traer cadena embebida.',
|
||||
};
|
||||
}
|
||||
|
||||
if (chainStatus === 'leaf_only') {
|
||||
return {
|
||||
label: 'Sin cadena completa',
|
||||
text: 'El archivo P12 parece contener solo el certificado del firmante.',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function CertificatesPage() {
|
||||
const [certs, setCerts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({ alias: '', password: '' });
|
||||
const [file, setFile] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [pendingUpload, setPendingUpload] = useState(null); // { file, alias } — waiting for password
|
||||
const fileInputRef = useRef();
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
listCertificates()
|
||||
.then(({ data }) => setCerts(data.data))
|
||||
.catch(() => toast.error('Error al cargar certificados'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const doUpload = async (uploadFile, password, alias, fromModal = false) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadCertificate(uploadFile, password, alias || uploadFile.name);
|
||||
toast.success('Certificado registrado correctamente');
|
||||
setShowForm(false);
|
||||
setForm({ alias: '', password: '' });
|
||||
setFile(null);
|
||||
setPendingUpload(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
const code = err.response?.data?.code;
|
||||
// Show popup only if: no password was provided OR we're already in the modal and password was wrong
|
||||
if (code === 'PASSWORD_REQUIRED' && !password) {
|
||||
setPendingUpload({ file: uploadFile, alias: alias || uploadFile.name });
|
||||
} else if (code === 'PASSWORD_REQUIRED' && fromModal) {
|
||||
toast.error('Contraseña incorrecta. Intenta de nuevo.');
|
||||
setPendingUpload({ file: uploadFile, alias: alias || uploadFile.name });
|
||||
} else {
|
||||
toast.error(err.response?.data?.error || 'Error al subir certificado');
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!file) { toast.error('Selecciona un archivo P12/PFX'); return; }
|
||||
await doUpload(file, form.password, form.alias);
|
||||
};
|
||||
|
||||
const handlePasswordModalConfirm = async (password) => {
|
||||
if (!pendingUpload) return;
|
||||
await doUpload(pendingUpload.file, password, pendingUpload.alias, true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id, alias) => {
|
||||
if (!confirm(`¿Eliminar certificado "${alias}"?`)) return;
|
||||
try {
|
||||
await deleteCertificate(id);
|
||||
toast.success('Certificado eliminado');
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || 'Error al eliminar');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{pendingUpload && (
|
||||
<PasswordModal
|
||||
title="Contraseña del certificado"
|
||||
description={`El certificado "${pendingUpload.alias}" requiere contraseña para ser validado. No se almacenará — solo se usa en este momento.`}
|
||||
onConfirm={handlePasswordModalConfirm}
|
||||
onCancel={() => setPendingUpload(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h2>Certificados Digitales</h2>
|
||||
<p>{certs.length} certificado{certs.length !== 1 ? 's' : ''} registrado{certs.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="btn btn-ghost" onClick={load} title="Actualizar"><RefreshCw size={16} /></button>
|
||||
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
|
||||
<Plus size={16} /> Agregar certificado
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="form-card">
|
||||
<h3>Registrar certificado P12/PFX</h3>
|
||||
<form onSubmit={handleUpload}>
|
||||
<div className="field">
|
||||
<label>Archivo P12/PFX</label>
|
||||
<input type="file" accept=".p12,.pfx" ref={fileInputRef} onChange={(e) => setFile(e.target.files[0] || null)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Alias (nombre descriptivo)</label>
|
||||
<input type="text" value={form.alias} onChange={(e) => setForm(f => ({ ...f, alias: e.target.value }))} placeholder="Ej: Mi certificado BCE 2026" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label><KeyRound size={13} /> Contraseña <span style={{ fontWeight: 400, color: 'var(--gray-400)' }}>(opcional)</span></label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder="Dejar vacío si no tiene, o si prefieres ingresarla después"
|
||||
/>
|
||||
<small className="field-hint">Si el certificado tiene contraseña y no la ingresas aquí, se te solicitará en el momento de la validación.</small>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setShowForm(false)}>Cancelar</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={uploading}>
|
||||
<Upload size={15} /> {uploading ? 'Validando...' : 'Registrar certificado'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-area"><div className="spinner" /></div>
|
||||
) : certs.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<Shield size={48} />
|
||||
<p>No tienes certificados. Agrega tu primer certificado P12/PFX vigente.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="certs-grid">
|
||||
{certs.map((cert) => {
|
||||
const status = certStatus(cert.valid_to);
|
||||
const chain = chainStatusMeta(cert.chain_status, cert.certificate_count);
|
||||
return (
|
||||
<div key={cert.id} className={`cert-card ${!cert.is_active ? 'inactive' : ''}`}>
|
||||
<div className="cert-header">
|
||||
<Shield size={20} />
|
||||
<span className="cert-alias">{cert.alias}</span>
|
||||
{status && <span className={`badge ${status.cls}`}>{status.label}</span>}
|
||||
</div>
|
||||
<div className="cert-details">
|
||||
{cert.subject_cn && <p><strong>Sujeto:</strong> {cert.subject_cn}</p>}
|
||||
{cert.issuer_cn && <p><strong>Emisor:</strong> {cert.issuer_cn}</p>}
|
||||
<p><strong>Válido:</strong> {formatDate(cert.valid_from)} — {formatDate(cert.valid_to)}</p>
|
||||
{typeof cert.certificate_count === 'number' && <p><strong>Certificados en P12:</strong> {cert.certificate_count}</p>}
|
||||
{chain && <p><strong>Cadena:</strong> {chain.label}</p>}
|
||||
</div>
|
||||
{chain && (
|
||||
<div className="cert-warning">
|
||||
<Shield size={13} /> {chain.text}
|
||||
</div>
|
||||
)}
|
||||
{status?.cls === 'badge-rejected' && (
|
||||
<div className="cert-warning">
|
||||
<AlertTriangle size={13} /> Este certificado está vencido y no puede usarse para firmar.
|
||||
</div>
|
||||
)}
|
||||
<div className="cert-footer">
|
||||
<small className="cert-password-note"><KeyRound size={11} /> Se solicitará contraseña al firmar</small>
|
||||
<button className="btn-icon danger" title="Eliminar" onClick={() => handleDelete(cert.id, cert.alias)}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { listDocuments } from '../../api/documents';
|
||||
import { FileText, PenTool, CheckCircle2, CircleDot, Search } from 'lucide-react';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [stats, setStats] = useState({ docs: 0, certs: 0, signed: 0, pending: 0, review: 0 });
|
||||
const [recentDocs, setRecentDocs] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
listDocuments({ limit: 100 })
|
||||
.then((docsRes) => {
|
||||
const docs = docsRes.data.data.rows || [];
|
||||
const pending = docs.filter((d) => d.status === 'pending').length;
|
||||
const review = docs.filter((d) => d.status === 'partially_signed').length;
|
||||
setStats({
|
||||
docs: docsRes.data.data.total || 0,
|
||||
signed: docs.filter((d) => d.status === 'signed').length,
|
||||
pending,
|
||||
review,
|
||||
});
|
||||
setRecentDocs(docs.slice(0, 5));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="step-bar">
|
||||
<div className="step">
|
||||
<div className="step-num done"><CheckCircle2 size={10} /></div>
|
||||
<span className="step-label done-label">Subir documento</span>
|
||||
</div>
|
||||
<div className="step-line done-line" />
|
||||
<div className="step">
|
||||
<div className="step-num done"><CheckCircle2 size={10} /></div>
|
||||
<span className="step-label done-label">Revisar contenido</span>
|
||||
</div>
|
||||
<div className="step-line done-line" />
|
||||
<div className="step">
|
||||
<div className="step-num active">3</div>
|
||||
<span className="step-label active-label">Firmar</span>
|
||||
</div>
|
||||
<div className="step-line" />
|
||||
<div className="step">
|
||||
<div className="step-num todo">4</div>
|
||||
<span className="step-label todo-label">Verificar identidad</span>
|
||||
</div>
|
||||
<div className="step-line" />
|
||||
<div className="step">
|
||||
<div className="step-num todo">5</div>
|
||||
<span className="step-label todo-label">Completado</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-row">
|
||||
<div className="stat-card" onClick={() => navigate('/documents')}>
|
||||
<div className="stat-label">Total documentos</div>
|
||||
<div className="stat-val">{stats.docs}</div>
|
||||
<span className="stat-badge badge-green">+{Math.max(stats.docs - stats.signed, 0)} este mes</span>
|
||||
</div>
|
||||
<div className="stat-card" onClick={() => navigate('/documents')}>
|
||||
<div className="stat-label">Pendientes de firma</div>
|
||||
<div className="stat-val">{stats.pending}</div>
|
||||
<span className="stat-badge badge-orange">Requieren acción</span>
|
||||
</div>
|
||||
<div className="stat-card" onClick={() => navigate('/signatures')}>
|
||||
<div className="stat-label">Firmados</div>
|
||||
<div className="stat-val">{stats.signed}</div>
|
||||
<span className="stat-badge badge-green">{stats.docs ? Math.round((stats.signed / stats.docs) * 100) : 0}% tasa de éxito</span>
|
||||
</div>
|
||||
<div className="stat-card" onClick={() => navigate('/documents')}>
|
||||
<div className="stat-label">En revisión</div>
|
||||
<div className="stat-val">{stats.review}</div>
|
||||
<span className="stat-badge badge-blue">En proceso</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<div className="panel-header">
|
||||
<span className="panel-title">Documentos recientes</span>
|
||||
<span className="panel-action" onClick={() => navigate('/documents')}>Ver todos</span>
|
||||
</div>
|
||||
<div className="table-header">
|
||||
<span className="th">Documento</span>
|
||||
<span className="th">Firmante</span>
|
||||
<span className="th">Fecha</span>
|
||||
<span className="th">Estado</span>
|
||||
<span className="th"></span>
|
||||
</div>
|
||||
|
||||
{recentDocs.map((doc) => {
|
||||
const status = doc.status === 'signed'
|
||||
? { cls: 'pill-signed', label: 'Firmado', icon: <CheckCircle2 size={12} /> }
|
||||
: doc.status === 'partially_signed'
|
||||
? { cls: 'pill-review', label: 'Revisión', icon: <Search size={12} /> }
|
||||
: { cls: 'pill-pending', label: 'Pendiente', icon: <CircleDot size={12} /> };
|
||||
return (
|
||||
<div className="table-row" key={doc.id}>
|
||||
<div className="doc-cell">
|
||||
<div className="doc-thumb" style={{ background: doc.status === 'signed' ? 'var(--c-success-bg)' : 'var(--c-info-bg)' }}>
|
||||
<FileText size={13} color={doc.status === 'signed' ? 'var(--c-success)' : 'var(--c-primary)'} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="doc-n">{doc.title}</div>
|
||||
<div className="doc-m">{((doc.file_size || 0) / 1024).toFixed(1)} KB</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="td">{user?.name || '—'}</span>
|
||||
<span className="td">{doc.created_at ? new Date(doc.created_at).toLocaleDateString('es-EC') : '—'}</span>
|
||||
<span className={`status-pill ${status.cls}`}><span className="dot" />{status.label}</span>
|
||||
<button className="action-ico" type="button" title={doc.status === 'signed' ? 'Ver documento' : 'Firmar'} onClick={() => navigate(doc.status === 'signed' ? '/documents' : `/signatures?docId=${doc.id}`)}>
|
||||
{doc.status === 'signed' ? <Search size={13} /> : <PenTool size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { listDocuments, uploadDocument, deleteDocument, downloadDocument } from '../../api/documents';
|
||||
import { listSignedDocuments, downloadSigned } from '../../api/signatures';
|
||||
import { Upload, Trash2, Download, PenTool, FileText, RefreshCw, Eye, X } from 'lucide-react';
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
const STATUS_LABELS = {
|
||||
pending: { label: 'Pendiente', cls: 'badge-pending' },
|
||||
signed: { label: 'Firmado', cls: 'badge-signed' },
|
||||
partially_signed: { label: 'Parcial', cls: 'badge-partial' },
|
||||
rejected: { label: 'Rechazado', cls: 'badge-rejected' },
|
||||
};
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [signedDocs, setSignedDocs] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef();
|
||||
|
||||
// Viewer state
|
||||
const [viewerBlob, setViewerBlob] = useState(null);
|
||||
const [viewerTitle, setViewerTitle] = useState('');
|
||||
const [numPages, setNumPages] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [loadingViewer, setLoadingViewer] = useState(false);
|
||||
|
||||
const loadDocs = () => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
listDocuments({ limit: 50 }),
|
||||
listSignedDocuments(),
|
||||
])
|
||||
.then(([documentsResponse, signedResponse]) => {
|
||||
setDocs(documentsResponse.data.data.rows);
|
||||
setTotal(documentsResponse.data.data.total);
|
||||
setSignedDocs(signedResponse.data.data || []);
|
||||
})
|
||||
.catch(() => toast.error('Error al cargar documentos'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { loadDocs(); }, []);
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (file.type !== 'application/pdf') { toast.error('Solo se permiten archivos PDF'); return; }
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadDocument(file);
|
||||
toast.success('Documento subido correctamente');
|
||||
loadDocs();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || 'Error al subir documento');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id, title) => {
|
||||
if (!confirm(`¿Eliminar "${title}"?`)) return;
|
||||
try {
|
||||
await deleteDocument(id);
|
||||
toast.success('Documento eliminado');
|
||||
loadDocs();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || 'Error al eliminar');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (id, filename) => {
|
||||
try {
|
||||
const { data } = await downloadDocument(id);
|
||||
const url = URL.createObjectURL(data);
|
||||
const a = document.createElement('a'); a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch { toast.error('Error al descargar'); }
|
||||
};
|
||||
|
||||
const handleDownloadSigned = async (id, filename) => {
|
||||
try {
|
||||
const { data } = await downloadSigned(id);
|
||||
const url = URL.createObjectURL(data);
|
||||
const a = document.createElement('a'); a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch { toast.error('Error al descargar PDF firmado'); }
|
||||
};
|
||||
|
||||
const handleView = async (id, title, source = 'original') => {
|
||||
setLoadingViewer(true);
|
||||
setViewerTitle(title);
|
||||
setViewerBlob(null);
|
||||
setCurrentPage(1);
|
||||
setNumPages(null);
|
||||
try {
|
||||
const request = source === 'signed' ? downloadSigned(id) : downloadDocument(id);
|
||||
const { data } = await request;
|
||||
setViewerBlob(data);
|
||||
} catch { toast.error('No se pudo cargar el documento'); }
|
||||
finally { setLoadingViewer(false); }
|
||||
};
|
||||
|
||||
const closeViewer = () => { setViewerBlob(null); setViewerTitle(''); };
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
|
||||
{/* ── PDF Viewer Modal ── */}
|
||||
{(viewerBlob || loadingViewer) && (
|
||||
<div className="modal-overlay" onClick={closeViewer}>
|
||||
<div className="pdf-viewer-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="pdf-viewer-header">
|
||||
<span className="pdf-viewer-title"><FileText size={16} /> {viewerTitle}</span>
|
||||
<div className="pdf-viewer-controls">
|
||||
{numPages && (
|
||||
<>
|
||||
<button className="btn btn-ghost" disabled={currentPage <= 1}
|
||||
onClick={() => setCurrentPage(p => p - 1)}>‹</button>
|
||||
<span>{currentPage} / {numPages}</span>
|
||||
<button className="btn btn-ghost" disabled={currentPage >= numPages}
|
||||
onClick={() => setCurrentPage(p => p + 1)}>›</button>
|
||||
</>
|
||||
)}
|
||||
<button className="btn-icon" onClick={closeViewer}><X size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pdf-viewer-body">
|
||||
{loadingViewer ? (
|
||||
<div className="loading-area"><div className="spinner" /></div>
|
||||
) : (
|
||||
<Document
|
||||
file={viewerBlob}
|
||||
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
|
||||
loading={<div className="loading-area"><div className="spinner" /></div>}
|
||||
>
|
||||
<Page pageNumber={currentPage} width={720} />
|
||||
</Document>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h2>Documentos</h2>
|
||||
<p>{total} documento{total !== 1 ? 's' : ''} original{total !== 1 ? 'es' : ''}</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="btn btn-ghost" onClick={loadDocs} title="Actualizar"><RefreshCw size={16} /></button>
|
||||
<button className="btn btn-primary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
||||
<Upload size={16} /> {uploading ? 'Subiendo...' : 'Subir PDF'}
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,application/pdf" style={{ display: 'none' }} onChange={handleUpload} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-area"><div className="spinner" /></div>
|
||||
) : (
|
||||
<div className="documents-sections">
|
||||
<section className="documents-section">
|
||||
<div className="section-header">
|
||||
<h3>Documentos originales para firmar</h3>
|
||||
</div>
|
||||
{docs.length === 0 ? (
|
||||
<div className="empty-state compact">
|
||||
<FileText size={40} />
|
||||
<p>No tienes documentos originales aún. Sube tu primer PDF.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Tamaño</th>
|
||||
<th>Estado</th>
|
||||
<th>Fecha</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.map((doc) => {
|
||||
const s = STATUS_LABELS[doc.status] || STATUS_LABELS.pending;
|
||||
return (
|
||||
<tr key={doc.id}>
|
||||
<td className="doc-name"><FileText size={14} /> {doc.title}</td>
|
||||
<td>{(doc.file_size / 1024).toFixed(1)} KB</td>
|
||||
<td><span className={`badge ${s.cls}`}>{s.label}</span></td>
|
||||
<td>{new Date(doc.created_at).toLocaleDateString('es-EC')}</td>
|
||||
<td className="actions-cell">
|
||||
<button className="btn-icon" title="Ver documento" onClick={() => handleView(doc.id, doc.title, 'original')}>
|
||||
<Eye size={15} />
|
||||
</button>
|
||||
<button className="btn-icon" title="Firmar" onClick={() => navigate(`/signatures?docId=${doc.id}`)}>
|
||||
<PenTool size={15} />
|
||||
</button>
|
||||
<button className="btn-icon" title="Descargar" onClick={() => handleDownload(doc.id, doc.filename)}>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
<button className="btn-icon danger" title="Eliminar" onClick={() => handleDelete(doc.id, doc.title)}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="documents-section">
|
||||
<div className="section-header">
|
||||
<h3>Documentos firmados generados</h3>
|
||||
<p>{signedDocs.length} archivo{signedDocs.length !== 1 ? 's' : ''} firmado{signedDocs.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
{signedDocs.length === 0 ? (
|
||||
<div className="empty-state compact">
|
||||
<FileText size={40} />
|
||||
<p>Aún no has generado documentos firmados.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>PDF firmado</th>
|
||||
<th>Documento origen</th>
|
||||
<th>Certificado</th>
|
||||
<th>Fecha</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{signedDocs.map((doc) => (
|
||||
<tr key={doc.id}>
|
||||
<td className="doc-name"><FileText size={14} /> {doc.signed_filename}</td>
|
||||
<td>{doc.document_title}</td>
|
||||
<td>{doc.certificate_alias}</td>
|
||||
<td>{new Date(doc.signed_at).toLocaleDateString('es-EC')}</td>
|
||||
<td className="actions-cell">
|
||||
<button className="btn-icon" title="Ver PDF firmado" onClick={() => handleView(doc.id, doc.signed_filename, 'signed')}>
|
||||
<Eye size={15} />
|
||||
</button>
|
||||
<button className="btn-icon" title="Descargar PDF firmado" onClick={() => handleDownloadSigned(doc.id, doc.signed_filename)}>
|
||||
<Download size={15} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, Palette } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import SignatureStylePicker from '../../components/ui/SignatureStylePicker';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
export default function SignatureStylePage() {
|
||||
const { user, updateSignatureAppearancePreference } = useAuth();
|
||||
const [form, setForm] = useState({
|
||||
signatureStyle: user?.signaturePreferences?.signatureStyle || 'qr',
|
||||
qrPosition: user?.signaturePreferences?.qrPosition || 'left',
|
||||
qrBackground: user?.signaturePreferences?.qrBackground || 'transparent',
|
||||
qrBorder: user?.signaturePreferences?.qrBorder || 'none',
|
||||
signatureImageBase64: user?.signaturePreferences?.signatureImageBase64 || null,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setForm({
|
||||
signatureStyle: user?.signaturePreferences?.signatureStyle || 'qr',
|
||||
qrPosition: user?.signaturePreferences?.qrPosition || 'left',
|
||||
qrBackground: user?.signaturePreferences?.qrBackground || 'transparent',
|
||||
qrBorder: user?.signaturePreferences?.qrBorder || 'none',
|
||||
signatureImageBase64: user?.signaturePreferences?.signatureImageBase64 || null,
|
||||
});
|
||||
}, [user?.signaturePreferences]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateSignatureAppearancePreference(form);
|
||||
toast.success('Estilo de firma guardado');
|
||||
} catch {
|
||||
toast.error('No se pudo guardar el estilo de firma');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h2>Estilo Firma</h2>
|
||||
<p>Configura cómo se verá tu firma visible en los documentos.</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
<Save size={16} /> {saving ? 'Guardando...' : 'Guardar estilo'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-card">
|
||||
<div className="style-page-intro">
|
||||
<Palette size={18} />
|
||||
<p>Este menú define el estilo visual que usará la pantalla Firmar por defecto.</p>
|
||||
</div>
|
||||
|
||||
<SignatureStylePicker
|
||||
value={form.signatureStyle}
|
||||
onChange={(signatureStyle) => setForm((currentForm) => ({ ...currentForm, signatureStyle }))}
|
||||
qrPosition={form.qrPosition}
|
||||
onQrPositionChange={(qrPosition) => setForm((currentForm) => ({ ...currentForm, qrPosition }))}
|
||||
qrBackground={form.qrBackground}
|
||||
onQrBackgroundChange={(qrBackground) => setForm((currentForm) => ({ ...currentForm, qrBackground }))}
|
||||
qrBorder={form.qrBorder}
|
||||
onQrBorderChange={(qrBorder) => setForm((currentForm) => ({ ...currentForm, qrBorder }))}
|
||||
signatureImage={form.signatureImageBase64}
|
||||
onImageChange={(signatureImageBase64) => setForm((currentForm) => ({ ...currentForm, signatureImageBase64 }))}
|
||||
signerName={user?.name || 'Firmante'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { listDocuments, downloadDocument } from '../../api/documents';
|
||||
import { listCertificates } from '../../api/certificates';
|
||||
import { signDocument, getSignatures, downloadSigned } from '../../api/signatures';
|
||||
import { PenTool, FileText, Shield, CheckCircle, MapPin, Download } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import PasswordModal from '../../components/ui/PasswordModal';
|
||||
import PdfSignaturePlacer from '../../components/ui/PdfSignaturePlacer';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const STEPS = ['Selección', 'Posición', 'Firmar'];
|
||||
|
||||
export default function SignaturesPage() {
|
||||
const { user } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [certs, setCerts] = useState([]);
|
||||
const [form, setForm] = useState({
|
||||
documentId: searchParams.get('docId') || '',
|
||||
certificateId: '',
|
||||
reason: 'Firmado digitalmente',
|
||||
location: '',
|
||||
signatureStyle: user?.signaturePreferences?.signatureStyle || 'qr',
|
||||
qrPosition: user?.signaturePreferences?.qrPosition || 'left',
|
||||
qrBackground: user?.signaturePreferences?.qrBackground || 'transparent',
|
||||
qrBorder: user?.signaturePreferences?.qrBorder || 'none',
|
||||
signatureImageBase64: user?.signaturePreferences?.signatureImageBase64 || null,
|
||||
});
|
||||
const [step, setStep] = useState(0); // 0=select, 1=place, 2=sign
|
||||
const [pdfBlob, setPdfBlob] = useState(null);
|
||||
const [sigPlacement, setSigPlacement] = useState(null); // { page, rect }
|
||||
const [signing, setSigning] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [sigs, setSigs] = useState([]);
|
||||
const [pendingSign, setPendingSign] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
listDocuments({ limit: 100 }).then(({ data }) => setDocs(data.data.rows.filter(d => d.status !== 'signed'))),
|
||||
listCertificates().then(({ data }) => setCerts(data.data.filter(c => c.is_active))),
|
||||
]).catch(() => toast.error('Error al cargar datos'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (form.documentId) {
|
||||
getSignatures(form.documentId).then(({ data }) => setSigs(data.data || [])).catch(() => setSigs([]));
|
||||
}
|
||||
}, [form.documentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.signaturePreferences) return;
|
||||
setForm((currentForm) => ({
|
||||
...currentForm,
|
||||
signatureStyle: user.signaturePreferences.signatureStyle || 'qr',
|
||||
qrPosition: user.signaturePreferences.qrPosition || 'left',
|
||||
qrBackground: user.signaturePreferences.qrBackground || 'transparent',
|
||||
qrBorder: user.signaturePreferences.qrBorder || 'none',
|
||||
signatureImageBase64: user.signaturePreferences.signatureImageBase64 || null,
|
||||
}));
|
||||
}, [user?.signaturePreferences]);
|
||||
|
||||
const handleChange = (e) => setForm(f => ({ ...f, [e.target.name]: e.target.value }));
|
||||
|
||||
// Step 0 → 1: load PDF for placement
|
||||
const handleGoToPlace = async () => {
|
||||
if (!form.documentId || !form.certificateId) {
|
||||
toast.error('Selecciona documento y certificado'); return;
|
||||
}
|
||||
try {
|
||||
const { data } = await downloadDocument(form.documentId);
|
||||
setPdfBlob(data);
|
||||
setSigPlacement(null);
|
||||
setStep(1);
|
||||
} catch { toast.error('No se pudo cargar el documento'); }
|
||||
};
|
||||
|
||||
// Step 1 → 2: placement confirmed
|
||||
const handlePlaced = (placement) => {
|
||||
setSigPlacement(placement);
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const doSign = async (certPassword = '', fromModal = false) => {
|
||||
if (!form.documentId || !form.certificateId) {
|
||||
toast.error('Faltan datos de documento o certificado. Vuelve al paso 1.');
|
||||
setStep(0);
|
||||
return;
|
||||
}
|
||||
setSigning(true);
|
||||
const payload = {
|
||||
documentId: Number(form.documentId),
|
||||
certificateId: Number(form.certificateId),
|
||||
certPassword,
|
||||
reason: form.reason,
|
||||
location: form.location,
|
||||
page: sigPlacement?.page ?? 0,
|
||||
rect: sigPlacement?.rect ?? null,
|
||||
signatureStyle: form.signatureStyle,
|
||||
qrPosition: form.qrPosition,
|
||||
qrBackground: form.qrBackground,
|
||||
qrBorder: form.qrBorder,
|
||||
signatureImageBase64: form.signatureImageBase64,
|
||||
};
|
||||
try {
|
||||
const { data } = await signDocument(payload);
|
||||
setResult(data.data);
|
||||
toast.success('¡Documento firmado exitosamente!');
|
||||
setDocs(prev => prev.filter(d => d.id !== payload.documentId));
|
||||
setForm(f => ({ ...f, documentId: '', certificateId: '' }));
|
||||
setSigPlacement(null);
|
||||
setPdfBlob(null);
|
||||
setStep(0);
|
||||
setPendingSign(null);
|
||||
} catch (err) {
|
||||
const code = err.response?.data?.code;
|
||||
if (code === 'CERT_PASSWORD_REQUIRED' && !fromModal) {
|
||||
setPendingSign(payload);
|
||||
} else if (code === 'CERT_PASSWORD_REQUIRED' && fromModal) {
|
||||
toast.error('Contraseña incorrecta. Intenta de nuevo.');
|
||||
setPendingSign(payload);
|
||||
} else {
|
||||
toast.error(err.response?.data?.error || 'Error al firmar');
|
||||
}
|
||||
} finally { setSigning(false); }
|
||||
};
|
||||
|
||||
const selectedCert = certs.find(c => c.id === Number(form.certificateId));
|
||||
const selectedDoc = docs.find(d => d.id === Number(form.documentId));
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
{pendingSign && (
|
||||
<PasswordModal
|
||||
title="Contraseña del certificado"
|
||||
description={`El certificado "${selectedCert?.alias || ''}" requiere contraseña para firmar. No se almacenará.`}
|
||||
onConfirm={(pw) => doSign(pw, true)}
|
||||
onCancel={() => setPendingSign(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="page-header">
|
||||
<h2>Firmar Documento</h2>
|
||||
<p>Firma digital con certificado P12 — firma visible en el PDF</p>
|
||||
</div>
|
||||
|
||||
{/* Steps indicator */}
|
||||
<div className="steps-bar">
|
||||
{STEPS.map((label, i) => (
|
||||
<div key={i} className={`step ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`}>
|
||||
<div className="step-dot">{i < step ? '✓' : i + 1}</div>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Step 0: Selección ── */}
|
||||
{step === 0 && (
|
||||
<div className="sign-layout">
|
||||
<div className="sign-form-card">
|
||||
<div className="field">
|
||||
<label><FileText size={14} /> Documento</label>
|
||||
<select name="documentId" value={form.documentId} onChange={handleChange} required>
|
||||
<option value="">Selecciona un documento...</option>
|
||||
{docs.map(d => <option key={d.id} value={d.id}>{d.title}</option>)}
|
||||
</select>
|
||||
{docs.length === 0 && <small className="field-hint">No hay documentos pendientes.</small>}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label><Shield size={14} /> Certificado digital</label>
|
||||
<select name="certificateId" value={form.certificateId} onChange={handleChange} required>
|
||||
<option value="">Selecciona un certificado...</option>
|
||||
{certs.map(c => <option key={c.id} value={c.id}>{c.alias}{c.subject_cn ? ` (${c.subject_cn})` : ''}</option>)}
|
||||
</select>
|
||||
{certs.length === 0 && <small className="field-hint">No tienes certificados activos.</small>}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Razón de firma</label>
|
||||
<input type="text" name="reason" value={form.reason} onChange={handleChange} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Ubicación</label>
|
||||
<input type="text" name="location" value={form.location} onChange={handleChange} placeholder="Ciudad, País..." />
|
||||
</div>
|
||||
<div className="info-box">
|
||||
<h4>Estilo aplicado</h4>
|
||||
<p>La firma usará tu configuración guardada en el menú <strong>Estilo Firma</strong>.</p>
|
||||
<p><strong>Tipo:</strong> {form.signatureStyle}</p>
|
||||
{form.signatureStyle === 'qr' && <p><strong>Posición QR:</strong> {form.qrPosition === 'left' ? 'Izquierda' : 'Derecha'}</p>}
|
||||
{form.signatureStyle === 'qr' && <p><strong>Fondo:</strong> {form.qrBackground === 'transparent' ? 'Transparente' : 'Suave'}</p>}
|
||||
{form.signatureStyle === 'qr' && <p><strong>Borde:</strong> {form.qrBorder === 'none' ? 'Sin borde' : 'Con borde'}</p>}
|
||||
</div>
|
||||
<button className="btn btn-primary btn-full" onClick={handleGoToPlace}>
|
||||
<MapPin size={16} /> Elegir posición de firma →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sign-info-panel">
|
||||
{result && (
|
||||
<div className="result-card success">
|
||||
<CheckCircle size={32} />
|
||||
<h3>¡Firma aplicada!</h3>
|
||||
<p>Firma visible + firma criptográfica válida.</p>
|
||||
<code>{result.signedFilename}</code>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ marginTop: 12, width: '100%', justifyContent: 'center' }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const { data } = await downloadSigned(result.sigId);
|
||||
const url = URL.createObjectURL(data);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = result.signedFilename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch { toast.error('Error al descargar'); }
|
||||
}}
|
||||
>
|
||||
<Download size={15} /> Descargar PDF firmado
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{sigs.length > 0 && (
|
||||
<div className="sigs-list">
|
||||
<h3>Firmas del documento</h3>
|
||||
{sigs.map(sig => (
|
||||
<div key={sig.id} className="sig-item">
|
||||
<CheckCircle size={16} className="sig-icon" />
|
||||
<div>
|
||||
<strong>{sig.signer_name}</strong>
|
||||
<p>{sig.signer_email}</p>
|
||||
<small>{new Date(sig.signed_at).toLocaleString('es-EC')}</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="info-box">
|
||||
<h4>Proceso de firma</h4>
|
||||
<ol>
|
||||
<li>Selecciona documento y certificado</li>
|
||||
<li>Elige dónde colocar la firma en el PDF</li>
|
||||
<li>Confirma — se pedirá contraseña si es necesario</li>
|
||||
<li>Descarga el PDF firmado con firma visible</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 1: Posicionamiento ── */}
|
||||
{step === 1 && pdfBlob && (
|
||||
<div>
|
||||
<div className="step-nav">
|
||||
<button className="btn btn-ghost" onClick={() => setStep(0)}>← Volver</button>
|
||||
<p className="step-desc">
|
||||
Haz clic donde quieres que aparezca la firma en <strong>{selectedDoc?.title}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<PdfSignaturePlacer
|
||||
pdfBlob={pdfBlob}
|
||||
signerName={selectedCert?.subject_cn || user?.name || 'Firmante'}
|
||||
signatureStyle={form.signatureStyle}
|
||||
qrPosition={form.qrPosition}
|
||||
qrBackground={form.qrBackground}
|
||||
qrBorder={form.qrBorder}
|
||||
signatureImage={form.signatureImageBase64}
|
||||
onPlaced={handlePlaced}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Confirmar y firmar ── */}
|
||||
{step === 2 && (
|
||||
<div className="sign-confirm-card">
|
||||
<div className="sign-confirm-header">
|
||||
<CheckCircle size={24} color="var(--success)" />
|
||||
<h3>Posición confirmada — listo para firmar</h3>
|
||||
</div>
|
||||
<div className="sign-confirm-details">
|
||||
<p><strong>Documento:</strong> {selectedDoc?.title}</p>
|
||||
<p><strong>Certificado:</strong> {selectedCert?.alias}</p>
|
||||
<p><strong>Razón:</strong> {form.reason}</p>
|
||||
{form.location && <p><strong>Ubicación:</strong> {form.location}</p>}
|
||||
<p><strong>Estilo visual:</strong> {form.signatureStyle}</p>
|
||||
{form.signatureStyle === 'qr' && <p><strong>Posición QR:</strong> {form.qrPosition === 'left' ? 'Izquierda' : 'Derecha'}</p>}
|
||||
{form.signatureStyle === 'qr' && <p><strong>Fondo:</strong> {form.qrBackground === 'transparent' ? 'Transparente' : 'Suave'}</p>}
|
||||
{form.signatureStyle === 'qr' && <p><strong>Borde:</strong> {form.qrBorder === 'none' ? 'Sin borde' : 'Con borde'}</p>}
|
||||
<p><strong>Página:</strong> {(sigPlacement?.page ?? 0) + 1}</p>
|
||||
</div>
|
||||
<div className="sign-confirm-actions">
|
||||
<button className="btn btn-ghost" onClick={() => setStep(1)}>← Cambiar posición</button>
|
||||
<button className="btn btn-primary" onClick={() => doSign('')} disabled={signing}>
|
||||
<PenTool size={16} /> {signing ? 'Firmando...' : 'Firmar documento'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { ShieldCheck, ShieldAlert, FileText, UserRound, CalendarDays, MapPin } from 'lucide-react';
|
||||
import { verifySignature } from '../../api/public';
|
||||
|
||||
export default function VerifySignaturePage() {
|
||||
const { code } = useParams();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
verifySignature(code)
|
||||
.then(({ data }) => {
|
||||
setResult(data.data);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.response?.data?.error || 'No se pudo verificar la firma.');
|
||||
setResult(null);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<div className="verify-page">
|
||||
<div className="verify-card">
|
||||
{loading ? (
|
||||
<div className="loading-area"><div className="spinner" /></div>
|
||||
) : error ? (
|
||||
<>
|
||||
<div className="verify-status verify-status--invalid">
|
||||
<ShieldAlert size={28} />
|
||||
<div>
|
||||
<h1>Verificación no disponible</h1>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/login" className="btn btn-ghost">Volver</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={`verify-status ${result.isValid ? 'verify-status--valid' : 'verify-status--invalid'}`}>
|
||||
{result.isValid ? <ShieldCheck size={28} /> : <ShieldAlert size={28} />}
|
||||
<div>
|
||||
<h1>{result.isValid ? 'Firma electrónica válida' : 'Firma electrónica no válida'}</h1>
|
||||
<p>
|
||||
{result.isValid
|
||||
? 'La integridad del documento coincide con el registro de LinkedSign.'
|
||||
: 'La integridad del documento no coincide con el registro de LinkedSign.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="verify-grid">
|
||||
<div className="verify-item">
|
||||
<FileText size={16} />
|
||||
<div>
|
||||
<strong>Documento</strong>
|
||||
<span>{result.documentTitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verify-item">
|
||||
<UserRound size={16} />
|
||||
<div>
|
||||
<strong>Firmante</strong>
|
||||
<span>{result.certificateSubjectCn || result.signerName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verify-item">
|
||||
<CalendarDays size={16} />
|
||||
<div>
|
||||
<strong>Fecha de firma</strong>
|
||||
<span>{new Date(result.signedAt).toLocaleString('es-EC')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verify-item">
|
||||
<MapPin size={16} />
|
||||
<div>
|
||||
<strong>Ubicación</strong>
|
||||
<span>{result.location || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="verify-details">
|
||||
<p><strong>Motivo:</strong> {result.reason || 'Firmado digitalmente'}</p>
|
||||
<p><strong>Emisor del certificado:</strong> {result.certificateIssuerCn || '—'}</p>
|
||||
<p><strong>Código de verificación:</strong> {result.verificationCode}</p>
|
||||
<p><strong>Archivo firmado:</strong> {result.signedFilename}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user