Skip to content

Commit 8ba1375

Browse files
authored
Merge pull request #7041 from LibreSign/backport/6983/stable33
[stable33] feat: signature confirmation steps
2 parents 1296c3c + ed33bb4 commit 8ba1375

14 files changed

Lines changed: 1249 additions & 540 deletions

lib/Controller/SignFileController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ private function getCode(SignRequest $signRequest): DataResponse {
326326
signMethodName: $this->request->getParam('signMethod', ''),
327327
identify: $this->request->getParam('identify', ''),
328328
);
329-
$message = $this->l10n->t('The code to sign file was successfully requested.');
329+
$message = $this->l10n->t('Verification code sent.');
330330
$statusCode = Http::STATUS_OK;
331331
} catch (\Throwable $th) {
332332
$message = $th->getMessage();

package-lock.json

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,19 +78,20 @@
7878
"npm": "^11.3.0"
7979
},
8080
"devDependencies": {
81-
"@playwright/test": "^1.58.1",
8281
"@nextcloud/browserslist-config": "^3.1.2",
8382
"@nextcloud/eslint-config": "^8.4.2",
8483
"@nextcloud/stylelint-config": "^3.2.1",
8584
"@nextcloud/vite-config": "^2.5.2",
8685
"@pinia/testing": "^1.0.3",
86+
"@playwright/test": "^1.58.1",
8787
"@testing-library/dom": "^10.4.1",
8888
"@testing-library/vue": "^8.1.0",
8989
"@vitejs/plugin-vue": "^6.0.3",
9090
"@vitest/coverage-v8": "^4.0.18",
9191
"@vue/test-utils": "^2.4.6",
9292
"@vue/tsconfig": "^0.8.1",
9393
"happy-dom": "^20.7.0",
94+
"mailpit-api": "^1.7.2",
9495
"openapi-typescript": "^7.13.0",
9596
"typescript": "^5.9.3",
9697
"vite": "^7.1.10",

playwright.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { defineConfig, devices } from '@playwright/test'
99
* See https://playwright.dev/docs/test-configuration.
1010
*/
1111
export default defineConfig({
12-
testDir: './playwright',
12+
testDir: './playwright/e2e',
1313

1414
/* Run tests in files in parallel */
1515
fullyParallel: true,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { test, expect } from '@playwright/test';
7+
import { login } from '../support/nc-login'
8+
import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning'
9+
import { createMailpitClient, waitForEmailTo, extractSignLink, extractTokenFromEmail } from '../support/mailpit'
10+
11+
test('sign document with email token as unauthenticated signer', async ({ page }) => {
12+
await login(
13+
page.request,
14+
process.env.NEXTCLOUD_ADMIN_USER ?? 'admin',
15+
process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin',
16+
)
17+
18+
await configureOpenSsl(page.request, 'LibreSign Test', {
19+
C: 'BR',
20+
OU: ['Organization Unit'],
21+
ST: 'Rio de Janeiro',
22+
O: 'LibreSign',
23+
L: 'Rio de Janeiro',
24+
})
25+
26+
await setAppConfig(
27+
page.request,
28+
'libresign',
29+
'identify_methods',
30+
JSON.stringify([
31+
{ name: 'account', enabled: true, mandatory: false },
32+
{ name: 'email', enabled: true, mandatory: true, signatureMethods: { emailToken: { enabled: true } }, can_create_account: false },
33+
]),
34+
)
35+
36+
await page.goto('./apps/libresign')
37+
await page.getByRole('button', { name: 'Upload from URL' }).click();
38+
await page.getByRole('textbox', { name: 'URL of a PDF file' }).click();
39+
await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('http://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf');
40+
await page.getByRole('button', { name: 'Send' }).click();
41+
await page.getByRole('button', { name: 'Add signer' }).click();
42+
await page.getByRole('tab', { name: 'Email' }).click();
43+
await page.getByPlaceholder('Email').click();
44+
await page.getByPlaceholder('Email').fill('signer01@libresign.coop');
45+
await page.getByRole('option', { name: 'signer01@libresign.coop' }).click();
46+
await page.getByRole('textbox', { name: 'Signer name' }).click();
47+
await page.getByRole('textbox', { name: 'Signer name' }).press('ControlOrMeta+a');
48+
await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 01');
49+
await page.getByRole('button', { name: 'Save' }).click();
50+
51+
const mailpit = createMailpitClient()
52+
await mailpit.deleteMessages()
53+
54+
await page.getByRole('button', { name: 'Request signatures' }).click();
55+
await page.getByRole('button', { name: 'Send' }).click();
56+
57+
// Logout before accessing the sign link to avoid session-related issues.
58+
await page.getByRole('button', { name: 'Settings menu' }).click();
59+
await page.getByRole('link', { name: 'Log out' }).click();
60+
61+
const email = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign')
62+
const signLink = extractSignLink(email.Text)
63+
if (!signLink) throw new Error('Sign link not found in email')
64+
await page.goto(signLink);
65+
await page.getByRole('button', { name: 'Sign the document.' }).click();
66+
await page.getByRole('textbox', { name: 'Email' }).click();
67+
await page.getByRole('textbox', { name: 'Email' }).fill('signer01@libresign.coop');
68+
await page.getByRole('button', { name: 'Send verification code' }).click();
69+
70+
const tokenEmail = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: Code to sign file')
71+
const token = extractTokenFromEmail(tokenEmail.Text)
72+
if (!token) throw new Error('Token not found in email')
73+
await page.getByRole('textbox', { name: 'Enter your code' }).click();
74+
await page.getByRole('textbox', { name: 'Enter your code' }).fill(token);
75+
await page.getByRole('button', { name: 'Validate code' }).click();
76+
77+
await expect(page.getByRole('heading', { name: 'Signature confirmation' })).toBeVisible();
78+
await expect(page.getByText('Step 3 of 3 - Signature')).toBeVisible();
79+
await expect(page.getByText('Your identity has been')).toBeVisible();
80+
await expect(page.getByText('You can now sign the document.')).toBeVisible();
81+
await page.getByRole('button', { name: 'Sign document' }).click();
82+
await page.waitForURL('**/validation/**');
83+
await expect(page.getByText('This document is valid')).toBeVisible();
84+
await expect(page.getByText('Congratulations you have')).toBeVisible();
85+
});

playwright/e2e/sign-herself-with-click-to-sign.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ test('sign herself with click to sign', async ({ page }) => {
4444
await page.getByRole('button', { name: 'Send' }).click();
4545
await page.getByRole('button', { name: 'Sign document' }).click();
4646
await page.getByRole('button', { name: 'Sign the document.' }).click();
47-
await page.getByRole('button', { name: 'Confirm' }).click();
47+
await page.getByRole('button', { name: 'Sign document' }).click();
4848
await page.waitForURL('**/validation/**');
4949
await expect(page.getByText('This document is valid')).toBeVisible();
5050
await page.getByRole('button', { name: 'Expand details' }).click();

playwright/support/mailpit.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { MailpitClient } from 'mailpit-api'
7+
8+
export type { MailpitClient }
9+
10+
type Message = Awaited<ReturnType<MailpitClient['getMessageSummary']>>
11+
12+
/** Creates a MailpitClient using MAILPIT_URL (default: http://localhost:8025). */
13+
export function createMailpitClient(): MailpitClient {
14+
return new MailpitClient(process.env.MAILPIT_URL ?? 'http://localhost:8025')
15+
}
16+
17+
/** Fetches the latest email sent to `toAddress`, optionally filtered by `subject`. */
18+
export async function getLatestEmailTo(
19+
client: MailpitClient,
20+
toAddress: string,
21+
subject?: string,
22+
): Promise<Message> {
23+
const query = subject
24+
? `to:${toAddress} subject:"${subject}"`
25+
: `to:${toAddress}`
26+
const result = await client.searchMessages({ query })
27+
if (!result.messages || result.messages.length === 0) {
28+
throw new Error(`No email found for "${toAddress}"${subject ? ` with subject "${subject}"` : ''}`)
29+
}
30+
return await client.getMessageSummary(result.messages[0].ID)
31+
}
32+
33+
/**
34+
* Polls MailPit until an email matching `toAddress` (and optional `subject`) is found,
35+
* or until `timeout` ms elapse. Checks every `interval` ms (defaults: 30 s / 1 s).
36+
*/
37+
export async function waitForEmailTo(
38+
client: MailpitClient,
39+
toAddress: string,
40+
subject?: string,
41+
options?: { timeout?: number; interval?: number },
42+
): Promise<Message> {
43+
const timeout = options?.timeout ?? 30_000
44+
const interval = options?.interval ?? 1_000
45+
const deadline = Date.now() + timeout
46+
while (Date.now() < deadline) {
47+
try {
48+
return await getLatestEmailTo(client, toAddress, subject)
49+
} catch {
50+
// email not arrived yet
51+
}
52+
await new Promise(resolve => setTimeout(resolve, interval))
53+
}
54+
throw new Error(
55+
`Timeout (${timeout} ms) waiting for email to "${toAddress}"${
56+
subject ? ` with subject "${subject}"` : ''
57+
}`,
58+
)
59+
}
60+
61+
/** Extracts a LibreSign sign link from an email body matching /p/sign/{uuid}. */
62+
export function extractSignLink(body: string): string | null {
63+
const match = body.match(/\S+\/p\/sign\/[\w-]+/)
64+
return match ? match[0] : null
65+
}
66+
67+
/** Extracts a numeric token from an email body. Default pattern: 4-8 digit sequence. */
68+
export function extractTokenFromEmail(
69+
body: string,
70+
pattern: RegExp = /Use this code to sign the document:[\s\S]*?(\d{6})/,
71+
): string | null {
72+
const match = body.match(pattern)
73+
return match ? match[1] : null
74+
}
75+
76+
/** Extracts the first URL from an email body (email.Text). */
77+
export function extractLinkFromEmail(body: string): string | null {
78+
const match = body.match(/https?:\/\/\S+/)
79+
return match ? match[0] : null
80+
}

0 commit comments

Comments
 (0)