Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
674 changes: 50 additions & 624 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.36.2",
"@fontsource/dancing-script": "^5.2.8",
"@libresign/pdf-elements": "^1.0.2",
"@libresign/pdf-elements": "^1.1.0",
"@marionebl/option": "^1.0.8",
"@mdi/js": "^7.4.47",
"@mdi/svg": "^7.4.47",
Expand Down
110 changes: 110 additions & 0 deletions playwright/e2e/sign-herself-with-drawn-signature.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* 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'

test('sign herself with drawn signature', 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: true, signatureMethods: { clickToSign: { enabled: true } } },
{ name: 'email', enabled: false, mandatory: 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('https://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.getByPlaceholder('Account').click();
await page.getByPlaceholder('Account').fill('a');
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('Admin Name');


await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'Setup signature positions' }).click();
await expect(page.getByLabel('Page 1 of 1.')).toBeVisible();
await page.getByLabel('Signature positions').getByRole('link', { name: 'Edit signer Admin Name' }).click();

await expect(page.getByText('Click on the place you want to add.')).toBeVisible();

// Placing a signature element on the PDF canvas requires three steps:
// 1. hover() triggers handleMouseMove, which sets previewVisible=true inside a
// requestAnimationFrame callback.
// 2. Waiting for .preview-element confirms the rAF ran. Without this, finishAdding()
// (bound to mouseup on document) returns early because previewVisible is still false.
// 3. click() fires mouseup on the document, which triggers finishAdding() and places
// the element at the current preview position.
const overlay = page.getByLabel('Page 1 of 1. Press Enter or Space to place the signature here.')
await overlay.hover()
await page.getByLabel('Signature positions').locator('.preview-element').first().waitFor({ state: 'visible' })
await overlay.click()
await expect(
page.getByLabel('Signature positions').getByRole('img', { name: 'Signature position for Admin Name' })
).toBeVisible()

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

await expect(
page.getByLabel('PDF document to sign').getByRole('img', { name: 'Signature position for Admin Name' })
).toBeVisible()

// If a signature already exists from a previous run, delete it before creating a new one
const deleteSignatureBtn = page.getByRole('button', { name: 'Delete signature' })
await deleteSignatureBtn.waitFor({ state: 'visible', timeout: 3000 }).catch(() => null)
if (await deleteSignatureBtn.isVisible()) {
await deleteSignatureBtn.click()
}

await page.getByRole('button', { name: 'Define your signature.' }).click();

await page.getByRole('dialog', { name: 'Customize your signatures' }).locator('canvas').click({
position: {
x: 156,
y: 132
}
});
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('heading', { name: 'Confirm your signature' })).toBeVisible();
await expect(page.getByRole('img', { name: 'Signature preview' })).toBeVisible();
await page.getByLabel('Confirm your signature').getByRole('button', { name: 'Save' }).click();

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()
await page.getByRole('button', { name: 'Expand details' }).click()
await page.getByRole('button', { name: 'Expand validation status', exact: true }).click()
await expect(page.getByRole('link', { name: 'Document integrity verified' })).toBeVisible()
await page.getByRole('button', { name: 'Expand document certification', exact: true }).click()
await expect(page.getByRole('link', { name: 'Document has not been modified after signing' })).toBeVisible()
});
18 changes: 18 additions & 0 deletions src/components/Draw/Editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
:aria-label="t('libresign', 'Choose color')" />
</NcColorPicker>
</div>
<!-- TRANSLATORS Accessible label for the button that clears the current drawing from the canvas. Does not delete any saved file. -->
<NcButton :aria-label="t('libresign', 'Delete')"
@click="clear">
<template #icon>
Expand All @@ -26,8 +27,14 @@
</NcButton>
</div>
<div ref="canvasWrapper" class="canvas-wrapper">
<p class="sr-only">
<!-- TRANSLATORS Screen-reader-only instruction for the signature drawing canvas. "Text" and "Upload" must match the translated labels of the other two tabs in this dialog. -->
{{ t('libresign', 'Drawing area. Use a mouse or touch screen to draw your signature. If you cannot draw, use the Text or Upload tabs instead.') }}
</p>
<canvas ref="canvas"
class="canvas"
:aria-label="t('libresign', 'Draw your signature here')"
role="img"
_width="10px"
_height="10px" />
</div>
Expand Down Expand Up @@ -253,4 +260,15 @@ img{
width: 100%;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
18 changes: 18 additions & 0 deletions src/components/PdfEditor/PdfEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
:init-files="files"
:init-file-names="fileNames"
:page-count-format="t('libresign', '{currentPage} of {totalPages}')"
:page-aria-label="getPageAriaLabel"
:auto-fit-zoom="true"
:read-only="readOnly"
:emit-object-click="true"
Expand Down Expand Up @@ -130,6 +131,23 @@ export default {
},
methods: {
t,
getPageAriaLabel({ docIndex, docName, totalDocs, pageNumber, totalPages, isAddingMode }) {
const docNumber = docIndex + 1
if (totalDocs > 1 && isAddingMode) {
// TRANSLATORS Accessible label for a PDF page overlay when placing a signature in a multi-document envelope. {docNumber} is the current document number, {totalDocs} is the total number of documents, {docName} is the document file name, {pageNumber} is the current page, {totalPages} is the total pages.
return t('libresign', 'Document {docNumber} of {totalDocs} ({docName}), page {pageNumber} of {totalPages}. Press Enter or Space to place the signature here.', { docNumber, totalDocs, docName, pageNumber, totalPages })
}
if (totalDocs > 1) {
// TRANSLATORS Accessible label for a PDF page in a multi-document envelope (read-only mode). {docNumber} is the current document number, {totalDocs} is the total number of documents, {docName} is the document file name, {pageNumber} is the current page, {totalPages} is the total pages.
return t('libresign', 'Document {docNumber} of {totalDocs} ({docName}), page {pageNumber} of {totalPages}.', { docNumber, totalDocs, docName, pageNumber, totalPages })
}
if (isAddingMode) {
// TRANSLATORS Accessible label for a PDF page overlay when placing a signature in a single document. {pageNumber} is the current page, {totalPages} is the total number of pages.
return t('libresign', 'Page {pageNumber} of {totalPages}. Press Enter or Space to place the signature here.', { pageNumber, totalPages })
}
// TRANSLATORS Accessible label for a PDF page in a single document (read-only mode). {pageNumber} is the current page, {totalPages} is the total number of pages.
return t('libresign', 'Page {pageNumber} of {totalPages}.', { pageNumber, totalPages })
},
endInit(event) {
this.$nextTick(async () => {
const shouldAutoFit = this.$refs.pdfElements?.autoFitZoom
Expand Down
12 changes: 10 additions & 2 deletions src/components/PdfEditor/SignatureBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="signature-box" :style="boxStyle">
<span class="label">{{ label }}</span>
<div class="signature-box"
:style="boxStyle"
role="img"
:aria-label="signatureBoxAriaLabel">
<span class="label" aria-hidden="true">{{ label }}</span>
</div>
</template>

<script>
import { t } from '@nextcloud/l10n'
import { usernameToColor } from '@nextcloud/vue/functions/usernameToColor'

export default {
Expand All @@ -24,6 +28,10 @@ export default {
},
},
computed: {
signatureBoxAriaLabel() {
// TRANSLATORS Accessible label for a placed signature box on the PDF. {name} is the signer's display name.
return t('libresign', 'Signature position for {name}', { name: this.label })
},
boxStyle() {
const signer = this.signer || {}
const seed = signer.displayName || signer.name || signer.email || signer.id || this.label
Expand Down
7 changes: 7 additions & 0 deletions src/components/PreviewSignature/PreviewSignature.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<div v-show="isLoaded" class="wrapper">
<img v-show="isLoaded"
:src="imageData"
:alt="alt"
:style="{
width,
height,
Expand Down Expand Up @@ -41,6 +42,12 @@ export default {
required: false,
default: '',
},
alt: {
type: String,
required: false,
// TRANSLATORS Alt text for an image showing the user's handwritten, typed, or uploaded signature. Used as fallback when the parent component does not pass a more specific description, for example "Current signature" or "Confirm your initials".
default: () => t('libresign', 'Signature preview'),
},
},
data() {
return {
Expand Down
24 changes: 22 additions & 2 deletions src/components/Request/VisibleElements.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,26 @@
<div v-else class="visible-elements-container">
<div class="sign-details">
<div class="modal_name">
<NcChip :text="statusLabel" :variant="isDraft ? 'warning' : 'primary'" no-close />
<NcChip :text="statusLabel"
:variant="isDraft ? 'warning' : 'primary'"
:aria-label="t('libresign', 'Document status: {status}', { status: statusLabel })"
no-close />
<h2 class="name">{{ document.name }}</h2>
</div>
<span role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only">
<template v-if="!signerSelected">{{ t('libresign', 'Select a signer to set their signature position') }}</template>
</span>
<p v-if="!signerSelected">
<NcNoteCard type="info"
:text="t('libresign', 'Select a signer to set their signature position')" />
</p>
<ul class="view-sign-detail__sidebar">
<li v-if="signerSelected"
:class="{ tip: signerSelected }">
{{ t('libresign', 'Click on the place you want to add.') }}
<span>{{ t('libresign', 'Click on the place you want to add.') }}</span>
<NcButton variant="primary"
@click="stopAddSigner">
{{ t('libresign', 'Cancel') }}
Expand Down Expand Up @@ -533,6 +542,17 @@ export default {
border-radius: 4px;
text-align: center;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
}
}
</style>
26 changes: 25 additions & 1 deletion src/components/Signers/Signer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,31 @@
<template>
<NcListItem ref="listItem"
:name="signerName"
:link-aria-label="signerLinkAriaLabel"
:counter-number="counterNumber"
:counter-type="counterType"
:force-display-actions="true"
:class="signerClass"
:title="disabledTooltip"
:aria-disabled="isMethodDisabled || signer.signed ? true : undefined"
@click="signerClickAction">
<template #icon>
<NcAvatar :size="44" :display-name="signer.displayName" />
<NcAvatar :size="44" :display-name="signer.displayName" aria-hidden="true" />
</template>
<template #subname>
<div class="signer-subname">
<NcChip v-for="method in identifyMethodsNames"
:key="method"
:text="method"
:aria-label="t('libresign', 'Identification method: {method}', { method })"
:no-close="true" />
<NcChip :text="signer.statusText"
:variant="chipType"
:icon-path="statusIconPath"
:aria-label="t('libresign', 'Signer status: {status}', { status: signer.statusText })"
:no-close="true"
class="signer-status-chip" />
<span v-if="disabledTooltip" class="sr-only">{{ disabledTooltip }}</span>
</div>
</template>
<template #extra>
Expand Down Expand Up @@ -187,6 +192,14 @@ export default {
return 'secondary'
}
},
signerLinkAriaLabel() {
if (this.signer.signed) {
// TRANSLATORS Accessible label for a signed signer list item. {name} is the signer's display name.
return t('libresign', 'Signer {name} (already signed)', { name: this.signerName })
}
// TRANSLATORS Accessible label for a signer list item. {name} is the signer's display name.
return t('libresign', 'Edit signer {name}', { name: this.signerName })
},
statusIconPath() {
switch (this.signer.status) {
case SIGN_REQUEST_STATUS.SIGNED:
Expand Down Expand Up @@ -263,6 +276,17 @@ export default {
opacity: 1;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.signer-signed .drag-handle {
cursor: not-allowed;
opacity: 0.3;
Expand Down
Loading
Loading