diff --git a/lib/Service/IdentifyMethod/Email.php b/lib/Service/IdentifyMethod/Email.php index bd97a1d59f..3019a6df26 100644 --- a/lib/Service/IdentifyMethod/Email.php +++ b/lib/Service/IdentifyMethod/Email.php @@ -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 { 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 @@ - +