From 976c6c554441b780b069b47cca29d3b12e7564d6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:41:17 -0300 Subject: [PATCH 1/2] fix: handle private validation URL redirect and string error messages When make_validation_url_private=true, the backend returns HTTP 401 with action=REDIRECT and errors as a string array. The frontend was only handling object-format errors ({message}) and not the redirect action, causing a false 'Failed to validate document' message. Adds handleValidationRedirect() and fixes getValidationErrorMessage() to handle both string and object error formats. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/tests/views/Validation.spec.ts | 48 ++++++++++++++++++++++++++++++ src/views/Validation.vue | 41 +++++++++++++++++++++---- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/tests/views/Validation.spec.ts b/src/tests/views/Validation.spec.ts index 1c34ffaac5..958c3a3a20 100644 --- a/src/tests/views/Validation.spec.ts +++ b/src/tests/views/Validation.spec.ts @@ -38,6 +38,7 @@ type ValidationVm = { handleValidationSuccess: (data: Record) => void handleSigningComplete: (file: Record | null) => void refreshAfterAsyncSigning: () => Promise + validateByUUID: (uuid: string, options?: { suppressLoading?: boolean }) => Promise $nextTick: () => Promise } @@ -986,6 +987,53 @@ describe('Validation.vue - Business Logic', () => { }) }) + describe('validation API error handling', () => { + const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000' + + it('redirects to login when validation URL is private', async () => { + const hrefSpy = vi.spyOn(window.location, 'href', 'set') + vi.mocked(axios.get).mockRejectedValueOnce({ + response: { + status: 401, + data: { + ocs: { + data: { + action: 1000, + redirect: '/index.php/login?redirect_url=%2Fapps%2Flibresign%2Fvalidation%2F550e8400-e29b-41d4-a716-446655440000', + errors: ['You are not logged in. Please log in.'], + }, + }, + }, + }, + }) + + await wrapper.vm.validateByUUID(VALID_UUID) + + expect(hrefSpy).toHaveBeenCalledWith('/index.php/login?redirect_url=%2Fapps%2Flibresign%2Fvalidation%2F550e8400-e29b-41d4-a716-446655440000') + expect(wrapper.vm.validationErrorMessage).toBe(null) + hrefSpy.mockRestore() + }) + + it('shows string-based backend errors instead of generic fallback', async () => { + vi.mocked(axios.get).mockRejectedValueOnce({ + response: { + status: 401, + data: { + ocs: { + data: { + errors: ['You are not logged in. Please log in.'], + }, + }, + }, + }, + }) + + await wrapper.vm.validateByUUID(VALID_UUID) + + expect(wrapper.vm.validationErrorMessage).toBe('You are not logged in. Please log in.') + }) + }) + describe('status contract guards', () => { const createLoadedValidationDocument = (patch: Record = {}) => ({ id: 100, diff --git a/src/views/Validation.vue b/src/views/Validation.vue index d67fb750ea..edd7bbee0c 100644 --- a/src/views/Validation.vue +++ b/src/views/Validation.vue @@ -137,6 +137,7 @@ import { MODIFICATION_VIOLATION, toValidationDocument, } from '../services/validationDocument' +import { ACTION_CODES } from '../helpers/ActionMapping' import { normalizeRouteRecord } from '../services/routeNormalization.js' import logger from '../logger.js' import { useFilesStore } from '../store/files.js' @@ -196,13 +197,17 @@ type StatusPresentation = { type ErrorMessageEntry = { message?: string } +type ValidationErrorEntry = ErrorMessageEntry | string +type ValidationErrorPayload = { + errors?: ValidationErrorEntry[] + action?: number + redirect?: string +} type ValidationErrorResponse = { status?: number data?: { ocs?: { - data?: { - errors?: ErrorMessageEntry[] - } + data?: ValidationErrorPayload } } } @@ -223,12 +228,29 @@ function isSignedDocumentStatus(status: unknown): boolean { } function getValidationErrorMessage(response: ValidationErrorResponse | undefined, fallback: string): string { - if (response?.data?.ocs?.data?.errors?.length) { - return response.data.ocs.data.errors[0]?.message || fallback + const errors = response?.data?.ocs?.data?.errors + if (errors?.length) { + const [firstError] = errors + if (typeof firstError === 'string' && firstError.length > 0) { + return firstError + } + if (typeof firstError?.message === 'string' && firstError.message.length > 0) { + return firstError.message + } } return fallback } +function handleValidationRedirect(response: ValidationErrorResponse | undefined): boolean { + const action = response?.data?.ocs?.data?.action + const redirect = response?.data?.ocs?.data?.redirect + if (action !== ACTION_CODES.REDIRECT || typeof redirect !== 'string' || redirect.length === 0) { + return false + } + window.location.href = redirect + return true +} + const signStore = useSignStore() const sidebarStore = useSidebarStore() const filesStore = useFilesStore() @@ -333,6 +355,9 @@ async function upload(file: File) { handleValidationSuccess(data.ocs.data) }) .catch((error: { response?: ValidationErrorResponse }) => { + if (handleValidationRedirect(error.response)) { + return + } const errorMsg = getValidationErrorMessage(error.response, t('libresign', 'Failed to validate document')) setValidationError(errorMsg) }) @@ -397,6 +422,9 @@ async function validateByUUID(uuid: string, { suppressLoading = false }: { suppr }) .catch((error: { response?: ValidationErrorResponse }) => { const response = error.response + if (handleValidationRedirect(response)) { + return + } if (response?.status === 404) { setValidationError(t('libresign', 'Document not found')) } else { @@ -419,6 +447,9 @@ async function validateByNodeID(nodeId: string, { suppressLoading = false }: { s }) .catch((error: { response?: ValidationErrorResponse }) => { const response = error.response + if (handleValidationRedirect(response)) { + return + } if (response?.status === 404) { setValidationError(t('libresign', 'Document not found')) } else { From 4eee886c8c2fe5851f30fbd1253f4544f9caa4df Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:43:09 -0300 Subject: [PATCH 2/2] fix: handle private validation URL redirect and string error messages When make_validation_url_private=true, the backend returns HTTP 401 with action=REDIRECT and errors as a string array. The frontend was only handling object-format errors ({message}) and not the redirect action, causing a false 'Failed to validate document' message. Adds handleValidationRedirect() and fixes getValidationErrorMessage() to handle both string and object error formats. Signed-off-by: Vitor Mattos Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/PdfEditor/PdfEditor.vue | 21 ++++++++++- src/tests/views/SignPDF/Sign.spec.ts | 48 ++++++++++++++++++++++++++ src/views/SignPDF/_partials/Sign.vue | 41 ++++++++++++++++++++++ src/views/Validation.vue | 6 ++-- 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/components/PdfEditor/PdfEditor.vue b/src/components/PdfEditor/PdfEditor.vue index c16da8759d..9e469947f4 100644 --- a/src/components/PdfEditor/PdfEditor.vue +++ b/src/components/PdfEditor/PdfEditor.vue @@ -9,7 +9,7 @@ :init-file-names="fileNames" :page-count-format="t('libresign', '{currentPage} of {totalPages}')" :page-aria-label="getPageAriaLabel" - :auto-fit-zoom="true" + :auto-fit-zoom="enableAutoFitZoom" :read-only="readOnly" :emit-object-click="true" :hide-selection-ui="readOnly" @@ -129,6 +129,9 @@ type PdfElementsInstance = { pdfDocuments?: PdfDocument[] selectedDocIndex?: number autoFitZoom?: boolean + scale?: number + visualScale?: number + commitZoom?: () => void } defineOptions({ @@ -158,6 +161,17 @@ const emit = defineEmits<{ const pdfElements = ref(null) +// Auto-fit can fight user zoom on touch devices; keep one-shot fit from endInit and let user control zoom afterwards. +const enableAutoFitZoom = computed(() => { + const isTouchDevice = typeof window !== 'undefined' + && ( + (window.matchMedia?.('(pointer: coarse)').matches ?? false) + || 'ontouchstart' in window + || (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0) + ) + return !isTouchDevice +}) + const ignoreClickOutsideSelectors = computed(() => ['.action-item__popper', '.action-item']) const toolbarStyleVars = computed(() => ({ @@ -313,6 +327,8 @@ function cancelAdding() { pdfElements.value?.cancelAdding() } + + async function addSigner(signer: SignerSummaryRecord | SignerDetailRecord, visibleElement: VisibleElementRecord, options: { documentIndex?: number } = {}) { if (!pdfElements.value || !visibleElement.coordinates) { return @@ -385,6 +401,7 @@ defineExpose({ findObjectLocation, startAddingSigner, cancelAdding, + addSigner, waitForPageRender, getTotalObjectsCount, @@ -396,6 +413,8 @@ defineExpose({ .pdf-editor { width: 100%; height: 100%; + overflow: hidden; + overscroll-behavior: contain; } diff --git a/src/tests/views/SignPDF/Sign.spec.ts b/src/tests/views/SignPDF/Sign.spec.ts index c68a73348a..5a31e45f6e 100644 --- a/src/tests/views/SignPDF/Sign.spec.ts +++ b/src/tests/views/SignPDF/Sign.spec.ts @@ -1687,6 +1687,54 @@ describe('Sign.vue - signWithTokenCode', () => { expect(wrapper.vm.hasSignatures).toBe(false) expect(wrapper.vm.needCreateSignature).toBe(false) }) + + it('shows a mobile orientation hint when signature setup is required on portrait phones', async () => { + const { default: realSign } = await import('../../../views/SignPDF/_partials/Sign.vue') + const { useSignStore } = await import('../../../store/sign.js') + const signStore = useSignStore() + + signStore.document = createSignDocument({ + nodeType: 'file', + signers: [ + { signRequestId: 501, me: true }, + ], + visibleElements: [ + { elementId: 201, fileId: 1, signRequestId: 501, type: 'signature', coordinates: { page: 1, left: 10, top: 20, width: 30, height: 40 } }, + ], + }) + + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390, writable: true }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844, writable: true }) + + const wrapper = mount(realSign, { + global: { + stubs: { + NcButton: true, + NcDialog: true, + NcLoadingIcon: true, + TokenManager: true, + EmailManager: true, + UploadCertificate: true, + Documents: true, + Signatures: true, + Draw: true, + ManagePassword: true, + CreatePassword: true, + NcNoteCard: false, + NcPasswordField: true, + NcRichText: true, + }, + mocks: { + $watch: vi.fn(), + }, + }, + }) + + await flushPromises() + + expect(wrapper.vm.needCreateSignature).toBe(true) + expect(wrapper.text()).toContain('For a better signing experience on mobile, rotate your phone to landscape mode.') + }) }) describe('Sign.vue - create signature modal', () => { diff --git a/src/views/SignPDF/_partials/Sign.vue b/src/views/SignPDF/_partials/Sign.vue index 07061cb7e7..d26986aaed 100644 --- a/src/views/SignPDF/_partials/Sign.vue +++ b/src/views/SignPDF/_partials/Sign.vue @@ -8,6 +8,9 @@
+ + {{ t('libresign', 'For a better signing experience on mobile, rotate your phone to landscape mode.') }} + ({ account: { uid: '', emailAddress: '', displayName: '' }, settings: { canRequestSign: false, hasSignatureFile: false, phoneNumber: '' }, @@ -472,6 +476,7 @@ const needCreateSignature = computed(() => { } return hasVisibleElementsForCurrentUser(visibleElementsDocument.value) }) +const showMobileOrientationHint = computed(() => needCreateSignature.value && isMobilePortrait.value) const needIdentificationDocuments = computed(() => identificationDocumentStore.showDocumentsComponent()) const canCreateSignature = computed(() => { const capabilities = getCapabilities() as LibresignCapabilities @@ -543,6 +548,19 @@ function clearBlockingSignError() { signStore.clearSigningErrors() } +function updateOrientationHint() { + if (typeof window === 'undefined') { + isMobilePortrait.value = false + return + } + + const isMobileViewport = window.innerWidth <= 512 + const isPortrait = window.matchMedia?.('(orientation: portrait)').matches + ?? window.innerHeight > window.innerWidth + + isMobilePortrait.value = isMobileViewport && isPortrait +} + function saveSignature() { if (signatureElementsStore.success.length) { showSuccess(signatureElementsStore.success) @@ -728,6 +746,10 @@ function executeSigningAction(action: string) { } onMounted(async () => { + updateOrientationHint() + window.addEventListener('resize', updateOrientationHint, { passive: true }) + window.addEventListener('orientationchange', updateOrientationHint) + loading.value = true signatureElementsStore.signRequestUuid = signRequestUuid.value signatureElementsStore.loadSignatures() @@ -775,6 +797,8 @@ watch(signRequestUuid, (newUuid, oldUuid) => { }) onBeforeUnmount(() => { + window.removeEventListener('resize', updateOrientationHint) + window.removeEventListener('orientationchange', updateOrientationHint) resetSignMethodsState() if (unwatchPendingAction) { unwatchPendingAction() @@ -792,6 +816,23 @@ defineExpose({