diff --git a/lib/Service/IdentifyMethod/Email.php b/lib/Service/IdentifyMethod/Email.php
index 30be6c60ed..900df89d29 100644
--- a/lib/Service/IdentifyMethod/Email.php
+++ b/lib/Service/IdentifyMethod/Email.php
@@ -127,15 +127,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 {
diff --git a/playwright/e2e/multi-signer-parallel.spec.ts b/playwright/e2e/multi-signer-parallel.spec.ts
new file mode 100644
index 0000000000..9ceb2ab163
--- /dev/null
+++ b/playwright/e2e/multi-signer-parallel.spec.ts
@@ -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('signer01@libresign.coop', { delay: 50 })
+ await page.getByRole('option', { name: 'signer01@libresign.coop' }).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('signer02@libresign.coop', { delay: 50 })
+ await page.getByRole('option', { name: 'signer02@libresign.coop' }).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, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign')
+ await waitForEmailTo(mailpit, 'signer02@libresign.coop', '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)
+})
diff --git a/playwright/e2e/multi-signer-sequential.spec.ts b/playwright/e2e/multi-signer-sequential.spec.ts
new file mode 100644
index 0000000000..f8f25313f1
--- /dev/null
+++ b/playwright/e2e/multi-signer-sequential.spec.ts
@@ -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('signer01@libresign.coop', { delay: 50 })
+ await page.getByRole('option', { name: 'signer01@libresign.coop' }).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('signer02@libresign.coop', { delay: 50 })
+ await page.getByRole('option', { name: 'signer02@libresign.coop' }).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, 'signer01@libresign.coop', '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, 'signer02@libresign.coop', '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)
+})
diff --git a/src/components/RightSidebar/RequestSignatureTab.vue b/src/components/RightSidebar/RequestSignatureTab.vue
index 4fb9531cf5..285bbb9de0 100644
--- a/src/components/RightSidebar/RequestSignatureTab.vue
+++ b/src/components/RightSidebar/RequestSignatureTab.vue
@@ -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
}
})
diff --git a/src/components/Signers/Signers.vue b/src/components/Signers/Signers.vue
index 698d7cf98d..4a97756e39 100644
--- a/src/components/Signers/Signers.vue
+++ b/src/components/Signers/Signers.vue
@@ -5,15 +5,15 @@