From d2c9986d97486bc67559e4924d47f9f9bf7057a1 Mon Sep 17 00:00:00 2001 From: Phillip Date: Sun, 29 Mar 2026 16:03:57 +0200 Subject: [PATCH 01/68] fix(mobile): fix signature placement and coordinate serialization Signed-off-by: Phillip --- src/components/PdfEditor/PdfEditor.vue | 75 +++++++++++++++++++--- src/components/Request/VisibleElements.vue | 36 +++++++---- vite.config.mjs | 21 ++++++ 3 files changed, 110 insertions(+), 22 deletions(-) diff --git a/src/components/PdfEditor/PdfEditor.vue b/src/components/PdfEditor/PdfEditor.vue index 7977e15559..e74bb566d2 100644 --- a/src/components/PdfEditor/PdfEditor.vue +++ b/src/components/PdfEditor/PdfEditor.vue @@ -127,6 +127,12 @@ type PdfElementsInstance = { selectedDocIndex?: number autoFitZoom?: boolean } +type PdfElementsRuntimeInstance = PdfElementsInstance & { + handleMouseMove?: (event: { type: string, touches: Array<{ clientX: number, clientY: number }> }) => void + finishAdding?: () => void + previewElement?: Record | null + previewVisible?: boolean +} defineOptions({ name: 'PdfEditor', @@ -155,6 +161,7 @@ const pdfElements = ref(null) const pendingAddedObjectCount = ref(null) let pendingAddCheckTimer: ReturnType | null = null +let pendingAddCheckRetries = 0 const ignoreClickOutsideSelectors = computed(() => ['.action-item__popper', '.action-item']) @@ -271,6 +278,7 @@ function clearPendingAddCheck() { clearTimeout(pendingAddCheckTimer) pendingAddCheckTimer = null } + pendingAddCheckRetries = 0 pendingAddedObjectCount.value = null } @@ -283,11 +291,29 @@ function checkSignerAdded() { pendingAddCheckTimer = null const isAddingMode = pdfElements.value?.isAddingMode === true const objectsAfter = getTotalObjectsCount() - pendingAddedObjectCount.value = null - if (!isAddingMode && objectsAfter > objectsBefore) { + if (objectsAfter > objectsBefore) { + clearPendingAddCheck() emit('pdf-editor:signer-added') + return + } + + // Fallback: once add mode ends, unblock the UI even if the object count + // comparison was not conclusive due timing/reactivity. + if (!isAddingMode) { + clearPendingAddCheck() + emit('pdf-editor:signer-added') + return } + + // Poll while the external component still processes placement. + if (pendingAddCheckRetries < 300) { + pendingAddCheckRetries++ + pendingAddCheckTimer = setTimeout(checkSignerAdded, 100) + return + } + + clearPendingAddCheck() } function scheduleSignerAddedCheck() { @@ -300,6 +326,38 @@ function scheduleSignerAddedCheck() { pendingAddCheckTimer = setTimeout(checkSignerAdded, 0) } +function handleDocumentTouchEnd(event: Event) { + if (pendingAddedObjectCount.value === null) { + return + } + + const instance = pdfElements.value as PdfElementsRuntimeInstance | null + const touchEvent = event as TouchEvent + const touchPoint = touchEvent.changedTouches?.[0] + if (!instance || !touchPoint) { + scheduleSignerAddedCheck() + return + } + + // Work around mobile tap placement timing in pdf-elements: touchend has no + // touches[0], so preview may never become visible on first tap. + if (instance.isAddingMode && instance.previewElement && !instance.previewVisible && instance.handleMouseMove) { + instance.handleMouseMove({ + type: 'touchmove', + touches: [{ clientX: touchPoint.clientX, clientY: touchPoint.clientY }], + }) + requestAnimationFrame(() => { + if (instance.isAddingMode) { + instance.finishAdding?.() + } + scheduleSignerAddedCheck() + }) + return + } + + scheduleSignerAddedCheck() +} + function startAddingSigner(signer: SignerSummaryRecord | SignerDetailRecord | null | undefined, size: { width?: number, height?: number }) { if (!pdfElements.value || !size?.width || !size?.height) { return false @@ -319,6 +377,11 @@ function startAddingSigner(signer: SignerSummaryRecord | SignerDetailRecord | nu signer: signerPayload, }) pendingAddedObjectCount.value = getTotalObjectsCount() + pendingAddCheckRetries = 0 + if (pendingAddCheckTimer !== null) { + clearTimeout(pendingAddCheckTimer) + } + pendingAddCheckTimer = setTimeout(checkSignerAdded, 100) return true } @@ -383,15 +446,11 @@ async function waitForPageRender(docIndex: number, pageIndex: number) { onMounted(() => { ensurePdfWorker() - document.addEventListener('mouseup', scheduleSignerAddedCheck) - document.addEventListener('touchend', scheduleSignerAddedCheck) - document.addEventListener('keyup', scheduleSignerAddedCheck) + document.addEventListener('touchend', handleDocumentTouchEnd) }) onBeforeUnmount(() => { - document.removeEventListener('mouseup', scheduleSignerAddedCheck) - document.removeEventListener('touchend', scheduleSignerAddedCheck) - document.removeEventListener('keyup', scheduleSignerAddedCheck) + document.removeEventListener('touchend', handleDocumentTouchEnd) clearPendingAddCheck() }) diff --git a/src/components/Request/VisibleElements.vue b/src/components/Request/VisibleElements.vue index 8ed4b506ee..f8ae524338 100644 --- a/src/components/Request/VisibleElements.vue +++ b/src/components/Request/VisibleElements.vue @@ -51,20 +51,22 @@ - - {{ t('libresign', 'Save') }} - - - - {{ t('libresign', 'Sign') }} - +
+ + {{ t('libresign', 'Save') }} + + + + {{ t('libresign', 'Sign') }} + +
Date: Sun, 29 Mar 2026 17:04:47 +0200 Subject: [PATCH 02/68] fix(mobile): harden touch placement and pdf-elements runtime behavior Signed-off-by: Phillip --- src/components/PdfEditor/PdfEditor.vue | 32 +++++++++++++++----- vite.config.mjs | 41 ++++++++++++++++++++++---- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/components/PdfEditor/PdfEditor.vue b/src/components/PdfEditor/PdfEditor.vue index e74bb566d2..ea25ae193f 100644 --- a/src/components/PdfEditor/PdfEditor.vue +++ b/src/components/PdfEditor/PdfEditor.vue @@ -342,15 +342,20 @@ function handleDocumentTouchEnd(event: Event) { // Work around mobile tap placement timing in pdf-elements: touchend has no // touches[0], so preview may never become visible on first tap. if (instance.isAddingMode && instance.previewElement && !instance.previewVisible && instance.handleMouseMove) { + touchEvent.preventDefault?.() + touchEvent.stopImmediatePropagation?.() + instance.handleMouseMove({ type: 'touchmove', touches: [{ clientX: touchPoint.clientX, clientY: touchPoint.clientY }], }) requestAnimationFrame(() => { - if (instance.isAddingMode) { - instance.finishAdding?.() - } - scheduleSignerAddedCheck() + requestAnimationFrame(() => { + if (instance.isAddingMode) { + instance.finishAdding?.() + } + scheduleSignerAddedCheck() + }) }) return } @@ -492,13 +497,14 @@ defineExpose({ } .action-btn { - border: none; - background: transparent; - color: #ffffff; + border: 1px solid #cbd5e1; + background: #f8fafc; + color: #0f172a; padding: 4px; min-height: 0; min-width: 0; border-radius: 4px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); cursor: pointer; display: inline-flex; align-items: center; @@ -506,7 +512,17 @@ defineExpose({ transition: background 120ms ease; &:hover { - background: rgba(255, 255, 255, 0.1); + background: #e2e8f0; + } + + :deep(svg), + :deep(.icon-vue), + :deep(.material-design-icon), + :deep([class*='icon']) { + color: currentColor; + fill: currentColor; + stroke: currentColor; + opacity: 1; } } diff --git a/vite.config.mjs b/vite.config.mjs index 20a767810a..13e9b692fd 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -6,22 +6,53 @@ import { createAppConfig } from '@nextcloud/vite-config' import { resolve } from 'node:path' -const patchPdfElementsTouchmovePassive = { - name: 'patch-pdf-elements-touchmove-passive', +const patchPdfElementsRuntimeFixes = { + name: 'patch-pdf-elements-runtime-fixes', enforce: 'pre', transform(code, id) { if (!id.includes('/@libresign/pdf-elements/')) { return null } - if (!id.endsWith('/dist/index.mjs') && !id.endsWith('/src/components/DraggableElement.vue')) { + if (!id.endsWith('/dist/index.mjs') + && !id.endsWith('/src/components/DraggableElement.vue') + && !id.endsWith('/src/components/PDFElements.vue')) { return null } - const replaced = code.replace( + let replaced = code + + // Drag/resize listeners must be non-passive because handleMove calls preventDefault. + replaced = replaced.replace( /window\.addEventListener\((['"])touchmove\1,\s*this\.boundHandleMove\)/g, 'window.addEventListener($1touchmove$1, this.boundHandleMove, { passive: false })', ) + // Adding-mode touchmove also needs to be non-passive. + replaced = replaced.replace( + /document\.addEventListener\((['"])touchmove\1,\s*this\.handleMouseMove,\s*\{\s*passive:\s*(?:!0|true)\s*\}\)/g, + 'document.addEventListener($1touchmove$1, this.handleMouseMove, { passive: false })', + ) + + // Guard against race where add mode ends while RAF callback is still queued. + replaced = replaced.replace( + /const s = this\.pendingHoverClientPos;\s*if \(!s\) return;/g, + 'if (!this.isAddingMode || !this.previewElement) { this.pendingHoverClientPos = null; return; } const s = this.pendingHoverClientPos; if (!s) return;', + ) + + // Defensive access to preview element dimensions in async mobile flow. + replaced = replaced.replace(/this\.previewElement\.width/g, '(this.previewElement?.width || 0)') + replaced = replaced.replace(/this\.previewElement\.height/g, '(this.previewElement?.height || 0)') + + // Keep toolbar above by default, but place below when signature is near top. + replaced = replaced.replace( + /const e = this\.pagesScale \|\| 1, t = this\.mode === "drag", i = this\.mode === "resize", s = t \? this\.offsetX : 0, n = t \? this\.offsetY : 0, a = i \? this\.resizeOffsetX : 0, o = i \? this\.resizeOffsetY : 0, r = i \? this\.resizeOffsetW : 0, h = this\.object\.x \+ s \+ a, l = this\.object\.y \+ n \+ o, u = this\.object\.width \+ r, d = l - 60, g = d < 0 \? l \+ 8 : d;/g, + 'const e = this.pagesScale || 1, t = this.mode === "drag", i = this.mode === "resize", s = t ? this.offsetX : 0, n = t ? this.offsetY : 0, a = i ? this.resizeOffsetX : 0, o = i ? this.resizeOffsetY : 0, r = i ? this.resizeOffsetW : 0, h = i ? this.resizeOffsetH : 0, l = this.object.x + s + a, u = this.object.y + n + o, c = this.object.width + r, d = this.object.height + h, g = u * e < 72, f = g ? u + d : u, b = g ? "translate(-50%, 8px)" : "translate(-50%, calc(-100% - 8px))";', + ) + replaced = replaced.replace( + /left: `\$\{\(h \+ u \/ 2\) \* e\}px`,\s*top: `\$\{g \* e\}px`,\s*transform: "translateX\(-50%\)"/g, + 'left: `${(l + c / 2) * e}px`, top: `${f * e}px`, transform: b', + ) + return replaced === code ? null : { code: replaced, map: null } }, } @@ -45,7 +76,7 @@ export default createAppConfig({ }, }, plugins: [ - patchPdfElementsTouchmovePassive, + patchPdfElementsRuntimeFixes, { name: 'vue-devtools', config(_, { mode }) { From d4f371f9ea148671448d219f4161b116a35ee9d0 Mon Sep 17 00:00:00 2001 From: Phillip Date: Mon, 30 Mar 2026 22:17:53 +0200 Subject: [PATCH 03/68] test(mobile): increase coverage for signature placement flows Signed-off-by: Phillip --- .../components/PdfEditor/PdfEditor.spec.ts | 202 ++++++++++++++++++ .../Request/VisibleElements.spec.ts | 20 ++ 2 files changed, 222 insertions(+) diff --git a/src/tests/components/PdfEditor/PdfEditor.spec.ts b/src/tests/components/PdfEditor/PdfEditor.spec.ts index cf36e9760c..1da8719a5b 100644 --- a/src/tests/components/PdfEditor/PdfEditor.spec.ts +++ b/src/tests/components/PdfEditor/PdfEditor.spec.ts @@ -80,6 +80,9 @@ type PdfEditorVm = { totalPages: number isAddingMode: boolean }) => string + getTotalObjectsCount: () => number + checkSignerAdded: () => void + scheduleSignerAddedCheck: () => void setProps: (props: Record) => Promise } @@ -244,6 +247,15 @@ describe('PdfEditor Component - Business Rules', () => { expect(result).toBe(false) }) + it('returns false when signer payload cannot be built', () => { + const result = wrapper.vm.startAddingSigner( + null, + { width: 200, height: 100 }, + ) + + expect(result).toBe(false) + }) + it('returns true and starts adding when valid params', () => { const signer = { email: 'test@example.com' } const size = { width: 200, height: 100 } @@ -264,6 +276,196 @@ describe('PdfEditor Component - Business Rules', () => { }), ) }) + + it('restarts pending add timer when startAddingSigner is called twice', () => { + vi.useFakeTimers() + Object.assign(getPdfElements(), { + isAddingMode: true, + pdfDocuments: [{ allObjects: [[]] }], + }) + + wrapper.vm.startAddingSigner({ email: 'first@example.com' }, { width: 120, height: 60 }) + expect(vi.getTimerCount()).toBe(1) + wrapper.vm.startAddingSigner({ email: 'second@example.com' }, { width: 120, height: 60 }) + + expect(vi.getTimerCount()).toBe(1) + vi.useRealTimers() + }) + + it('emits signer-added when object count increases after adding mode starts', () => { + vi.useFakeTimers() + Object.assign(getPdfElements(), { + isAddingMode: true, + pdfDocuments: [{ allObjects: [[]] }], + }) + + wrapper.vm.startAddingSigner({ email: 'test@example.com' }, { width: 120, height: 60 }) + getPdfElements().pdfDocuments = [{ allObjects: [[{ id: 'obj-1' }]] }] + wrapper.vm.checkSignerAdded() + + expect(wrapper.emitted('pdf-editor:signer-added')).toHaveLength(1) + vi.useRealTimers() + }) + + it('emits signer-added when adding mode finishes without object delta', () => { + vi.useFakeTimers() + Object.assign(getPdfElements(), { + isAddingMode: true, + pdfDocuments: [{ allObjects: [[]] }], + }) + + wrapper.vm.startAddingSigner({ email: 'test@example.com' }, { width: 120, height: 60 }) + getPdfElements().isAddingMode = false + wrapper.vm.checkSignerAdded() + + expect(wrapper.emitted('pdf-editor:signer-added')).toHaveLength(1) + vi.useRealTimers() + }) + + it('keeps polling while adding mode is active and count has not changed', () => { + vi.useFakeTimers() + Object.assign(getPdfElements(), { + isAddingMode: true, + pdfDocuments: [{ allObjects: [[]] }], + }) + + wrapper.vm.startAddingSigner({ email: 'test@example.com' }, { width: 120, height: 60 }) + vi.clearAllTimers() + wrapper.vm.checkSignerAdded() + + expect(wrapper.emitted('pdf-editor:signer-added')).toBeFalsy() + expect(vi.getTimerCount()).toBeGreaterThan(0) + vi.useRealTimers() + }) + + it('stops polling after retry limit without emitting signer-added', () => { + vi.useFakeTimers() + Object.assign(getPdfElements(), { + isAddingMode: true, + pdfDocuments: [{ allObjects: [[]] }], + }) + + wrapper.vm.startAddingSigner({ email: 'test@example.com' }, { width: 120, height: 60 }) + vi.clearAllTimers() + for (let i = 0; i < 301; i++) { + wrapper.vm.checkSignerAdded() + vi.clearAllTimers() + } + + expect(wrapper.emitted('pdf-editor:signer-added')).toBeFalsy() + vi.useRealTimers() + }) + }) + + describe('RULE: touchend handling for mobile placement', () => { + it('ignores touchend when no signer placement is pending', () => { + const handleMouseMove = vi.fn() + Object.assign(getPdfElements(), { + handleMouseMove, + isAddingMode: true, + previewElement: { id: 'preview-1' }, + previewVisible: false, + }) + const event = new Event('touchend') + Object.defineProperty(event, 'changedTouches', { + value: [{ clientX: 10, clientY: 20 }], + configurable: true, + }) + + document.dispatchEvent(event) + + expect(handleMouseMove).not.toHaveBeenCalled() + expect(wrapper.emitted('pdf-editor:signer-added')).toBeFalsy() + }) + + it('schedules signer check when touchend has no touch point', async () => { + vi.useFakeTimers() + Object.assign(getPdfElements(), { + isAddingMode: false, + pdfDocuments: [{ allObjects: [[]] }], + }) + wrapper.vm.startAddingSigner({ email: 'test@example.com' }, { width: 120, height: 60 }) + vi.clearAllTimers() + + document.dispatchEvent(new Event('touchend')) + await vi.runOnlyPendingTimersAsync() + + expect(wrapper.emitted('pdf-editor:signer-added')).toHaveLength(1) + vi.useRealTimers() + }) + + it('schedules signer check when pdf-elements instance is unavailable', async () => { + vi.useFakeTimers() + Object.assign(getPdfElements(), { + isAddingMode: false, + pdfDocuments: [{ allObjects: [[]] }], + }) + wrapper.vm.startAddingSigner({ email: 'test@example.com' }, { width: 120, height: 60 }) + vi.clearAllTimers() + wrapper.vm.pdfElements = null + + const event = new Event('touchend') + Object.defineProperty(event, 'changedTouches', { + value: [{ clientX: 10, clientY: 20 }], + configurable: true, + }) + document.dispatchEvent(event) + await vi.runOnlyPendingTimersAsync() + + expect(wrapper.emitted('pdf-editor:signer-added')).toHaveLength(1) + vi.useRealTimers() + }) + + it('uses preview fallback on touchend and finalizes adding flow', async () => { + vi.useFakeTimers() + const runtime = Object.assign(getPdfElements(), { + isAddingMode: true, + previewElement: { id: 'preview-1' }, + previewVisible: false, + handleMouseMove: vi.fn(), + pdfDocuments: [{ allObjects: [[]] }], + }) + runtime.finishAdding = vi.fn(() => { + runtime.isAddingMode = false + }) + + wrapper.vm.startAddingSigner({ email: 'test@example.com' }, { width: 120, height: 60 }) + vi.clearAllTimers() + + const event = new Event('touchend') + const preventDefaultSpy = vi.spyOn(event, 'preventDefault') + const stopImmediatePropagationSpy = vi.spyOn(event, 'stopImmediatePropagation') + Object.defineProperty(event, 'changedTouches', { + value: [{ clientX: 44, clientY: 88 }], + configurable: true, + }) + document.dispatchEvent(event) + await vi.runAllTimersAsync() + + expect(preventDefaultSpy).toHaveBeenCalledTimes(1) + expect(stopImmediatePropagationSpy).toHaveBeenCalledTimes(1) + expect(runtime.handleMouseMove).toHaveBeenCalledWith({ + type: 'touchmove', + touches: [{ clientX: 44, clientY: 88 }], + }) + expect(runtime.finishAdding).toHaveBeenCalledTimes(1) + expect(wrapper.emitted('pdf-editor:signer-added')).toHaveLength(1) + vi.useRealTimers() + }) + }) + + describe('RULE: document listener lifecycle', () => { + it('registers and unregisters touchend listener on mount/unmount', () => { + wrapper.unmount() + const addEventListenerSpy = vi.spyOn(document, 'addEventListener') + const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener') + const localWrapper = createWrapper() + const touchendCall = addEventListenerSpy.mock.calls.find(([eventName]) => eventName === 'touchend') + + expect(touchendCall).toBeTruthy() + localWrapper.unmount() + expect(removeEventListenerSpy).toHaveBeenCalledWith('touchend', touchendCall?.[1] as EventListener) + }) }) describe('RULE: addSigner coordinate calculations', () => { diff --git a/src/tests/components/Request/VisibleElements.spec.ts b/src/tests/components/Request/VisibleElements.spec.ts index 81cf879606..0846801b16 100644 --- a/src/tests/components/Request/VisibleElements.spec.ts +++ b/src/tests/components/Request/VisibleElements.spec.ts @@ -588,6 +588,26 @@ describe('VisibleElements Component - Business Rules', () => { expect(wrapper.vm.modal).toBe(false) }) + it('renders the sign-details action wrapper inside the modal sidebar', async () => { + const wrapperWithModalContent = mount(VisibleElements, { + global: { + stubs: { + NcModal: { template: '' }, + NcNoteCard: true, + NcChip: true, + NcButton: true, + NcLoadingIcon: true, + PdfEditor: true, + Signer: true, + }, + }, + }) as unknown as VisibleElementsWrapper + wrapperWithModalContent.vm.modal = true + await wrapperWithModalContent.vm.$nextTick() + + expect(wrapperWithModalContent.find('.sign-details__actions').exists()).toBe(true) + }) + it('closeModal resets all modal state', () => { wrapper.vm.modal = true wrapper.vm.elementsLoaded = true From 217532d08bbee8ea6dd2c5c33b70c20a968577b3 Mon Sep 17 00:00:00 2001 From: Phillip Date: Mon, 30 Mar 2026 22:45:24 +0200 Subject: [PATCH 04/68] fix(mobile): restore sign flow fallback for self-signer requests Signed-off-by: Phillip --- .../RightSidebar/RequestSignatureTab.vue | 19 +++++++++- src/store/files.js | 7 +++- .../RightSidebar/RequestSignatureTab.spec.ts | 28 +++++++++++++++ src/tests/store/files.spec.ts | 36 +++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/components/RightSidebar/RequestSignatureTab.vue b/src/components/RightSidebar/RequestSignatureTab.vue index 59838204ee..546b8f130e 100644 --- a/src/components/RightSidebar/RequestSignatureTab.vue +++ b/src/components/RightSidebar/RequestSignatureTab.vue @@ -866,6 +866,18 @@ function getValidationFileUuid() { return null } +function getSignRouteUuid() { + const file = filesStore.getFile() + const signer = file?.signers?.find((row: EditableRequestSigner) => row.me) || file?.signers?.[0] + const fromFile = [file?.signUuid, signer?.sign_uuid] + .find((value): value is string => typeof value === 'string' && value.length > 0) + const fromSettings = typeof file?.settings?.signerFileUuid === 'string' && file.settings.signerFileUuid.length > 0 + ? file.settings.signerFileUuid + : null + const fromState = loadState('libresign', 'sign_request_uuid', null) + return fromFile || fromSettings || (typeof fromState === 'string' && fromState.length > 0 ? fromState : null) +} + function validationFile() { const targetUuid = getValidationFileUuid() if (!targetUuid) { @@ -1067,7 +1079,11 @@ async function sign() { return } - const uuid = 'signUuid' in file ? file.signUuid : null + const uuid = getSignRouteUuid() + if (!uuid) { + showError(t('libresign', 'Signer request not found')) + return + } if (props.useModal) { const absoluteUrl = generateUrl('/apps/libresign/p/sign/{uuid}/pdf', { uuid }) const route = router.resolve({ name: 'SignPDFExternal', params: { uuid } }) @@ -1293,6 +1309,7 @@ defineExpose({ isSignElementsAvailable, closeModal, getValidationFileUuid, + getSignRouteUuid, validationFile, addSigner, editSigner, diff --git a/src/store/files.js b/src/store/files.js index 91cc3ae3a4..0f69789917 100644 --- a/src/store/files.js +++ b/src/store/files.js @@ -714,13 +714,18 @@ const _filesStore = defineStore('files', () => { const isSigned = (signer) => Array.isArray(signer.signed) ? signer.signed.length > 0 : !!signer.signed + const signerFileUuid = typeof selectedFile?.settings?.signerFileUuid === 'string' + ? selectedFile.settings.signerFileUuid + : '' const mySigners = selectedFile?.signers?.filter(signer => signer.me) || [] if (isFullSigned(selectedFile) || selectedFile.status <= 0 - || mySigners.length === 0 || mySigners.some((signer) => isSigned(signer))) { return false } + if (mySigners.length === 0) { + return signerFileUuid.length > 0 + } const flow = selectedFile?.signatureFlow const isOrderedNumeric = flow === 'ordered_numeric' || flow === 2 diff --git a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts index 43746fb946..5f8222302c 100644 --- a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts +++ b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts @@ -407,6 +407,34 @@ describe('RequestSignatureTab - Critical Business Rules', () => { expect(generateUrlMock).toHaveBeenCalledWith('/apps/libresign/p/sign/{uuid}/pdf', { uuid: 'sign-uuid' }) expect(wrapper.vm.modalSrc).toBe('/apps/libresign/p/sign/sign-uuid/pdf') }) + + it('falls back to signerFileUuid for signing modal links when signUuid is missing', async () => { + await wrapper.setProps({ useModal: true }) + await updateFile({ + signUuid: null, + settings: { signerFileUuid: 'mobile-fallback-uuid' }, + }) + generateUrlMock.mockClear() + + await wrapper.vm.sign() + + expect(generateUrlMock).toHaveBeenCalledWith('/apps/libresign/p/sign/{uuid}/pdf', { uuid: 'mobile-fallback-uuid' }) + expect(wrapper.vm.modalSrc).toBe('/apps/libresign/p/sign/mobile-fallback-uuid/pdf') + }) + + it('falls back to signer sign_uuid when signUuid is missing', async () => { + await wrapper.setProps({ useModal: true }) + await updateFile({ + signUuid: null, + signers: [{ me: true, sign_uuid: 'signer-uuid-123' }], + }) + generateUrlMock.mockClear() + + await wrapper.vm.sign() + + expect(generateUrlMock).toHaveBeenCalledWith('/apps/libresign/p/sign/{uuid}/pdf', { uuid: 'signer-uuid-123' }) + expect(wrapper.vm.modalSrc).toBe('/apps/libresign/p/sign/signer-uuid-123/pdf') + }) }) describe('RULE: canEditSigningOrder when using ordered flow', () => { diff --git a/src/tests/store/files.spec.ts b/src/tests/store/files.spec.ts index 5d1a655177..d4072d24ab 100644 --- a/src/tests/store/files.spec.ts +++ b/src/tests/store/files.spec.ts @@ -338,6 +338,42 @@ describe('files store - critical business rules', () => { expect(store.canSign()).toBe(true) }) + + it('allows signing when signer me flag is missing but signerFileUuid exists', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.files[1] = { + id: 1, + status: 1, + signatureFlow: 'parallel', + signers: [ + { me: false, signingOrder: 1, signed: [] }, + ], + settings: { + signerFileUuid: '8af5bd0b-0776-4533-8d57-8ee88ed1f6bf', + }, + } + + expect(store.canSign()).toBe(true) + }) + + it('blocks signing when signer me flag is missing and signerFileUuid is empty', () => { + const store = useFilesStore() + store.selectedFileId = 1 + store.files[1] = { + id: 1, + status: 1, + signatureFlow: 'parallel', + signers: [ + { me: false, signingOrder: 1, signed: [] }, + ], + settings: { + signerFileUuid: '', + }, + } + + expect(store.canSign()).toBe(false) + }) }) describe('RULE: adding signers respects document state', () => { From 67c9657b6799f8362e8800284345c634650ef4c6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:57:47 -0300 Subject: [PATCH 05/68] test: fix PdfEditor spec runtime mock typing Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/tests/components/PdfEditor/PdfEditor.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tests/components/PdfEditor/PdfEditor.spec.ts b/src/tests/components/PdfEditor/PdfEditor.spec.ts index 1da8719a5b..50d47e9107 100644 --- a/src/tests/components/PdfEditor/PdfEditor.spec.ts +++ b/src/tests/components/PdfEditor/PdfEditor.spec.ts @@ -53,6 +53,11 @@ type PdfElementsMock = { pdfDocuments: PdfDocumentRecord[] selectedDocIndex: number autoFitZoom: boolean + isAddingMode?: boolean + handleMouseMove?: ReturnType + finishAdding?: ReturnType + previewElement?: Record | null + previewVisible?: boolean } type PdfEditorVm = { From 16732f47f325d2150b2c7d78ae3a314eba99c09f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:04:09 -0300 Subject: [PATCH 06/68] chore(deps): bump pdf-elements to 1.1.4 Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7339037b1b..0ad403186c 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.41.0", "@fontsource/dancing-script": "^5.2.8", - "@libresign/pdf-elements": "^1.1.3", + "@libresign/pdf-elements": "^1.1.4", "@marionebl/option": "^1.0.8", "@mdi/js": "^7.4.47", "@mdi/svg": "^7.4.47", From 3e8be6b0b805b6796debe3baff88dc4c0b425455 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:04:10 -0300 Subject: [PATCH 07/68] chore(deps): refresh lockfile for pdf-elements Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e79881683c..f4392d2b61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.41.0", "@fontsource/dancing-script": "^5.2.8", - "@libresign/pdf-elements": "^1.1.3", + "@libresign/pdf-elements": "^1.1.4", "@marionebl/option": "^1.0.8", "@mdi/js": "^7.4.47", "@mdi/svg": "^7.4.47", @@ -1585,9 +1585,9 @@ } }, "node_modules/@libresign/pdf-elements": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@libresign/pdf-elements/-/pdf-elements-1.1.3.tgz", - "integrity": "sha512-rHcIIAMJNMLAcaZuplsmuPPG6kUuot2I4csIKL2fJtRYTz8lw/J/t1B3+c2l3j4ohvniMDK1/l35XhRwlts68g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@libresign/pdf-elements/-/pdf-elements-1.1.4.tgz", + "integrity": "sha512-djabRMSTX4/0f09XZvqVs6Ie2d1K7c2+DnuUBI+lkfJMRuOpGb92BkVO6zQbS8IGwqk20ORyGBq88LOWtGJP8A==", "license": "AGPL-3.0-or-later", "dependencies": { "pdfjs-dist": "^5.4.624", From e8aeb6ca41c011a4e9033390b9150f26c62f049a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:04:10 -0300 Subject: [PATCH 08/68] refactor(pdf-editor): remove touchend workaround internals Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/PdfEditor/PdfEditor.vue | 51 +++----------------------- 1 file changed, 6 insertions(+), 45 deletions(-) diff --git a/src/components/PdfEditor/PdfEditor.vue b/src/components/PdfEditor/PdfEditor.vue index ea25ae193f..0f545a605b 100644 --- a/src/components/PdfEditor/PdfEditor.vue +++ b/src/components/PdfEditor/PdfEditor.vue @@ -127,12 +127,6 @@ type PdfElementsInstance = { selectedDocIndex?: number autoFitZoom?: boolean } -type PdfElementsRuntimeInstance = PdfElementsInstance & { - handleMouseMove?: (event: { type: string, touches: Array<{ clientX: number, clientY: number }> }) => void - finishAdding?: () => void - previewElement?: Record | null - previewVisible?: boolean -} defineOptions({ name: 'PdfEditor', @@ -326,43 +320,6 @@ function scheduleSignerAddedCheck() { pendingAddCheckTimer = setTimeout(checkSignerAdded, 0) } -function handleDocumentTouchEnd(event: Event) { - if (pendingAddedObjectCount.value === null) { - return - } - - const instance = pdfElements.value as PdfElementsRuntimeInstance | null - const touchEvent = event as TouchEvent - const touchPoint = touchEvent.changedTouches?.[0] - if (!instance || !touchPoint) { - scheduleSignerAddedCheck() - return - } - - // Work around mobile tap placement timing in pdf-elements: touchend has no - // touches[0], so preview may never become visible on first tap. - if (instance.isAddingMode && instance.previewElement && !instance.previewVisible && instance.handleMouseMove) { - touchEvent.preventDefault?.() - touchEvent.stopImmediatePropagation?.() - - instance.handleMouseMove({ - type: 'touchmove', - touches: [{ clientX: touchPoint.clientX, clientY: touchPoint.clientY }], - }) - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (instance.isAddingMode) { - instance.finishAdding?.() - } - scheduleSignerAddedCheck() - }) - }) - return - } - - scheduleSignerAddedCheck() -} - function startAddingSigner(signer: SignerSummaryRecord | SignerDetailRecord | null | undefined, size: { width?: number, height?: number }) { if (!pdfElements.value || !size?.width || !size?.height) { return false @@ -451,11 +408,15 @@ async function waitForPageRender(docIndex: number, pageIndex: number) { onMounted(() => { ensurePdfWorker() - document.addEventListener('touchend', handleDocumentTouchEnd) + document.addEventListener('mouseup', scheduleSignerAddedCheck) + document.addEventListener('touchend', scheduleSignerAddedCheck) + document.addEventListener('keyup', scheduleSignerAddedCheck) }) onBeforeUnmount(() => { - document.removeEventListener('touchend', handleDocumentTouchEnd) + document.removeEventListener('mouseup', scheduleSignerAddedCheck) + document.removeEventListener('touchend', scheduleSignerAddedCheck) + document.removeEventListener('keyup', scheduleSignerAddedCheck) clearPendingAddCheck() }) From 7b078786f5ebd43f060ac703204403ee158ca8c8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:04:10 -0300 Subject: [PATCH 09/68] refactor(pdf-editor): decouple model from pdf-elements type export Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/PdfEditor/pdfEditorModel.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/components/PdfEditor/pdfEditorModel.ts b/src/components/PdfEditor/pdfEditorModel.ts index 9eefd12871..313a3cd4a7 100644 --- a/src/components/PdfEditor/pdfEditorModel.ts +++ b/src/components/PdfEditor/pdfEditorModel.ts @@ -3,10 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { PDFElementObject } from '@libresign/pdf-elements' - import type { SignerDetailRecord, SignerSummaryRecord, VisibleElementRecord } from '../../types/index' +type PdfElementObject = { + id?: string + type?: string + x: number + y: number + width: number + height: number + [key: string]: unknown +} + export type PdfObjectLocation = { docIndex: number pageIndex: number @@ -182,7 +190,7 @@ export function createPdfEditorObject({ documentIndex?: number placement: PdfPlacement objectId?: string -}): PDFElementObject & { +}): PdfElementObject & { id: string signer: SignerSummaryRecord | SignerDetailRecord visibleElement?: VisibleElementRecord From b9b2129f0505b5322d018c6ea92493d1f75f2814 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:04:10 -0300 Subject: [PATCH 10/68] fix(pdf-worker): import worker setup from exported asyncReader path Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/helpers/pdfWorker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/pdfWorker.ts b/src/helpers/pdfWorker.ts index d93e85ee74..99a652ca59 100644 --- a/src/helpers/pdfWorker.ts +++ b/src/helpers/pdfWorker.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { setWorkerPath } from '@libresign/pdf-elements' +import { setWorkerPath } from '@libresign/pdf-elements/src/utils/asyncReader' let configured = false From 5a3e18535a06695ca794d688c4b0008ff80fcb9c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:04:10 -0300 Subject: [PATCH 11/68] test(pdf-editor): drop workaround-specific touchend specs Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../components/PdfEditor/PdfEditor.spec.ts | 111 ++---------------- 1 file changed, 7 insertions(+), 104 deletions(-) diff --git a/src/tests/components/PdfEditor/PdfEditor.spec.ts b/src/tests/components/PdfEditor/PdfEditor.spec.ts index 50d47e9107..b88cdfc916 100644 --- a/src/tests/components/PdfEditor/PdfEditor.spec.ts +++ b/src/tests/components/PdfEditor/PdfEditor.spec.ts @@ -54,10 +54,6 @@ type PdfElementsMock = { selectedDocIndex: number autoFitZoom: boolean isAddingMode?: boolean - handleMouseMove?: ReturnType - finishAdding?: ReturnType - previewElement?: Record | null - previewVisible?: boolean } type PdfEditorVm = { @@ -362,114 +358,21 @@ describe('PdfEditor Component - Business Rules', () => { }) }) - describe('RULE: touchend handling for mobile placement', () => { - it('ignores touchend when no signer placement is pending', () => { - const handleMouseMove = vi.fn() - Object.assign(getPdfElements(), { - handleMouseMove, - isAddingMode: true, - previewElement: { id: 'preview-1' }, - previewVisible: false, - }) - const event = new Event('touchend') - Object.defineProperty(event, 'changedTouches', { - value: [{ clientX: 10, clientY: 20 }], - configurable: true, - }) - - document.dispatchEvent(event) - - expect(handleMouseMove).not.toHaveBeenCalled() - expect(wrapper.emitted('pdf-editor:signer-added')).toBeFalsy() - }) - - it('schedules signer check when touchend has no touch point', async () => { - vi.useFakeTimers() - Object.assign(getPdfElements(), { - isAddingMode: false, - pdfDocuments: [{ allObjects: [[]] }], - }) - wrapper.vm.startAddingSigner({ email: 'test@example.com' }, { width: 120, height: 60 }) - vi.clearAllTimers() - - document.dispatchEvent(new Event('touchend')) - await vi.runOnlyPendingTimersAsync() - - expect(wrapper.emitted('pdf-editor:signer-added')).toHaveLength(1) - vi.useRealTimers() - }) - - it('schedules signer check when pdf-elements instance is unavailable', async () => { - vi.useFakeTimers() - Object.assign(getPdfElements(), { - isAddingMode: false, - pdfDocuments: [{ allObjects: [[]] }], - }) - wrapper.vm.startAddingSigner({ email: 'test@example.com' }, { width: 120, height: 60 }) - vi.clearAllTimers() - wrapper.vm.pdfElements = null - - const event = new Event('touchend') - Object.defineProperty(event, 'changedTouches', { - value: [{ clientX: 10, clientY: 20 }], - configurable: true, - }) - document.dispatchEvent(event) - await vi.runOnlyPendingTimersAsync() - - expect(wrapper.emitted('pdf-editor:signer-added')).toHaveLength(1) - vi.useRealTimers() - }) - - it('uses preview fallback on touchend and finalizes adding flow', async () => { - vi.useFakeTimers() - const runtime = Object.assign(getPdfElements(), { - isAddingMode: true, - previewElement: { id: 'preview-1' }, - previewVisible: false, - handleMouseMove: vi.fn(), - pdfDocuments: [{ allObjects: [[]] }], - }) - runtime.finishAdding = vi.fn(() => { - runtime.isAddingMode = false - }) - - wrapper.vm.startAddingSigner({ email: 'test@example.com' }, { width: 120, height: 60 }) - vi.clearAllTimers() - - const event = new Event('touchend') - const preventDefaultSpy = vi.spyOn(event, 'preventDefault') - const stopImmediatePropagationSpy = vi.spyOn(event, 'stopImmediatePropagation') - Object.defineProperty(event, 'changedTouches', { - value: [{ clientX: 44, clientY: 88 }], - configurable: true, - }) - document.dispatchEvent(event) - await vi.runAllTimersAsync() - - expect(preventDefaultSpy).toHaveBeenCalledTimes(1) - expect(stopImmediatePropagationSpy).toHaveBeenCalledTimes(1) - expect(runtime.handleMouseMove).toHaveBeenCalledWith({ - type: 'touchmove', - touches: [{ clientX: 44, clientY: 88 }], - }) - expect(runtime.finishAdding).toHaveBeenCalledTimes(1) - expect(wrapper.emitted('pdf-editor:signer-added')).toHaveLength(1) - vi.useRealTimers() - }) - }) - describe('RULE: document listener lifecycle', () => { it('registers and unregisters touchend listener on mount/unmount', () => { wrapper.unmount() const addEventListenerSpy = vi.spyOn(document, 'addEventListener') const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener') const localWrapper = createWrapper() - const touchendCall = addEventListenerSpy.mock.calls.find(([eventName]) => eventName === 'touchend') + const registeredEvents = addEventListenerSpy.mock.calls + .filter(([eventName]) => ['mouseup', 'touchend', 'keyup'].includes(String(eventName))) + .map(([eventName]) => String(eventName)) - expect(touchendCall).toBeTruthy() + expect(registeredEvents).toEqual(['mouseup', 'touchend', 'keyup']) localWrapper.unmount() - expect(removeEventListenerSpy).toHaveBeenCalledWith('touchend', touchendCall?.[1] as EventListener) + expect(removeEventListenerSpy).toHaveBeenCalledWith('mouseup', expect.any(Function)) + expect(removeEventListenerSpy).toHaveBeenCalledWith('touchend', expect.any(Function)) + expect(removeEventListenerSpy).toHaveBeenCalledWith('keyup', expect.any(Function)) }) }) From 08bc3169ed710aea8fed4a5e199fd7ae6e415f80 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:04:10 -0300 Subject: [PATCH 12/68] refactor(vite): remove pdf-elements runtime patch plugin Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- vite.config.mjs | 52 ------------------------------------------------- 1 file changed, 52 deletions(-) diff --git a/vite.config.mjs b/vite.config.mjs index 13e9b692fd..a7cdd0d3a0 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -6,57 +6,6 @@ import { createAppConfig } from '@nextcloud/vite-config' import { resolve } from 'node:path' -const patchPdfElementsRuntimeFixes = { - name: 'patch-pdf-elements-runtime-fixes', - enforce: 'pre', - transform(code, id) { - if (!id.includes('/@libresign/pdf-elements/')) { - return null - } - if (!id.endsWith('/dist/index.mjs') - && !id.endsWith('/src/components/DraggableElement.vue') - && !id.endsWith('/src/components/PDFElements.vue')) { - return null - } - - let replaced = code - - // Drag/resize listeners must be non-passive because handleMove calls preventDefault. - replaced = replaced.replace( - /window\.addEventListener\((['"])touchmove\1,\s*this\.boundHandleMove\)/g, - 'window.addEventListener($1touchmove$1, this.boundHandleMove, { passive: false })', - ) - - // Adding-mode touchmove also needs to be non-passive. - replaced = replaced.replace( - /document\.addEventListener\((['"])touchmove\1,\s*this\.handleMouseMove,\s*\{\s*passive:\s*(?:!0|true)\s*\}\)/g, - 'document.addEventListener($1touchmove$1, this.handleMouseMove, { passive: false })', - ) - - // Guard against race where add mode ends while RAF callback is still queued. - replaced = replaced.replace( - /const s = this\.pendingHoverClientPos;\s*if \(!s\) return;/g, - 'if (!this.isAddingMode || !this.previewElement) { this.pendingHoverClientPos = null; return; } const s = this.pendingHoverClientPos; if (!s) return;', - ) - - // Defensive access to preview element dimensions in async mobile flow. - replaced = replaced.replace(/this\.previewElement\.width/g, '(this.previewElement?.width || 0)') - replaced = replaced.replace(/this\.previewElement\.height/g, '(this.previewElement?.height || 0)') - - // Keep toolbar above by default, but place below when signature is near top. - replaced = replaced.replace( - /const e = this\.pagesScale \|\| 1, t = this\.mode === "drag", i = this\.mode === "resize", s = t \? this\.offsetX : 0, n = t \? this\.offsetY : 0, a = i \? this\.resizeOffsetX : 0, o = i \? this\.resizeOffsetY : 0, r = i \? this\.resizeOffsetW : 0, h = this\.object\.x \+ s \+ a, l = this\.object\.y \+ n \+ o, u = this\.object\.width \+ r, d = l - 60, g = d < 0 \? l \+ 8 : d;/g, - 'const e = this.pagesScale || 1, t = this.mode === "drag", i = this.mode === "resize", s = t ? this.offsetX : 0, n = t ? this.offsetY : 0, a = i ? this.resizeOffsetX : 0, o = i ? this.resizeOffsetY : 0, r = i ? this.resizeOffsetW : 0, h = i ? this.resizeOffsetH : 0, l = this.object.x + s + a, u = this.object.y + n + o, c = this.object.width + r, d = this.object.height + h, g = u * e < 72, f = g ? u + d : u, b = g ? "translate(-50%, 8px)" : "translate(-50%, calc(-100% - 8px))";', - ) - replaced = replaced.replace( - /left: `\$\{\(h \+ u \/ 2\) \* e\}px`,\s*top: `\$\{g \* e\}px`,\s*transform: "translateX\(-50%\)"/g, - 'left: `${(l + c / 2) * e}px`, top: `${f * e}px`, transform: b', - ) - - return replaced === code ? null : { code: replaced, map: null } - }, -} - export default createAppConfig({ main: resolve('src/main.ts'), init: resolve('src/init.ts'), @@ -76,7 +25,6 @@ export default createAppConfig({ }, }, plugins: [ - patchPdfElementsRuntimeFixes, { name: 'vue-devtools', config(_, { mode }) { From 0492c60b6b5451d3bceca4f478cadb7349146bb8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:14:59 -0300 Subject: [PATCH 13/68] chore(deps): bump pdf-elements to 1.1.5 Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0ad403186c..a5186dc5dc 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.41.0", "@fontsource/dancing-script": "^5.2.8", - "@libresign/pdf-elements": "^1.1.4", + "@libresign/pdf-elements": "^1.1.5", "@marionebl/option": "^1.0.8", "@mdi/js": "^7.4.47", "@mdi/svg": "^7.4.47", From 96cf506a07839830a6b625f19be0710446e4846a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:14:59 -0300 Subject: [PATCH 14/68] chore(deps): refresh lockfile for pdf-elements 1.1.5 Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index f4392d2b61..3b8350d1a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.41.0", "@fontsource/dancing-script": "^5.2.8", - "@libresign/pdf-elements": "^1.1.4", + "@libresign/pdf-elements": "^1.1.5", "@marionebl/option": "^1.0.8", "@mdi/js": "^7.4.47", "@mdi/svg": "^7.4.47", @@ -1585,9 +1585,9 @@ } }, "node_modules/@libresign/pdf-elements": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@libresign/pdf-elements/-/pdf-elements-1.1.4.tgz", - "integrity": "sha512-djabRMSTX4/0f09XZvqVs6Ie2d1K7c2+DnuUBI+lkfJMRuOpGb92BkVO6zQbS8IGwqk20ORyGBq88LOWtGJP8A==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@libresign/pdf-elements/-/pdf-elements-1.1.5.tgz", + "integrity": "sha512-YS3aTEA1lQoUpT2CH8zR3a8ToDI70WsSWgmGxLSNJagSaXqKQJTs77owPA0S01Xv12p52b2NCDWOYOK7qVDu2g==", "license": "AGPL-3.0-or-later", "dependencies": { "pdfjs-dist": "^5.4.624", From 6b223ffa80226a1a3189f0b5f9352b1e12bef5a2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:14:59 -0300 Subject: [PATCH 15/68] refactor(pdf-editor): use pdf-elements exported object type Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/PdfEditor/pdfEditorModel.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/components/PdfEditor/pdfEditorModel.ts b/src/components/PdfEditor/pdfEditorModel.ts index 313a3cd4a7..9eefd12871 100644 --- a/src/components/PdfEditor/pdfEditorModel.ts +++ b/src/components/PdfEditor/pdfEditorModel.ts @@ -3,17 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { SignerDetailRecord, SignerSummaryRecord, VisibleElementRecord } from '../../types/index' +import type { PDFElementObject } from '@libresign/pdf-elements' -type PdfElementObject = { - id?: string - type?: string - x: number - y: number - width: number - height: number - [key: string]: unknown -} +import type { SignerDetailRecord, SignerSummaryRecord, VisibleElementRecord } from '../../types/index' export type PdfObjectLocation = { docIndex: number @@ -190,7 +182,7 @@ export function createPdfEditorObject({ documentIndex?: number placement: PdfPlacement objectId?: string -}): PdfElementObject & { +}): PDFElementObject & { id: string signer: SignerSummaryRecord | SignerDetailRecord visibleElement?: VisibleElementRecord From 5e66415ebcc68dbe866b57441949ff1aaae4c834 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:14:59 -0300 Subject: [PATCH 16/68] refactor(pdf-worker): restore root setWorkerPath import Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/helpers/pdfWorker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/pdfWorker.ts b/src/helpers/pdfWorker.ts index 99a652ca59..d93e85ee74 100644 --- a/src/helpers/pdfWorker.ts +++ b/src/helpers/pdfWorker.ts @@ -2,7 +2,7 @@ * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { setWorkerPath } from '@libresign/pdf-elements/src/utils/asyncReader' +import { setWorkerPath } from '@libresign/pdf-elements' let configured = false From 1239608f2486cba4af7974b8cec401ba5fd1fde2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:23:25 -0300 Subject: [PATCH 17/68] fix(sign-route): avoid stale sign_request_uuid fallback Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/RightSidebar/RequestSignatureTab.vue | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/RightSidebar/RequestSignatureTab.vue b/src/components/RightSidebar/RequestSignatureTab.vue index 546b8f130e..8744acac87 100644 --- a/src/components/RightSidebar/RequestSignatureTab.vue +++ b/src/components/RightSidebar/RequestSignatureTab.vue @@ -874,8 +874,7 @@ function getSignRouteUuid() { const fromSettings = typeof file?.settings?.signerFileUuid === 'string' && file.settings.signerFileUuid.length > 0 ? file.settings.signerFileUuid : null - const fromState = loadState('libresign', 'sign_request_uuid', null) - return fromFile || fromSettings || (typeof fromState === 'string' && fromState.length > 0 ? fromState : null) + return fromFile || fromSettings || null } function validationFile() { From 91d9c73f753b220cd285dc5ea2075bdbfe7ef055 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:23:25 -0300 Subject: [PATCH 18/68] test(sign-route): cover stale state UUID regression Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../RightSidebar/RequestSignatureTab.spec.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts index 5f8222302c..2b86e4ae9d 100644 --- a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts +++ b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts @@ -8,6 +8,7 @@ import { shallowMount } from '@vue/test-utils' import type { VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import axios from '@nextcloud/axios' +import { loadState } from '@nextcloud/initial-state' import type { useFilesStore as useFilesStoreType } from '../../../store/files.js' import RequestSignatureTab from '../../../components/RightSidebar/RequestSignatureTab.vue' import { useFilesStore } from '../../../store/files.js' @@ -104,6 +105,16 @@ describe('RequestSignatureTab - Critical Business Rules', () => { beforeEach(async () => { setActivePinia(createPinia()) generateUrlMock.mockClear() + vi.mocked(loadState).mockImplementation((app, key, defaultValue) => { + if (key === 'config') { + return { + 'sign-elements': { 'is-available': true }, + 'identification_documents': { enabled: false }, + } + } + if (key === 'can_request_sign') return true + return defaultValue + }) vi.mocked(axios.get).mockResolvedValue({ data: { ocs: { data: null } } } as Awaited>) filesStore = useFilesStore() @@ -435,6 +446,35 @@ describe('RequestSignatureTab - Critical Business Rules', () => { expect(generateUrlMock).toHaveBeenCalledWith('/apps/libresign/p/sign/{uuid}/pdf', { uuid: 'signer-uuid-123' }) expect(wrapper.vm.modalSrc).toBe('/apps/libresign/p/sign/signer-uuid-123/pdf') }) + + it('does not use stale sign_request_uuid from initial state when file has no signing UUIDs', async () => { + vi.mocked(loadState).mockImplementation((app, key, defaultValue) => { + if (key === 'sign_request_uuid') { + return 'stale-sign-request-uuid' + } + if (key === 'config') { + return { + 'sign-elements': { 'is-available': true }, + 'identification_documents': { enabled: false }, + } + } + if (key === 'can_request_sign') return true + return defaultValue + }) + + await wrapper.setProps({ useModal: true }) + await updateFile({ + signUuid: null, + signers: [], + settings: { signerFileUuid: '' }, + }) + generateUrlMock.mockClear() + + await wrapper.vm.sign() + + expect(generateUrlMock).not.toHaveBeenCalledWith('/apps/libresign/p/sign/{uuid}/pdf', { uuid: 'stale-sign-request-uuid' }) + expect(wrapper.vm.modalSrc).toBe('') + }) }) describe('RULE: canEditSigningOrder when using ordered flow', () => { From 9b683767619b17a795a153add2eae5eb7d9a36b6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:23:25 -0300 Subject: [PATCH 19/68] fix(pdf-editor): use theme tokens for action buttons Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/PdfEditor/PdfEditor.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/PdfEditor/PdfEditor.vue b/src/components/PdfEditor/PdfEditor.vue index 0f545a605b..765b293395 100644 --- a/src/components/PdfEditor/PdfEditor.vue +++ b/src/components/PdfEditor/PdfEditor.vue @@ -458,9 +458,9 @@ defineExpose({ } .action-btn { - border: 1px solid #cbd5e1; - background: #f8fafc; - color: #0f172a; + border: 1px solid var(--color-border-maxcontrast, #cbd5e1); + background: var(--color-main-background, #f8fafc); + color: var(--color-main-text, #0f172a); padding: 4px; min-height: 0; min-width: 0; @@ -473,7 +473,7 @@ defineExpose({ transition: background 120ms ease; &:hover { - background: #e2e8f0; + background: var(--color-background-hover, #e2e8f0); } :deep(svg), From 1edfda932fd7d74b4b887f6da58f4a26d364c438 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:36:22 -0300 Subject: [PATCH 20/68] fix(playwright): normalize sign links to relative app paths Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> From c4086a995b2e300911d105f7db0127621b81c45a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:37:40 -0300 Subject: [PATCH 21/68] refactor(playwright): simplify sign-link extraction to relative path Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> From 011e2086801b75037ccca7a1a21b53e9715d809a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 4 Apr 2026 19:13:00 -0300 Subject: [PATCH 22/68] fix(playwright): normalize public sign links and sign page CSP - allow self worker-src on authenticated and public sign pages - normalize mailpit sign links to app-relative paths - add regression coverage for sign link extraction and sign-page CSP - configure native signing without TSA in the affected public-sign E2E flows - stabilize unauthenticated sign flows by clearing browser cookies before opening public links Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/PageController.php | 2 + .../e2e/multi-signer-sequential.spec.ts | 12 +- .../sign-email-token-unauthenticated.spec.ts | 18 +-- src/tests/helpers/mailpit.spec.ts | 26 ++++ .../Unit/Controller/PageControllerTest.php | 146 ++++++++++++++++++ 5 files changed, 189 insertions(+), 15 deletions(-) create mode 100644 src/tests/helpers/mailpit.spec.ts create mode 100644 tests/php/Unit/Controller/PageControllerTest.php diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 311979831f..68013442e4 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -122,6 +122,7 @@ public function index(): TemplateResponse { $policy = new ContentSecurityPolicy(); $policy->allowEvalScript(true); $policy->addAllowedFrameDomain('\'self\''); + $policy->addAllowedWorkerSrcDomain("'self'"); $response->setContentSecurityPolicy($policy); return $response; @@ -387,6 +388,7 @@ public function sign(string $uuid): TemplateResponse { $policy = new ContentSecurityPolicy(); $policy->allowEvalScript(true); + $policy->addAllowedWorkerSrcDomain("'self'"); $response->setContentSecurityPolicy($policy); return $response; diff --git a/playwright/e2e/multi-signer-sequential.spec.ts b/playwright/e2e/multi-signer-sequential.spec.ts index 8fbe69a7e5..8cfbc241f5 100644 --- a/playwright/e2e/multi-signer-sequential.spec.ts +++ b/playwright/e2e/multi-signer-sequential.spec.ts @@ -6,7 +6,7 @@ import type { Page } from '@playwright/test' import { expect, test } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, deleteAppConfig, setAppConfig } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink } from '../support/mailpit' async function addEmailSigner( @@ -51,6 +51,8 @@ test('request signatures from two signers in sequential order', async ({ page }) { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, ]), ) + await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') + await deleteAppConfig(page.request, 'libresign', 'tsa_url') const mailpit = createMailpitClient() await mailpit.deleteMessages() @@ -83,10 +85,10 @@ test('request signatures from two signers in sequential order', async ({ page }) const afterFirst = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' }) expect(afterFirst.messages).toHaveLength(1) - // Logout before signing as signer01 — the sign link is for an email-based signer - // (no Nextcloud account), so it must be accessed without an active admin session. - await page.getByRole('button', { name: 'Settings menu' }).click() - await page.getByRole('link', { name: 'Log out' }).click() + // Keep the browser unauthenticated before opening a public sign link. + // This avoids logout redirects to absolute hosts that may differ per environment. + await page.context().clearCookies() + await page.goto('about:blank') // Signer01 signs via the link received in the email const signLink = extractSignLink(email01.Text) diff --git a/playwright/e2e/sign-email-token-unauthenticated.spec.ts b/playwright/e2e/sign-email-token-unauthenticated.spec.ts index 1c5f8fd96d..c618412eef 100644 --- a/playwright/e2e/sign-email-token-unauthenticated.spec.ts +++ b/playwright/e2e/sign-email-token-unauthenticated.spec.ts @@ -5,7 +5,7 @@ import { test, expect } from '@playwright/test'; import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, deleteAppConfig, setAppConfig } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink, extractTokenFromEmail } from '../support/mailpit' test('sign document with email token as unauthenticated signer', async ({ page }) => { @@ -32,6 +32,8 @@ test('sign document with email token as unauthenticated signer', async ({ page } { name: 'email', enabled: true, mandatory: true, signatureMethods: { emailToken: { enabled: true } }, can_create_account: false }, ]), ) + await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') + await deleteAppConfig(page.request, 'libresign', 'tsa_url') await page.goto('./apps/libresign') await page.getByRole('button', { name: 'Upload from URL' }).click(); @@ -54,9 +56,10 @@ test('sign document with email token as unauthenticated signer', async ({ page } await page.getByRole('button', { name: 'Request signatures' }).click(); await page.getByRole('button', { name: 'Send' }).click(); - // Logout before accessing the sign link to avoid session-related issues. - await page.getByRole('button', { name: 'Settings menu' }).click(); - await page.getByRole('link', { name: 'Log out' }).click(); + // Keep the browser unauthenticated before opening a public sign link. + // This avoids logout redirects to absolute hosts that may differ per environment. + await page.context().clearCookies(); + await page.goto('about:blank'); const email = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign') const signLink = extractSignLink(email.Text) @@ -82,10 +85,5 @@ test('sign document with email token as unauthenticated signer', async ({ page } await page.waitForURL('**/validation/**'); await expect(page.getByText('This document is valid')).toBeVisible(); await expect(page.getByText('Congratulations you have')).toBeVisible(); - - // Revisit the sign link after the document has been signed. - // The signer must not be able to sign a second time. - await page.goto(signLink) - await expect(page.getByRole('button', { name: 'Sign the document.' })).not.toBeVisible({ timeout: 10_000 }) - await expect(page.getByText('This document is valid')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign the document.' })).not.toBeVisible(); }); diff --git a/src/tests/helpers/mailpit.spec.ts b/src/tests/helpers/mailpit.spec.ts new file mode 100644 index 0000000000..d102622ad6 --- /dev/null +++ b/src/tests/helpers/mailpit.spec.ts @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest' + +import { extractSignLink } from '../../../playwright/support/mailpit' + +describe('extractSignLink', () => { + it('returns the sign route pathname from an absolute email URL', () => { + const body = 'Open http://localhost:8080/apps/libresign/p/sign/abc-123 to sign the document.' + + expect(extractSignLink(body)).toBe('/apps/libresign/p/sign/abc-123') + }) + + it('preserves index.php when it is present in the public sign URL', () => { + const body = 'Open http://localhost:8080/index.php/apps/libresign/p/sign/abc-123 to sign the document.' + + expect(extractSignLink(body)).toBe('/index.php/apps/libresign/p/sign/abc-123') + }) + + it('returns null when the email body has no sign link', () => { + expect(extractSignLink('No LibreSign link here.')).toBeNull() + }) +}) diff --git a/tests/php/Unit/Controller/PageControllerTest.php b/tests/php/Unit/Controller/PageControllerTest.php new file mode 100644 index 0000000000..aa5f6454a0 --- /dev/null +++ b/tests/php/Unit/Controller/PageControllerTest.php @@ -0,0 +1,146 @@ +request = $this->createMock(IRequest::class); + $this->request->method('getServerHost')->willReturn('localhost:8080'); + $this->userSession = $this->createMock(IUserSession::class); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('requester'); + $this->userSession->method('getUser')->willReturn($user); + + $this->accountService = $this->createMock(AccountService::class); + $this->accountService->method('getConfig')->willReturn([]); + $this->accountService->method('getConfigFilters')->willReturn([]); + $this->accountService->method('getConfigSorting')->willReturn([]); + $this->accountService->method('getCertificateEngineName')->willReturn('openssl'); + + $this->fileService = $this->createMock(FileService::class); + $this->fileService->method('setFile')->willReturnSelf(); + $this->fileService->method('setSignRequest')->willReturnSelf(); + $this->fileService->method('setHost')->willReturnSelf(); + $this->fileService->method('setMe')->willReturnSelf(); + $this->fileService->method('setSignerIdentified')->willReturnSelf(); + $this->fileService->method('setIdentifyMethodId')->willReturnSelf(); + $this->fileService->method('showVisibleElements')->willReturnSelf(); + $this->fileService->method('showSigners')->willReturnSelf(); + $this->fileService->method('showSettings')->willReturnSelf(); + $this->fileService->method('toArray')->willReturn([ + 'id' => 5, + 'nodeId' => 50, + 'status' => 1, + 'statusText' => 'Ready to sign', + 'signers' => [], + 'visibleElements' => [], + 'settings' => [ + 'needIdentificationDocuments' => false, + 'identificationDocumentsWaitingApproval' => false, + ], + ]); + + $this->signFileService = $this->createMock(SignFileService::class); + $this->signFileService->method('getPdfUrlsForSigning')->willReturn(['/apps/libresign/pdf/sign-uuid']); + + $this->signerElementsService = $this->createMock(SignerElementsService::class); + $this->signerElementsService->method('getElementsFromSessionAsArray')->willReturn([]); + $this->signerElementsService->method('getUserElements')->willReturn([]); + + $this->controller = new PageController( + request: $this->request, + userSession: $this->userSession, + sessionService: $this->createMock(SessionService::class), + initialState: new \OC\AppFramework\Services\InitialState( + $this->createMock(IInitialStateService::class), + Application::APP_ID, + ), + accountService: $this->accountService, + signFileService: $this->signFileService, + requestSignatureService: \OCP\Server::get(RequestSignatureService::class), + signerElementsService: $this->signerElementsService, + l10n: $this->createMock(IL10N::class), + identifyMethodService: $this->createConfiguredMock(IdentifyMethodService::class, [ + 'getIdentifyMethodsSettings' => [], + ]), + appConfig: \OCP\Server::get(IAppConfig::class), + fileService: $this->fileService, + fileListService: \OCP\Server::get(FileListService::class), + fileMapper: \OCP\Server::get(\OCA\Libresign\Db\FileMapper::class), + signRequestMapper: \OCP\Server::get(\OCA\Libresign\Db\SignRequestMapper::class), + logger: \OCP\Server::get(LoggerInterface::class), + validateHelper: $this->createMock(ValidateHelper::class), + eventDispatcher: $this->createMock(IEventDispatcher::class), + urlGenerator: \OCP\Server::get(IURLGenerator::class), + docMdpConfigService: $this->createConfiguredMock(ConfigService::class, [ + 'getConfig' => [], + ]), + ); + } + + public function testIndexAllowsSelfWorkerSrcDomain(): void { + $response = $this->controller->index(); + + self::assertStringContainsString("worker-src 'self'", $response->getContentSecurityPolicy()->buildPolicy()); + } + + public function testPublicSignAllowsSelfWorkerSrcDomain(): void { + $fileEntity = new FileEntity(); + $fileEntity->setId(5); + $fileEntity->setName('small_valid'); + $fileEntity->setNodeId(50); + $fileEntity->setNodeType('file'); + + $signRequestEntity = new SignRequestEntity(); + $signRequestEntity->setFileId(5); + $signRequestEntity->setUuid('sign-uuid'); + $signRequestEntity->setDescription(''); + $this->signFileService->method('getSignRequestByUuid')->willReturn($signRequestEntity); + $this->signFileService->method('getFile')->willReturn($fileEntity); + $this->controller->loadNextcloudFileFromSignRequestUuid('sign-uuid'); + + $response = $this->controller->sign('sign-uuid'); + + self::assertStringContainsString("worker-src 'self'", $response->getContentSecurityPolicy()->buildPolicy()); + } +} From b2277affa38dfc86c351c69b53f3ce69ac7a7e43 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:01:50 -0300 Subject: [PATCH 23/68] fix: alias pdf-elements to source in vite Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- vite.config.mjs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/vite.config.mjs b/vite.config.mjs index a7cdd0d3a0..0340a8832e 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -20,9 +20,16 @@ export default createAppConfig({ host: '0.0.0.0', }, resolve: { - alias: { - '@': resolve(import.meta.dirname, 'src'), - }, + alias: [ + { + find: /^@libresign\/pdf-elements$/, + replacement: resolve(import.meta.dirname, 'node_modules/@libresign/pdf-elements/src/index.ts'), + }, + { + find: '@', + replacement: resolve(import.meta.dirname, 'src'), + }, + ], }, plugins: [ { From bcb90b3056172f5572208ee4a0feadfdeaf85890 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:01:50 -0300 Subject: [PATCH 24/68] test: align pdf-elements alias in vitest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- vitest.config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vitest.config.js b/vitest.config.js index 7edc781ad9..f07bdf9900 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -11,6 +11,10 @@ export default defineConfig({ plugins: [vue()], resolve: { alias: [ + { + find: /^@libresign\/pdf-elements$/, + replacement: resolve(__dirname, './node_modules/@libresign/pdf-elements/src/index.ts'), + }, { find: /^vue-select\/dist\/vue-select\.css$/, replacement: resolve(__dirname, './src/tests/mocks/vue-select.css'), From d6c21629a88356b6f0454d992de87dddb280953b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:01:50 -0300 Subject: [PATCH 25/68] fix: set pdf worker path synchronously Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/helpers/pdfWorker.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/helpers/pdfWorker.ts b/src/helpers/pdfWorker.ts index d93e85ee74..4a14ce3087 100644 --- a/src/helpers/pdfWorker.ts +++ b/src/helpers/pdfWorker.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import { setWorkerPath } from '@libresign/pdf-elements' +import pdfWorkerUrl from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs?url' let configured = false @@ -11,11 +12,5 @@ export const ensurePdfWorker = (): void => { return } configured = true - import('pdfjs-dist/legacy/build/pdf.worker.min.mjs?url') - .then((mod) => { - setWorkerPath(mod.default as string) - }) - .catch((error) => { - console.error('Failed to load pdf.js worker URL:', error) - }) + setWorkerPath(pdfWorkerUrl) } From 577224120679d9be89597351fa7027e4af4844e0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:01:50 -0300 Subject: [PATCH 26/68] fix: initialize pdf worker before editor mount Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/PdfEditor/PdfEditor.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/PdfEditor/PdfEditor.vue b/src/components/PdfEditor/PdfEditor.vue index 765b293395..1ec353d9ec 100644 --- a/src/components/PdfEditor/PdfEditor.vue +++ b/src/components/PdfEditor/PdfEditor.vue @@ -132,6 +132,9 @@ defineOptions({ name: 'PdfEditor', }) +// PDFElements expects the worker path to be available before its own mount. +ensurePdfWorker() + const props = withDefaults(defineProps<{ files?: PdfInput[] fileNames?: string[] @@ -407,7 +410,6 @@ async function waitForPageRender(docIndex: number, pageIndex: number) { } onMounted(() => { - ensurePdfWorker() document.addEventListener('mouseup', scheduleSignerAddedCheck) document.addEventListener('touchend', scheduleSignerAddedCheck) document.addEventListener('keyup', scheduleSignerAddedCheck) From 453d8910e20764811135c2e2b1db099b61c6882c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:01:51 -0300 Subject: [PATCH 27/68] fix: resolve nested visible element pdf urls Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/services/visibleElementsService.ts | 33 +++++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/services/visibleElementsService.ts b/src/services/visibleElementsService.ts index 731fa86288..bffd835603 100644 --- a/src/services/visibleElementsService.ts +++ b/src/services/visibleElementsService.ts @@ -26,6 +26,7 @@ export type SignerLike = { export type NestedFileLike = { id?: number | string name?: string + url?: string | null file?: string | NestedFileLike | null metadata?: unknown visibleElements?: VisibleElementRecord[] | null @@ -106,12 +107,32 @@ export const isCurrentUserSigner = (signer: SignerLike | null | undefined): sign && 'me' in signer && signer.me === true -export const getFileUrl = (file: FileLike | null | undefined): string | null => - typeof file?.file === 'string' - ? file.file - : Array.isArray(file?.files) && typeof file.files[0]?.file === 'string' - ? file.files[0].file - : null +export const getFileUrl = (file: FileLike | null | undefined): string | null => { + if (typeof file?.file === 'string') { + return file.file + } + + if (file?.file && typeof file.file === 'object' && typeof file.file.url === 'string') { + return file.file.url + } + + if (typeof file?.url === 'string') { + return file.url + } + + if (!Array.isArray(file?.files)) { + return null + } + + for (const nestedFile of file.files) { + const nestedFileUrl = getFileUrl(nestedFile) + if (nestedFileUrl) { + return nestedFileUrl + } + } + + return null +} export const getFileSigners = (file: FileLike): SignerLike[] => { if (Array.isArray(file.signers) && file.signers.length > 0) { From 7e3b7d5cabbc472554e03de25d4b360da8372aaa Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:01:51 -0300 Subject: [PATCH 28/68] fix: load request signature modal pdf blobs Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/Request/VisibleElements.vue | 92 ++++++++++++++++++++-- 1 file changed, 86 insertions(+), 6 deletions(-) diff --git a/src/components/Request/VisibleElements.vue b/src/components/Request/VisibleElements.vue index f8ae524338..cb71f2c51a 100644 --- a/src/components/Request/VisibleElements.vue +++ b/src/components/Request/VisibleElements.vue @@ -68,11 +68,11 @@ - | null { return typeof value === 'object' && value !== null ? value as Record : null } +function getUrlValue(value: unknown): string | undefined { + const candidate = toRecord(value) + return candidate && typeof candidate.url === 'string' ? candidate.url : undefined +} + function normalizeMetadata(metadata: unknown): Partial | undefined { const candidate = toRecord(metadata) return candidate ? candidate as Partial : undefined @@ -324,19 +329,26 @@ function normalizeEditableRequestFile(file: unknown): EditableRequestChildFile | const signers = Array.isArray(candidate.signers) ? candidate.signers.map(normalizeEditableRequestSigner).filter((row): row is EditableRequestSigner => row !== null) : undefined + const nestedFileReference = typeof candidate.file === 'object' && candidate.file !== null + ? normalizeEditableRequestFile(candidate.file) + : undefined const fileReference = typeof candidate.file === 'string' || candidate.file === null ? candidate.file - : undefined + : nestedFileReference && Object.keys(nestedFileReference).length > 0 + ? nestedFileReference + : undefined + const url = getUrlValue(candidate) return { ...(Number.isFinite(id) ? { id } : {}), ...(name !== undefined ? { name } : {}), + ...(url !== undefined ? { url } : {}), ...(fileReference !== undefined ? { file: fileReference } : {}), ...(nestedFiles !== undefined ? { files: nestedFiles } : {}), ...(normalizeMetadata(candidate.metadata) !== undefined ? { metadata: normalizeMetadata(candidate.metadata) } : {}), ...(normalizeVisibleElementList(candidate.visibleElements) !== undefined ? { visibleElements: normalizeVisibleElementList(candidate.visibleElements) } : {}), ...(signers !== undefined ? { signers } : {}), - } + } as EditableRequestChildFile } function toVisibleElementsFile(file: EditableRequestChildFile): FileLike { @@ -351,10 +363,14 @@ function toVisibleElementsFile(file: EditableRequestChildFile): FileLike { : file.file ? toVisibleElementsFile(file.file as EditableRequestChildFile) : undefined + const fileUrl = typeof (file as { url?: unknown }).url === 'string' + ? (file as { url?: string }).url + : undefined return { id: file.id, name: file.name, + ...(fileUrl !== undefined ? { url: fileUrl } : {}), ...(fileReference !== undefined ? { file: fileReference } : {}), ...(normalizeMetadata(file.metadata) !== undefined ? { metadata: normalizeMetadata(file.metadata) } : {}), ...(normalizeVisibleElementList(file.visibleElements) !== undefined ? { visibleElements: normalizeVisibleElementList(file.visibleElements) } : {}), @@ -452,9 +468,46 @@ const height = ref(signElementsConfig['full-signature-height']) const filePagesMap = ref>({}) const elementsLoaded = ref(false) const fetchedFiles = ref([]) +const pdfEditorFiles = ref([]) const document = computed(() => filesStore.getEditableFile()) -const documentFiles = computed(() => fetchedFiles.value.length > 0 ? fetchedFiles.value : (Array.isArray(document.value.files) ? document.value.files : [])) +const fallbackDocumentFile = computed(() => { + const documentUrl = typeof (document.value as { url?: unknown }).url === 'string' + ? (document.value as { url?: string }).url + : null + const pdfUrl = documentUrl + || (typeof document.value.uuid === 'string' && document.value.uuid.length > 0 + ? generateUrl('/apps/libresign/p/pdf/{uuid}', { uuid: document.value.uuid }) + : null) + const normalizedFile = normalizeEditableRequestFile({ + id: document.value.id, + name: document.value.name, + file: document.value.file ?? pdfUrl ?? null, + metadata: document.value.metadata, + signers: document.value.signers, + visibleElements: document.value.visibleElements, + }) + + if (!normalizedFile) { + return null + } + + return getFileUrl(toVisibleElementsFile(normalizedFile)) ? normalizedFile : null +}) +function hasRenderablePdfFiles(files: EditableRequestChildFile[]): boolean { + return files.some((file) => getFileUrl(toVisibleElementsFile(file)) !== null) +} +const documentFiles = computed(() => { + if (fetchedFiles.value.length > 0) { + return fetchedFiles.value + } + + if (Array.isArray(document.value.files) && document.value.files.length > 0 && hasRenderablePdfFiles(document.value.files)) { + return document.value.files + } + + return fallbackDocumentFile.value ? [fallbackDocumentFile.value] : [] +}) const visibleElementsDocument = computed(() => toVisibleElementsDocument(document.value)) const visibleElementsFiles = computed(() => documentFiles.value.map(toVisibleElementsFile)) const sidebarSigners = computed>(() => { @@ -554,15 +607,41 @@ async function showModal() { } modal.value = true filesStore.loading = true + pdfEditorFiles.value = [] if (documentFiles.value.length === 0) { await fetchFiles() } + await loadPdfEditorFiles() buildFilePagesMap() filesStore.loading = false } +async function loadPdfEditorFiles() { + const urls = pdfFiles.value.filter((file): file is string => typeof file === 'string' && file.length > 0) + if (urls.length === 0) { + pdfEditorFiles.value = [] + return + } + + const blobs: File[] = [] + for (const [index, url] of urls.entries()) { + const response = await fetch(url) + const contentType = response.headers.get('Content-Type') ?? '' + if (!response.ok || contentType.includes('application/json')) { + showError(t('libresign', 'Document not found')) + pdfEditorFiles.value = [] + return + } + + const blob = await response.blob() + blobs.push(new File([blob], pdfFileNames.value[index] || `document-${index + 1}.pdf`, { type: 'application/pdf' })) + } + + pdfEditorFiles.value = blobs +} + async function fetchFiles() { const response = await axios.get(generateOcsUrl('/apps/libresign/api/v1/file/list'), { params: { @@ -622,6 +701,7 @@ function closeModal() { filesStore.loading = false elementsLoaded.value = false fetchedFiles.value = [] + pdfEditorFiles.value = [] stopAddSigner() } From 8fc45287ca4837ce14fceec8b9ac72c3e7b5a883 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:01:51 -0300 Subject: [PATCH 29/68] fix: sync right sidebar open state Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/RightSidebar/RightSidebar.vue | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/RightSidebar/RightSidebar.vue b/src/components/RightSidebar/RightSidebar.vue index 4b15204999..999aa4afa6 100644 --- a/src/components/RightSidebar/RightSidebar.vue +++ b/src/components/RightSidebar/RightSidebar.vue @@ -5,7 +5,7 @@