Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7243f02
test(e2e): add parallel multi-signer signature request test
vitormattos Mar 2, 2026
f40e57b
test(e2e): add sequential multi-signer signature request test
vitormattos Mar 2, 2026
7aaf9eb
fix(signers): migrate draggable to vuedraggable 4 slot syntax
vitormattos Mar 2, 2026
e312959
test(signers): update draggable stub for vuedraggable 4 slot API
vitormattos Mar 2, 2026
494bcee
fix(request): reassign sequential orders when all signers share same …
vitormattos Mar 2, 2026
83b60c6
test(request): add regression test for duplicate signingOrder on enable
vitormattos Mar 2, 2026
a8812bd
fix(email): throw when authenticated user has no email or wrong email
vitormattos Mar 2, 2026
501c554
test(email): add DataProvider coverage for throwIfIsAuthenticatedWith…
vitormattos Mar 2, 2026
479a670
test(e2e): fix selectors in parallel multi-signer test
vitormattos Mar 2, 2026
42b7244
test(e2e): fix selectors and add assertions in sequential multi-signe…
vitormattos Mar 2, 2026
a2a609b
fix(validation): rename interpolation variable to signerName for tran…
vitormattos Mar 2, 2026
f049142
test(validation): refactor toggleDetailsAriaLabel tests to it.each
vitormattos Mar 2, 2026
553a442
test(e2e): expand signer01 details and assert validation items after …
vitormattos Mar 2, 2026
abb5c0f
fix(e2e): wait for signer form to close before adding second signer
vitormattos Mar 2, 2026
b8f9095
fix(e2e): wait for signer form to close before adding second signer
vitormattos Mar 2, 2026
3f4f35c
test(e2e): use pressSequentially for email autocomplete field
vitormattos Mar 2, 2026
4865d84
test(e2e): add delay to pressSequentially for email autocomplete
vitormattos Mar 3, 2026
2583279
test(unit): mock async components in Validation.spec to fix vitest wo…
vitormattos Mar 3, 2026
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
17 changes: 9 additions & 8 deletions lib/Service/IdentifyMethod/Email.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,16 @@ private function throwIfIsAuthenticatedWithDifferentAccount(): void {
return;
}
$email = $this->entity->getIdentifierValue();
if (!empty($user->getEMailAddress()) && $user->getEMailAddress() !== $email) {
if ($this->getEntity()->getCode() && !$this->getEntity()->getIdentifiedAtDate()) {
return;
}
throw new LibresignException(json_encode([
'action' => JSActions::ACTION_DO_NOTHING,
'errors' => [['message' => $this->identifyService->getL10n()->t('Invalid user')]],
]));
if (!empty($user->getEMailAddress()) && $user->getEMailAddress() === $email) {
return;
}
if ($this->getEntity()->getCode() && !$this->getEntity()->getIdentifiedAtDate()) {
return;
}
throw new LibresignException(json_encode([
'action' => JSActions::ACTION_DO_NOTHING,
'errors' => [['message' => $this->identifyService->getL10n()->t('This document is not yours. Log out and use the sign link again.')]],
]));
}

private function throwIfAccountAlreadyExists(): void {
Expand Down
78 changes: 78 additions & 0 deletions playwright/e2e/multi-signer-parallel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect, test } from '@playwright/test'
import { login } from '../support/nc-login'
import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning'
import { createMailpitClient, waitForEmailTo } from '../support/mailpit'

test('request signatures from two signers in parallel', async ({ page }) => {
await login(
page.request,
process.env.NEXTCLOUD_ADMIN_USER ?? 'admin',
process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin',
)

await configureOpenSsl(page.request, 'LibreSign Test', {
C: 'BR',
OU: ['Organization Unit'],
ST: 'Rio de Janeiro',
O: 'LibreSign',
L: 'Rio de Janeiro',
})

await setAppConfig(
page.request,
'libresign',
'identify_methods',
JSON.stringify([
{ name: 'account', enabled: false, mandatory: false },
{ name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false },
]),
)

const mailpit = createMailpitClient()
await mailpit.deleteMessages()

await page.goto('./apps/libresign')
await page.getByRole('button', { name: 'Upload from URL' }).click()
await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf')
await page.getByRole('button', { name: 'Send' }).click()

// Add first signer — only email method is active, so the field appears directly (no tabs)
await page.getByRole('button', { name: 'Add signer' }).click()
await page.getByPlaceholder('Email').click()
await page.getByPlaceholder('Email').pressSequentially('[email protected]', { delay: 50 })
await page.getByRole('option', { name: '[email protected]' }).click()
await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 01')
await page.getByRole('button', { name: 'Save' }).click()

// Add second signer
await page.getByRole('button', { name: 'Add signer' }).click()
await page.getByPlaceholder('Email').click()
await page.getByPlaceholder('Email').pressSequentially('[email protected]', { delay: 50 })
await page.getByRole('option', { name: '[email protected]' }).click()
await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 02')
await page.getByRole('button', { name: 'Save' }).click()

// With 2+ signers the "Sign in order" switch must be visible and unchecked by default,
// meaning parallel flow — both signers will be notified at the same time.
const signInOrderSwitch = page.getByLabel('Sign in order')
await expect(signInOrderSwitch).toBeVisible()
await expect(signInOrderSwitch).not.toBeChecked()

// Send the signature request
await page.getByRole('button', { name: 'Request signatures' }).click()
await page.getByRole('button', { name: 'Send' }).click()

// In parallel mode both signers are notified simultaneously.
// Proof: wait for signer01's email, then verify that signer02's email also arrived.
await waitForEmailTo(mailpit, '[email protected]', 'LibreSign: There is a file for you to sign')
await waitForEmailTo(mailpit, '[email protected]', 'LibreSign: There is a file for you to sign')

// Both emails arrived — both signers were notified at the same time, confirming parallel mode.
const result = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' })
expect(result.messages).toHaveLength(2)
})
106 changes: 106 additions & 0 deletions playwright/e2e/multi-signer-sequential.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { expect, test } from '@playwright/test'
import { login } from '../support/nc-login'
import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning'
import { createMailpitClient, waitForEmailTo, extractSignLink } from '../support/mailpit'

test('request signatures from two signers in sequential order', async ({ page }) => {
await login(
page.request,
process.env.NEXTCLOUD_ADMIN_USER ?? 'admin',
process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin',
)

await configureOpenSsl(page.request, 'LibreSign Test', {
C: 'BR',
OU: ['Organization Unit'],
ST: 'Rio de Janeiro',
O: 'LibreSign',
L: 'Rio de Janeiro',
})

await setAppConfig(
page.request,
'libresign',
'identify_methods',
JSON.stringify([
{ name: 'account', enabled: false, mandatory: false },
{ name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false },
]),
)

const mailpit = createMailpitClient()
await mailpit.deleteMessages()

await page.goto('./apps/libresign')
await page.getByRole('button', { name: 'Upload from URL' }).click()
await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf')
await page.getByRole('button', { name: 'Send' }).click()

// Add first signer — only email method is active, so the field appears directly (no tabs)
await page.getByRole('button', { name: 'Add signer' }).click()
await page.getByPlaceholder('Email').click()
await page.getByPlaceholder('Email').pressSequentially('[email protected]', { delay: 50 })
await page.getByRole('option', { name: '[email protected]' }).click()
await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 01')
await page.getByRole('button', { name: 'Save' }).click()

// Add second signer
await page.getByRole('button', { name: 'Add signer' }).click()
await page.getByPlaceholder('Email').click()
await page.getByPlaceholder('Email').pressSequentially('[email protected]', { delay: 50 })
await page.getByRole('option', { name: '[email protected]' }).click()
await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 02')
await page.getByRole('button', { name: 'Save' }).click()

// Enable sequential signing.
// The checkbox input is hidden by CSS; click the visible label text to toggle it.
await expect(page.getByLabel('Sign in order')).toBeVisible()
await page.getByText('Sign in order').click()
await expect(page.getByLabel('Sign in order')).toBeChecked()

// Send the signature request
await page.getByRole('button', { name: 'Request signatures' }).click()
await page.getByRole('button', { name: 'Send' }).click()

// In sequential mode only signer01 (order 1) gets the email immediately.
// Proof: signer01's email arrives, but signer02's does NOT at this point.
const email01 = await waitForEmailTo(mailpit, '[email protected]', 'LibreSign: There is a file for you to sign')

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()

// Signer01 signs via the link received in the email
const signLink = extractSignLink(email01.Text)
if (!signLink) throw new Error('Sign link not found in email')
await page.goto(signLink)
await page.getByRole('button', { name: 'Sign the document.' }).click()
await page.getByRole('button', { name: 'Sign document' }).click()
await page.waitForURL('**/validation/**')
await expect(page.getByText('This document is valid')).toBeVisible()
// Signer01 signed; signer02 is still waiting (sequential mode proof at this point)
await expect(page.getByText('Signer 01')).toBeVisible()
await page.getByRole('button', { name: 'Expand details of Signer 01' }).click()
await page.getByRole('button', { name: 'Expand validation status', exact: true }).click();
await page.getByRole('link', { name: 'Document integrity verified' }).click();
await page.getByRole('button', { name: 'Expand document certification', exact: true }).click();
await page.getByRole('link', { name: 'Document has not been' }).click();

await expect(page.getByText('Signer 02')).toBeVisible()
await expect(page.getByText('Not signed yet')).toBeVisible()

// Now that signer01 has signed, signer02 must receive their notification.
await waitForEmailTo(mailpit, '[email protected]', 'LibreSign: There is a file for you to sign')

const afterSecond = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' })
expect(afterSecond.messages).toHaveLength(2)
})
4 changes: 3 additions & 1 deletion src/components/RightSidebar/RequestSignatureTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -744,8 +744,10 @@ export default {

if (value) {
if (file?.signers) {
const orders = file.signers.map(s => s.signingOrder || 0)
const hasDuplicateOrders = orders.length !== new Set(orders).size
file.signers.forEach((signer, index) => {
if (!signer.signingOrder) {
if (!signer.signingOrder || hasDuplicateOrders) {
signer.signingOrder = index + 1
}
})
Expand Down
24 changes: 4 additions & 20 deletions src/components/Signers/Signers.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,23 @@
<template>
<draggable v-if="isOrderedNumeric && canReorder"
v-model="sortableSigners"
item-key="identify"
tag="ul"
handle=".list-item"
class="signers-list"
chosenClass="signer-dragging"
dragClass="signer-drag-ghost"
@end="onDragEnd">
<transition-group name="signer-list" tag="div">
<Signer v-for="(signer, index) in sortableSigners"
:key="signer.identify"
<template #item="{ element: signer, index }">
<Signer
:signer-index="index"
:event="event"
:draggable="!signer.signed">
<template #actions="{closeActions}">
<slot name="actions" :signer="signer" :closeActions="closeActions" />
</template>
</Signer>
</transition-group>
</template>
</draggable>
<ul v-else>
<Signer v-for="(signer, index) in signers"
Expand Down Expand Up @@ -128,20 +128,4 @@ export default {
border-radius: var(--border-radius-large);
}

.signer-list {
&-move {
transition: transform 0.3s ease;
}

&-enter-active,
&-leave-active {
transition: all 0.3s ease;
}

&-enter-from,
&-leave-to {
opacity: 0;
transform: translateX(30px);
}
}
</style>
17 changes: 16 additions & 1 deletion src/components/validation/SignerDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
</template>
<template #extra-actions>
<NcButton v-if="signer.signed" variant="tertiary"
:aria-label="isOpen ? t('libresign', 'Collapse details') : t('libresign', 'Expand details')"
:aria-label="toggleDetailsAriaLabel"
@click.stop="toggleOpen">
<template #icon>
<NcIconSvgWrapper v-if="isOpen"
Expand Down Expand Up @@ -296,6 +296,21 @@ export default {
n,
}
},
computed: {
toggleDetailsAriaLabel() {
const signerName = this.getName(this.signer)
if (this.isOpen) {
// TRANSLATORS Accessible label for the button that collapses the signature details of
// a specific signer in the document validation page. {signerName} is the signer's
// display name, email, or "Unknown" when no identification is available.
return t('libresign', 'Collapse details of {signerName}', { signerName })
}
// TRANSLATORS Accessible label for the button that expands the signature details of
// a specific signer in the document validation page. {signerName} is the signer's
// display name, email, or "Unknown" when no identification is available.
return t('libresign', 'Expand details of {signerName}', { signerName })
},
},
data() {
return {
isOpen: this.initiallyOpen,
Expand Down
17 changes: 17 additions & 0 deletions src/tests/components/RightSidebar/RequestSignatureTab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,23 @@ describe('RequestSignatureTab - Critical Business Rules', () => {
expect(filesStore.files[1].signers[1].signingOrder).toBe(2)
})

it('reassigns sequential orders when all signers share the same signingOrder', async () => {
// Signers saved via the API return signingOrder: 1 as default for all of them.
// The old check (!signer.signingOrder) would skip them because !1 === false,
// leaving both at order 1 and causing the backend to notify both simultaneously.
await updateFile({
signatureFlow: 'parallel',
signers: [
{ email: '[email protected]', signed: [], signingOrder: 1 },
{ email: '[email protected]', signed: [], signingOrder: 1 },
],
})
wrapper.vm.onPreserveOrderChange(true)
await wrapper.vm.$nextTick()
expect(filesStore.files[1].signers[0].signingOrder).toBe(1)
expect(filesStore.files[1].signers[1].signingOrder).toBe(2)
})

it('reverts to parallel when disabling', async () => {
await wrapper.setData({ adminSignatureFlow: 'none' })
await updateFile({
Expand Down
15 changes: 9 additions & 6 deletions src/tests/components/Signers/Signers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,17 @@ describe('Signers', () => {
plugins: [pinia],
stubs: {
Signer: true,
// Stub for vuedraggable 4 (Vue 3) which uses #item slot per element
draggable: {
name: 'draggable',
template: '<div><slot /></div>',
props: ['modelValue', 'tag', 'handle', 'class', 'chosenClass', 'dragClass'],
},
'transition-group': {
name: 'transition-group',
template: '<div><slot /></div>',
template: `
<div>
<template v-for="(element, index) in modelValue" :key="index">
<slot name="item" :element="element" :index="index" />
</template>
</div>
`,
props: ['modelValue', 'itemKey', 'tag', 'handle', 'class', 'chosenClass', 'dragClass'],
},
},
},
Expand Down
Loading
Loading