diff --git a/appinfo/info.xml b/appinfo/info.xml index e6b6adfc6f..e3689ca04e 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -27,7 +27,7 @@ If your organization uses LibreSign, support its development: For enterprise support, contact LibreCode: https://libresign.coop ]]> - 16.0.0-dev.1 + 16.0.0-dev.2 agpl LibreCode diff --git a/lib/Activity/Listener.php b/lib/Activity/Listener.php index 9c0969fd14..0f4e1b12fc 100644 --- a/lib/Activity/Listener.php +++ b/lib/Activity/Listener.php @@ -222,12 +222,20 @@ protected function generateCanceledActivity( * @return array{type: 'file', id: string, name: string, path: string, link: string} */ protected function getFileParameter(SignRequest $signRequest, FileEntity $libreSignFile): array { + if ($signRequest->isObserver()) { + $link = $this->url->linkToRouteAbsolute('libresign.page.validationFilePublic', [ + 'uuid' => $libreSignFile->getUuid(), + ]); + } else { + $link = $this->url->linkToRouteAbsolute('libresign.page.sign', ['uuid' => $signRequest->getUuid()]); + } + return [ 'type' => 'file', 'id' => (string)$libreSignFile->getNodeId(), 'name' => $libreSignFile->getName(), 'path' => $libreSignFile->getName(), - 'link' => $this->url->linkToRouteAbsolute('libresign.page.sign', ['uuid' => $signRequest->getUuid()]), + 'link' => $link, ]; } diff --git a/lib/Db/SignRequest.php b/lib/Db/SignRequest.php index 3c4e6da98a..0b9b68a544 100644 --- a/lib/Db/SignRequest.php +++ b/lib/Db/SignRequest.php @@ -8,6 +8,7 @@ namespace OCA\Libresign\Db; +use OCA\Libresign\Enum\ParticipantRole; use OCA\Libresign\Enum\SignRequestStatus; use OCP\AppFramework\Db\Entity; use OCP\DB\Types; @@ -37,6 +38,8 @@ * @method int getSigningOrder() * @method void setStatus(int $status) * @method int getStatus() + * @method void setParticipantRole(string $participantRole) + * @method string getParticipantRole() */ class SignRequest extends Entity { protected ?int $fileId = null; @@ -50,6 +53,7 @@ class SignRequest extends Entity { protected int $docmdpLevel = 0; protected int $signingOrder = 1; protected int $status = 0; + protected string $participantRole = 'signer'; public function __construct() { $this->addType('id', Types::INTEGER); @@ -64,6 +68,19 @@ public function __construct() { $this->addType('docmdpLevel', Types::SMALLINT); $this->addType('signingOrder', Types::INTEGER); $this->addType('status', Types::SMALLINT); + $this->addType('participantRole', Types::STRING); + } + + public function getParticipantRoleEnum(): ParticipantRole { + return ParticipantRole::fromNullable($this->participantRole); + } + + public function setParticipantRoleEnum(ParticipantRole $role): void { + $this->setParticipantRole($role->value); + } + + public function isObserver(): bool { + return $this->getParticipantRoleEnum() === ParticipantRole::OBSERVER; } public function getStatusEnum(): SignRequestStatus { diff --git a/lib/Enum/ParticipantRole.php b/lib/Enum/ParticipantRole.php new file mode 100644 index 0000000000..fd611a32ff --- /dev/null +++ b/lib/Enum/ParticipantRole.php @@ -0,0 +1,41 @@ + $l10n->t('Signer'), + // TRANSLATORS Participant role label for someone who can only view the document and track progress. + self::OBSERVER => $l10n->t('Observer'), + }; + } + + /** + * @throws \ValueError When $value is not empty and is not a known participant role + */ + public static function fromNullable(?string $value): self { + if ($value === null || $value === '') { + return self::SIGNER; + } + + return self::from($value); + } +} diff --git a/lib/Enum/SignRequestStatus.php b/lib/Enum/SignRequestStatus.php index 3cf9a002b2..af0aa06d85 100644 --- a/lib/Enum/SignRequestStatus.php +++ b/lib/Enum/SignRequestStatus.php @@ -15,6 +15,7 @@ enum SignRequestStatus: int { case DRAFT = 0; case ABLE_TO_SIGN = 1; case SIGNED = 2; + case OBSERVING = 3; public function getLabel(IL10N $l10n): string { return match($this) { @@ -24,6 +25,8 @@ public function getLabel(IL10N $l10n): string { self::ABLE_TO_SIGN => $l10n->t('Ready to sign'), // TRANSLATORS Signer workflow status shown after this signer has successfully signed the document. self::SIGNED => $l10n->t('Signed'), + // TRANSLATORS Workflow status shown when an observer can view the document but cannot sign it. + self::OBSERVING => $l10n->t('Observing'), }; } } diff --git a/lib/Helper/ValidateHelper.php b/lib/Helper/ValidateHelper.php index 9906732c19..098005e7cb 100644 --- a/lib/Helper/ValidateHelper.php +++ b/lib/Helper/ValidateHelper.php @@ -21,6 +21,7 @@ use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Db\UserElementMapper; use OCA\Libresign\Enum\FileStatus; +use OCA\Libresign\Enum\ParticipantRole; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\DocMdp\Validator as DocMdpValidator; use OCA\Libresign\Service\FileService; @@ -28,6 +29,7 @@ use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; use OCA\Libresign\Service\IdentifyMethod\RuntimeRequirementValidator; use OCA\Libresign\Service\IdentifyMethodService; +use OCA\Libresign\Service\Policy\Provider\ObserverProfile\ObserverProfilePolicyService; use OCA\Libresign\Service\Policy\RequestSignAuthorizationService; use OCA\Libresign\Service\SequentialSigningService; use OCA\Libresign\Service\SignerElementsService; @@ -71,6 +73,7 @@ public function __construct( private DocMdpValidator $docMdpValidator, private RequestSignAuthorizationService $requestSignAuthorizationService, private RuntimeRequirementValidator $runtimeRequirementValidator, + private ObserverProfilePolicyService $observerProfilePolicyService, ) { } @@ -607,14 +610,39 @@ public function validateIdentifySigners(array $data): void { } $this->validateSignersDataStructure($data); + $this->validateSigningParticipantsRequired($data); $this->docMdpValidator->validateSignersCount($data); $this->validateDocMdpPdfRestrictions($data); foreach ($data['signers'] as $signer) { - $this->validateSignerData($signer); + $this->validateSignerData($signer, $data); } } + private function validateSigningParticipantsRequired(array $data): void { + if (($data['status'] ?? FileStatus::DRAFT->value) === FileStatus::DRAFT->value) { + return; + } + + if (!is_array($data['signers'])) { + return; + } + + foreach ($data['signers'] as $signer) { + if (!is_array($signer)) { + continue; + } + + $role = ParticipantRole::fromNullable($signer['participantRole'] ?? null); + if ($role->canSign()) { + return; + } + } + + // TRANSLATORS Validation error when requesting signatures without any signing participants. + throw new LibresignException($this->l10n->t('At least one signer is required')); + } + private function validateSignersDataStructure(array $data): void { if (empty($data) || !array_key_exists('signers', $data) || !is_array($data['signers']) || empty($data['signers'])) { // TRANSLATORS Validation error when a signature request is submitted without any signers. @@ -622,7 +650,7 @@ private function validateSignersDataStructure(array $data): void { } } - private function validateSignerData(mixed $signer): void { + private function validateSignerData(mixed $signer, array $data): void { if (!is_array($signer) || empty($signer)) { // TRANSLATORS Validation error when a signature request is submitted without any signers. throw new LibresignException($this->l10n->t('No signers')); @@ -630,6 +658,40 @@ private function validateSignerData(mixed $signer): void { $this->validateSignerDisplayName($signer); $this->validateSignerIdentifyMethods($signer); + $this->validateParticipantRole($signer, $data); + } + + private function validateParticipantRole(array $signer, array $data): void { + $roleValue = $signer['participantRole'] ?? ParticipantRole::SIGNER->value; + if (!is_string($roleValue)) { + throw new LibresignException('Invalid participant role'); + } + + try { + $role = ParticipantRole::from($roleValue); + } catch (\ValueError) { + throw new LibresignException('Invalid participant role'); + } + + if ($role === ParticipantRole::OBSERVER + && !$this->observerProfilePolicyService->isEnabled($this->getExistingRequestFile($data)) + ) { + // TRANSLATORS Validation error when observer participants are submitted while the feature is disabled by policy. + throw new LibresignException($this->l10n->t('Observer participants are not enabled')); + } + } + + private function getExistingRequestFile(array $data): ?File { + $uuid = $data['uuid'] ?? null; + if (!is_string($uuid) || $uuid === '') { + return null; + } + + try { + return $this->fileMapper->getByUuid($uuid); + } catch (DoesNotExistException) { + return null; + } } private function validateSignerDisplayName(array $signer): void { @@ -788,6 +850,15 @@ public function validateSignerUuid(string $uuid): void { */ private function validateSignerStatus(string $uuid): void { $signRequest = $this->signRequestMapper->getByUuid($uuid); + + if (!$signRequest->getParticipantRoleEnum()->canSign()) { + throw new LibresignException(json_encode([ + 'action' => JSActions::ACTION_DO_NOTHING, + // TRANSLATORS Validation error when an observer tries to sign a document. + 'errors' => [['message' => $this->l10n->t('Observers cannot sign this document')]], + ])); + } + $status = $signRequest->getStatusEnum(); $file = $this->fileMapper->getById($signRequest->getFileId()); diff --git a/lib/Listener/NotificationListener.php b/lib/Listener/NotificationListener.php index b1b41e07ed..506373717a 100644 --- a/lib/Listener/NotificationListener.php +++ b/lib/Listener/NotificationListener.php @@ -213,12 +213,20 @@ private function sendCanceledNotification( * @psalm-return array{type: 'file', id: string, name: string, path: string, link: string} */ protected function getFileParameter(SignRequest $signRequest, FileEntity $libreSignFile): array { + if ($signRequest->isObserver()) { + $link = $this->url->linkToRouteAbsolute('libresign.page.validationFilePublic', [ + 'uuid' => $libreSignFile->getUuid(), + ]); + } else { + $link = $this->url->linkToRouteAbsolute('libresign.page.signFPath', ['uuid' => $signRequest->getUuid(), 'path' => 'pdf']); + } + return [ 'type' => 'file', 'id' => (string)$libreSignFile->getNodeId(), 'name' => $libreSignFile->getName(), 'path' => $libreSignFile->getName(), - 'link' => $this->url->linkToRouteAbsolute('libresign.page.signFPath', ['uuid' => $signRequest->getUuid(), 'path' => 'pdf']), + 'link' => $link, ]; } diff --git a/lib/Middleware/InjectionMiddleware.php b/lib/Middleware/InjectionMiddleware.php index 9cb632cce9..15725ec684 100644 --- a/lib/Middleware/InjectionMiddleware.php +++ b/lib/Middleware/InjectionMiddleware.php @@ -340,7 +340,7 @@ private function redirectSignedToValidationIfNeeded(RequireSignRequestUuid $requ try { $signRequest = $this->signRequestMapper->getByUuid($uuid); - if ($signRequest->getStatusEnum() !== SignRequestStatus::SIGNED) { + if ($signRequest->getStatusEnum() !== SignRequestStatus::SIGNED && !$signRequest->isObserver()) { return; } $file = $this->fileMapper->getById($signRequest->getFileId()); diff --git a/lib/Migration/Version19000Date20260830200000.php b/lib/Migration/Version19000Date20260830200000.php new file mode 100644 index 0000000000..65ed5c726e --- /dev/null +++ b/lib/Migration/Version19000Date20260830200000.php @@ -0,0 +1,44 @@ +hasTable('libresign_sign_request')) { + return null; + } + + $table = $schema->getTable('libresign_sign_request'); + if ($table->hasColumn('participant_role')) { + return null; + } + + $table->addColumn('participant_role', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + 'default' => 'signer', + ]); + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 23ccb4a435..fb9af5bbd6 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -47,6 +47,7 @@ * }, * envelopeFolderId?: int, * } + * @psalm-type LibresignParticipantRole = 'signer'|'observer' * @psalm-type LibresignGeolocationCollectionStatus = 'collected'|'denied'|'unavailable'|'skipped' * @psalm-type LibresignSignerGeolocationPolicyMode = 'disabled'|'optional'|'required' * @psalm-type LibresignGeolocationRequirement = 'disabled'|'required' @@ -68,6 +69,7 @@ * notify?: non-negative-int, * signingOrder?: non-negative-int, * status?: int, + * participantRole?: LibresignParticipantRole, * geolocationRequired?: bool, * } * @psalm-type LibresignNewFile = array{ @@ -206,8 +208,9 @@ * email?: ?string, * identifyMethods?: LibresignIdentifyMethod[], * signed: ?string, - * status: 0|1|2, + * status: 0|1|2|3, * statusText: string, + * participantRole?: LibresignParticipantRole, * } * @psalm-type LibresignSignerDetail = LibresignSignerSummary&array{ * description: ?string, @@ -505,6 +508,10 @@ * effectiveValue: int, * sourceScope: string, * } + * @psalm-type LibresignPolicySnapshotBooleanEntry = array{ + * effectiveValue: bool, + * sourceScope: string, + * } * @psalm-type LibresignPolicySnapshotIdentificationDocumentsValue = array{ * enabled: bool, * approvers: list, @@ -541,6 +548,7 @@ * identification_documents?: LibresignPolicySnapshotIdentificationDocumentsEntry, * identify_methods?: LibresignPolicySnapshotIdentifyMethodsEntry, * signer_geolocation?: LibresignPolicySnapshotSignerGeolocationEntry, + * enable_observer_profile?: LibresignPolicySnapshotBooleanEntry, * } * @psalm-type LibresignValidateMetadata = array{ * extension: string, diff --git a/lib/Service/DocMdp/Validator.php b/lib/Service/DocMdp/Validator.php index b82214b8c8..7b7bc255a6 100644 --- a/lib/Service/DocMdp/Validator.php +++ b/lib/Service/DocMdp/Validator.php @@ -11,6 +11,7 @@ use OCA\Libresign\Db\File; use OCA\Libresign\Db\FileMapper; +use OCA\Libresign\Enum\ParticipantRole; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\File\Pdf\PdfValidator; use OCP\AppFramework\Db\DoesNotExistException; @@ -43,7 +44,7 @@ public function validateSignersCount(array $data): void { ); } - if (count($data['signers']) > 1) { + if ($this->countSigningParticipants($data['signers']) > 1) { throw new LibresignException( // TRANSLATORS Error shown when trying to add more signers to a document certified with DocMDP level that forbids further changes. $this->l10n->t('This document has been certified with no changes allowed. You cannot add more signers to this document.') @@ -75,6 +76,26 @@ public function validatePdfRestrictions(File $file): void { $this->pdfValidator->validate($content, $fileName); } + /** + * @param list $signers + */ + private function countSigningParticipants(array $signers): int { + $count = 0; + + foreach ($signers as $signer) { + if (!is_array($signer)) { + continue; + } + + $role = ParticipantRole::fromNullable($signer['participantRole'] ?? null); + if ($role->canSign()) { + $count++; + } + } + + return $count; + } + private function getDocMdpLevel(array $data, ?File &$file): int { $docMdpLevel = null; diff --git a/lib/Service/Envelope/EnvelopeService.php b/lib/Service/Envelope/EnvelopeService.php index 65ee2023fa..c6ea9b53a3 100644 --- a/lib/Service/Envelope/EnvelopeService.php +++ b/lib/Service/Envelope/EnvelopeService.php @@ -18,9 +18,12 @@ use OCA\Libresign\Service\FolderService; use OCA\Libresign\Service\Policy\PolicyService; use OCA\Libresign\Service\Policy\Provider\Envelope\EnvelopePolicy; +use OCA\Libresign\Service\Policy\Provider\ObserverProfile\ObserverProfilePolicy; +use OCA\Libresign\Service\Policy\Provider\ObserverProfile\ObserverProfilePolicyValue; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IAppConfig; use OCP\IL10N; +use OCP\IUser; use Sabre\DAV\UUIDUtil; class EnvelopeService { @@ -55,11 +58,15 @@ public function validateEnvelopeConstraints(int $fileCount): void { } } + /** + * @param array $policyData + */ public function createEnvelope( string $name, string $userId, int $filesCount = 0, ?string $path = null, + array $policyData = [], ): FileEntity { $this->folderService->setUserId($userId); @@ -81,6 +88,7 @@ public function createEnvelope( $envelope->setStatusEnum(FileStatus::DRAFT); $envelope->setMetadata(['filesCount' => $filesCount]); + $this->storeObserverProfilePolicySnapshot($envelope, $policyData); if ($userId) { $envelope->setUserId($userId); @@ -145,4 +153,54 @@ public function getEnvelopeFolder(FileEntity $envelope): \OCP\Files\Folder { private function getMaxFilesPerEnvelope(): int { return $this->appConfig->getValueInt(Application::APP_ID, 'envelope_max_files', 50); } + + /** + * @param array $policyData + */ + private function storeObserverProfilePolicySnapshot(FileEntity $envelope, array $policyData): void { + $user = ($policyData['userManager'] ?? null) instanceof IUser ? $policyData['userManager'] : null; + $requestOverrides = []; + if (isset($policyData['policyOverrides']) && is_array($policyData['policyOverrides']) + && array_key_exists(ObserverProfilePolicy::KEY, $policyData['policyOverrides']) + ) { + $requestOverrides[ObserverProfilePolicy::KEY] = ObserverProfilePolicyValue::normalize( + $policyData['policyOverrides'][ObserverProfilePolicy::KEY], + ); + } + + $activeContext = $this->extractPolicyActiveContext($policyData); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUser(ObserverProfilePolicy::KEY, $user, $requestOverrides) + : $this->policyService->resolveForUser(ObserverProfilePolicy::KEY, $user, $requestOverrides, $activeContext); + + $metadata = $envelope->getMetadata() ?? []; + $policySnapshot = $metadata['policy_snapshot'] ?? []; + $policySnapshot[ObserverProfilePolicy::KEY] = [ + 'effectiveValue' => ObserverProfilePolicyValue::normalize($resolvedPolicy->getEffectiveValue()), + 'sourceScope' => $resolvedPolicy->getSourceScope(), + ]; + $metadata['policy_snapshot'] = $policySnapshot; + $envelope->setMetadata($metadata); + } + + /** + * @param array $policyData + * @return array{type: string, id: string}|null + */ + private function extractPolicyActiveContext(array $policyData): ?array { + if (!isset($policyData['policyActiveContext']) || !is_array($policyData['policyActiveContext'])) { + return null; + } + + $type = $policyData['policyActiveContext']['type'] ?? null; + $id = $policyData['policyActiveContext']['id'] ?? null; + if (!is_string($type) || !is_string($id) || $type === '' || $id === '') { + return null; + } + + return [ + 'type' => $type, + 'id' => $id, + ]; + } } diff --git a/lib/Service/Envelope/EnvelopeStatusDeterminer.php b/lib/Service/Envelope/EnvelopeStatusDeterminer.php index 2fdee4c3cf..17ab72d9f4 100644 --- a/lib/Service/Envelope/EnvelopeStatusDeterminer.php +++ b/lib/Service/Envelope/EnvelopeStatusDeterminer.php @@ -22,9 +22,14 @@ public function determineStatus(array $childFiles, array $signRequestsMap): int foreach ($childFiles as $childFile) { $signRequests = $signRequestsMap[$childFile->getId()] ?? []; - $totalSignRequests += count($signRequests); foreach ($signRequests as $signRequest) { + if ($signRequest->isObserver()) { + continue; + } + + $totalSignRequests++; + if ($signRequest->getSigned()) { $signedSignRequests++; } diff --git a/lib/Service/File/EnvelopeProgressService.php b/lib/Service/File/EnvelopeProgressService.php index fb21f0f3f8..4f9b480296 100644 --- a/lib/Service/File/EnvelopeProgressService.php +++ b/lib/Service/File/EnvelopeProgressService.php @@ -9,6 +9,7 @@ namespace OCA\Libresign\Service\File; +use OCA\Libresign\Db\SignRequest; use stdClass; class EnvelopeProgressService { @@ -54,6 +55,10 @@ private function aggregateSignerProgress(array $childrenFiles, array $signReques foreach ($childrenFiles as $childFile) { $signRequests = $signRequestsByFileId[$childFile->getId()] ?? []; foreach ($signRequests as $signRequest) { + if ($signRequest instanceof SignRequest && $signRequest->isObserver()) { + continue; + } + $signRequestId = $signRequest->getId(); $identifyMethods = $identifyMethodsBySignRequest[$signRequestId] ?? []; diff --git a/lib/Service/File/FileListService.php b/lib/Service/File/FileListService.php index 5aee9a202b..050e959c75 100644 --- a/lib/Service/File/FileListService.php +++ b/lib/Service/File/FileListService.php @@ -336,7 +336,10 @@ private function formatSingleFileSummary( $mySigners = array_values(array_filter($signers, fn (SignRequest $signer) => $this->isCurrentUserSigner($identifyMethods[$signer->getId()] ?? [], $user), )); - $pendingSigners = array_values(array_filter($signers, fn (SignRequest $signer) => $signer->getSigned() === null)); + $pendingSigners = array_values(array_filter( + $signers, + fn (SignRequest $signer) => $signer->getSigned() === null && $signer->getParticipantRoleEnum()->canSign(), + )); $isOrderedNumeric = SignatureFlow::fromNumeric($fileEntity->getSignatureFlow())->value === SignatureFlow::ORDERED_NUMERIC->value; $minOrder = empty($pendingSigners) ? null @@ -425,6 +428,7 @@ private function formatSignerData( 'signingOrder' => $signer->getSigningOrder(), 'status' => $signer->getStatus(), 'statusText' => $this->signRequestMapper->getTextOfSignerStatus($signer->getStatus()), + 'participantRole' => $signer->getParticipantRoleEnum()->value, 'me' => $me, 'visibleElements' => isset($visibleElements[$signer->getId()]) ? $this->fileElementService->formatVisibleElements( @@ -560,6 +564,7 @@ private function formatSignerDataBasic( 'signingOrder' => $signer->getSigningOrder(), 'status' => $signer->getStatus(), 'statusText' => $this->signRequestMapper->getTextOfSignerStatus($signer->getStatus()), + 'participantRole' => $signer->getParticipantRoleEnum()->value, 'me' => false, 'visibleElements' => isset($visibleElements[$signer->getId()]) ? $this->fileElementService->formatVisibleElements( diff --git a/lib/Service/File/SignersLoader.php b/lib/Service/File/SignersLoader.php index 7d370633de..1213b0f6b6 100644 --- a/lib/Service/File/SignersLoader.php +++ b/lib/Service/File/SignersLoader.php @@ -85,6 +85,7 @@ public function loadLibreSignSigners( $fileData->signers[$index]->status = $signer->getStatus(); $fileData->signers[$index]->statusText = $this->signRequestMapper->getTextOfSignerStatus($signer->getStatus()); $fileData->signers[$index]->signingOrder = $signer->getSigningOrder(); + $fileData->signers[$index]->participantRole = $signer->getParticipantRoleEnum()->value; $fileData->signers[$index]->description = $signer->getDescription(); $fileData->signers[$index]->displayName = $signer->getDisplayName(); $fileData->signers[$index]->request_sign_date = $signer->getCreatedAt()->format(DateTimeInterface::ATOM); @@ -179,7 +180,10 @@ public function loadLibreSignSigners( if ($fileData->signers[$index]->me) { $fileData->signers[$index]->sign_request_uuid = $signer->getUuid(); - if (!$signer->getSigned() && isset($fileData->settings)) { + if (!$signer->getSigned() + && $signer->getParticipantRoleEnum()->canSign() + && isset($fileData->settings) + ) { $fileData->settings['canSign'] = true; } $fileData->signers[$index]->signatureMethods = []; diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 3d104c7b43..96c6d96eba 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -684,12 +684,13 @@ private function mapSignerDetailsToSummary(array $signers): array { } /** - * @psalm-return 0|1|2 + * @psalm-return 0|1|2|3 */ private function normalizeSignerSummaryStatus(mixed $status): int { return match ((string)$status) { '1' => SignRequestStatus::ABLE_TO_SIGN->value, '2' => SignRequestStatus::SIGNED->value, + '3' => SignRequestStatus::OBSERVING->value, default => SignRequestStatus::DRAFT->value, }; } diff --git a/lib/Service/MailService.php b/lib/Service/MailService.php index b5e331af4c..96590a2844 100644 --- a/lib/Service/MailService.php +++ b/lib/Service/MailService.php @@ -58,27 +58,48 @@ private function getFileById(int $fileId): File { * @psalm-suppress MixedMethodCall */ public function notifySignDataUpdated(SignRequest $data, string $email, ?string $description = null): void { + $file = $this->getFileById($data->getFileId()); + $isObserver = $data->isObserver(); + $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); - // TRANSLATORS Email subject notifying a signer that a pending signature request changed and should be reviewed again. - $emailTemplate->setSubject($this->l10n->t('LibreSign: Changes were made to a document waiting for your signature')); - $emailTemplate->addHeader(); - // TRANSLATORS Email heading shown above a pending document that still needs the recipient's signature. - $emailTemplate->addHeading($this->l10n->t('Document to sign'), false); + if ($isObserver) { + // TRANSLATORS Email subject notifying an observer that a document changed and should be reviewed again. + $emailTemplate->setSubject($this->l10n->t('LibreSign: Changes were made to a document')); + $emailTemplate->addHeader(); + // TRANSLATORS Email heading shown above a document available for viewing. + $emailTemplate->addHeading($this->l10n->t('Document to view'), false); + } else { + // TRANSLATORS Email subject notifying a signer that a pending signature request changed and should be reviewed again. + $emailTemplate->setSubject($this->l10n->t('LibreSign: Changes were made to a document waiting for your signature')); + $emailTemplate->addHeader(); + // TRANSLATORS Email heading shown above a pending document that still needs the recipient's signature. + $emailTemplate->addHeading($this->l10n->t('Document to sign'), false); + } if (!empty($description)) { $emailTemplate->addBodyText($description); $emailTemplate->addBodyText(''); } - // TRANSLATORS Email body telling the signer to reopen the request because some request details changed. - $emailTemplate->addBodyText($this->l10n->t('Changes were made to a document you need to sign. Open the link below:')); - $link = $this->urlGenerator->linkToRouteAbsolute('libresign.page.sign', ['uuid' => $data->getUuid()]); - $file = $this->getFileById($data->getFileId()); - $emailTemplate->addBodyButton( - // TRANSLATORS Email button label that opens the signing page. %s is the document filename. - $this->l10n->t('Sign "%s"', [$file->getName()]), - $link - ); + if ($isObserver) { + // TRANSLATORS Email body telling an observer to reopen the document because some request details changed. + $emailTemplate->addBodyText($this->l10n->t('Changes were made to a document. Open the link below:')); + $link = $this->buildValidationLink($file); + $emailTemplate->addBodyButton( + // TRANSLATORS Email button label that opens the document validation view. %s is the document filename. + $this->l10n->t('View "%s"', [$file->getName()]), + $link + ); + } else { + // TRANSLATORS Email body telling the signer to reopen the request because some request details changed. + $emailTemplate->addBodyText($this->l10n->t('Changes were made to a document you need to sign. Open the link below:')); + $link = $this->urlGenerator->linkToRouteAbsolute('libresign.page.sign', ['uuid' => $data->getUuid()]); + $emailTemplate->addBodyButton( + // TRANSLATORS Email button label that opens the signing page. %s is the document filename. + $this->l10n->t('Sign "%s"', [$file->getName()]), + $link + ); + } try { $this->sendSignRequestNotification($emailTemplate, $data, $email); } catch (\Exception $e) { @@ -91,27 +112,48 @@ public function notifySignDataUpdated(SignRequest $data, string $email, ?string * @psalm-suppress MixedMethodCall */ public function notifyUnsignedUser(SignRequest $data, string $email, ?string $description = null): void { + $file = $this->getFileById($data->getFileId()); + $isObserver = $data->isObserver(); + $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); - // TRANSLATORS Email subject notifying a signer that a document is ready for their digital signature. - $emailTemplate->setSubject($this->l10n->t('LibreSign: A document is ready for your signature')); - $emailTemplate->addHeader(); - // TRANSLATORS Email heading shown above a document awaiting the recipient's signature. - $emailTemplate->addHeading($this->l10n->t('Document to sign'), false); + if ($isObserver) { + // TRANSLATORS Email subject notifying an observer that a document is available to view. + $emailTemplate->setSubject($this->l10n->t('LibreSign: A document is ready for signature')); + $emailTemplate->addHeader(); + // TRANSLATORS Email heading shown above a document available for viewing. + $emailTemplate->addHeading($this->l10n->t('Document to view'), false); + } else { + // TRANSLATORS Email subject notifying a signer that a document is ready for their digital signature. + $emailTemplate->setSubject($this->l10n->t('LibreSign: A document is ready for your signature')); + $emailTemplate->addHeader(); + // TRANSLATORS Email heading shown above a document awaiting the recipient's signature. + $emailTemplate->addHeading($this->l10n->t('Document to sign'), false); + } if (!empty($description)) { $emailTemplate->addBodyText($description); $emailTemplate->addBodyText(''); } - // TRANSLATORS Email body inviting the signer to open the document and sign it. - $emailTemplate->addBodyText($this->l10n->t('A document is ready for your signature. Open the link below:')); - $link = $this->urlGenerator->linkToRouteAbsolute('libresign.page.sign', ['uuid' => $data->getUuid()]); - $file = $this->getFileById($data->getFileId()); - $emailTemplate->addBodyButton( - // TRANSLATORS Email button label that opens the signing page. %s is the document filename. - $this->l10n->t('Sign "%s"', [$file->getName()]), - $link - ); + if ($isObserver) { + // TRANSLATORS Email body inviting an observer to open the document and view it. + $emailTemplate->addBodyText($this->l10n->t('A document is ready for signature. Open the link below:')); + $link = $this->buildValidationLink($file); + $emailTemplate->addBodyButton( + // TRANSLATORS Email button label that opens the document validation view. %s is the document filename. + $this->l10n->t('View "%s"', [$file->getName()]), + $link + ); + } else { + // TRANSLATORS Email body inviting the signer to open the document and sign it. + $emailTemplate->addBodyText($this->l10n->t('A document is ready for your signature. Open the link below:')); + $link = $this->urlGenerator->linkToRouteAbsolute('libresign.page.sign', ['uuid' => $data->getUuid()]); + $emailTemplate->addBodyButton( + // TRANSLATORS Email button label that opens the signing page. %s is the document filename. + $this->l10n->t('Sign "%s"', [$file->getName()]), + $link + ); + } try { $this->sendSignRequestNotification($emailTemplate, $data, $email); } catch (\Exception $e) { @@ -282,6 +324,12 @@ public function notifyCanceledRequest(SignRequest $signRequest, string $email, F } } + private function buildValidationLink(File $file): string { + return $this->urlGenerator->linkToRouteAbsolute('libresign.page.validationFilePublic', [ + 'uuid' => $file->getUuid(), + ]); + } + public function sendCodeToSign(string $email, string $name, string $code): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); // TRANSLATORS Email subject for a one-time verification code required to sign a document. diff --git a/lib/Service/Policy/Provider/ObserverProfile/FilePolicy/ObserverProfileFilePolicyApplier.php b/lib/Service/Policy/Provider/ObserverProfile/FilePolicy/ObserverProfileFilePolicyApplier.php new file mode 100644 index 0000000000..e57cc60429 --- /dev/null +++ b/lib/Service/Policy/Provider/ObserverProfile/FilePolicy/ObserverProfileFilePolicyApplier.php @@ -0,0 +1,112 @@ +extractSinglePolicyOverride( + $data, + ObserverProfilePolicy::KEY, + ObserverProfilePolicyValue::normalize(...), + ); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUser(ObserverProfilePolicy::KEY, $user, $requestOverrides) + : $this->policyService->resolveForUser(ObserverProfilePolicy::KEY, $user, $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $this->storeObserverProfilePolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + if (!$this->requestContainsObserver($data)) { + return; + } + + if ($this->getStoredSnapshotValue($file) === true) { + return; + } + + $user = ($data['userManager'] ?? null) instanceof IUser ? $data['userManager'] : null; + $resolvedPolicy = $this->policyService->resolveForUser(ObserverProfilePolicy::KEY, $user, []); + if (!ObserverProfilePolicyValue::normalize($resolvedPolicy->getEffectiveValue())) { + return; + } + + $metadataBeforeUpdate = $file->getMetadata() ?? []; + $this->storeObserverProfilePolicySnapshot($file, $resolvedPolicy); + if (($file->getMetadata() ?? []) !== $metadataBeforeUpdate) { + $this->fileService->update($file); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return true; + } + + /** @param array $requestOverrides */ + private function assertOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void { + $this->assertRequestOverrideAllowed($requestOverrides, $resolvedPolicy, 'Observer profile override is blocked by %s.'); + } + + private function storeObserverProfilePolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy): void { + parent::storePolicySnapshot( + $file, + $resolvedPolicy, + ObserverProfilePolicyValue::normalize($resolvedPolicy->getEffectiveValue()), + ); + } + + /** @param array $data */ + private function requestContainsObserver(array $data): bool { + $signers = $data['signers'] ?? null; + if (!is_array($signers)) { + return false; + } + + foreach ($signers as $signer) { + if (!is_array($signer)) { + continue; + } + + $roleValue = $signer['participantRole'] ?? ParticipantRole::SIGNER->value; + if ($roleValue === ParticipantRole::OBSERVER->value) { + return true; + } + } + + return false; + } + + private function getStoredSnapshotValue(FileEntity $file): ?bool { + $metadata = $file->getMetadata() ?? []; + $policySnapshot = $metadata['policy_snapshot'] ?? null; + if (!is_array($policySnapshot)) { + return null; + } + + $entry = $policySnapshot[ObserverProfilePolicy::KEY] ?? null; + if (!is_array($entry) || !array_key_exists('effectiveValue', $entry)) { + return null; + } + + return ObserverProfilePolicyValue::normalize($entry['effectiveValue']); + } +} diff --git a/lib/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicy.php b/lib/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicy.php new file mode 100644 index 0000000000..b63d620c01 --- /dev/null +++ b/lib/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicy.php @@ -0,0 +1,102 @@ + new PolicySpec( + key: self::KEY, + defaultSystemValue: false, + allowedValues: [ + false, + true, + ], + normalizer: ObserverProfilePolicyValue::normalize(...), + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportedScopes: [ + PolicySpec::SCOPE_SYSTEM, + PolicySpec::SCOPE_GROUP, + PolicySpec::SCOPE_USER, + ], + groupPolicyManager: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, array $groupLayers): bool { + $actorRole = $context->getActorRole(); + + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if ($actorRole->manageableGroupCount < 1) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return DelegationLayerHelper::hasSystemCreatedGroupDelegation($groupLayers); + }, + systemCreatedGroupRuleEditor: static function (PolicyContext $context, ?PolicyLayer $systemPolicy, PolicyLayer $existingPolicy): bool { + $actorRole = $context->getActorRole(); + + if ($actorRole->canManageSystemPolicies) { + return true; + } + + if (!$actorRole->canManageGroupPolicies) { + return false; + } + + if (!$existingPolicy->isVisibleToChild()) { + return false; + } + + if (!$existingPolicy->isAllowChildOverride()) { + return false; + } + + if ($existingPolicy->getValue() === null) { + return false; + } + + if (DelegationLayerHelper::hasExplicitGlobalDelegation($systemPolicy)) { + return true; + } + + return $existingPolicy->isCreatedBySystemAdmin(); + }, + supportsGroupAdminDelegation: true, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . PolicyKeyNormalizer::normalize($policyKey)), + }; + } +} diff --git a/lib/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicyService.php b/lib/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicyService.php new file mode 100644 index 0000000000..18fee4d786 --- /dev/null +++ b/lib/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicyService.php @@ -0,0 +1,60 @@ +getSnapshotValue($file); + if ($snapshotValue === true) { + return true; + } + if ($snapshotValue === false) { + return $this->isLivePolicyEnabled(); + } + + return false; + } + + return $this->isLivePolicyEnabled(); + } + + private function isLivePolicyEnabled(): bool { + return ObserverProfilePolicyValue::normalize( + $this->policyService->resolve(ObserverProfilePolicy::KEY)->getEffectiveValue(), + ); + } + + private function getSnapshotValue(?FileEntity $file): ?bool { + if (!$file instanceof FileEntity) { + return null; + } + + $metadata = $file->getMetadata() ?? []; + $policySnapshot = $metadata['policy_snapshot'] ?? null; + if (!is_array($policySnapshot)) { + return null; + } + + $entry = $policySnapshot[ObserverProfilePolicy::KEY] ?? null; + if (!is_array($entry) || !array_key_exists('effectiveValue', $entry)) { + return null; + } + + return ObserverProfilePolicyValue::normalize($entry['effectiveValue']); + } +} diff --git a/lib/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicyValue.php b/lib/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicyValue.php new file mode 100644 index 0000000000..a0ea82db04 --- /dev/null +++ b/lib/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicyValue.php @@ -0,0 +1,15 @@ + WorkerConfigPolicy::class, IdentificationDocumentsPolicy::KEY => IdentificationDocumentsPolicy::class, IdentifyMethodsPolicy::KEY => IdentifyMethodsPolicy::class, + ObserverProfilePolicy::KEY => ObserverProfilePolicy::class, SignatureTextPolicy::KEY => SignatureTextPolicy::class, SignerGeolocationPolicy::KEY => SignerGeolocationPolicy::class, TsaPolicy::KEY => TsaPolicy::class, diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 0908c4f5ac..0e06ef100d 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -15,6 +15,7 @@ use OCA\Libresign\Db\SignRequest as SignRequestEntity; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Enum\FileStatus; +use OCA\Libresign\Enum\ParticipantRole; use OCA\Libresign\Events\SignRequestCanceledEvent; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\DocMdpHandler; @@ -179,7 +180,7 @@ public function saveEnvelope(array $data): array { try { $envelopePath = $data['settings']['path'] ?? null; - $envelope = $this->envelopeService->createEnvelope($envelopeName, $userId, $filesCount, $envelopePath); + $envelope = $this->envelopeService->createEnvelope($envelopeName, $userId, $filesCount, $envelopePath, $data); $envelopeFolder = $this->envelopeService->getEnvelopeFolder($envelope); $envelopeSettings = array_merge($data['settings'] ?? [], [ @@ -491,8 +492,11 @@ private function associateToSigners(array $data, FileEntity $file): array { $requester = ($data['userManager'] ?? null) instanceof IUser ? $data['userManager'] : null; foreach ($normalizedSigners as $signer) { + $participantRole = ParticipantRole::fromNullable($signer['participantRole'] ?? null); $userProvidedOrder = isset($signer['signingOrder']) ? (int)$signer['signingOrder'] : null; - $signingOrder = $this->sequentialSigningService->determineSigningOrder($userProvidedOrder); + $signingOrder = $participantRole->canSign() + ? $this->sequentialSigningService->determineSigningOrder($userProvidedOrder) + : 0; $signerStatus = $signer['status'] ?? null; $shouldNotify = !isset($signer['notify']) || $signer['notify'] !== 0; $lastSignRequest = null; @@ -515,6 +519,7 @@ private function associateToSigners(array $data, FileEntity $file): array { signingOrder: $signingOrder, fileStatus: $fileStatus, signerStatus: $signerStatus, + participantRole: $participantRole, afterPersist: function (SignRequestEntity $signRequest) use ($file, $requesterRequiresGeolocation, $requester): void { $this->signerGeolocationPolicyService->persistEffectiveRequirement( $signRequest, diff --git a/lib/Service/SequentialSigningService.php b/lib/Service/SequentialSigningService.php index d02ccb590f..3d85026676 100644 --- a/lib/Service/SequentialSigningService.php +++ b/lib/Service/SequentialSigningService.php @@ -120,7 +120,8 @@ public function reorderAfterDeletion(int $fileId, int $deletedOrder): void { private function isOrderFullyCompleted(array $signRequests, int $order): bool { $pendingSigners = array_filter( $signRequests, - fn ($sr) => $sr->getSigningOrder() === $order + fn ($sr) => $this->isSigningParticipant($sr) + && $sr->getSigningOrder() === $order && $sr->getStatusEnum() !== SignRequestStatus::SIGNED ); @@ -128,7 +129,11 @@ private function isOrderFullyCompleted(array $signRequests, int $order): bool { } private function findNextOrder(array $signRequests, int $completedOrder): ?int { - $allOrders = array_unique(array_map(fn ($sr) => $sr->getSigningOrder(), $signRequests)); + $allOrders = array_unique(array_map( + fn ($sr) => $this->isSigningParticipant($sr) ? $sr->getSigningOrder() : null, + $signRequests, + )); + $allOrders = array_values(array_filter($allOrders, static fn (?int $order) => $order !== null && $order > 0)); sort($allOrders); foreach ($allOrders as $order) { @@ -143,7 +148,8 @@ private function findNextOrder(array $signRequests, int $completedOrder): ?int { private function activateSignersForOrder(array $signRequests, int $order): void { $signersToActivate = array_filter( $signRequests, - fn ($sr) => $sr->getSigningOrder() === $order + fn ($sr) => $this->isSigningParticipant($sr) + && $sr->getSigningOrder() === $order ); foreach ($signersToActivate as $signer) { @@ -176,6 +182,10 @@ public function hasPendingLowerOrderSigners(int $fileId, int $currentOrder): boo $signRequests = $this->signRequestMapper->getByFileId($fileId); foreach ($signRequests as $signRequest) { + if (!$this->isSigningParticipant($signRequest)) { + continue; + } + $order = $signRequest->getSigningOrder(); $status = $signRequest->getStatusEnum(); @@ -188,6 +198,10 @@ public function hasPendingLowerOrderSigners(int $fileId, int $currentOrder): boo return false; } + private function isSigningParticipant(\OCA\Libresign\Db\SignRequest $signRequest): bool { + return $signRequest->getParticipantRoleEnum()->canSign(); + } + /** * Check if changing from currentStatus to desiredStatus is an upgrade (or same level) * Status hierarchy: DRAFT (0) < ABLE_TO_SIGN (1) < SIGNED (2) diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index 36d68ceee6..7f3c342af0 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -1104,6 +1104,10 @@ private function updateEntityCacheAfterDbSave(FileEntity $file): void { private function evaluateStatusFromSigners(): ?int { $signers = $this->excludeIdDocUploaderPlaceholder($this->getSigners()); + $signers = array_values(array_filter( + $signers, + static fn (SignRequestEntity $signer): bool => !$signer->isObserver(), + )); $total = count($signers); diff --git a/lib/Service/SignRequest/SignRequestService.php b/lib/Service/SignRequest/SignRequestService.php index 5bda9d722e..0b997099fe 100644 --- a/lib/Service/SignRequest/SignRequestService.php +++ b/lib/Service/SignRequest/SignRequestService.php @@ -10,7 +10,9 @@ use OCA\Libresign\Db\SignRequest as SignRequestEntity; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Enum\ParticipantRole; use OCA\Libresign\Enum\SignRequestStatus; +use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; use OCA\Libresign\Service\IdentifyMethodService; use OCP\AppFramework\Db\DoesNotExistException; @@ -39,6 +41,7 @@ public function __construct( * @param int $signingOrder Signing order * @param int|null $fileStatus File status * @param int|null $signerStatus Signer status + * @param ParticipantRole $participantRole Participant role (signer or observer) * @param callable(SignRequestEntity): void|null $afterPersist Callback invoked after the sign request is persisted and before identify methods are saved * @return SignRequestEntity */ @@ -51,6 +54,7 @@ public function createOrUpdateSignRequest( int $signingOrder = 0, ?int $fileStatus = null, ?int $signerStatus = null, + ParticipantRole $participantRole = ParticipantRole::SIGNER, ?callable $afterPersist = null, ): SignRequestEntity { $identifyMethodsInstances = $this->identifyMethodService->getByUserData($identifyMethods); @@ -64,8 +68,10 @@ public function createOrUpdateSignRequest( $fileId ); + $this->assertParticipantRoleCanBeUpdated($signRequest, $participantRole); + $displayName = $this->getDisplayNameFromIdentifyMethodIfEmpty($identifyMethodsInstances, $displayName); - $this->populateSignRequest($signRequest, $displayName, $signingOrder, $description, $fileId); + $this->populateSignRequest($signRequest, $displayName, $signingOrder, $description, $fileId, $participantRole); $isNewSignRequest = !$signRequest->getId(); $currentStatus = $signRequest->getStatusEnum(); @@ -73,13 +79,15 @@ public function createOrUpdateSignRequest( if ($isNewSignRequest || $currentStatus === SignRequestStatus::DRAFT || $currentStatus === SignRequestStatus::ABLE_TO_SIGN + || $currentStatus === SignRequestStatus::OBSERVING ) { $desiredStatus = $this->signRequestStatusService->determineInitialStatus( $signingOrder, $fileId, $fileStatus, $signerStatus, - $currentStatus + $currentStatus, + $participantRole, ); $this->signRequestStatusService->updateStatusIfAllowed($signRequest, $currentStatus, $desiredStatus, $isNewSignRequest); } @@ -113,15 +121,37 @@ public function getSignRequestByIdentifyMethod(IIdentifyMethod $identifyMethod, return $signRequest; } + private function assertParticipantRoleCanBeUpdated( + SignRequestEntity $signRequest, + ParticipantRole $participantRole, + ): void { + if (!$signRequest->getId()) { + return; + } + + if ($signRequest->getStatusEnum() !== SignRequestStatus::SIGNED) { + return; + } + + if ($signRequest->getParticipantRoleEnum() === $participantRole) { + return; + } + + // TRANSLATORS Error shown when trying to change the participant role after the document was already signed. + throw new LibresignException($this->l10n->t('Cannot change the participant role after the document has been signed')); + } + private function populateSignRequest( SignRequestEntity $signRequest, string $displayName, int $signingOrder, string $description, int $fileId, + ParticipantRole $participantRole, ): void { $signRequest->setFileId($fileId); $signRequest->setSigningOrder($signingOrder); + $signRequest->setParticipantRoleEnum($participantRole); if (!$signRequest->getUuid()) { $signRequest->setUuid(UUIDUtil::getUUID()); } diff --git a/lib/Service/SignRequest/StatusService.php b/lib/Service/SignRequest/StatusService.php index 50edba06eb..00dfe9ee32 100644 --- a/lib/Service/SignRequest/StatusService.php +++ b/lib/Service/SignRequest/StatusService.php @@ -11,6 +11,7 @@ use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Db\SignRequest as SignRequestEntity; use OCA\Libresign\Enum\FileStatus; +use OCA\Libresign\Enum\ParticipantRole; use OCA\Libresign\Enum\SignRequestStatus; use OCA\Libresign\Service\FileStatusService; use OCA\Libresign\Service\SequentialSigningService; @@ -30,7 +31,8 @@ public function shouldNotifySignRequest(SignRequestStatus $signRequestStatus, ?i } public function canNotifySignRequest(SignRequestStatus $status): bool { - return $status === SignRequestStatus::ABLE_TO_SIGN; + return $status === SignRequestStatus::ABLE_TO_SIGN + || $status === SignRequestStatus::OBSERVING; } public function cacheFileStatus(FileEntity $file): void { @@ -72,7 +74,16 @@ public function determineInitialStatus( ?int $fileStatus = null, ?int $signerStatus = null, ?SignRequestStatus $currentStatus = null, + ParticipantRole $participantRole = ParticipantRole::SIGNER, ): SignRequestStatus { + if ($participantRole === ParticipantRole::OBSERVER) { + if ($fileStatus === FileStatus::DRAFT->value) { + return SignRequestStatus::DRAFT; + } + + return SignRequestStatus::OBSERVING; + } + if ($fileStatus === FileStatus::DRAFT->value) { return SignRequestStatus::DRAFT; } diff --git a/openapi-full.json b/openapi-full.json index 117d87f4f7..ff13c13859 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -2178,6 +2178,9 @@ "type": "integer", "format": "int64" }, + "participantRole": { + "$ref": "#/components/schemas/ParticipantRole" + }, "geolocationRequired": { "type": "boolean" } @@ -2270,6 +2273,13 @@ } } }, + "ParticipantRole": { + "type": "string", + "enum": [ + "signer", + "observer" + ] + }, "PolicyScope": { "type": "string", "enum": [ @@ -2293,6 +2303,21 @@ } } }, + "PolicySnapshotBooleanEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "boolean" + }, + "sourceScope": { + "type": "string" + } + } + }, "PolicySnapshotEntry": { "type": "object", "required": [ @@ -3138,11 +3163,15 @@ "enum": [ 0, 1, - 2 + 2, + 3 ] }, "statusText": { "type": "string" + }, + "participantRole": { + "$ref": "#/components/schemas/ParticipantRole" } } }, @@ -3464,6 +3493,9 @@ }, "signer_geolocation": { "$ref": "#/components/schemas/PolicySnapshotSignerGeolocationEntry" + }, + "enable_observer_profile": { + "$ref": "#/components/schemas/PolicySnapshotBooleanEntry" } } }, diff --git a/openapi.json b/openapi.json index b33ed368f3..5104896347 100644 --- a/openapi.json +++ b/openapi.json @@ -1642,6 +1642,9 @@ "type": "integer", "format": "int64" }, + "participantRole": { + "$ref": "#/components/schemas/ParticipantRole" + }, "geolocationRequired": { "type": "boolean" } @@ -1734,6 +1737,13 @@ } } }, + "ParticipantRole": { + "type": "string", + "enum": [ + "signer", + "observer" + ] + }, "PolicyScope": { "type": "string", "enum": [ @@ -1742,6 +1752,21 @@ "user" ] }, + "PolicySnapshotBooleanEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "boolean" + }, + "sourceScope": { + "type": "string" + } + } + }, "PolicySnapshotEntry": { "type": "object", "required": [ @@ -2511,11 +2536,15 @@ "enum": [ 0, 1, - 2 + 2, + 3 ] }, "statusText": { "type": "string" + }, + "participantRole": { + "$ref": "#/components/schemas/ParticipantRole" } } }, @@ -2762,6 +2791,9 @@ }, "signer_geolocation": { "$ref": "#/components/schemas/PolicySnapshotSignerGeolocationEntry" + }, + "enable_observer_profile": { + "$ref": "#/components/schemas/PolicySnapshotBooleanEntry" } } }, diff --git a/playwright/e2e/confetti-after-signing-policy.spec.ts b/playwright/e2e/confetti-after-signing-policy.spec.ts index 15251e9c49..cd3024eff6 100644 --- a/playwright/e2e/confetti-after-signing-policy.spec.ts +++ b/playwright/e2e/confetti-after-signing-policy.spec.ts @@ -6,7 +6,11 @@ import { expect, test, type Page } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, deleteAppConfig, setAppConfig, setCertificateEngine, setSystemPolicy } from '../support/nc-provisioning' +import { configureOpenSsl, deleteAppConfig, resetUserSigningCertificate, setAppConfig, setCertificateEngine, setSystemPolicy } from '../support/nc-provisioning' +import { clickAddSigner, selectAccountSigner } from '../support/request-signature' +import { useFooterPolicyGuard } from '../support/system-policies' + +useFooterPolicyGuard() async function sortByCreatedAtDescending(page: Page) { const createdAtTh = page.getByRole('columnheader', { name: 'Created at' }) @@ -24,10 +28,8 @@ async function runSelfSigningFlow(page: Page): Promise { 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() - await page.getByRole('button', { name: 'Add signer' }).click() - await page.getByPlaceholder('Account').click() - await page.getByPlaceholder('Account').fill('a') - await page.locator('.account-or-email__option__title').filter({ hasText: /^admin$/ }).click() + await clickAddSigner(page) + await selectAccountSigner(page, 'a') await page.getByRole('button', { name: 'Save' }).click() await page.getByRole('button', { name: 'Request signatures' }).click() await page.getByRole('button', { name: 'Send' }).click() @@ -83,11 +85,11 @@ function confettiCanvasLocator(page: Page) { test.describe.configure({ mode: 'serial', retries: 0, timeout: 120000 }) test.beforeEach(async ({ page }) => { - await login( - page.request, - process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', - process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', - ) + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await resetUserSigningCertificate(page.request, adminUser, adminPassword) await configureOpenSsl(page.request, 'LibreSign Test', { C: 'BR', diff --git a/playwright/e2e/delete-pending-request.spec.ts b/playwright/e2e/delete-pending-request.spec.ts index 473607a15f..9c25014331 100644 --- a/playwright/e2e/delete-pending-request.spec.ts +++ b/playwright/e2e/delete-pending-request.spec.ts @@ -6,6 +6,10 @@ import { expect, test } from '@playwright/test' import { login } from '../support/nc-login' import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' +import { clickAddSigner, selectAccountSigner } from '../support/request-signature' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() test('delete pending signature request', async ({ page }) => { await login( @@ -38,9 +42,8 @@ test('delete pending signature request', async ({ page }) => { 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() - await page.getByRole('button', { name: 'Add signer' }).click() - await page.getByPlaceholder('Account').fill('a') - await page.getByRole('option', { name: 'admin@email.tld' }).click() + await clickAddSigner(page) + await selectAccountSigner(page, 'a') await page.getByRole('button', { name: 'Save' }).click() await page.getByRole('button', { name: 'Request signatures' }).click() await page.getByRole('button', { name: 'Send' }).click() diff --git a/playwright/e2e/envelope-validation-multi-file-bug.spec.ts b/playwright/e2e/envelope-validation-multi-file-bug.spec.ts index 1e4170e0f6..8fea6e74f1 100644 --- a/playwright/e2e/envelope-validation-multi-file-bug.spec.ts +++ b/playwright/e2e/envelope-validation-multi-file-bug.spec.ts @@ -11,6 +11,7 @@ import type { APIRequestContext, Page } from '@playwright/test' import { createMailpitClient, extractSignLink, waitForEmailTo } from '../support/mailpit' import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' import { getSmallValidPdfBase64 } from '../support/pdf-fixtures' +import { clickSignDocumentButton } from '../support/sign-flow' import { useRequestSignPolicyGuard } from '../support/system-policies' useRequestSignPolicyGuard() @@ -111,6 +112,15 @@ async function enableEnvelopeScenario(request: APIRequestContext) { }), ) await setSystemPolicy(request, 'make_validation_url_private', '0') + await setSystemPolicy(request, 'signature_stamp', JSON.stringify({ + template: '{{SignerCommonName}}', + template_font_size: 9.8, + signature_font_size: 20, + signature_width: 350, + signature_height: 100, + render_mode: 'default', + background_type: 'default', + })) } /** @@ -211,23 +221,12 @@ async function openInvitationAsExternalSigner(page: Page, signLink: string) { throw new Error(`Invitation link redirected to login instead of public sign page: ${page.url()}`) } -/** - * - * @param page - */ -async function defineClickToSignature(page: Page) { - // Wait for click-to-sign button - await expect(page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' })).toBeVisible({ timeout: 15_000 }) -} - /** * * @param page */ async function finishSigning(page: Page) { - const signButton = page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }) - await expect(signButton).toBeVisible({ timeout: 15_000 }) - await signButton.click({ force: true }) + await clickSignDocumentButton(page) const confirmSignButton = page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }) await expect(confirmSignButton).toBeVisible({ timeout: 15_000 }) await confirmSignButton.click() @@ -250,7 +249,6 @@ test('validation screen should display all data correctly for envelope with 2 fi }) await test.step('And completes the signing process with click-to-sign', async () => { - await defineClickToSignature(page) await finishSigning(page) }) diff --git a/playwright/e2e/files-new-signature-request.spec.ts b/playwright/e2e/files-new-signature-request.spec.ts index 20119615aa..82418b83d7 100644 --- a/playwright/e2e/files-new-signature-request.spec.ts +++ b/playwright/e2e/files-new-signature-request.spec.ts @@ -9,6 +9,7 @@ import { login } from '../support/nc-login' import { ensureFilesHomeInitialized, waitForFilesNewMenuEntry } from '../support/nc-files' import { configureOpenSsl } from '../support/nc-provisioning' import { getSmallValidPdfBuffer } from '../support/pdf-fixtures' +import { expectAddSignerControlVisible } from '../support/request-signature' import { useRequestSignPolicyGuard } from '../support/system-policies' useRequestSignPolicyGuard() @@ -57,7 +58,7 @@ test('new signature request opens LibreSign tab and does not duplicate file row' const libresignTab = page.getByRole('tab', { name: 'LibreSign' }) await expect(libresignTab).toHaveAttribute('aria-selected', 'true', { timeout: 15000 }) - await expect(page.getByRole('button', { name: 'Add signer' })).toBeVisible({ timeout: 15000 }) + await expectAddSignerControlVisible(page) const filesTable = page.getByRole('table', { name: /List of your files and folders/i, diff --git a/playwright/e2e/files-open-in-libresign-context-menu.spec.ts b/playwright/e2e/files-open-in-libresign-context-menu.spec.ts index b0dda2270b..e98d04274a 100644 --- a/playwright/e2e/files-open-in-libresign-context-menu.spec.ts +++ b/playwright/e2e/files-open-in-libresign-context-menu.spec.ts @@ -9,6 +9,7 @@ import { login } from '../support/nc-login' import { ensureFilesHomeInitialized, uploadFileToFilesApp, waitForFilesAction } from '../support/nc-files' import { configureOpenSsl } from '../support/nc-provisioning' import { getSmallValidPdfBuffer } from '../support/pdf-fixtures' +import { expectAddSignerControlVisible } from '../support/request-signature' import { useRequestSignPolicyGuard } from '../support/system-policies' useRequestSignPolicyGuard() @@ -64,6 +65,6 @@ test('open PDF in LibreSign from Files context menu', async ({ page }) => { const libresignTab = page.getByRole('tab', { name: 'LibreSign' }) await expect(libresignTab).toHaveAttribute('aria-selected', 'true', { timeout: 15000 }) - await expect(page.getByRole('button', { name: 'Add signer' })).toBeVisible({ timeout: 15000 }) + await expectAddSignerControlVisible(page) await expect(page.locator('.app-sidebar-header__mainname')).toHaveText(fileName, { timeout: 15000 }) }) diff --git a/playwright/e2e/multi-signer-parallel.spec.ts b/playwright/e2e/multi-signer-parallel.spec.ts index 5716ba31e3..2c13dc185b 100644 --- a/playwright/e2e/multi-signer-parallel.spec.ts +++ b/playwright/e2e/multi-signer-parallel.spec.ts @@ -8,6 +8,7 @@ import { expect, test } from '@playwright/test' import { createMailpitClient, waitForEmailTo } from '../support/mailpit' import { login } from '../support/nc-login' import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' +import { clickAddSigner, selectEmailSigner } from '../support/request-signature' import { useRequestSignPolicyGuard } from '../support/system-policies' useRequestSignPolicyGuard() @@ -48,18 +49,14 @@ test('request signatures from two signers in parallel', async ({ page }) => { 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 clickAddSigner(page) + await selectEmailSigner(page, 'signer01@libresign.coop') 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 clickAddSigner(page) + await selectEmailSigner(page, 'signer02@libresign.coop') await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 02') await page.getByRole('button', { name: 'Save' }).click() diff --git a/playwright/e2e/multi-signer-sequential.spec.ts b/playwright/e2e/multi-signer-sequential.spec.ts index 41c88716cd..6164705b98 100644 --- a/playwright/e2e/multi-signer-sequential.spec.ts +++ b/playwright/e2e/multi-signer-sequential.spec.ts @@ -10,6 +10,8 @@ import { createMailpitClient, waitForEmailTo, extractSignLink } from '../support import { login } from '../support/nc-login' import { configureOpenSsl, deleteAppConfig, getAppConfig, setAppConfig, setCertificateEngine, getSystemPolicyValue, setSystemPolicy } from '../support/nc-provisioning' import { setSystemPolicyEntry } from '../support/policy-api' +import { clickAddSigner, selectEmailSigner } from '../support/request-signature' +import { clickSignDocumentButton } from '../support/sign-flow' import { makeAdminContext, useRequestSignPolicyGuard } from '../support/system-policies' useRequestSignPolicyGuard() @@ -80,13 +82,8 @@ async function addEmailSigner( email: string, name: string, ) { - await page.getByRole('button', { name: 'Add signer' }).click() - const emailInput = page.getByPlaceholder('Email') - await emailInput.click() - await emailInput.pressSequentially(email, { delay: 50 }) - const option = page.getByRole('option', { name: email }) - await expect(option).toBeVisible({ timeout: 10_000 }) - await option.click() + await clickAddSigner(page) + await selectEmailSigner(page, email) const signerNameInput = page.getByRole('textbox', { name: 'Signer name' }) await expect(signerNameInput).toBeVisible() await signerNameInput.fill(name) @@ -127,11 +124,8 @@ async function openInvitationAsExternalSigner(page: Page, signLink: string) { } async function openSignDocument(page: Page) { - const signButton = page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }) - await expect(signButton).toBeVisible({ timeout: 15_000 }) - await signButton.click({ force: true }) - const confirmSignButton = page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }) - await expect(confirmSignButton).toBeVisible({ timeout: 15_000 }) + await clickSignDocumentButton(page) + await expect(page.getByRole('dialog', { name: 'Sign document' })).toBeVisible({ timeout: 15_000 }) } test('request signatures from two signers in sequential order', async ({ page, adminContext }) => { diff --git a/playwright/e2e/observer-participant-flow.spec.ts b/playwright/e2e/observer-participant-flow.spec.ts new file mode 100644 index 0000000000..1547662062 --- /dev/null +++ b/playwright/e2e/observer-participant-flow.spec.ts @@ -0,0 +1,103 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' + +import { createMailpitClient, extractSignLink, extractValidationLink, waitForEmailTo } from '../support/mailpit' +import { login } from '../support/nc-login' +import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' +import { clickAddObserver, clickAddSigner, selectEmailSigner } from '../support/request-signature' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() + +test('observer receives validation link and cannot enter signing flow', 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 setSystemPolicy(page.request, 'enable_observer_profile', JSON.stringify(true)) + await setSystemPolicy(page.request, 'make_validation_url_private', '0') + await setSystemPolicy( + page.request, + 'identify_methods', + JSON.stringify({ + can_create_account: false, + factors: [ + { name: 'account', enabled: false, requirement: 'optional' }, + { name: 'email', enabled: true, requirement: 'required', signatureMethods: { clickToSign: { enabled: true } } }, + ], + }), + ) + + try { + 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() + + await clickAddSigner(page) + await selectEmailSigner(page, 'signer01@libresign.coop') + await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 01') + await page.getByRole('button', { name: 'Save' }).click() + + await clickAddObserver(page) + await selectEmailSigner(page, 'observer01@libresign.coop') + await page.getByRole('textbox', { name: 'Observer name' }).fill('Observer 01') + await page.getByRole('button', { name: 'Save' }).click() + + const signersSection = page.locator('.participants-section').filter({ + has: page.getByRole('heading', { name: 'Signers', exact: true }), + }) + const observersSection = page.locator('.participants-section').filter({ + has: page.getByRole('heading', { name: 'Observers', exact: true }), + }) + await expect(signersSection.getByText('Signer 01', { exact: true })).toBeVisible() + await expect(observersSection.getByText('Observer 01', { exact: true })).toBeVisible() + + await page.getByRole('button', { name: 'Request signatures' }).click() + await page.getByRole('button', { name: 'Send' }).click() + + const signerEmail = await waitForEmailTo( + mailpit, + 'signer01@libresign.coop', + 'LibreSign: A document is ready for your signature', + ) + const observerEmail = await waitForEmailTo( + mailpit, + 'observer01@libresign.coop', + 'LibreSign: A document is ready for signature', + ) + + const signerLink = extractSignLink(signerEmail.Text || signerEmail.HTML || '') + expect(signerLink).toBeTruthy() + expect(signerLink).toMatch(/\/p\/sign\//) + + const observerLink = extractValidationLink(observerEmail.Text || observerEmail.HTML || '') + expect(observerLink).toBeTruthy() + expect(observerLink).toMatch(/validation\//) + expect(extractSignLink(observerEmail.Text || observerEmail.HTML || '')).toBeNull() + + await page.goto(`.${observerLink}`) + await page.waitForURL('**/validation/**', { waitUntil: 'commit' }) + await expect(page).not.toHaveURL(/\/p\/sign\//) + await expect(page.getByRole('button', { name: 'Sign', exact: true })).toHaveCount(0) + } finally { + await setSystemPolicy(page.request, 'enable_observer_profile', JSON.stringify(false)) + } +}) diff --git a/playwright/e2e/policy-workbench-collect-metadata-rule-management.spec.ts b/playwright/e2e/policy-workbench-collect-metadata-rule-management.spec.ts index 8427b54b79..5fabb1e329 100644 --- a/playwright/e2e/policy-workbench-collect-metadata-rule-management.spec.ts +++ b/playwright/e2e/policy-workbench-collect-metadata-rule-management.spec.ts @@ -6,7 +6,7 @@ import { expect, test } from '@playwright/test' import { bootstrapLibreSignAdmin, ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' -import { waitForPolicyWorkbenchIdle } from '../support/policy-workbench-rules' +import { clearPolicyWorkbenchRules, openPolicyWorkbenchSystemRuleEditor, waitForPolicyWorkbenchIdle } from '../support/policy-workbench-rules' test.describe.configure({ mode: 'serial', retries: 0, timeout: 120000 }) @@ -20,25 +20,21 @@ test('collect_metadata allows creating and persisting a system rule from workben const dialog = page.getByRole('dialog').filter({ hasText: /Collect signer metadata/i }).first() await expect(dialog).toBeVisible({ timeout: 10000 }) await page.getByText(/Loading rules/i).waitFor({ state: 'hidden', timeout: 20000 }).catch(() => {}) + await clearPolicyWorkbenchRules(dialog) - const createRuleButton = page.getByRole('button', { name: /Create rule/i }).first() - await expect(createRuleButton).toBeVisible({ timeout: 10000 }) - await createRuleButton.click() - - const createScopeDialog = page.getByRole('dialog').filter({ hasText: /What do you want to create\?/i }).last() - if (await createScopeDialog.isVisible().catch(() => false)) { - await createScopeDialog.getByRole('option', { name: /^Everyone\b/i }).first().click() - } - - const createDialog = page.getByRole('dialog', { name: /Create rule/i }).last() + const createDialog = await openPolicyWorkbenchSystemRuleEditor(dialog) await expect(createDialog).toBeVisible({ timeout: 10000 }) - const enableOption = createDialog.getByRole('radio', { name: /Collect signer metadata/i }).first() - if (await enableOption.isVisible().catch(() => false)) { - await enableOption.click({ force: true }) - await expect(enableOption).toBeChecked({ timeout: 5000 }) + const selectMetadataOption = async (enabled: boolean) => { + const label = enabled ? /Collect signer metadata/i : /Disable metadata collection/i + const option = createDialog.locator('.checkbox-radio-switch').filter({ hasText: label }).first() + await expect(option).toBeVisible({ timeout: 10_000 }) + await option.locator('.checkbox-radio-switch__content').click() } + await selectMetadataOption(false) + await selectMetadataOption(true) + const saveResponse = page.waitForResponse((response) => { return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) && response.url().includes('/apps/libresign/api/v1/policies/system/collect_metadata') diff --git a/playwright/e2e/policy-workbench-identification-documents-rule-management.spec.ts b/playwright/e2e/policy-workbench-identification-documents-rule-management.spec.ts index a0a229070f..2ca84ff3e5 100644 --- a/playwright/e2e/policy-workbench-identification-documents-rule-management.spec.ts +++ b/playwright/e2e/policy-workbench-identification-documents-rule-management.spec.ts @@ -6,7 +6,7 @@ import { expect, test } from '@playwright/test' import { bootstrapLibreSignAdmin, ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' -import { waitForPolicyWorkbenchIdle } from '../support/policy-workbench-rules' +import { clearPolicyWorkbenchRules, openPolicyWorkbenchSystemRuleEditor, waitForPolicyWorkbenchIdle } from '../support/policy-workbench-rules' test.describe.configure({ mode: 'serial', retries: 0, timeout: 120000 }) @@ -20,20 +20,22 @@ test('identification_documents allows creating and persisting a system rule from const dialog = page.getByRole('dialog').filter({ hasText: /Identification documents flow/i }).first() await expect(dialog).toBeVisible({ timeout: 10000 }) await page.getByText(/Loading rules/i).waitFor({ state: 'hidden', timeout: 20000 }).catch(() => {}) + await clearPolicyWorkbenchRules(dialog) - const createRuleButton = page.getByRole('button', { name: /Create rule/i }).first() - await expect(createRuleButton).toBeVisible({ timeout: 10000 }) - await createRuleButton.click() + const createDialog = await openPolicyWorkbenchSystemRuleEditor(dialog) + await expect(createDialog).toBeVisible({ timeout: 10000 }) - const createScopeDialog = page.getByRole('dialog').filter({ hasText: /What do you want to create\?/i }).last() - if (await createScopeDialog.isVisible().catch(() => false)) { - await createScopeDialog.getByRole('option', { name: /^Everyone\b/i }).first().click() + const enableOption = createDialog.locator('.checkbox-radio-switch').filter({ hasText: /Enable identification documents flow/i }).first() + const disableOption = createDialog.locator('.checkbox-radio-switch').filter({ hasText: /Disable identification documents flow/i }).first() + if (await enableOption.isVisible().catch(() => false)) { + if (await disableOption.isVisible().catch(() => false)) { + await disableOption.locator('.checkbox-radio-switch__content').click() + } + await enableOption.locator('.checkbox-radio-switch__content').click() + } else { + await createDialog.getByText('Enable identification documents flow', { exact: true }).first().click() } - const createDialog = page.getByRole('dialog', { name: /Create rule/i }).last() - await expect(createDialog).toBeVisible({ timeout: 10000 }) - await createDialog.getByText('Enable identification documents flow', { exact: true }).first().click() - const submitButton = createDialog.getByRole('button', { name: /Create rule|Save changes/i }).first() await expect(submitButton).toBeEnabled({ timeout: 10000 }) const [response] = await Promise.all([ diff --git a/playwright/e2e/policy-workbench-signature-stamp-rule-management.spec.ts b/playwright/e2e/policy-workbench-signature-stamp-rule-management.spec.ts index 9176edeef5..fed08c18bf 100644 --- a/playwright/e2e/policy-workbench-signature-stamp-rule-management.spec.ts +++ b/playwright/e2e/policy-workbench-signature-stamp-rule-management.spec.ts @@ -38,12 +38,23 @@ test.describe('P06: signature_stamp persists a system rule from the workbench UI // Wait for the workbench editor to be ready await waitForPolicyWorkbenchIdle(page) - // Change render mode to a different option (e.g., "text only") - // The signature stamp has radio options for different render modes - const textOnlyOption = ruleDialog.getByText('Signature only', { exact: true }).first() - if (await textOnlyOption.isVisible()) { - await textOnlyOption.click() - await page.waitForTimeout(500) // Allow UI update + // Pick a render mode different from the current selection so save becomes enabled. + const renderModeLabels = [ + 'Description only', + 'Signer name and description', + 'Signature and description', + 'Signature only', + ] + for (const label of renderModeLabels) { + const option = ruleDialog.getByText(label, { exact: true }).first() + if (!(await option.isVisible().catch(() => false))) { + continue + } + await option.click() + const saveButton = ruleDialog.getByRole('button', { name: /Save|Create rule|Save changes/i }).first() + if (await saveButton.isEnabled().catch(() => false)) { + break + } } // Save the change via the Save button diff --git a/playwright/e2e/send-reminder.spec.ts b/playwright/e2e/send-reminder.spec.ts index 88212d51be..ccba09337e 100644 --- a/playwright/e2e/send-reminder.spec.ts +++ b/playwright/e2e/send-reminder.spec.ts @@ -7,6 +7,7 @@ import { expect, test } from '@playwright/test' import { login } from '../support/nc-login' import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo } from '../support/mailpit' +import { clickAddSigner, selectEmailSigner } from '../support/request-signature' import { useRequestSignPolicyGuard } from '../support/system-policies' useRequestSignPolicyGuard() @@ -53,10 +54,8 @@ test('admin can send a reminder to a pending signer', async ({ page }) => { await page.getByRole('button', { name: 'Send' }).click() // Only the email method is active — no tabs in the Add signer dialog - 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 clickAddSigner(page) + await selectEmailSigner(page, 'signer01@libresign.coop') await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 01') await page.getByRole('button', { name: 'Save' }).click() diff --git a/playwright/e2e/sign-email-token-authenticated.spec.ts b/playwright/e2e/sign-email-token-authenticated.spec.ts index 9bbd5a501e..d57503b8ae 100644 --- a/playwright/e2e/sign-email-token-authenticated.spec.ts +++ b/playwright/e2e/sign-email-token-authenticated.spec.ts @@ -5,8 +5,9 @@ import { test, expect } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, deleteAppConfig, setCertificateEngine, setSystemPolicy } from '../support/nc-provisioning' +import { configureOpenSsl, deleteAppConfig, resetUserSigningCertificate, setCertificateEngine, setSystemPolicy } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink, extractTokenFromEmail } from '../support/mailpit' +import { clickAddSigner, selectEmailSigner } from '../support/request-signature' import { useFooterPolicyGuard, useRequestSignPolicyGuard } from '../support/system-policies' useFooterPolicyGuard() @@ -24,11 +25,11 @@ test.setTimeout(120_000) * email matches the signer email in throwIfIsAuthenticatedWithDifferentAccount). */ test('sign document with email token as authenticated signer', async ({ page }) => { - await login( - page.request, - process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', - process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', - ) + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await resetUserSigningCertificate(page.request, adminUser, adminPassword) await configureOpenSsl(page.request, 'LibreSign Test', { C: 'BR', @@ -59,10 +60,8 @@ test('sign document with email token as authenticated signer', async ({ page }) await page.getByRole('button', { name: 'Send' }).click() // Add signer by email to exercise the email-token flow deterministically. - await page.getByRole('button', { name: 'Add signer' }).click() - await page.getByPlaceholder('Email').click() - await page.getByPlaceholder('Email').pressSequentially('admin@email.tld', { delay: 50 }) - await page.getByRole('option', { name: 'admin@email.tld' }).first().click() + await clickAddSigner(page) + await selectEmailSigner(page, 'admin@email.tld') await page.getByRole('textbox', { name: 'Signer name' }).first().fill('Admin') await page.getByRole('button', { name: 'Save' }).click() diff --git a/playwright/e2e/sign-email-token-unauthenticated.spec.ts b/playwright/e2e/sign-email-token-unauthenticated.spec.ts index c4a85d6cc8..040a1c32ef 100644 --- a/playwright/e2e/sign-email-token-unauthenticated.spec.ts +++ b/playwright/e2e/sign-email-token-unauthenticated.spec.ts @@ -106,7 +106,7 @@ test('sign document with email token as unauthenticated signer', async ({ page } if (!invitationOpened) { throw new Error(`Invitation link redirected to login instead of public sign page: ${page.url()}`) } - const openSignButton = page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }).first() + const openSignButton = page.getByRole('button', { name: 'Sign document' }).first() const emailTextbox = page.getByRole('textbox', { name: 'Email' }).first() await Promise.any([ openSignButton.waitFor({ state: 'visible', timeout: 10_000 }), @@ -114,7 +114,8 @@ test('sign document with email token as unauthenticated signer', async ({ page } ]) if (!await emailTextbox.isVisible()) { await expect(openSignButton).toBeVisible({ timeout: 15_000 }) - await openSignButton.click({ force: true }) + await openSignButton.scrollIntoViewIfNeeded() + await openSignButton.click() } await expect(emailTextbox).toBeVisible() await emailTextbox.click(); diff --git a/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts b/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts index d2cee78a4b..c7cbe2ae5f 100644 --- a/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts +++ b/playwright/e2e/sign-envelope-unauthenticated-visible-signature.spec.ts @@ -9,6 +9,7 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test' import { createMailpitClient, extractSignLink, waitForEmailTo } from '../support/mailpit' import { configureOpenSsl, setCertificateEngine, setSystemPolicy } from '../support/nc-provisioning' import { getSmallValidPdfBase64 } from '../support/pdf-fixtures' +import { clickSignDocumentButton } from '../support/sign-flow' import { useFooterPolicyGuard, useRequestSignPolicyGuard } from '../support/system-policies' useFooterPolicyGuard() @@ -100,6 +101,15 @@ async function enableEnvelopeScenario(request: APIRequestContext) { ], }), ) + await setSystemPolicy(request, 'signature_stamp', JSON.stringify({ + template: '{{SignerCommonName}}', + template_font_size: 9.8, + signature_font_size: 20, + signature_width: 350, + signature_height: 100, + render_mode: 'default', + background_type: 'default', + })) } function findSigner(files: OcsEnvelopeChildFile[] | undefined, scenario: EnvelopeSigningScenario) { @@ -209,21 +219,21 @@ async function drawSignatureOnCanvas(signatureDialog: Locator, page: Page) { } async function defineVisibleSignature(page: Page) { - const openSignButton = page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }) - const defineSignatureButton = page.locator('.button-wrapper').getByRole('button', { name: /Define your signature\.?/i }).first() - if (!await defineSignatureButton.isVisible().catch(() => false)) { - if (await openSignButton.isVisible().catch(() => false)) { - await openSignButton.click({ force: true }) - } - } + const defineSignatureButton = page.getByRole('button', { name: /Define your signature\.?/i }).first() + const deleteSignatureButton = page.getByRole('button', { name: /Delete signature/i }).first() + const createSignatureLink = page.getByText(/No signature, click here to create a new one/i).first() - const deleteSignatureButton = page.getByRole('button', { name: 'Delete signature' }) if (await deleteSignatureButton.isVisible().catch(() => false)) { await deleteSignatureButton.click() + await expect(defineSignatureButton.or(createSignatureLink)).toBeVisible({ timeout: 15_000 }) } - await expect(defineSignatureButton).toBeVisible({ timeout: 15_000 }) - await defineSignatureButton.click({ force: true }) + if (await createSignatureLink.isVisible().catch(() => false)) { + await createSignatureLink.click() + } else { + await expect(defineSignatureButton).toBeVisible({ timeout: 15_000 }) + await defineSignatureButton.click() + } const signatureDialog = page.getByRole('dialog', { name: 'Customize your signatures' }) await expect(signatureDialog).toBeVisible() @@ -234,15 +244,11 @@ async function defineVisibleSignature(page: Page) { await expect(confirmDialog).toBeVisible() await confirmDialog.getByRole('button', { name: 'Save' }).click() - const signDocumentCta = page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }) - await expect(signDocumentCta).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('button', { name: 'Sign document' }).first()).toBeVisible({ timeout: 15_000 }) } async function finishSigning(page: Page) { - const openSignButton = page.locator('.button-wrapper').getByRole('button', { name: 'Sign document' }) - if (await openSignButton.isVisible().catch(() => false)) { - await openSignButton.click({ force: true }) - } + await clickSignDocumentButton(page) await page.getByRole('dialog', { name: 'Sign document' }).getByRole('button', { name: 'Sign document' }).click() } diff --git a/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts b/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts index 8e4544129d..e05e928c76 100644 --- a/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts +++ b/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts @@ -6,7 +6,7 @@ import { expect, test, type Page } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, setCertificateEngine, setSystemPolicy } from '../support/nc-provisioning' +import { configureOpenSsl, resetUserSigningCertificate, setCertificateEngine, setSystemPolicy } from '../support/nc-provisioning' import { getSmallValidPdfBase64 } from '../support/pdf-fixtures.ts' import { useFooterPolicyGuard, useRequestSignPolicyGuard } from '../support/system-policies' @@ -108,11 +108,13 @@ async function sortByCreatedAtDescending(page: Page) { test('updates files list status after signing with native engine', async ({ page }) => { const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' await login( page.request, adminUser, - process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + adminPassword, ) + await resetUserSigningCertificate(page.request, adminUser, adminPassword) await configureOpenSsl(page.request, 'LibreSign Test', { C: 'BR', 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 1f432d883a..7d830709d1 100644 --- a/playwright/e2e/sign-herself-with-click-to-sign.spec.ts +++ b/playwright/e2e/sign-herself-with-click-to-sign.spec.ts @@ -6,17 +6,19 @@ import { expect, test } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' -import { useRequestSignPolicyGuard } from '../support/system-policies' +import { configureOpenSsl, resetUserSigningCertificate, setSystemPolicy } from '../support/nc-provisioning' +import { clickAddSigner, selectAccountSigner } from '../support/request-signature' +import { useFooterPolicyGuard, useRequestSignPolicyGuard } from '../support/system-policies' +useFooterPolicyGuard() useRequestSignPolicyGuard() test('sign herself with click to sign', async ({ page }) => { - await login( - page.request, - process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', - process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', - ) + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await resetUserSigningCertificate(page.request, adminUser, adminPassword) await configureOpenSsl(page.request, 'LibreSign Test', { C: 'BR', @@ -41,10 +43,8 @@ test('sign herself with click to sign', async ({ page }) => { 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() - await page.getByRole('button', { name: 'Add signer' }).click() - await page.getByPlaceholder('Account').click() - await page.getByPlaceholder('Account').fill('a') - await page.locator('.account-or-email__option__title').filter({ hasText: /^admin$/ }).click() + await clickAddSigner(page) + await selectAccountSigner(page, 'a') await page.getByRole('button', { name: 'Save' }).click() await page.getByRole('button', { name: 'Request signatures' }).click() await page.getByRole('button', { name: 'Send' }).click() diff --git a/playwright/e2e/sign-herself-with-drawn-signature.spec.ts b/playwright/e2e/sign-herself-with-drawn-signature.spec.ts index afbc8689ed..de69d6640d 100644 --- a/playwright/e2e/sign-herself-with-drawn-signature.spec.ts +++ b/playwright/e2e/sign-herself-with-drawn-signature.spec.ts @@ -7,11 +7,16 @@ import { expect, test } from '@playwright/test' import type { Locator, Page } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' -import { useRequestSignPolicyGuard } from '../support/system-policies' +import { configureOpenSsl, clearSignatureElements, resetUserSigningCertificate, setSystemPolicy } from '../support/nc-provisioning' +import { clickAddSigner, selectAccountSigner } from '../support/request-signature' +import { clickSignDocumentButton } from '../support/sign-flow' +import { useFooterPolicyGuard, useRequestSignPolicyGuard } from '../support/system-policies' +useFooterPolicyGuard() useRequestSignPolicyGuard() +test.setTimeout(120_000) + /** * * @param dialog @@ -45,11 +50,21 @@ async function drawSignatureOnCanvas(signatureDialog: Locator, page: Page) { } test('sign herself with drawn signature', async ({ page }) => { - await login( - page.request, - process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', - process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', - ) + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await resetUserSigningCertificate(page.request, adminUser, adminPassword) + await clearSignatureElements(page.request, adminUser, adminPassword).catch(() => {}) + await setSystemPolicy(page.request, 'signature_stamp', JSON.stringify({ + template: '{{SignerCommonName}}', + template_font_size: 9.8, + signature_font_size: 20, + signature_width: 350, + signature_height: 100, + render_mode: 'default', + background_type: 'default', + })) await configureOpenSsl(page.request, 'LibreSign Test', { C: 'BR', @@ -75,10 +90,8 @@ test('sign herself with drawn signature', async ({ page }) => { 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.locator('.account-or-email__option__title').filter({ hasText: /^admin$/ }).click() + await clickAddSigner(page) + await selectAccountSigner(page, 'a') await page.getByRole('textbox', { name: 'Signer name' }).click() await page.getByRole('textbox', { name: 'Signer name' }).press('ControlOrMeta+a') @@ -128,7 +141,9 @@ test('sign herself with drawn signature', async ({ page }) => { page.getByLabel('PDF document to sign').getByRole('img', { name: 'Signature position for Admin Name' }) ).toBeVisible({ timeout: 15000 }) - await page.getByRole('button', { name: 'Define your signature.' }).click() + const defineSignatureButton = page.getByRole('button', { name: /Define your signature\.?/i }).first() + await expect(defineSignatureButton).toBeVisible({ timeout: 15_000 }) + await defineSignatureButton.click() const signatureDialog = page.getByRole('dialog', { name: 'Customize your signatures' }) await expect(signatureDialog).toBeVisible() @@ -136,10 +151,9 @@ test('sign herself with drawn signature', async ({ page }) => { await page.getByRole('button', { name: 'Save' }).click() await expect(page.getByRole('heading', { name: 'Confirm your signature' })).toBeVisible() await page.getByLabel('Confirm your signature').getByRole('button', { name: 'Save' }).click() - const signButton = page.locator('.sign-pdf-sidebar .button-wrapper').getByRole('button', { name: 'Sign document' }) - await expect(signButton).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('button', { name: 'Sign document' }).first()).toBeVisible({ timeout: 15_000 }) - await signButton.click({ force: true }) + await clickSignDocumentButton(page) const signResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && response.url().includes('/apps/libresign/api/v1/sign/'), diff --git a/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts b/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts index 08beb1adea..4bfde9b72d 100644 --- a/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts +++ b/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts @@ -6,10 +6,14 @@ import { expect, test } from '@playwright/test' import { login } from '../support/nc-login' import { configureOpenSsl, deleteUserPfx, setSystemPolicy } from '../support/nc-provisioning' -import { useRequestSignPolicyGuard } from '../support/system-policies' +import { clickAddSigner, selectAccountSigner } from '../support/request-signature' +import { useFooterPolicyGuard, useRequestSignPolicyGuard } from '../support/system-policies' +useFooterPolicyGuard() useRequestSignPolicyGuard() +test.setTimeout(120_000) + test('sign herself with pkcs12 certificate', async ({ page }) => { const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' @@ -44,9 +48,8 @@ test('sign herself with pkcs12 certificate', async ({ page }) => { 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() - await page.getByRole('button', { name: 'Add signer' }).click() - await page.getByPlaceholder('Account').fill(adminUser) - await page.getByText('admin@email.tld').click() + await clickAddSigner(page) + await selectAccountSigner(page, adminUser) await page.getByRole('button', { name: 'Save' }).click() await page.getByRole('button', { name: 'Request signatures' }).click() await page.getByRole('button', { name: 'Send' }).click() diff --git a/playwright/e2e/sign-password-non-retriable-error.spec.ts b/playwright/e2e/sign-password-non-retriable-error.spec.ts index 382cdcc2d9..5e1751d500 100644 --- a/playwright/e2e/sign-password-non-retriable-error.spec.ts +++ b/playwright/e2e/sign-password-non-retriable-error.spec.ts @@ -6,6 +6,7 @@ import { expect, test } from '@playwright/test' import { login } from '../support/nc-login' import { configureOpenSsl, deleteUserPfx, setSystemPolicy } from '../support/nc-provisioning' +import { clickAddSigner, selectAccountSigner } from '../support/request-signature' import { useRequestSignPolicyGuard } from '../support/system-policies' useRequestSignPolicyGuard() @@ -16,9 +17,8 @@ async function prepareSignFlow(page: Parameters[1] extends (args: i 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() - await page.getByRole('button', { name: 'Add signer' }).click() - await page.getByPlaceholder('Account').fill(adminUser) - await page.getByText('admin@email.tld').click() + await clickAddSigner(page) + await selectAccountSigner(page, adminUser) await page.getByRole('button', { name: 'Save' }).click() await page.getByRole('button', { name: 'Request signatures' }).click() await page.getByRole('button', { name: 'Send' }).click() diff --git a/playwright/e2e/sign-wrong-session.spec.ts b/playwright/e2e/sign-wrong-session.spec.ts index 1b230397a9..94ddede303 100644 --- a/playwright/e2e/sign-wrong-session.spec.ts +++ b/playwright/e2e/sign-wrong-session.spec.ts @@ -7,6 +7,7 @@ import { expect, test } from '@playwright/test' import { login } from '../support/nc-login' import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink } from '../support/mailpit' +import { clickAddSigner } from '../support/request-signature' import { useRequestSignPolicyGuard } from '../support/system-policies' useRequestSignPolicyGuard() @@ -59,7 +60,7 @@ test('authenticated user sees authentication guidance when accessing another sig await page.getByRole('button', { name: 'Send' }).click() // Email signer — only the email method is active so there are no tabs in the Add signer dialog. - await page.getByRole('button', { name: 'Add signer' }).click() + await clickAddSigner(page) await page.getByPlaceholder('Email').fill('signer01@libresign.coop') await page.getByRole('option', { name: 'signer01@libresign.coop' }).click() await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 01') diff --git a/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts b/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts index 090f706fc1..f0e9aab8e7 100644 --- a/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts +++ b/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts @@ -18,6 +18,7 @@ import { createAuthenticatedRequestContext, setSystemPolicyEntry, } from '../support/policy-api' +import { clickAddSigner, selectEmailSigner } from '../support/request-signature' import { useRequestSignPolicyGuard } from '../support/system-policies' useRequestSignPolicyGuard() @@ -51,12 +52,9 @@ test.describe.configure({ mode: 'serial' }) async function addEmailSigner(page: Page, email: string, name: string) { + await clickAddSigner(page) + await selectEmailSigner(page, email) const dialog = page.getByRole('dialog', { name: 'Add new signer' }) - await page.getByRole('button', { name: 'Add signer' }).click() - await dialog.getByPlaceholder('Email').click() - await dialog.getByPlaceholder('Email').pressSequentially(email, { delay: 50 }) - await expect(page.getByRole('option', { name: email })).toBeVisible({ timeout: 10_000 }) - await page.getByRole('option', { name: email }).click() await dialog.getByRole('textbox', { name: 'Signer name' }).fill(name) const saveSignerResponsePromise = page.waitForResponse((response) => { diff --git a/playwright/e2e/signature-footer-template-editor.spec.ts b/playwright/e2e/signature-footer-template-editor.spec.ts index 740875761a..ab0a3d659d 100644 --- a/playwright/e2e/signature-footer-template-editor.spec.ts +++ b/playwright/e2e/signature-footer-template-editor.spec.ts @@ -131,8 +131,9 @@ async function getFooterEditorContext(scope: Locator): Promise<{ async function replaceCodeMirrorContent(editorField: Locator, value: string): Promise { await editorField.click() - await editorField.press('Control+a') - await editorField.fill(value) + await editorField.selectText() + await editorField.page().keyboard.insertText(value) + await expect(editorField).toHaveText(value) } async function saveRule(ruleDialog: Locator): Promise<{ request: Request, response: Response }> { diff --git a/playwright/e2e/visible-element-persistence.spec.ts b/playwright/e2e/visible-element-persistence.spec.ts index 30fc5b54b8..c6cbd7a5b3 100644 --- a/playwright/e2e/visible-element-persistence.spec.ts +++ b/playwright/e2e/visible-element-persistence.spec.ts @@ -7,6 +7,10 @@ import { expect, test } from '@playwright/test' import type { Locator } from '@playwright/test' import { login } from '../support/nc-login' import { configureOpenSsl, setSystemPolicy } from '../support/nc-provisioning' +import { clickAddSigner, selectAccountSigner } from '../support/request-signature' +import { useRequestSignPolicyGuard } from '../support/system-policies' + +useRequestSignPolicyGuard() function getVisiblePdfOverlay(dialog: Locator) { return dialog.locator('.overlay:visible').first() @@ -66,9 +70,8 @@ test('visible signature element persists and can be deleted', async ({ page }) = 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() - await page.getByRole('button', { name: 'Add signer' }).click() - await page.getByPlaceholder('Account').fill('a') - await page.getByRole('option', { name: 'admin@email.tld' }).click() + await clickAddSigner(page) + await selectAccountSigner(page, 'a') 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') diff --git a/playwright/support/mailpit.ts b/playwright/support/mailpit.ts index 59940e860b..72641dc118 100644 --- a/playwright/support/mailpit.ts +++ b/playwright/support/mailpit.ts @@ -89,6 +89,22 @@ export function extractSignLink(body: string): string | null { return normalizedMatch.replace(/^\/index\.php/, '') } +/** Extracts a LibreSign validation link from an email body matching /p/validation/{uuid} or /validation/{uuid}. */ +export function extractValidationLink(body: string): string | null { + const match = body.match(/(?:https?:\/\/[^\s"'<>)]*)?\/(?:index\.php\/)?(?:[^\s"'<>)]*\/)?(?:p\/)?validation\/[\w-]+(?:\?[^\s"'<>)]*)?(?:#[^\s"'<>)]*)?/) + if (!match?.[0]) { + return null + } + + const normalizedMatch = match[0].replace(/[).,;]+$/, '') + if (normalizedMatch.startsWith('http://') || normalizedMatch.startsWith('https://')) { + const parsedUrl = new URL(normalizedMatch) + return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`.replace(/^\/index\.php/, '') + } + + return normalizedMatch.replace(/^\/index\.php/, '') +} + /** Extracts a numeric token from an email body. Default pattern: 4-8 digit sequence. */ export function extractTokenFromEmail( body: string, diff --git a/playwright/support/nc-provisioning.ts b/playwright/support/nc-provisioning.ts index abbfb0c6ee..34a4d5826c 100644 --- a/playwright/support/nc-provisioning.ts +++ b/playwright/support/nc-provisioning.ts @@ -490,6 +490,18 @@ export async function deleteUserPfx( await ocsRequest(request, 'DELETE', '/apps/libresign/api/v1/account/pfx', userId, password) } +/** + * Clears a user's stored signing certificate so the next signing attempt can + * regenerate credentials from the current engine configuration. + */ +export async function resetUserSigningCertificate( + request: APIRequestContext, + userId: string, + password: string, +): Promise { + await deleteUserPfx(request, userId, password).catch(() => {}) +} + /** * Configures the OpenSSL certificate engine. * Equivalent to: `occ libresign:configure:openssl --cn=... --c=... ...` diff --git a/playwright/support/policy-workbench-rules.ts b/playwright/support/policy-workbench-rules.ts index 1b97e3e629..51cf060ace 100644 --- a/playwright/support/policy-workbench-rules.ts +++ b/playwright/support/policy-workbench-rules.ts @@ -103,6 +103,70 @@ export async function clearPolicyWorkbenchRules( } } +async function clickRuleMenuAction(page: Page, actionName: 'Edit'): Promise { + const actionItem = page + .locator('.action-item:visible, [role="menuitem"]:visible, li.action:visible') + .filter({ hasText: /^Edit$/i }) + .first() + + if (!(await actionItem.isVisible().catch(() => false))) { + return false + } + + return actionItem.click({ timeout: 1500 }).then(() => true).catch(() => false) +} + +async function openExistingEveryoneRuleEditor(dialog: Locator): Promise { + const page = dialog.page() + const changeButton = dialog.getByRole('button', { name: /^Change$/i }).first() + if (await changeButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await changeButton.click() + return + } + + const everyoneRow = dialog.locator('tbody tr').filter({ hasText: /^Everyone\b/i }).first() + await expect(everyoneRow).toBeVisible({ timeout: 8000 }) + await everyoneRow.getByRole('button', { name: 'Rule actions' }).first().click() + const edited = await clickRuleMenuAction(page, 'Edit') + expect(edited, 'Expected Edit action for existing Everyone rule').toBe(true) +} + +async function confirmEveryoneScopeSelection(createScopeDialog: Locator): Promise { + const confirmScopeButton = createScopeDialog.getByRole('button', { name: /Create rule|Continue|Next/i }).first() + if (await confirmScopeButton.isVisible().catch(() => false)) { + await confirmScopeButton.click() + } +} + +async function selectEveryoneScopeOrEditExisting( + dialog: Locator, + createScopeDialog: Locator, +): Promise { + const page = dialog.page() + const everyoneOption = createScopeDialog.getByRole('option', { name: /^Everyone\b/i }).first() + const everyoneRadio = createScopeDialog.getByRole('radio', { name: /^Everyone\b/i }).first() + + if (await everyoneOption.isVisible().catch(() => false)) { + await everyoneOption.click() + await confirmEveryoneScopeSelection(createScopeDialog) + return + } + + if (await everyoneRadio.isVisible().catch(() => false)) { + await everyoneRadio.click() + await confirmEveryoneScopeSelection(createScopeDialog) + return + } + + await page.keyboard.press('Escape').catch(() => {}) + const cancelButton = createScopeDialog.getByRole('button', { name: /Cancel|Close/i }).first() + if (await cancelButton.isVisible().catch(() => false)) { + await cancelButton.click() + } + await createScopeDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}) + await openExistingEveryoneRuleEditor(dialog) +} + export async function openPolicyWorkbenchSystemRuleEditor( dialog: Locator, options?: { @@ -124,24 +188,7 @@ export async function openPolicyWorkbenchSystemRuleEditor( const page = dialog.page() const createScopeDialog = page.getByRole('dialog').filter({ hasText: /What do you want to create\?/i }).last() if (await createScopeDialog.isVisible({ timeout: 3000 }).catch(() => false)) { - const everyoneOption = createScopeDialog.getByRole('option', { name: /^Everyone\b/i }).first() - const everyoneRadio = createScopeDialog.getByRole('radio', { name: /^Everyone\b/i }).first() - const everyoneButton = createScopeDialog.getByRole('button', { name: /^Everyone\b/i }).first() - - if (await everyoneOption.isVisible().catch(() => false)) { - await everyoneOption.click() - } else if (await everyoneRadio.isVisible().catch(() => false)) { - await everyoneRadio.click({ force: true }) - } else if (await everyoneButton.isVisible().catch(() => false)) { - await everyoneButton.click() - } else { - await createScopeDialog.getByText(/^Everyone\b/i).first().click({ force: true }) - } - - const confirmScopeButton = createScopeDialog.getByRole('button', { name: /Create rule|Continue|Next/i }).first() - if (await confirmScopeButton.isVisible().catch(() => false)) { - await confirmScopeButton.click() - } + await selectEveryoneScopeOrEditExisting(dialog, createScopeDialog) } } diff --git a/playwright/support/request-signature.ts b/playwright/support/request-signature.ts new file mode 100644 index 0000000000..2b7aaa49d3 --- /dev/null +++ b/playwright/support/request-signature.ts @@ -0,0 +1,128 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, type Page } from '@playwright/test' + +/** Accessible name of the admin option in the account signer search list. */ +export const ADMIN_SIGNER_OPTION = /admin.*admin@email\.tld/i + +/** + * Opens the add-signer dialog from the request-signature sidebar. + * + * When `enable_observer_profile` is disabled the UI exposes "Add signer". + * When enabled, "Add" opens a menu and this helper chooses "Signer". + */ +export async function clickAddSigner(page: Page): Promise { + const addSignerButton = page.getByRole('button', { name: 'Add signer', exact: true }) + const addButton = page.getByRole('button', { name: 'Add', exact: true }) + await expect(addSignerButton.or(addButton)).toBeVisible({ timeout: 15_000 }) + + if (await addSignerButton.isVisible()) { + await addSignerButton.click() + } else { + await addButton.click() + + const signerMenuItem = page.getByRole('menuitem', { name: 'Signer' }) + .or(page.getByRole('button', { name: 'Signer', exact: true })) + await expect(signerMenuItem.first()).toBeVisible({ timeout: 5_000 }) + await signerMenuItem.first().click() + } + + await expect(getAddSignerDialog(page)).toBeVisible({ timeout: 10_000 }) +} + +/** + * Opens the add-observer dialog from the request-signature sidebar. + * + * Requires `enable_observer_profile` to be enabled so the Add menu is shown. + */ +export async function clickAddObserver(page: Page): Promise { + const addButton = page.getByRole('button', { name: 'Add', exact: true }) + await expect(addButton).toBeVisible({ timeout: 15_000 }) + await addButton.click() + + const observerMenuItem = page.getByRole('menuitem', { name: 'Observer' }) + .or(page.getByRole('button', { name: 'Observer', exact: true })) + await expect(observerMenuItem.first()).toBeVisible({ timeout: 5_000 }) + await observerMenuItem.first().click() + + await expect(getAddObserverDialog(page)).toBeVisible({ timeout: 10_000 }) +} + +/** + * Asserts that the request-signature sidebar exposes the add-participant control. + */ +export async function expectAddSignerControlVisible(page: Page): Promise { + const addSignerButton = page.getByRole('button', { name: 'Add signer', exact: true }) + const addMenuButton = page.getByRole('button', { name: 'Add', exact: true }) + await expect(addSignerButton.or(addMenuButton)).toBeVisible({ timeout: 15_000 }) +} + +function getAddSignerDialog(page: Page) { + return page.getByRole('dialog', { name: /Add new signer/i }).last() +} + +function getAddObserverDialog(page: Page) { + return page.getByRole('dialog', { name: /Add new observer/i }).last() +} + +function getParticipantDialog(page: Page) { + return page.getByRole('dialog', { name: /Add new (signer|observer)/i }).last() +} + +function getSignerSearchCombobox(page: Page) { + // NcSelect exposes the method placeholder as combobox name (e.g. Account, Email), + // while input-label stays on the visible label. Target the stable input id instead. + return getParticipantDialog(page).locator('#account-or-email-input') +} + +/** + * Selects an account-backed signer from the add-signer dialog search list. + */ +export async function selectAccountSigner( + page: Page, + query: string, + optionName: string | RegExp = ADMIN_SIGNER_OPTION, +): Promise { + const search = getSignerSearchCombobox(page) + await expect(search).toBeVisible({ timeout: 10_000 }) + await search.click() + await search.fill('') + await search.pressSequentially(query, { delay: 50 }) + + await expect.poll(async () => page.getByRole('option').count(), { + timeout: 15_000, + message: `Expected account search results for query "${query}"`, + }).toBeGreaterThan(0) + + const namedOption = page.getByRole('option', { name: optionName }).first() + if (await namedOption.isVisible({ timeout: 2000 }).catch(() => false)) { + await namedOption.click() + return + } + + const emailOption = page.getByRole('option').filter({ hasText: /admin@email\.tld/i }).first() + await expect(emailOption).toBeVisible({ timeout: 10_000 }) + await emailOption.click() +} + +/** + * Selects an email-backed signer from the add-signer dialog search list. + */ +export async function selectEmailSigner(page: Page, email: string): Promise { + const search = getSignerSearchCombobox(page) + await expect(search).toBeVisible({ timeout: 10_000 }) + await search.click() + await search.pressSequentially(email, { delay: 50 }) + + await expect.poll(async () => page.getByRole('option').count(), { + timeout: 15_000, + message: `Expected email search results for "${email}"`, + }).toBeGreaterThan(0) + + const option = page.getByRole('option', { name: email }).first() + await expect(option).toBeVisible({ timeout: 10_000 }) + await option.click() +} diff --git a/playwright/support/sign-flow.ts b/playwright/support/sign-flow.ts new file mode 100644 index 0000000000..ec172c20a0 --- /dev/null +++ b/playwright/support/sign-flow.ts @@ -0,0 +1,22 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, type Page } from '@playwright/test' + +/** + * Clicks the primary "Sign document" CTA in the sign sidebar. + * Public sign pages can leave the button outside the viewport; force clicks + * still fail there, so scroll first and fall back to a DOM click. + */ +export async function clickSignDocumentButton(page: Page): Promise { + const signButton = page.getByRole('button', { name: 'Sign document' }).first() + await expect(signButton).toBeVisible({ timeout: 15_000 }) + await signButton.scrollIntoViewIfNeeded().catch(() => {}) + + const clicked = await signButton.click({ timeout: 5_000 }).then(() => true).catch(() => false) + if (!clicked) { + await signButton.evaluate((element: HTMLElement) => element.click()) + } +} diff --git a/src/components/Request/IdentifySigner.vue b/src/components/Request/IdentifySigner.vue index 552d804d57..b58e31ccfb 100644 --- a/src/components/Request/IdentifySigner.vue +++ b/src/components/Request/IdentifySigner.vue @@ -5,8 +5,10 @@