Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
054cb3e
feat: Add Step 3 - Signature confirmation to ModalEmailManager
vitormattos Feb 20, 2026
17ac8f0
feat: Add Step 3 - Signature confirmation to ModalTokenManager
vitormattos Feb 20, 2026
fed29f7
test: Add unit tests for ModalEmailManager Step 3
vitormattos Feb 20, 2026
f6919cd
test: Add unit tests for ModalTokenManager Step 3
vitormattos Feb 20, 2026
0679d4b
test: Create SignFinalModals test file with proper imports
vitormattos Feb 20, 2026
506866e
fix: Fix getCapabilities mock to enable signature creation tests
vitormattos Feb 20, 2026
723a1d6
test: Fix Sign.spec.js getCapabilities mock
vitormattos Feb 20, 2026
7ecc8dc
refactor(sign-file-controller): simplify verification code response m…
vitormattos Feb 27, 2026
88058fb
refactor(sign): remove ModalEmailManager replaced by ModalVerificatio…
vitormattos Feb 27, 2026
6f2630f
refactor(sign): remove ModalTokenManager replaced by ModalVerificatio…
vitormattos Feb 27, 2026
50fed0f
feat(sign): add unified ModalVerificationCode component
vitormattos Feb 27, 2026
f6a51fb
refactor(sign): use ModalVerificationCode instead of separate modals
vitormattos Feb 27, 2026
c5eba38
test(sign): remove ModalEmailManager.spec.js
vitormattos Feb 27, 2026
bb9f1b8
test(sign): remove ModalEmailManager.spec.ts
vitormattos Feb 27, 2026
62dd859
test(sign): remove ModalTokenManager.spec.js
vitormattos Feb 27, 2026
6631ee9
test(sign): remove dead SignFinalModals spec
vitormattos Feb 27, 2026
d63799c
test(sign): add ModalVerificationCode spec
vitormattos Feb 27, 2026
7ddd25c
test(sign): remove invalid $emit mock from Sign spec
vitormattos Feb 27, 2026
797353c
fix: return the kind of modal
vitormattos Feb 27, 2026
6b27c15
feat(sign): improve email verification step descriptions
vitormattos Feb 27, 2026
f46d89e
fix(ModalVerificationCode): keep modal open during signing request
vitormattos Mar 1, 2026
0c4b0d7
fix: change the button name at step
vitormattos Mar 1, 2026
8556dea
test(e2e): sign document with email token as unauthenticated signer
vitormattos Mar 1, 2026
9bca65a
chore(deps): add mailpit-api as dev dependency
vitormattos Mar 1, 2026
a093c6e
test(e2e): add mailpit helper for email-based test flows
vitormattos Mar 1, 2026
d5c7da6
test(e2e): move testDir to playwright/e2e
vitormattos Mar 1, 2026
7e724b1
fix: add spdx header
vitormattos Mar 1, 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
2 changes: 1 addition & 1 deletion lib/Controller/SignFileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ private function getCode(SignRequest $signRequest): DataResponse {
signMethodName: $this->request->getParam('signMethod', ''),
identify: $this->request->getParam('identify', ''),
);
$message = $this->l10n->t('The code to sign file was successfully requested.');
$message = $this->l10n->t('Verification code sent.');
$statusCode = Http::STATUS_OK;
} catch (\Throwable $th) {
$message = $th->getMessage();
Expand Down
23 changes: 23 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,19 +78,20 @@
"npm": "^11.3.0"
},
"devDependencies": {
"@playwright/test": "^1.58.1",
"@nextcloud/browserslist-config": "^3.1.2",
"@nextcloud/eslint-config": "^8.4.2",
"@nextcloud/stylelint-config": "^3.2.1",
"@nextcloud/vite-config": "^2.5.2",
"@pinia/testing": "^1.0.3",
"@playwright/test": "^1.58.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/vue": "^8.1.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vitest/coverage-v8": "^4.0.18",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"happy-dom": "^20.7.0",
"mailpit-api": "^1.7.2",
"openapi-typescript": "^7.13.0",
"typescript": "^5.9.3",
"vite": "^7.1.10",
Expand Down
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { defineConfig, devices } from '@playwright/test'
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './playwright',
testDir: './playwright/e2e',

/* Run tests in files in parallel */
fullyParallel: true,
Expand Down
85 changes: 85 additions & 0 deletions playwright/e2e/sign-email-token-unauthenticated.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

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

test('sign document with email token as unauthenticated signer', 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: true, mandatory: false },
{ name: 'email', enabled: true, mandatory: true, signatureMethods: { emailToken: { enabled: true } }, can_create_account: false },
]),
)

await page.goto('./apps/libresign')
await page.getByRole('button', { name: 'Upload from URL' }).click();
await page.getByRole('textbox', { name: 'URL of a PDF file' }).click();
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');
await page.getByRole('button', { name: 'Send' }).click();
await page.getByRole('button', { name: 'Add signer' }).click();
await page.getByRole('tab', { name: 'Email' }).click();
await page.getByPlaceholder('Email').click();
await page.getByPlaceholder('Email').fill('[email protected]');
await page.getByRole('option', { name: '[email protected]' }).click();
await page.getByRole('textbox', { name: 'Signer name' }).click();
await page.getByRole('textbox', { name: 'Signer name' }).press('ControlOrMeta+a');
await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 01');
await page.getByRole('button', { name: 'Save' }).click();

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

await page.getByRole('button', { name: 'Request signatures' }).click();
await page.getByRole('button', { name: 'Send' }).click();

// Logout before accessing the sign link to avoid session-related issues.
await page.getByRole('button', { name: 'Settings menu' }).click();
await page.getByRole('link', { name: 'Log out' }).click();

const email = await waitForEmailTo(mailpit, '[email protected]', 'LibreSign: There is a file for you to sign')
const signLink = extractSignLink(email.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('textbox', { name: 'Email' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('[email protected]');
await page.getByRole('button', { name: 'Send verification code' }).click();

const tokenEmail = await waitForEmailTo(mailpit, '[email protected]', 'LibreSign: Code to sign file')
const token = extractTokenFromEmail(tokenEmail.Text)
if (!token) throw new Error('Token not found in email')
await page.getByRole('textbox', { name: 'Enter your code' }).click();
await page.getByRole('textbox', { name: 'Enter your code' }).fill(token);
await page.getByRole('button', { name: 'Validate code' }).click();

await expect(page.getByRole('heading', { name: 'Signature confirmation' })).toBeVisible();
await expect(page.getByText('Step 3 of 3 - Signature')).toBeVisible();
await expect(page.getByText('Your identity has been')).toBeVisible();
await expect(page.getByText('You can now sign the document.')).toBeVisible();
await page.getByRole('button', { name: 'Sign document' }).click();
await page.waitForURL('**/validation/**');
await expect(page.getByText('This document is valid')).toBeVisible();
await expect(page.getByText('Congratulations you have')).toBeVisible();
});
2 changes: 1 addition & 1 deletion playwright/e2e/sign-herself-with-click-to-sign.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ test('sign herself with click to sign', async ({ page }) => {
await page.getByRole('button', { name: 'Send' }).click();
await page.getByRole('button', { name: 'Sign document' }).click();
await page.getByRole('button', { name: 'Sign the document.' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await page.getByRole('button', { name: 'Sign document' }).click();
await page.waitForURL('**/validation/**');
await expect(page.getByText('This document is valid')).toBeVisible();
await page.getByRole('button', { name: 'Expand details' }).click();
Expand Down
80 changes: 80 additions & 0 deletions playwright/support/mailpit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { MailpitClient } from 'mailpit-api'

export type { MailpitClient }

type Message = Awaited<ReturnType<MailpitClient['getMessageSummary']>>

/** Creates a MailpitClient using MAILPIT_URL (default: http://localhost:8025). */
export function createMailpitClient(): MailpitClient {
return new MailpitClient(process.env.MAILPIT_URL ?? 'http://localhost:8025')
}

/** Fetches the latest email sent to `toAddress`, optionally filtered by `subject`. */
export async function getLatestEmailTo(
client: MailpitClient,
toAddress: string,
subject?: string,
): Promise<Message> {
const query = subject
? `to:${toAddress} subject:"${subject}"`
: `to:${toAddress}`
const result = await client.searchMessages({ query })
if (!result.messages || result.messages.length === 0) {
throw new Error(`No email found for "${toAddress}"${subject ? ` with subject "${subject}"` : ''}`)
}
return await client.getMessageSummary(result.messages[0].ID)
}

/**
* Polls MailPit until an email matching `toAddress` (and optional `subject`) is found,
* or until `timeout` ms elapse. Checks every `interval` ms (defaults: 30 s / 1 s).
*/
export async function waitForEmailTo(
client: MailpitClient,
toAddress: string,
subject?: string,
options?: { timeout?: number; interval?: number },
): Promise<Message> {
const timeout = options?.timeout ?? 30_000
const interval = options?.interval ?? 1_000
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
try {
return await getLatestEmailTo(client, toAddress, subject)
} catch {
// email not arrived yet
}
await new Promise(resolve => setTimeout(resolve, interval))
}
throw new Error(
`Timeout (${timeout} ms) waiting for email to "${toAddress}"${
subject ? ` with subject "${subject}"` : ''
}`,
)
}

/** Extracts a LibreSign sign link from an email body matching /p/sign/{uuid}. */
export function extractSignLink(body: string): string | null {
const match = body.match(/\S+\/p\/sign\/[\w-]+/)
return match ? match[0] : null
}

/** Extracts a numeric token from an email body. Default pattern: 4-8 digit sequence. */
export function extractTokenFromEmail(
body: string,
pattern: RegExp = /Use this code to sign the document:[\s\S]*?(\d{6})/,
): string | null {
const match = body.match(pattern)
return match ? match[1] : null
}

/** Extracts the first URL from an email body (email.Text). */
export function extractLinkFromEmail(body: string): string | null {
const match = body.match(/https?:\/\/\S+/)
return match ? match[0] : null
}
90 changes: 89 additions & 1 deletion src/tests/views/SignPDF/Sign.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,15 @@ vi.mock('@nextcloud/initial-state', () => ({
}))

vi.mock('@nextcloud/capabilities', () => ({
getCapabilities: vi.fn(() => ({})),
getCapabilities: vi.fn(() => ({
libresign: {
config: {
'sign-elements': {
'can-create-signature': true,
},
},
},
})),
}))

vi.mock('vue-select', () => ({
Expand Down Expand Up @@ -857,12 +865,14 @@ describe('Sign.vue - signWithTokenCode', () => {
// VERIFY: Must send identify method, NOT signature method name
expect(submitSignatureMock).toHaveBeenCalledWith({
method: testCase.expectedIdentifyMethod, // 'whatsapp', 'sms', 'signal'
modalCode: 'token',
token: testCase.token,
})

// Double-check: Should NOT send the signature method key name
expect(submitSignatureMock).not.toHaveBeenCalledWith({
method: testCase.signatureMethodKey, // NOT 'whatsappToken', 'smsToken', etc
modalCode: 'token',
token: testCase.token,
})
}
Expand Down Expand Up @@ -935,6 +945,84 @@ describe('Sign.vue - signWithTokenCode', () => {
{ elementId: 201, fileId: 10, signRequestId: 501, type: 'signature' },
])
})

it('updates elements when signature is created dynamically', async () => {
const { default: realSign } = await import('../../../views/SignPDF/_partials/Sign.vue')
const { useSignStore } = await import('../../../store/sign.js')
const { useSignatureElementsStore } = await import('../../../store/signatureElements.js')

const signStore = useSignStore()
const signatureElementsStore = useSignatureElementsStore()

signStore.document = {
id: 1,
nodeType: 'envelope',
signers: [
{ signRequestId: 501, me: true },
],
files: [],
visibleElements: [
{ elementId: 201, signRequestId: 501, type: 'signature' },
],
}

// Initially, no signature exists
signatureElementsStore.signs.signature = {
id: 0,
type: '',
file: { url: '', nodeId: 0 },
starred: 0,
createdAt: '', // Empty createdAt means no signature
}

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: true,
NcPasswordField: true,
NcRichText: true,
},
mocks: {
$watch: vi.fn(),
},
},
})

// Initially, elements should be empty (no signature created)
expect(wrapper.vm.elements).toEqual([])
expect(wrapper.vm.hasSignatures).toBe(false)
expect(wrapper.vm.needCreateSignature).toBe(true)

// Now simulate creating a signature (like when user draws one)
signatureElementsStore.signs.signature = {
id: 1,
type: 'signature',
file: { url: '/sig.png', nodeId: 11623 },
starred: 0,
createdAt: '2024-01-01', // Now has a createdAt, signature exists
}

// Force Vue to update
await wrapper.vm.$nextTick()

// After signature is created, elements should include it
expect(wrapper.vm.elements).toEqual([
{ elementId: 201, signRequestId: 501, type: 'signature' },
])
expect(wrapper.vm.hasSignatures).toBe(true)
expect(wrapper.vm.needCreateSignature).toBe(false)
})
})

describe('Sign.vue - create signature modal', () => {
Expand Down
Loading
Loading