Skip to content

Commit 4c4b5de

Browse files
authored
Merge pull request #7545 from LibreSign/backport/7543/stable32
[stable32] fix: handle private validation URL redirect and string error messages
2 parents 737b5c7 + 4eee886 commit 4c4b5de

5 files changed

Lines changed: 193 additions & 6 deletions

File tree

src/components/PdfEditor/PdfEditor.vue

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
:init-file-names="fileNames"
1010
:page-count-format="t('libresign', '{currentPage} of {totalPages}')"
1111
:page-aria-label="getPageAriaLabel"
12-
:auto-fit-zoom="true"
12+
:auto-fit-zoom="enableAutoFitZoom"
1313
:read-only="readOnly"
1414
:emit-object-click="true"
1515
:hide-selection-ui="readOnly"
@@ -129,6 +129,9 @@ type PdfElementsInstance = {
129129
pdfDocuments?: PdfDocument[]
130130
selectedDocIndex?: number
131131
autoFitZoom?: boolean
132+
scale?: number
133+
visualScale?: number
134+
commitZoom?: () => void
132135
}
133136
134137
defineOptions({
@@ -158,6 +161,17 @@ const emit = defineEmits<{
158161
159162
const pdfElements = ref<PdfElementsInstance | null>(null)
160163
164+
// Auto-fit can fight user zoom on touch devices; keep one-shot fit from endInit and let user control zoom afterwards.
165+
const enableAutoFitZoom = computed(() => {
166+
const isTouchDevice = typeof window !== 'undefined'
167+
&& (
168+
(window.matchMedia?.('(pointer: coarse)').matches ?? false)
169+
|| 'ontouchstart' in window
170+
|| (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0)
171+
)
172+
return !isTouchDevice
173+
})
174+
161175
const ignoreClickOutsideSelectors = computed(() => ['.action-item__popper', '.action-item'])
162176
163177
const toolbarStyleVars = computed(() => ({
@@ -313,6 +327,8 @@ function cancelAdding() {
313327
pdfElements.value?.cancelAdding()
314328
}
315329
330+
331+
316332
async function addSigner(signer: SignerSummaryRecord | SignerDetailRecord, visibleElement: VisibleElementRecord, options: { documentIndex?: number } = {}) {
317333
if (!pdfElements.value || !visibleElement.coordinates) {
318334
return
@@ -385,6 +401,7 @@ defineExpose({
385401
findObjectLocation,
386402
startAddingSigner,
387403
cancelAdding,
404+
388405
addSigner,
389406
waitForPageRender,
390407
getTotalObjectsCount,
@@ -396,6 +413,8 @@ defineExpose({
396413
.pdf-editor {
397414
width: 100%;
398415
height: 100%;
416+
overflow: hidden;
417+
overscroll-behavior: contain;
399418
}
400419
401420
</style>

src/tests/views/SignPDF/Sign.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1687,6 +1687,54 @@ describe('Sign.vue - signWithTokenCode', () => {
16871687
expect(wrapper.vm.hasSignatures).toBe(false)
16881688
expect(wrapper.vm.needCreateSignature).toBe(false)
16891689
})
1690+
1691+
it('shows a mobile orientation hint when signature setup is required on portrait phones', async () => {
1692+
const { default: realSign } = await import('../../../views/SignPDF/_partials/Sign.vue')
1693+
const { useSignStore } = await import('../../../store/sign.js')
1694+
const signStore = useSignStore()
1695+
1696+
signStore.document = createSignDocument({
1697+
nodeType: 'file',
1698+
signers: [
1699+
{ signRequestId: 501, me: true },
1700+
],
1701+
visibleElements: [
1702+
{ elementId: 201, fileId: 1, signRequestId: 501, type: 'signature', coordinates: { page: 1, left: 10, top: 20, width: 30, height: 40 } },
1703+
],
1704+
})
1705+
1706+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390, writable: true })
1707+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844, writable: true })
1708+
1709+
const wrapper = mount(realSign, {
1710+
global: {
1711+
stubs: {
1712+
NcButton: true,
1713+
NcDialog: true,
1714+
NcLoadingIcon: true,
1715+
TokenManager: true,
1716+
EmailManager: true,
1717+
UploadCertificate: true,
1718+
Documents: true,
1719+
Signatures: true,
1720+
Draw: true,
1721+
ManagePassword: true,
1722+
CreatePassword: true,
1723+
NcNoteCard: false,
1724+
NcPasswordField: true,
1725+
NcRichText: true,
1726+
},
1727+
mocks: {
1728+
$watch: vi.fn(),
1729+
},
1730+
},
1731+
})
1732+
1733+
await flushPromises()
1734+
1735+
expect(wrapper.vm.needCreateSignature).toBe(true)
1736+
expect(wrapper.text()).toContain('For a better signing experience on mobile, rotate your phone to landscape mode.')
1737+
})
16901738
})
16911739

16921740
describe('Sign.vue - create signature modal', () => {

src/tests/views/Validation.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type ValidationVm = {
3838
handleValidationSuccess: (data: Record<string, any>) => void
3939
handleSigningComplete: (file: Record<string, any> | null) => void
4040
refreshAfterAsyncSigning: () => Promise<void>
41+
validateByUUID: (uuid: string, options?: { suppressLoading?: boolean }) => Promise<void>
4142
$nextTick: () => Promise<void>
4243
}
4344

@@ -986,6 +987,53 @@ describe('Validation.vue - Business Logic', () => {
986987
})
987988
})
988989

990+
describe('validation API error handling', () => {
991+
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
992+
993+
it('redirects to login when validation URL is private', async () => {
994+
const hrefSpy = vi.spyOn(window.location, 'href', 'set')
995+
vi.mocked(axios.get).mockRejectedValueOnce({
996+
response: {
997+
status: 401,
998+
data: {
999+
ocs: {
1000+
data: {
1001+
action: 1000,
1002+
redirect: '/index.php/login?redirect_url=%2Fapps%2Flibresign%2Fvalidation%2F550e8400-e29b-41d4-a716-446655440000',
1003+
errors: ['You are not logged in. Please log in.'],
1004+
},
1005+
},
1006+
},
1007+
},
1008+
})
1009+
1010+
await wrapper.vm.validateByUUID(VALID_UUID)
1011+
1012+
expect(hrefSpy).toHaveBeenCalledWith('/index.php/login?redirect_url=%2Fapps%2Flibresign%2Fvalidation%2F550e8400-e29b-41d4-a716-446655440000')
1013+
expect(wrapper.vm.validationErrorMessage).toBe(null)
1014+
hrefSpy.mockRestore()
1015+
})
1016+
1017+
it('shows string-based backend errors instead of generic fallback', async () => {
1018+
vi.mocked(axios.get).mockRejectedValueOnce({
1019+
response: {
1020+
status: 401,
1021+
data: {
1022+
ocs: {
1023+
data: {
1024+
errors: ['You are not logged in. Please log in.'],
1025+
},
1026+
},
1027+
},
1028+
},
1029+
})
1030+
1031+
await wrapper.vm.validateByUUID(VALID_UUID)
1032+
1033+
expect(wrapper.vm.validationErrorMessage).toBe('You are not logged in. Please log in.')
1034+
})
1035+
})
1036+
9891037
describe('status contract guards', () => {
9901038
const createLoadedValidationDocument = (patch: Record<string, unknown> = {}) => ({
9911039
id: 100,

src/views/SignPDF/_partials/Sign.vue

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
<Signatures v-if="hasSignatures" />
99
</div>
1010
<div v-if="!loading" class="button-wrapper">
11+
<NcNoteCard v-if="showMobileOrientationHint" type="warning">
12+
{{ t('libresign', 'For a better signing experience on mobile, rotate your phone to landscape mode.') }}
13+
</NcNoteCard>
1114
<NcNoteCard v-for="(error, index) in signStore.errors"
1215
:key="index"
1316
:heading="error.title || ''"
@@ -433,6 +436,7 @@ const sidebarStore = useSidebarStore() as SidebarStoreContract
433436
const identificationDocumentStore = useIdentificationDocumentStore() as IdentificationDocumentStoreContract
434437
435438
const loading = ref(true)
439+
const isMobilePortrait = ref(false)
436440
const user = ref<UserInfo>({
437441
account: { uid: '', emailAddress: '', displayName: '' },
438442
settings: { canRequestSign: false, hasSignatureFile: false, phoneNumber: '' },
@@ -472,6 +476,7 @@ const needCreateSignature = computed(() => {
472476
}
473477
return hasVisibleElementsForCurrentUser(visibleElementsDocument.value)
474478
})
479+
const showMobileOrientationHint = computed(() => needCreateSignature.value && isMobilePortrait.value)
475480
const needIdentificationDocuments = computed(() => identificationDocumentStore.showDocumentsComponent())
476481
const canCreateSignature = computed(() => {
477482
const capabilities = getCapabilities() as LibresignCapabilities
@@ -543,6 +548,19 @@ function clearBlockingSignError() {
543548
signStore.clearSigningErrors()
544549
}
545550
551+
function updateOrientationHint() {
552+
if (typeof window === 'undefined') {
553+
isMobilePortrait.value = false
554+
return
555+
}
556+
557+
const isMobileViewport = window.innerWidth <= 512
558+
const isPortrait = window.matchMedia?.('(orientation: portrait)').matches
559+
?? window.innerHeight > window.innerWidth
560+
561+
isMobilePortrait.value = isMobileViewport && isPortrait
562+
}
563+
546564
function saveSignature() {
547565
if (signatureElementsStore.success.length) {
548566
showSuccess(signatureElementsStore.success)
@@ -728,6 +746,10 @@ function executeSigningAction(action: string) {
728746
}
729747
730748
onMounted(async () => {
749+
updateOrientationHint()
750+
window.addEventListener('resize', updateOrientationHint, { passive: true })
751+
window.addEventListener('orientationchange', updateOrientationHint)
752+
731753
loading.value = true
732754
signatureElementsStore.signRequestUuid = signRequestUuid.value
733755
signatureElementsStore.loadSignatures()
@@ -775,6 +797,8 @@ watch(signRequestUuid, (newUuid, oldUuid) => {
775797
})
776798
777799
onBeforeUnmount(() => {
800+
window.removeEventListener('resize', updateOrientationHint)
801+
window.removeEventListener('orientationchange', updateOrientationHint)
778802
resetSignMethodsState()
779803
if (unwatchPendingAction) {
780804
unwatchPendingAction()
@@ -792,6 +816,23 @@ defineExpose({
792816
</script>
793817

794818
<style lang="scss" scoped>
819+
.document-sign {
820+
display: flex;
821+
flex-direction: column;
822+
height: 100%;
823+
width: 100%;
824+
overscroll-behavior: contain;
825+
-webkit-user-select: none;
826+
user-select: none;
827+
-webkit-touch-callout: none;
828+
}
829+
830+
.sign-elements {
831+
flex: 1;
832+
overflow: hidden;
833+
width: 100%;
834+
}
835+
795836
.progress-indicator {
796837
font-weight: bold;
797838
color: var(--color-primary-element);

src/views/Validation.vue

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ import {
137137
MODIFICATION_VIOLATION,
138138
toValidationDocument,
139139
} from '../services/validationDocument'
140+
import { ACTION_CODES } from '../helpers/ActionMapping'
140141
import { normalizeRouteRecord } from '../services/routeNormalization.js'
141142
import logger from '../logger.js'
142143
import { useFilesStore } from '../store/files.js'
@@ -196,13 +197,17 @@ type StatusPresentation = {
196197
type ErrorMessageEntry = {
197198
message?: string
198199
}
200+
type ValidationErrorEntry = ErrorMessageEntry | string
201+
type ValidationErrorPayload = {
202+
errors?: ValidationErrorEntry[]
203+
action?: number
204+
redirect?: string
205+
}
199206
type ValidationErrorResponse = {
200207
status?: number
201208
data?: {
202209
ocs?: {
203-
data?: {
204-
errors?: ErrorMessageEntry[]
205-
}
210+
data?: ValidationErrorPayload
206211
}
207212
}
208213
}
@@ -223,12 +228,29 @@ function isSignedDocumentStatus(status: unknown): boolean {
223228
}
224229
225230
function getValidationErrorMessage(response: ValidationErrorResponse | undefined, fallback: string): string {
226-
if (response?.data?.ocs?.data?.errors?.length) {
227-
return response.data.ocs.data.errors[0]?.message || fallback
231+
const errors = response?.data?.ocs?.data?.errors
232+
if (errors?.length) {
233+
const [firstError] = errors
234+
if (typeof firstError === 'string') {
235+
return firstError || fallback
236+
}
237+
if (firstError?.message) {
238+
return firstError.message
239+
}
228240
}
229241
return fallback
230242
}
231243
244+
function handleValidationRedirect(response: ValidationErrorResponse | undefined): boolean {
245+
const action = response?.data?.ocs?.data?.action
246+
const redirect = response?.data?.ocs?.data?.redirect
247+
if (action !== ACTION_CODES.REDIRECT || typeof redirect !== 'string' || redirect.length === 0) {
248+
return false
249+
}
250+
window.location.href = redirect
251+
return true
252+
}
253+
232254
const signStore = useSignStore()
233255
const sidebarStore = useSidebarStore()
234256
const filesStore = useFilesStore()
@@ -333,6 +355,9 @@ async function upload(file: File) {
333355
handleValidationSuccess(data.ocs.data)
334356
})
335357
.catch((error: { response?: ValidationErrorResponse }) => {
358+
if (handleValidationRedirect(error.response)) {
359+
return
360+
}
336361
const errorMsg = getValidationErrorMessage(error.response, t('libresign', 'Failed to validate document'))
337362
setValidationError(errorMsg)
338363
})
@@ -397,6 +422,9 @@ async function validateByUUID(uuid: string, { suppressLoading = false }: { suppr
397422
})
398423
.catch((error: { response?: ValidationErrorResponse }) => {
399424
const response = error.response
425+
if (handleValidationRedirect(response)) {
426+
return
427+
}
400428
if (response?.status === 404) {
401429
setValidationError(t('libresign', 'Document not found'))
402430
} else {
@@ -419,6 +447,9 @@ async function validateByNodeID(nodeId: string, { suppressLoading = false }: { s
419447
})
420448
.catch((error: { response?: ValidationErrorResponse }) => {
421449
const response = error.response
450+
if (handleValidationRedirect(response)) {
451+
return
452+
}
422453
if (response?.status === 404) {
423454
setValidationError(t('libresign', 'Document not found'))
424455
} else {

0 commit comments

Comments
 (0)