diff --git a/lib/Controller/SignFileController.php b/lib/Controller/SignFileController.php index f9b4ef7044..75b2a0fcae 100644 --- a/lib/Controller/SignFileController.php +++ b/lib/Controller/SignFileController.php @@ -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(); diff --git a/package-lock.json b/package-lock.json index 43b638a11d..f7c4dd8eff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "@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", @@ -8449,6 +8450,13 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-polyfill": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz", + "integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -10930,6 +10938,21 @@ "source-map-js": "^1.2.1" } }, + "node_modules/mailpit-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/mailpit-api/-/mailpit-api-1.7.2.tgz", + "integrity": "sha512-bk8D84CoCL4ztXx0a+fDspLkJacU+dMM41LDFlbPQoYpynMzHm4jrtM6ECOKmw30xovv0Ap5WO8mdAGOK65WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.13.5", + "partysocket": "^1.1.10", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", diff --git a/package.json b/package.json index b6feac9890..9ab395deb8 100644 --- a/package.json +++ b/package.json @@ -78,12 +78,12 @@ "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", @@ -91,6 +91,7 @@ "@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", diff --git a/playwright.config.ts b/playwright.config.ts index 55e760738b..654f774181 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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, diff --git a/playwright/e2e/sign-email-token-unauthenticated.spec.ts b/playwright/e2e/sign-email-token-unauthenticated.spec.ts new file mode 100644 index 0000000000..4f1eb4b474 --- /dev/null +++ b/playwright/e2e/sign-email-token-unauthenticated.spec.ts @@ -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('signer01@libresign.coop'); + await page.getByRole('option', { name: 'signer01@libresign.coop' }).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, 'signer01@libresign.coop', '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('signer01@libresign.coop'); + await page.getByRole('button', { name: 'Send verification code' }).click(); + + const tokenEmail = await waitForEmailTo(mailpit, 'signer01@libresign.coop', '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(); +}); diff --git a/playwright/e2e/sign-herself-with-click-to-sign.spec.ts b/playwright/e2e/sign-herself-with-click-to-sign.spec.ts index 58d1042c92..067eb0d508 100644 --- a/playwright/e2e/sign-herself-with-click-to-sign.spec.ts +++ b/playwright/e2e/sign-herself-with-click-to-sign.spec.ts @@ -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(); diff --git a/playwright/support/mailpit.ts b/playwright/support/mailpit.ts new file mode 100644 index 0000000000..838a6623e6 --- /dev/null +++ b/playwright/support/mailpit.ts @@ -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> + +/** 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 { + 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 { + 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 +} diff --git a/src/tests/views/SignPDF/Sign.spec.ts b/src/tests/views/SignPDF/Sign.spec.ts index af7aeb1962..cec48adc5a 100644 --- a/src/tests/views/SignPDF/Sign.spec.ts +++ b/src/tests/views/SignPDF/Sign.spec.ts @@ -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', () => ({ @@ -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, }) } @@ -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', () => { diff --git a/src/tests/views/SignPDF/_partials/ModalEmailManager.spec.ts b/src/tests/views/SignPDF/_partials/ModalEmailManager.spec.ts deleted file mode 100644 index cd01207263..0000000000 --- a/src/tests/views/SignPDF/_partials/ModalEmailManager.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 LibreSign contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { mount } from '@vue/test-utils' - -const loadStateMock = vi.fn() - -vi.mock('@nextcloud/initial-state', () => ({ - loadState: (...args: unknown[]) => loadStateMock(...args), -})) - -vi.mock('../../../../store/sign.js', () => ({ - useSignStore: () => ({ - document: { - fileId: 1, - signers: [], - }, - }), -})) - -vi.mock('../../../../store/signMethods.js', () => ({ - useSignMethodsStore: () => ({ - settings: { - emailToken: { - hasConfirmCode: false, - hashOfEmail: '', - identifyMethod: 'email', - }, - }, - blurredEmail: () => '', - setEmailToken: vi.fn(), - setHasEmailConfirmCode: vi.fn(), - }), -})) - -vi.mock('@nextcloud/l10n', () => ({ - t: vi.fn((_app: string, text: string) => text), - translate: vi.fn((_app: string, text: string) => text), - translatePlural: vi.fn((_app: string, singular: string, plural: string, count: number) => (count === 1 ? singular : plural)), - n: vi.fn((_app: string, singular: string, plural: string, count: number) => (count === 1 ? singular : plural)), - isRTL: vi.fn(() => false), - getLanguage: vi.fn(() => 'en'), - getLocale: vi.fn(() => 'en'), -})) - -let ModalEmailManager: unknown - -beforeAll(async () => { - ;({ default: ModalEmailManager } = await import('../../../../views/SignPDF/_partials/ModalEmailManager.vue')) -}) - -describe('ModalEmailManager', () => { - beforeEach(() => { - loadStateMock.mockReset() - }) - - it('registers icon wrapper and exposes mdi icon paths used in template', () => { - loadStateMock.mockImplementation((_app: string, _key: string, fallback: unknown) => fallback) - - const wrapper = mount(ModalEmailManager as never, { - global: { - stubs: { - NcDialog: { template: '
' }, - NcTextField: { template: '
' }, - NcButton: { template: '' }, - NcLoadingIcon: true, - NcIconSvgWrapper: { name: 'NcIconSvgWrapper', props: ['path'], template: '' }, - }, - }, - }) - - expect(wrapper.vm.$options.components.NcIconSvgWrapper).toBeTruthy() - expect(wrapper.vm.mdiFormTextboxPassword).toBeTruthy() - expect(wrapper.vm.mdiEmail).toBeTruthy() - }) -}) diff --git a/src/tests/views/SignPDF/_partials/ModalVerificationCode.spec.ts b/src/tests/views/SignPDF/_partials/ModalVerificationCode.spec.ts new file mode 100644 index 0000000000..c70c3e9337 --- /dev/null +++ b/src/tests/views/SignPDF/_partials/ModalVerificationCode.spec.ts @@ -0,0 +1,445 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { mount } from '@vue/test-utils' +import ModalVerificationCode from '@/views/SignPDF/_partials/ModalVerificationCode.vue' +import { useSignMethodsStore } from '@/store/signMethods.js' +import { useSignStore } from '@/store/sign.js' + +// Mock axios +vi.mock('@nextcloud/axios', () => ({ + default: vi.fn().mockResolvedValue({ data: { ocs: { data: {} } } }), + post: vi.fn().mockResolvedValue({ data: { ocs: { data: { message: 'Code sent' } } } }), +})) + +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: vi.fn((path: string) => `/ocs/v2.php/apps/libresign${path}`), +})) + +vi.mock('@nextcloud/initial-state', () => ({ + loadState: vi.fn(() => 6), +})) + +vi.mock('@nextcloud/dialogs', () => ({ + showError: vi.fn(), + showSuccess: vi.fn(), +})) + +vi.mock('@nextcloud/password-confirmation', () => ({ + confirmPassword: vi.fn().mockResolvedValue(true), +})) + +describe('ModalVerificationCode (email mode)', () => { + let wrapper: ReturnType + let signMethodsStore: ReturnType + + const stubs = { + NcDialog: { template: '
' }, + NcTextField: { template: '' }, + NcButton: { template: '' }, + NcLoadingIcon: { template: '
' }, + NcIconSvgWrapper: { template: '
' }, + } + + const stubsWithActions = { + ...stubs, + NcDialog: { template: '
' }, + } + + const stubsWithName = { + ...stubs, + NcDialog: { props: ['name'], template: '
' }, + } + + const mountEmail = (extraProps = {}) => mount(ModalVerificationCode, { + props: { mode: 'email', ...extraProps }, + global: { stubs }, + }) + + beforeEach(() => { + setActivePinia(createPinia()) + signMethodsStore = useSignMethodsStore() + signMethodsStore.modal.emailToken = true + signMethodsStore.settings.emailToken = { + hasConfirmCode: false, + hashOfEmail: '5d41402abc4b2a76b9719d911017c592', + blurredEmail: 'u***@email.com', + } + }) + + it('displays progress indicator on step 1', async () => { + wrapper = mountEmail() + + const progressIndicator = wrapper.find('.progress-indicator') + expect(progressIndicator.exists()).toBe(true) + expect(progressIndicator.text()).toContain('Step 1 of 3 - Email verification') + }) + + it('displays explanatory text on step 1', async () => { + wrapper = mountEmail() + + const explanation = wrapper.find('.step-explanation') + expect(explanation.exists()).toBe(true) + expect(explanation.text()).toContain('verify your identity') + expect(explanation.text()).toContain('verification code') + }) + + it('shows correct dialog title for step 1', async () => { + wrapper = mount(ModalVerificationCode, { + props: { mode: 'email' }, + global: { stubs: stubsWithName }, + }) + + expect(wrapper.vm.dialogTitle).toBe('Email verification') + }) + + it('renders step-content class for styling', async () => { + wrapper = mountEmail() + + expect(wrapper.find('.step-content').exists()).toBe(true) + }) + + it('shows contact on step 2', async () => { + signMethodsStore.settings.emailToken.hasConfirmCode = true + wrapper = mountEmail() + + const contactDisplay = wrapper.find('.contact-display') + expect(contactDisplay.exists()).toBe(true) + expect(contactDisplay.text()).toContain('u***@email.com') + }) + + it('shows correct state on step 1', async () => { + wrapper = mount(ModalVerificationCode, { + props: { mode: 'email' }, + global: { stubs: stubsWithActions }, + }) + + expect(wrapper.vm.signMethodsStore.settings.emailToken.hasConfirmCode).toBe(false) + expect(wrapper.vm.identityVerified).toBe(false) + }) + + it('shows correct state on step 2', async () => { + signMethodsStore.settings.emailToken.hasConfirmCode = true + wrapper = mount(ModalVerificationCode, { + props: { mode: 'email' }, + global: { stubs: stubsWithActions }, + }) + + expect(wrapper.vm.signMethodsStore.settings.emailToken.hasConfirmCode).toBe(true) + expect(wrapper.vm.identityVerified).toBe(false) + }) + + it('updates to step 3 when identityVerified is true', async () => { + signMethodsStore.settings.emailToken.hasConfirmCode = true + wrapper = mountEmail() + + wrapper.vm.identityVerified = true + await wrapper.vm.$nextTick() + + expect(wrapper.find('.progress-indicator').text()).toContain('Step 3 of 3 - Signature confirmation') + }) + + it('shows verification success message on step 3', async () => { + signMethodsStore.settings.emailToken.hasConfirmCode = true + wrapper = mountEmail() + + wrapper.vm.identityVerified = true + await wrapper.vm.$nextTick() + + const verificationSuccess = wrapper.find('.verification-success') + expect(verificationSuccess.exists()).toBe(true) + expect(verificationSuccess.text()).toContain('Your identity has been verified') + expect(verificationSuccess.text()).toContain('You can now sign the document') + }) + + it('shows correct dialog title on step 3', async () => { + signMethodsStore.settings.emailToken.hasConfirmCode = true + wrapper = mount(ModalVerificationCode, { + props: { mode: 'email' }, + global: { stubs: stubsWithActions }, + }) + + wrapper.vm.identityVerified = true + await wrapper.vm.$nextTick() + + expect(wrapper.vm.dialogTitle).toBe('Signature confirmation') + }) + + it('sendCode sets identityVerified to true', async () => { + signMethodsStore.settings.emailToken.hasConfirmCode = true + wrapper = mountEmail() + + wrapper.vm.token = '123456' + wrapper.vm.sendCode() + + expect(wrapper.vm.identityVerified).toBe(true) + }) + + it('requestNewCode resets identityVerified', async () => { + wrapper = mountEmail() + + wrapper.vm.identityVerified = true + wrapper.vm.requestNewCode() + + expect(wrapper.vm.identityVerified).toBe(false) + }) + + it('signDocument sets loading=true, emits change with token and does NOT self-close', async () => { + signMethodsStore.settings.emailToken.hasConfirmCode = true + wrapper = mountEmail() + + wrapper.vm.token = '123456' + wrapper.vm.identityVerified = true + await wrapper.vm.$nextTick() + + wrapper.vm.signDocument() + + expect(wrapper.vm.loading).toBe(true) + expect(wrapper.emitted('change')).toBeTruthy() + expect(wrapper.emitted('change')![0]).toEqual(['123456']) + expect(wrapper.emitted('close')).toBeFalsy() + }) + + it('signStore.errors watcher resets loading when signing fails', async () => { + signMethodsStore.settings.emailToken.hasConfirmCode = true + wrapper = mountEmail() + + const signStore = useSignStore() + wrapper.vm.loading = true + + signStore.setSigningErrors([{ message: 'Signing failed' }]) + await wrapper.vm.$nextTick() + + expect(wrapper.vm.loading).toBe(false) + }) + + it('signStore.errors watcher does NOT reset loading when loading is already false', async () => { + signMethodsStore.settings.emailToken.hasConfirmCode = true + wrapper = mountEmail() + + const signStore = useSignStore() + wrapper.vm.loading = false + + signStore.setSigningErrors([{ message: 'Signing failed' }]) + await wrapper.vm.$nextTick() + + // loading was already false, no change expected + expect(wrapper.vm.loading).toBe(false) + }) +}) + +describe('ModalVerificationCode (token mode)', () => { + let wrapper: ReturnType + let signMethodsStore: ReturnType + + const stubs = { + NcDialog: { template: '
' }, + NcTextField: { template: '' }, + NcButton: { template: '' }, + NcLoadingIcon: { template: '
' }, + NcIconSvgWrapper: { template: '
' }, + } + + const stubsWithActions = { + ...stubs, + NcDialog: { template: '
' }, + } + + const stubsWithName = { + ...stubs, + NcDialog: { props: ['name'], template: '
' }, + } + + const mountToken = (extraProps = {}) => mount(ModalVerificationCode, { + props: { mode: 'token', phoneNumber: '', ...extraProps }, + global: { stubs }, + }) + + beforeEach(() => { + setActivePinia(createPinia()) + signMethodsStore = useSignMethodsStore() + signMethodsStore.modal.token = true + signMethodsStore.settings.smsToken = { + identifyMethod: 'email', + } + }) + + it('displays progress indicator on step 1', async () => { + wrapper = mountToken() + + const progressIndicator = wrapper.find('.progress-indicator') + expect(progressIndicator.exists()).toBe(true) + expect(progressIndicator.text()).toContain('Step 1 of 3 - Identity verification') + }) + + it('displays generic explanatory text (not phone-specific) on step 1', async () => { + wrapper = mountToken() + + const explanation = wrapper.find('.step-explanation') + expect(explanation.exists()).toBe(true) + expect(explanation.text()).toContain('verify your identity') + expect(explanation.text()).toContain('contact information') + expect(explanation.text()).not.toContain('phone') + }) + + it('shows correct dialog title for step 1', async () => { + wrapper = mount(ModalVerificationCode, { + props: { mode: 'token', phoneNumber: '' }, + global: { stubs: stubsWithName }, + }) + + expect(wrapper.vm.dialogTitle).toBe('Identity verification') + }) + + it('uses generic "Contact information" label, not phone-specific', async () => { + wrapper = mount(ModalVerificationCode, { + props: { mode: 'token', phoneNumber: '' }, + global: { + stubs: { + ...stubs, + NcTextField: { props: ['label'], template: '' }, + }, + }, + }) + + expect(wrapper.html()).toContain('Contact information') + expect(wrapper.html()).not.toContain('Phone number') + }) + + it('renders step-content class for styling', async () => { + wrapper = mountToken() + + expect(wrapper.find('.step-content').exists()).toBe(true) + }) + + it('displays progress with correct 3-step numbering', async () => { + wrapper = mountToken() + + expect(wrapper.vm.progressText).toContain('of 3') + expect(wrapper.vm.progressText).not.toContain('of 2') + }) + + it('updates to step 2 when tokenRequested is true', async () => { + wrapper = mountToken({ phoneNumber: '+5511999999999' }) + + wrapper.vm.tokenRequested = true + await wrapper.vm.$nextTick() + + expect(wrapper.find('.progress-indicator').text()).toContain('Step 2 of 3 - Code validation') + }) + + it('updates to step 3 when identityVerified is true', async () => { + wrapper = mountToken({ phoneNumber: '+5511999999999' }) + + wrapper.vm.tokenRequested = true + wrapper.vm.identityVerified = true + await wrapper.vm.$nextTick() + + expect(wrapper.find('.progress-indicator').text()).toContain('Step 3 of 3 - Signature confirmation') + }) + + it('shows correct state on step 1', async () => { + wrapper = mount(ModalVerificationCode, { + props: { mode: 'token', phoneNumber: '' }, + global: { stubs: stubsWithActions }, + }) + + expect(wrapper.vm.tokenRequested).toBe(false) + expect(wrapper.vm.identityVerified).toBe(false) + }) + + it('shows correct state on step 2', async () => { + wrapper = mount(ModalVerificationCode, { + props: { mode: 'token', phoneNumber: '+5511999999999' }, + global: { stubs: stubsWithActions }, + }) + + wrapper.vm.tokenRequested = true + await wrapper.vm.$nextTick() + + expect(wrapper.vm.tokenRequested).toBe(true) + expect(wrapper.vm.identityVerified).toBe(false) + }) + + it('shows verification success message on step 3', async () => { + wrapper = mountToken({ phoneNumber: '+5511999999999' }) + + wrapper.vm.tokenRequested = true + wrapper.vm.identityVerified = true + await wrapper.vm.$nextTick() + + const verificationSuccess = wrapper.find('.verification-success') + expect(verificationSuccess.exists()).toBe(true) + expect(verificationSuccess.text()).toContain('Your identity has been verified') + expect(verificationSuccess.text()).toContain('You can now sign the document') + }) + + it('shows correct dialog title on step 3', async () => { + wrapper = mount(ModalVerificationCode, { + props: { mode: 'token', phoneNumber: '+5511999999999' }, + global: { stubs: stubsWithActions }, + }) + + wrapper.vm.tokenRequested = true + wrapper.vm.identityVerified = true + await wrapper.vm.$nextTick() + + expect(wrapper.vm.dialogTitle).toBe('Signature confirmation') + }) + + it('sendCode sets identityVerified to true', async () => { + wrapper = mountToken({ phoneNumber: '+5511999999999' }) + + wrapper.vm.tokenRequested = true + wrapper.vm.token = '123456' + wrapper.vm.sendCode() + + expect(wrapper.vm.identityVerified).toBe(true) + }) + + it('requestNewCode resets tokenRequested state', async () => { + wrapper = mountToken({ phoneNumber: '+5511999999999' }) + + wrapper.vm.tokenRequested = true + wrapper.vm.token = '123456' + wrapper.vm.identityVerified = true + + wrapper.vm.requestNewCode() + + expect(wrapper.vm.tokenRequested).toBe(false) + expect(wrapper.vm.token).toBe('') + expect(wrapper.vm.identityVerified).toBe(false) + }) + + it('signDocument sets loading=true, emits change with token and does NOT self-close', async () => { + wrapper = mountToken({ phoneNumber: '+5511999999999' }) + + wrapper.vm.tokenRequested = true + wrapper.vm.token = '654321' + wrapper.vm.identityVerified = true + await wrapper.vm.$nextTick() + + wrapper.vm.signDocument() + + expect(wrapper.vm.loading).toBe(true) + expect(wrapper.emitted('change')).toBeTruthy() + expect(wrapper.emitted('change')![0]).toEqual(['654321']) + expect(wrapper.emitted('close')).toBeFalsy() + }) + + it('signStore.errors watcher resets loading when signing fails', async () => { + wrapper = mountToken({ phoneNumber: '+5511999999999' }) + + const signStore = useSignStore() + wrapper.vm.loading = true + + signStore.setSigningErrors([{ message: 'Signing failed' }]) + await wrapper.vm.$nextTick() + + expect(wrapper.vm.loading).toBe(false) + }) +}) diff --git a/src/views/SignPDF/_partials/ModalEmailManager.vue b/src/views/SignPDF/_partials/ModalEmailManager.vue deleted file mode 100644 index bf7a3072b7..0000000000 --- a/src/views/SignPDF/_partials/ModalEmailManager.vue +++ /dev/null @@ -1,231 +0,0 @@ - - - - - - diff --git a/src/views/SignPDF/_partials/ModalTokenManager.vue b/src/views/SignPDF/_partials/ModalTokenManager.vue deleted file mode 100644 index ecde2cd3d1..0000000000 --- a/src/views/SignPDF/_partials/ModalTokenManager.vue +++ /dev/null @@ -1,208 +0,0 @@ - - - - - - diff --git a/src/views/SignPDF/_partials/ModalVerificationCode.vue b/src/views/SignPDF/_partials/ModalVerificationCode.vue new file mode 100644 index 0000000000..a5c1fc81d6 --- /dev/null +++ b/src/views/SignPDF/_partials/ModalVerificationCode.vue @@ -0,0 +1,450 @@ + + + + + + diff --git a/src/views/SignPDF/_partials/Sign.vue b/src/views/SignPDF/_partials/Sign.vue index b4a0ecad59..f00ed830e7 100644 --- a/src/views/SignPDF/_partials/Sign.vue +++ b/src/views/SignPDF/_partials/Sign.vue @@ -62,7 +62,7 @@
@@ -73,7 +73,11 @@ - {{ t('libresign', 'Confirm your signature') }} + +

+ {{ t('libresign', 'Confirm that you want to sign this document.') }} +

+
@@ -102,9 +106,15 @@ - {{ t('libresign', 'Subscription password.') }} + +

+ {{ t('libresign', 'Enter your signature password to sign the document.') }} +

+
- + {{ t('libresign', 'Forgot password?') }} - {{ t('libresign', 'Sign the document.') }} + {{ t('libresign', 'Sign document') }}
@@ -134,12 +144,14 @@ :useModal="true" :errors="signStore.errors" @certificate:uploaded="onSignatureFileCreated" /> - -
@@ -162,8 +174,7 @@ import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import NcPasswordField from '@nextcloud/vue/components/NcPasswordField' import NcRichText from '@nextcloud/vue/components/NcRichText' -import EmailManager from './ModalEmailManager.vue' -import TokenManager from './ModalTokenManager.vue' +import ModalVerificationCode from './ModalVerificationCode.vue' import Draw from '../../../components/Draw/Draw.vue' import Documents from '../../../views/Account/partials/Documents.vue' import Signatures from '../../../views/Account/partials/Signatures.vue' @@ -191,8 +202,7 @@ export default { NcPasswordField, NcRichText, CreatePassword, - TokenManager, - EmailManager, + ModalVerificationCode, UploadCertificate, Documents, Signatures, @@ -247,8 +257,10 @@ export default { const visibleElements = getVisibleElementsFromDocument(document) .filter(row => { - return this.signatureElementsStore.hasSignatureOfType(row.type) - && signRequestIds.has(String(row.signRequestId)) + // Access signatureElementsStore.signs[row.type] directly to ensure reactivity + const signatureData = this.signatureElementsStore.signs[row.type] + const hasSignature = signatureData && signatureData.createdAt && signatureData.createdAt.length > 0 + return hasSignature && signRequestIds.has(String(row.signRequestId)) }) return visibleElements }, @@ -427,12 +439,14 @@ export default { await this.submitSignature({ method: identifyMethod, + modalCode: 'token', token, }) }, async signWithEmailToken() { await this.submitSignature({ method: this.signMethodsStore.settings.emailToken.identifyMethod, + modalCode: 'emailToken', token: this.signMethodsStore.settings.emailToken.token, }) }, @@ -473,13 +487,13 @@ export default { ) if (result.status === 'signingInProgress') { - this.actionHandler.closeModal(methodConfig.method) + this.actionHandler.closeModal(methodConfig.modalCode || methodConfig.method) this.$emit('signing-started', { signRequestUuid: this.signRequestUuid, async: true, }) } else if (result.status === 'signed') { - this.actionHandler.closeModal(methodConfig.method) + this.actionHandler.closeModal(methodConfig.modalCode || methodConfig.method) this.sidebarStore.hideSidebar() this.$emit('signed', { ...result.data, @@ -544,6 +558,29 @@ export default {