diff --git a/CHANGELOG.md b/CHANGELOG.md index ba60e45..f2ac54a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,16 @@ ## Versión actual -- Versión base: `1.2.8` +- Versión base: `1.2.9` ## Historial +### 1.2.9 - 2026-04-23 + +- Web evita el refresco manual después de subir PDFs y actualiza la lista de documentos en caliente. +- El sello visible de firma usa el huso horario y locale detectados en el navegador del usuario. +- `Firmar ahora` agrega selector con botón para elegir documento y botón `+` para cargar un PDF desde el mismo flujo. + ### 1.2.8 - 2026-04-23 - Android simplifica la pantalla biométrica: se ocultan `Face ID`, `PIN` y `SMS OTP`. diff --git a/VERSION b/VERSION index db6fb4a..9d4f823 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.8 +1.2.9 diff --git a/android/app/build.gradle b/android/app/build.gradle index 7855cbd..52c055f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -18,8 +18,8 @@ android { applicationId "net.linkedcorp.linkedsign" minSdk 26 targetSdk 35 - versionCode 10208 - versionName "1.2.8" + versionCode 10209 + versionName "1.2.9" buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\"" } diff --git a/backend/package.json b/backend/package.json index 92ea433..da34691 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "linkedsign-backend", - "version": "1.2.8", + "version": "1.2.9", "private": true, "description": "Initial backend for LinkedSign", "main": "src/server.js", diff --git a/backend/src/modules/signatures/signatures.controller.js b/backend/src/modules/signatures/signatures.controller.js index a34442a..d3a34cb 100644 --- a/backend/src/modules/signatures/signatures.controller.js +++ b/backend/src/modules/signatures/signatures.controller.js @@ -8,18 +8,21 @@ const getCtx = (req) => ({ userName: req.user?.name || req.user?.email, userEmail: req.user?.email || null, frontendUrl: req.headers.origin || null, + clientLocale: req.body?.clientLocale || null, + clientTimeZone: req.body?.clientTimeZone || null, + clientCountry: req.body?.clientCountry || null, }); const sign = async (req, res, next) => { try { - const { documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 } = req.body; + const { documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64, clientLocale, clientTimeZone, clientCountry } = req.body; const docId = Number(documentId); const certId = Number(certificateId); if (!docId || !certId || isNaN(docId) || isNaN(certId)) { const e = new Error('documentId and certificateId are required.'); e.statusCode = 400; throw e; } const result = await signaturesService.sign( - { documentId: docId, certificateId: certId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }, + { documentId: docId, certificateId: certId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64, clientLocale, clientTimeZone, clientCountry }, req.user.id, getCtx(req) ); diff --git a/backend/src/modules/signatures/signatures.service.js b/backend/src/modules/signatures/signatures.service.js index 7038177..04e1491 100644 --- a/backend/src/modules/signatures/signatures.service.js +++ b/backend/src/modules/signatures/signatures.service.js @@ -9,7 +9,7 @@ const { signPdf } = require('../signing/signing.service'); const auditService = require('../audit/audit.service'); const env = require('../../config/env'); -const sign = async ({ documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64 }, userId, context = {}) => { +const sign = async ({ documentId, certificateId, certPassword, reason, location, page, rect, signatureStyle, qrPosition, qrBackground, qrBorder, signatureImageBase64, clientLocale, clientTimeZone, clientCountry }, userId, context = {}) => { const doc = await documentsRepository.findById(documentId); if (!doc) { const e = new Error('Document not found.'); e.statusCode = 404; throw e; } if (doc.user_id !== userId) { const e = new Error('Access denied.'); e.statusCode = 403; throw e; } @@ -50,6 +50,9 @@ const sign = async ({ documentId, certificateId, certPassword, reason, location, qrBorder: qrBorder || 'none', signatureImageBase64: signatureImageBase64 || null, verificationUrl, + clientLocale: clientLocale || context.clientLocale || null, + clientTimeZone: clientTimeZone || context.clientTimeZone || null, + clientCountry: clientCountry || context.clientCountry || null, })); } catch (signErr) { const msg = signErr.message || ''; diff --git a/backend/src/modules/signing/signing.service.js b/backend/src/modules/signing/signing.service.js index 035ccfe..0fcd0f6 100644 --- a/backend/src/modules/signing/signing.service.js +++ b/backend/src/modules/signing/signing.service.js @@ -39,6 +39,34 @@ function getSignatureWidth(style, signerName = '') { return Math.max(170, Math.min(270, Math.round(calculatedWidth))); } +function buildLocalizedSignatureTimestamp({ locale, timeZone } = {}) { + const now = new Date(); + const normalizedLocale = locale || 'es-EC'; + const options = timeZone ? { timeZone } : {}; + + try { + return { + dateStr: now.toLocaleDateString(normalizedLocale, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + ...options, + }), + timeStr: now.toLocaleTimeString(normalizedLocale, { + hour: '2-digit', + minute: '2-digit', + hour12: true, + ...options, + }), + }; + } catch { + return { + dateStr: now.toLocaleDateString('es-EC', { day: '2-digit', month: '2-digit', year: 'numeric' }), + timeStr: now.toLocaleTimeString('es-EC', { hour: '2-digit', minute: '2-digit', hour12: true }), + }; + } +} + // ── Style: CLASSIC (blue bar + DS badge) ────────────────────────────────────── async function drawStyleClassic(pdfDoc, page, sx, sy, sw, sh, { signerName, reason, dateStr, timeStr, location }) { const f1 = await pdfDoc.embedFont(StandardFonts.HelveticaBold); @@ -222,6 +250,8 @@ const signPdf = async ({ qrBackground = 'transparent', qrBorder = 'none', verificationUrl = null, + clientLocale = null, + clientTimeZone = null, outputDir = null, outputBaseName = null, }) => { @@ -258,9 +288,10 @@ const signPdf = async ({ }; const { x: sx, y: sy, width: sw, height: sh } = sigRect; - const now = new Date(); - const dateStr = now.toLocaleDateString('es-EC', { day: '2-digit', month: '2-digit', year: 'numeric' }); - const timeStr = now.toLocaleTimeString('es-EC', { hour: '2-digit', minute: '2-digit', hour12: true }); + const { dateStr, timeStr } = buildLocalizedSignatureTimestamp({ + locale: clientLocale, + timeZone: clientTimeZone, + }); const info = { signerName, reason, dateStr, timeStr, location, signatureImageBase64, verificationUrl, qrPosition, qrBackground, qrBorder }; diff --git a/frontend/package.json b/frontend/package.json index 896b65c..af951b4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.2.8", + "version": "1.2.9", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/ui/PdfSignaturePlacer.jsx b/frontend/src/components/ui/PdfSignaturePlacer.jsx index 5ce585e..0d7995d 100644 --- a/frontend/src/components/ui/PdfSignaturePlacer.jsx +++ b/frontend/src/components/ui/PdfSignaturePlacer.jsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { Document, Page, pdfjs } from 'react-pdf'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; +import { detectClientSignatureContext } from '../../utils/clientSignatureContext'; pdfjs.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', @@ -28,6 +29,7 @@ export default function PdfSignaturePlacer({ signatureImage = null, onPlaced, }) { + const clientSignatureContext = detectClientSignatureContext(); const [numPages, setNumPages] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [sigBox, setSigBox] = useState(null); // { x, y } in container px @@ -120,7 +122,9 @@ export default function PdfSignaturePlacer({ }); }; - const previewDate = new Date().toLocaleDateString('es-EC'); + const previewDate = new Date().toLocaleDateString(clientSignatureContext.locale, { + timeZone: clientSignatureContext.timeZone, + }); const renderPreview = () => { if (signatureStyle === 'qr') { diff --git a/frontend/src/index.css b/frontend/src/index.css index 24895a8..74905c0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1039,6 +1039,26 @@ select { padding: 24px; } +.select-action-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: stretch; +} + +.select-action-buttons { + display: flex; + gap: 8px; +} + +.btn-square { + min-width: 44px; + width: 44px; + padding-left: 0; + padding-right: 0; + justify-content: center; +} + .sign-form-card form { display: flex; flex-direction: column; gap: 14px; } .sign-info-panel { display: flex; flex-direction: column; gap: 14px; } @@ -2282,6 +2302,18 @@ select { grid-template-columns: 1fr; } + .select-action-row { + grid-template-columns: 1fr; + } + + .select-action-buttons { + width: 100%; + } + + .select-action-buttons .btn { + flex: 1; + } + .steps-bar { flex-direction: column; align-items: stretch; diff --git a/frontend/src/pages/documents/DocumentsPage.jsx b/frontend/src/pages/documents/DocumentsPage.jsx index fe599af..fc84f27 100644 --- a/frontend/src/pages/documents/DocumentsPage.jsx +++ b/frontend/src/pages/documents/DocumentsPage.jsx @@ -48,19 +48,21 @@ export default function DocumentsPage() { const [creatingFlow, setCreatingFlow] = useState(false); const [deletingInvitationId, setDeletingInvitationId] = useState(null); - 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)); + const loadDocs = async ({ silent = false } = {}) => { + if (!silent) setLoading(true); + try { + const [documentsResponse, signedResponse] = await Promise.all([ + listDocuments({ limit: 50 }), + listSignedDocuments(), + ]); + setDocs(documentsResponse.data.data.rows); + setTotal(documentsResponse.data.data.total); + setSignedDocs(signedResponse.data.data || []); + } catch { + toast.error('Error al cargar documentos'); + } finally { + if (!silent) setLoading(false); + } }; useEffect(() => { loadDocs(); }, []); @@ -79,9 +81,12 @@ export default function DocumentsPage() { if (file.type !== 'application/pdf') { toast.error('Solo se permiten archivos PDF'); return; } setUploading(true); try { - await uploadDocument(file); + const { data } = await uploadDocument(file); + const createdDoc = data.data; + setDocs((currentDocs) => [createdDoc, ...currentDocs.filter((doc) => doc.id !== createdDoc.id)]); + setTotal((currentTotal) => currentTotal + 1); toast.success('Documento subido correctamente'); - loadDocs(); + void loadDocs({ silent: true }); } catch (err) { toast.error(err.response?.data?.error || 'Error al subir documento'); } finally { diff --git a/frontend/src/pages/signatures/SignaturesPage.jsx b/frontend/src/pages/signatures/SignaturesPage.jsx index 1ec65c2..e7f755e 100644 --- a/frontend/src/pages/signatures/SignaturesPage.jsx +++ b/frontend/src/pages/signatures/SignaturesPage.jsx @@ -1,13 +1,14 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { listDocuments, downloadDocument } from '../../api/documents'; +import { listDocuments, downloadDocument, uploadDocument } 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 { PenTool, FileText, Shield, CheckCircle, MapPin, Download, Check, Plus } 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'; +import { detectClientSignatureContext } from '../../utils/clientSignatureContext'; const STEPS = ['Selección', 'Posición', 'Firmar']; const normalizeSignatureStyle = (value) => (value === 'image' ? 'qr' : (value || 'qr')); @@ -15,13 +16,16 @@ const normalizeSignatureStyle = (value) => (value === 'image' ? 'qr' : (value || export default function SignaturesPage() { const { user } = useAuth(); const [searchParams] = useSearchParams(); + const clientSignatureContext = detectClientSignatureContext(); + const initialDocumentId = searchParams.get('docId') || ''; const [docs, setDocs] = useState([]); const [certs, setCerts] = useState([]); + const [documentPickerId, setDocumentPickerId] = useState(initialDocumentId); const [form, setForm] = useState({ - documentId: searchParams.get('docId') || '', + documentId: initialDocumentId, certificateId: '', reason: 'Firmado digitalmente', - location: '', + location: clientSignatureContext.defaultLocation, signatureStyle: normalizeSignatureStyle(user?.signaturePreferences?.signatureStyle), qrPosition: user?.signaturePreferences?.qrPosition || 'left', qrBackground: user?.signaturePreferences?.qrBackground || 'transparent', @@ -34,6 +38,9 @@ export default function SignaturesPage() { const [result, setResult] = useState(null); const [sigs, setSigs] = useState([]); const [pendingSign, setPendingSign] = useState(null); + const [uploadingDocument, setUploadingDocument] = useState(false); + const [pendingLocalDocument, setPendingLocalDocument] = useState(null); + const documentInputRef = useRef(null); useEffect(() => { Promise.all([ @@ -59,16 +66,76 @@ export default function SignaturesPage() { })); }, [user?.signaturePreferences]); + useEffect(() => { + if (!clientSignatureContext.defaultLocation) return; + setForm((currentForm) => ( + currentForm.location + ? currentForm + : { ...currentForm, location: clientSignatureContext.defaultLocation } + )); + }, [clientSignatureContext.defaultLocation]); + const handleChange = (e) => setForm(f => ({ ...f, [e.target.name]: e.target.value })); + const handleChooseDocument = () => { + if (!documentPickerId) { + toast.error('Selecciona un documento del listado'); + return; + } + + if (documentPickerId === '__local__') { + if (!pendingLocalDocument) { + toast.error('Selecciona un PDF local válido'); + return; + } + setForm((currentForm) => ({ ...currentForm, documentId: '' })); + toast.success('PDF local seleccionado'); + return; + } + + setForm((currentForm) => ({ ...currentForm, documentId: documentPickerId })); + toast.success('Documento seleccionado'); + }; + + const handleUploadFromSignFlow = async (event) => { + const file = event.target.files?.[0]; + if (!file) return; + if (file.type !== 'application/pdf') { + toast.error('Solo se permiten archivos PDF'); + event.target.value = ''; + return; + } + + setUploadingDocument(true); + try { + setPendingLocalDocument({ + file, + title: file.name.replace(/\.pdf$/i, ''), + }); + setDocumentPickerId('__local__'); + setForm((currentForm) => ({ ...currentForm, documentId: '' })); + toast.success('Documento cargado localmente. Se guardará cuando completes la firma.'); + } catch { + toast.error('No se pudo preparar el documento'); + } finally { + setUploadingDocument(false); + event.target.value = ''; + } + }; + // Step 0 → 1: load PDF for placement const handleGoToPlace = async () => { - if (!form.documentId || !form.certificateId) { + const usingLocalDocument = documentPickerId === '__local__' && pendingLocalDocument; + if ((!form.documentId && !usingLocalDocument) || !form.certificateId) { toast.error('Selecciona documento y certificado'); return; } try { - const { data } = await downloadDocument(form.documentId); - setPdfBlob(data); + if (usingLocalDocument) { + setPdfBlob(pendingLocalDocument.file); + } else { + const { data } = await downloadDocument(form.documentId); + setPdfBlob(data); + } setSigPlacement(null); setStep(1); } catch { toast.error('No se pudo cargar el documento'); } @@ -81,18 +148,37 @@ export default function SignaturesPage() { }; const doSign = async (certPassword = '', fromModal = false) => { - if (!form.documentId || !form.certificateId) { + const usingLocalDocument = documentPickerId === '__local__' && pendingLocalDocument; + if ((!form.documentId && !usingLocalDocument) || !form.certificateId) { toast.error('Faltan datos de documento o certificado. Vuelve al paso 1.'); setStep(0); return; } setSigning(true); + let workingDocumentId = Number(form.documentId) || null; + + try { + if (!workingDocumentId && usingLocalDocument) { + const { data: uploadResponse } = await uploadDocument(pendingLocalDocument.file); + const createdDoc = uploadResponse.data; + workingDocumentId = Number(createdDoc.id); + setDocs((currentDocs) => [createdDoc, ...currentDocs.filter((doc) => doc.id !== createdDoc.id)]); + } + } catch (error) { + setSigning(false); + toast.error(error.response?.data?.error || 'No se pudo guardar el documento antes de firmar'); + return; + } + const payload = { - documentId: Number(form.documentId), + documentId: workingDocumentId, certificateId: Number(form.certificateId), certPassword, reason: form.reason, location: form.location, + clientLocale: clientSignatureContext.locale, + clientTimeZone: clientSignatureContext.timeZone, + clientCountry: clientSignatureContext.countryCode, page: sigPlacement?.page ?? 0, rect: sigPlacement?.rect ?? null, signatureStyle: form.signatureStyle, @@ -105,6 +191,8 @@ export default function SignaturesPage() { setResult(data.data); toast.success('¡Documento firmado exitosamente!'); setDocs(prev => prev.filter(d => d.id !== payload.documentId)); + setPendingLocalDocument(null); + setDocumentPickerId(''); setForm(f => ({ ...f, documentId: '', certificateId: '' })); setSigPlacement(null); setPdfBlob(null); @@ -125,6 +213,9 @@ export default function SignaturesPage() { const selectedCert = certs.find(c => c.id === Number(form.certificateId)); const selectedDoc = docs.find(d => d.id === Number(form.documentId)); + const selectedDocumentTitle = documentPickerId === '__local__' + ? pendingLocalDocument?.title + : selectedDoc?.title; return (
@@ -158,11 +249,38 @@ export default function SignaturesPage() {
- +
+ +
+ + +
+
+ {docs.length === 0 && No hay documentos pendientes.} + {selectedDocumentTitle && ( + Seleccionado: {selectedDocumentTitle} + )} + {uploadingDocument && Subiendo documento...}
diff --git a/frontend/src/utils/clientSignatureContext.js b/frontend/src/utils/clientSignatureContext.js new file mode 100644 index 0000000..5df0441 --- /dev/null +++ b/frontend/src/utils/clientSignatureContext.js @@ -0,0 +1,68 @@ +const COUNTRY_BY_TIMEZONE = { + 'America/Guayaquil': 'EC', + 'America/Bogota': 'CO', + 'America/Lima': 'PE', + 'America/Caracas': 'VE', + 'America/Santiago': 'CL', + 'America/Buenos_Aires': 'AR', + 'America/Sao_Paulo': 'BR', + 'America/Mexico_City': 'MX', + 'America/Panama': 'PA', + 'America/Costa_Rica': 'CR', + 'America/El_Salvador': 'SV', + 'America/Managua': 'NI', + 'America/Tegucigalpa': 'HN', + 'America/Guatemala': 'GT', + 'America/La_Paz': 'BO', + 'America/Asuncion': 'PY', + 'America/Montevideo': 'UY', + 'America/Puerto_Rico': 'PR', + 'America/New_York': 'US', + 'America/Chicago': 'US', + 'America/Denver': 'US', + 'America/Los_Angeles': 'US', + 'Europe/Madrid': 'ES', +}; + +const getClientLocale = () => { + if (typeof navigator === 'undefined') return 'es-EC'; + return navigator.languages?.[0] || navigator.language || 'es-EC'; +}; + +const getRegionFromLocale = (locale) => { + try { + const normalized = String(locale || '').replace('_', '-'); + if (typeof Intl.Locale === 'function') { + return new Intl.Locale(normalized).region || null; + } + const parts = normalized.split('-'); + return parts[1] || null; + } catch { + return null; + } +}; + +const getCountryName = (countryCode, locale) => { + if (!countryCode) return ''; + try { + const display = new Intl.DisplayNames([locale || 'es-EC'], { type: 'region' }); + return display.of(countryCode) || countryCode; + } catch { + return countryCode; + } +}; + +export const detectClientSignatureContext = () => { + const locale = getClientLocale(); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + const countryCode = getRegionFromLocale(locale) || COUNTRY_BY_TIMEZONE[timeZone] || null; + const countryName = getCountryName(countryCode, locale); + + return { + locale, + timeZone, + countryCode, + countryName, + defaultLocation: countryName || '', + }; +};