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/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/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({