Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/components/PdfEditor/PdfEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -129,6 +129,9 @@ type PdfElementsInstance = {
pdfDocuments?: PdfDocument[]
selectedDocIndex?: number
autoFitZoom?: boolean
scale?: number
visualScale?: number
commitZoom?: () => void
}

defineOptions({
Expand Down Expand Up @@ -158,6 +161,17 @@ const emit = defineEmits<{

const pdfElements = ref<PdfElementsInstance | null>(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(() => ({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -385,6 +401,7 @@ defineExpose({
findObjectLocation,
startAddingSigner,
cancelAdding,

addSigner,
waitForPageRender,
getTotalObjectsCount,
Expand All @@ -396,6 +413,8 @@ defineExpose({
.pdf-editor {
width: 100%;
height: 100%;
overflow: hidden;
overscroll-behavior: contain;
}

</style>
48 changes: 48 additions & 0 deletions src/tests/views/SignPDF/Sign.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
48 changes: 48 additions & 0 deletions src/tests/views/Validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type ValidationVm = {
handleValidationSuccess: (data: Record<string, any>) => void
handleSigningComplete: (file: Record<string, any> | null) => void
refreshAfterAsyncSigning: () => Promise<void>
validateByUUID: (uuid: string, options?: { suppressLoading?: boolean }) => Promise<void>
$nextTick: () => Promise<void>
}

Expand Down Expand Up @@ -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<string, unknown> = {}) => ({
id: 100,
Expand Down
41 changes: 41 additions & 0 deletions src/views/SignPDF/_partials/Sign.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
<Signatures v-if="hasSignatures" />
</div>
<div v-if="!loading" class="button-wrapper">
<NcNoteCard v-if="showMobileOrientationHint" type="warning">
{{ t('libresign', 'For a better signing experience on mobile, rotate your phone to landscape mode.') }}
</NcNoteCard>
<NcNoteCard v-for="(error, index) in signStore.errors"
:key="index"
:heading="error.title || ''"
Expand Down Expand Up @@ -433,6 +436,7 @@ const sidebarStore = useSidebarStore() as SidebarStoreContract
const identificationDocumentStore = useIdentificationDocumentStore() as IdentificationDocumentStoreContract

const loading = ref(true)
const isMobilePortrait = ref(false)
const user = ref<UserInfo>({
account: { uid: '', emailAddress: '', displayName: '' },
settings: { canRequestSign: false, hasSignatureFile: false, phoneNumber: '' },
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -775,6 +797,8 @@ watch(signRequestUuid, (newUuid, oldUuid) => {
})

onBeforeUnmount(() => {
window.removeEventListener('resize', updateOrientationHint)
window.removeEventListener('orientationchange', updateOrientationHint)
resetSignMethodsState()
if (unwatchPendingAction) {
unwatchPendingAction()
Expand All @@ -792,6 +816,23 @@ defineExpose({
</script>

<style lang="scss" scoped>
.document-sign {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overscroll-behavior: contain;
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}

.sign-elements {
flex: 1;
overflow: hidden;
width: 100%;
}

.progress-indicator {
font-weight: bold;
color: var(--color-primary-element);
Expand Down
41 changes: 36 additions & 5 deletions src/views/Validation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}
}
}
Expand All @@ -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') {
return firstError || fallback
}
if (firstError?.message) {
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()
Expand Down Expand Up @@ -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)
})
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Loading