Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
7ac3643
feat(db): add participant_role to sign requests
lfals Aug 30, 2026
e05c7f9
feat(policy): add enable_observer_profile setting
lfals Aug 30, 2026
9c8bb7c
feat(api): enforce observer participant workflow in backend
lfals Aug 30, 2026
f8ef798
feat(ui): add observer role selection in request signature flow
lfals Aug 30, 2026
bd2f828
chore(devcontainer): harden setup and add configure script
lfals Aug 31, 2026
0d6164d
fix(db): drop orphan participant_profile_id column
lfals Aug 31, 2026
c7fbcab
fix(observer): exclude observers from signing status calculation
lfals Aug 31, 2026
a9f4c1a
feat(observer): route observer notifications to validation view
lfals Aug 31, 2026
03d1e52
fix(ui): restore add participant button label in request tab
lfals Aug 31, 2026
de05049
feat(validation): separate observers and fix observer status handling
lfals Aug 31, 2026
e817e4d
chore(l10n): add observer participant role translations
lfals Aug 31, 2026
75900ef
fix(validation): retry validation after sign redirect
lfals Aug 31, 2026
450765a
chore(l10n): translate add participant button labels
lfals Aug 31, 2026
f039695
fix(ui): localize observer search labels in participant modal
lfals Aug 31, 2026
6de204c
fix(signing): require at least one signer to request signatures
lfals Aug 31, 2026
7820505
fix(observer): address pre-merge review checklist
lfals Aug 31, 2026
f5795b0
style: apply php-cs-fixer import ordering fixes
lfals Aug 31, 2026
cfa42bc
revert: chore(devcontainer): harden setup and add configure script
lfals Aug 31, 2026
5288952
test(playwright): adapt request-signature flows to observer UI
lfals Aug 31, 2026
9a56d6e
test(playwright): add Search signer selection helpers
lfals Aug 31, 2026
49658c2
test(playwright): use Search signer helpers in request flows
lfals Aug 31, 2026
fbb677e
test(playwright): fix policy workbench Everyone rule editing
lfals Aug 31, 2026
f53670b
test(playwright): stabilize signing flows against viewport and cert s…
lfals Aug 31, 2026
ed6d7f8
Merge branch 'main' into feat/participant-role-observer
lfals Aug 31, 2026
3340915
revert(l10n): drop manual translation updates from PR
lfals Sep 2, 2026
b3d6a83
merge: integrate main into feat/participant-role-observer
lfals Sep 2, 2026
884d2e4
test(mail): mock File uuid via entity __call
lfals Sep 2, 2026
c5a866f
Merge branch 'main' into feat/participant-role-observer
lfals Sep 2, 2026
16fe8e1
test(playwright): target signer search input by stable id
lfals Sep 2, 2026
75d72d4
chore: merge main into feat/participant-role-observer
lfals Sep 2, 2026
6c8ddb1
test(unit): align sign request mocks with participantRole param
lfals Sep 2, 2026
41c6882
fix(db): remove experimental participant_profile_id migration
lfals Sep 3, 2026
d21a668
fix(enum): reject invalid participant roles instead of falling back
lfals Sep 3, 2026
e086863
fix(sign-request): reject role changes after a request is signed
lfals Sep 3, 2026
0b94f23
fix(frontend): treat participantRole as the role source of truth
lfals Sep 3, 2026
ad6f028
fix(ui): keep Add signer button when observer profile is disabled
lfals Sep 3, 2026
a785b14
test(mail): use real entities instead of mocking entity __call
lfals Sep 3, 2026
ce49e17
test(observer): cover end-to-end and API observer workflows
lfals Sep 3, 2026
5f9ee6c
test(frontend): fix participant role CI regressions
lfals Sep 3, 2026
60eaa2d
test(observer): align integration scenario with API ordering
lfals Sep 3, 2026
4340d34
Merge branch 'LibreSign:main' into feat/participant-role-observer
lfals Sep 4, 2026
64ef5ec
test(observer): scope participant assertions to sidebar sections
lfals Sep 3, 2026
15fab62
fix(observer): preserve policy snapshot for requests
lfals Sep 4, 2026
a825a3a
fix(openapi): describe observer policy snapshot
lfals Sep 4, 2026
9cf7ce0
test(playwright): replace footer template reliably
lfals Sep 4, 2026
44e58cb
chore: merge main into observer participant branch
lfals Sep 4, 2026
583ae7c
fix(observer): skip live policy when file snapshot is missing
lfals Sep 4, 2026
278bfcb
fix(observer): store policy snapshot on envelope create
lfals Sep 4, 2026
ef592af
fix(observer): loosen envelope policy payload types for psalm
lfals Sep 4, 2026
fbb39c2
chore: bump app version to 16.0.0-dev.2
lfals Sep 4, 2026
2eafe83
fix(observer): allow existing requests to adopt observers when policy…
lfals Sep 4, 2026
7bf4625
fix(ui): show API error when participant save fails
lfals Sep 4, 2026
a9d9d2e
chore: merge main into observer participant branch
lfals Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ If your organization uses LibreSign, support its development:
For enterprise support, contact LibreCode:
https://libresign.coop
]]></description>
<version>16.0.0-dev.1</version>
<version>16.0.0-dev.2</version>
<licence>agpl</licence>
<author mail="contact@librecode.coop" homepage="https://librecode.coop">LibreCode</author>
<types>
Expand Down
10 changes: 9 additions & 1 deletion lib/Activity/Listener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
}

Expand Down
17 changes: 17 additions & 0 deletions lib/Db/SignRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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 {
Expand Down
41 changes: 41 additions & 0 deletions lib/Enum/ParticipantRole.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Enum;

use OCP\IL10N;

enum ParticipantRole: string {
case SIGNER = 'signer';
case OBSERVER = 'observer';

public function canSign(): bool {
return $this === self::SIGNER;
}

public function getLabel(IL10N $l10n): string {
return match ($this) {
// TRANSLATORS Participant role label for someone who must digitally sign the document.
self::SIGNER => $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);
}
Comment thread
vitormattos marked this conversation as resolved.
}
3 changes: 3 additions & 0 deletions lib/Enum/SignRequestStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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'),
};
}
}
75 changes: 73 additions & 2 deletions lib/Helper/ValidateHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@
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;
use OCA\Libresign\Service\IdDocsPolicyService;
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;
Expand Down Expand Up @@ -71,6 +73,7 @@ public function __construct(
private DocMdpValidator $docMdpValidator,
private RequestSignAuthorizationService $requestSignAuthorizationService,
private RuntimeRequirementValidator $runtimeRequirementValidator,
private ObserverProfilePolicyService $observerProfilePolicyService,
) {
}

Expand Down Expand Up @@ -607,29 +610,88 @@ 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.
throw new LibresignException($this->l10n->t('No signers'));
}
}

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'));
}

$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 {
Expand Down Expand Up @@ -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());
Expand Down
10 changes: 9 additions & 1 deletion lib/Listener/NotificationListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
}

Expand Down
2 changes: 1 addition & 1 deletion lib/Middleware/InjectionMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
44 changes: 44 additions & 0 deletions lib/Migration/Version19000Date20260830200000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

class Version19000Date20260830200000 extends SimpleMigrationStep {
/**
* @param Closure(): ISchemaWrapper $schemaClosure
*/
#[\Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

if (!$schema->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;
}
}
10 changes: 9 additions & 1 deletion lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -68,6 +69,7 @@
* notify?: non-negative-int,
* signingOrder?: non-negative-int,
* status?: int,
* participantRole?: LibresignParticipantRole,
* geolocationRequired?: bool,
* }
* @psalm-type LibresignNewFile = array{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -505,6 +508,10 @@
* effectiveValue: int,
* sourceScope: string,
* }
* @psalm-type LibresignPolicySnapshotBooleanEntry = array{
* effectiveValue: bool,
* sourceScope: string,
* }
* @psalm-type LibresignPolicySnapshotIdentificationDocumentsValue = array{
* enabled: bool,
* approvers: list<string>,
Expand Down Expand Up @@ -541,6 +548,7 @@
* identification_documents?: LibresignPolicySnapshotIdentificationDocumentsEntry,
* identify_methods?: LibresignPolicySnapshotIdentifyMethodsEntry,
* signer_geolocation?: LibresignPolicySnapshotSignerGeolocationEntry,
* enable_observer_profile?: LibresignPolicySnapshotBooleanEntry,
* }
* @psalm-type LibresignValidateMetadata = array{
* extension: string,
Expand Down
Loading
Loading