From 7ac3643a04a6365e5719eb8e1ece8d5b078afd45 Mon Sep 17 00:00:00 2001 From: Luis Amorim Date: Sun, 30 Aug 2026 20:46:00 -0300 Subject: [PATCH 01/46] feat(db): add participant_role to sign requests Introduce signer/observer participant roles at the database layer so request-signature flows can distinguish signing participants from view-only observers. Signed-off-by: Luis Amorim --- lib/Db/SignRequest.php | 17 +++++++ lib/Enum/ParticipantRole.php | 38 ++++++++++++++++ lib/Enum/SignRequestStatus.php | 3 ++ .../Version19000Date20260830200000.php | 44 +++++++++++++++++++ 4 files changed, 102 insertions(+) create mode 100644 lib/Enum/ParticipantRole.php create mode 100644 lib/Migration/Version19000Date20260830200000.php 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..d2b6a45fed --- /dev/null +++ b/lib/Enum/ParticipantRole.php @@ -0,0 +1,38 @@ + $l10n->t('Signer'), + // TRANSLATORS Participant role label for someone who can only view the document and track progress. + self::OBSERVER => $l10n->t('Observer'), + }; + } + + 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/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; + } +} From e05c7f994fc54d636c6aa8e7671f7f810ab85117 Mon Sep 17 00:00:00 2001 From: Luis Amorim Date: Sun, 30 Aug 2026 20:48:38 -0300 Subject: [PATCH 02/46] feat(policy): add enable_observer_profile setting Allow administrators to enable observer participants through the policy workbench before requesters can assign view-only roles. Signed-off-by: Luis Amorim --- .../ObserverProfile/ObserverProfilePolicy.php | 102 ++++++++++++++++++ .../Policy/Provider/PolicyProviders.php | 2 + .../observer-profile/realDefinition.spec.ts | 20 ++++ .../settings/realDefinitions.spec.ts | 1 + .../ObserverProfileRuleEditor.vue | 89 +++++++++++++++ .../observer-profile/realDefinition.ts | 92 ++++++++++++++++ .../settings/realDefinitions.ts | 2 + .../ObserverProfilePolicyTest.php | 24 +++++ 8 files changed, 332 insertions(+) create mode 100644 lib/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicy.php create mode 100644 src/tests/views/Settings/PolicyWorkbench/settings/observer-profile/realDefinition.spec.ts create mode 100644 src/views/Settings/PolicyWorkbench/settings/observer-profile/ObserverProfileRuleEditor.vue create mode 100644 src/views/Settings/PolicyWorkbench/settings/observer-profile/realDefinition.ts create mode 100644 tests/php/Unit/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicyTest.php diff --git a/lib/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicy.php b/lib/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicy.php new file mode 100644 index 0000000000..fcf7260df7 --- /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: static fn (mixed $rawValue): bool => filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false, + 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/PolicyProviders.php b/lib/Service/Policy/Provider/PolicyProviders.php index 9d3d95176e..3c04567b7c 100644 --- a/lib/Service/Policy/Provider/PolicyProviders.php +++ b/lib/Service/Policy/Provider/PolicyProviders.php @@ -20,6 +20,7 @@ use OCA\Libresign\Service\Policy\Provider\IdentificationDocuments\IdentificationDocumentsPolicy; use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicy; use OCA\Libresign\Service\Policy\Provider\LegalInformation\LegalInformationPolicy; +use OCA\Libresign\Service\Policy\Provider\ObserverProfile\ObserverProfilePolicy; use OCA\Libresign\Service\Policy\Provider\Reminder\ReminderPolicy; use OCA\Libresign\Service\Policy\Provider\RequestSignGroups\RequestSignGroupsPolicy; use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy; @@ -54,6 +55,7 @@ final class PolicyProviders { WorkerConfigPolicy::KEY => WorkerConfigPolicy::class, IdentificationDocumentsPolicy::KEY => IdentificationDocumentsPolicy::class, IdentifyMethodsPolicy::KEY => IdentifyMethodsPolicy::class, + ObserverProfilePolicy::KEY => ObserverProfilePolicy::class, SignatureTextPolicy::KEY => SignatureTextPolicy::class, TsaPolicy::KEY => TsaPolicy::class, ]; diff --git a/src/tests/views/Settings/PolicyWorkbench/settings/observer-profile/realDefinition.spec.ts b/src/tests/views/Settings/PolicyWorkbench/settings/observer-profile/realDefinition.spec.ts new file mode 100644 index 0000000000..e24347b8f4 --- /dev/null +++ b/src/tests/views/Settings/PolicyWorkbench/settings/observer-profile/realDefinition.spec.ts @@ -0,0 +1,20 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from 'vitest' + +import { observerProfileRealDefinition } from '../../../../../../views/Settings/PolicyWorkbench/settings/observer-profile/realDefinition' + +describe('observerProfileRealDefinition', () => { + it('defaults observer profile policy to disabled', () => { + expect(observerProfileRealDefinition.createEmptyValue()).toBe(false) + expect(observerProfileRealDefinition.normalizeDraftValue(null)).toBe(false) + }) + + it('summarizes enabled and disabled values', () => { + expect(observerProfileRealDefinition.summarizeValue(true)).toBe('Enabled') + expect(observerProfileRealDefinition.summarizeValue(false)).toBe('Disabled') + }) +}) diff --git a/src/tests/views/Settings/PolicyWorkbench/settings/realDefinitions.spec.ts b/src/tests/views/Settings/PolicyWorkbench/settings/realDefinitions.spec.ts index a221a4eeaf..331f9355a0 100644 --- a/src/tests/views/Settings/PolicyWorkbench/settings/realDefinitions.spec.ts +++ b/src/tests/views/Settings/PolicyWorkbench/settings/realDefinitions.spec.ts @@ -17,6 +17,7 @@ const expectedTopLevelKeys = [ 'groups_request_sign', 'identification_documents', 'identify_methods', + 'enable_observer_profile', 'signature_flow', 'envelope_enabled', 'add_footer', diff --git a/src/views/Settings/PolicyWorkbench/settings/observer-profile/ObserverProfileRuleEditor.vue b/src/views/Settings/PolicyWorkbench/settings/observer-profile/ObserverProfileRuleEditor.vue new file mode 100644 index 0000000000..4b7f187b5f --- /dev/null +++ b/src/views/Settings/PolicyWorkbench/settings/observer-profile/ObserverProfileRuleEditor.vue @@ -0,0 +1,89 @@ + + + + + + diff --git a/src/views/Settings/PolicyWorkbench/settings/observer-profile/realDefinition.ts b/src/views/Settings/PolicyWorkbench/settings/observer-profile/realDefinition.ts new file mode 100644 index 0000000000..b51b365325 --- /dev/null +++ b/src/views/Settings/PolicyWorkbench/settings/observer-profile/realDefinition.ts @@ -0,0 +1,92 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { t } from '@nextcloud/l10n' + +import type { EffectivePolicyValue } from '../../../../../types/index' +import type { RealPolicySettingDefinition } from '../realTypes' +import ObserverProfileRuleEditor from './ObserverProfileRuleEditor.vue' + +function resolveObserverProfile(value: EffectivePolicyValue): boolean | null { + if (typeof value === 'boolean') { + return value + } + + if (typeof value === 'number') { + if (value === 1) { + return true + } + + if (value === 0) { + return false + } + + return null + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (['1', 'true'].includes(normalized)) { + return true + } + + if (['0', 'false', ''].includes(normalized)) { + return false + } + } + + return null +} + +export const observerProfileRealDefinition: RealPolicySettingDefinition = { + key: 'enable_observer_profile', + // TRANSLATORS Policy title for enabling observer participants on signature requests. + title: t('libresign', 'Observer profile'), + // TRANSLATORS Policy description explaining whether document owners can assign observer role to participants. + description: t('libresign', 'Allow assigning observer role to participants who can view documents without signing.'), + groupAdminBehavior: { + allowGroupRuleCreationFromDescendantDelegation: true, + hideNonRemovableGroupRules: (policy) => policy?.editableByCurrentActor === false && policy?.canSaveAsUserDefault === true, + }, + editor: ObserverProfileRuleEditor, + createEmptyValue: () => false, + normalizeDraftValue: (value: EffectivePolicyValue) => { + const resolved = resolveObserverProfile(value) + return resolved ?? false + }, + hasSelectableDraftValue: (value: EffectivePolicyValue) => resolveObserverProfile(value) !== null, + normalizeAllowChildOverride: (_scope, allowChildOverride: boolean) => allowChildOverride, + getFallbackSystemDefault: (policyValue: EffectivePolicyValue | null | undefined, sourceScope?: string | null) => { + if (sourceScope === 'system' && policyValue !== null && policyValue !== undefined) { + return policyValue + } + + return false + }, + summarizeValue: (value: EffectivePolicyValue) => { + const resolved = resolveObserverProfile(value) + if (resolved === true) { + // TRANSLATORS Policy value meaning observer participants can be assigned. + return t('libresign', 'Enabled') + } + + if (resolved === false) { + // TRANSLATORS Policy value meaning observer participants cannot be assigned. + return t('libresign', 'Disabled') + } + + // TRANSLATORS Fallback policy summary when observer profile is not configured. + return t('libresign', 'Not configured') + }, + formatAllowOverride: (allowChildOverride: boolean) => { + if (allowChildOverride) { + // TRANSLATORS Policy inheritance message for observer profile child scopes. + return t('libresign', 'Groups and accounts can set their own rule') + } + + // TRANSLATORS Policy inheritance message requiring child scopes to follow observer profile value. + return t('libresign', 'Groups and accounts must follow this value') + }, +} diff --git a/src/views/Settings/PolicyWorkbench/settings/realDefinitions.ts b/src/views/Settings/PolicyWorkbench/settings/realDefinitions.ts index c3b44ea1c4..00eed48b77 100644 --- a/src/views/Settings/PolicyWorkbench/settings/realDefinitions.ts +++ b/src/views/Settings/PolicyWorkbench/settings/realDefinitions.ts @@ -16,6 +16,7 @@ import { import { identificationDocumentsRealDefinition } from './identification-documents/realDefinition' import { identifyMethodsRealDefinition } from './identify-methods/realDefinition' import { legalInformationRealDefinition } from './legal-information/realDefinition' +import { observerProfileRealDefinition } from './observer-profile/realDefinition' import type { RealPolicySettingDefinition } from './realTypes' import { reminderRealDefinition } from './reminder/realDefinition' import { requestSignGroupsRealDefinition } from './request-sign-groups/realDefinition' @@ -34,6 +35,7 @@ export const realDefinitions = { groups_request_sign: { ...requestSignGroupsRealDefinition, category: 'who-can-sign' }, identification_documents: { ...identificationDocumentsRealDefinition, category: 'who-can-sign' }, identify_methods: { ...identifyMethodsRealDefinition, category: 'who-can-sign' }, + enable_observer_profile: { ...observerProfileRealDefinition, category: 'who-can-sign' }, // 2. How signing works signature_flow: { ...signatureFlowRealDefinition, category: 'how-signing-works' }, diff --git a/tests/php/Unit/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicyTest.php b/tests/php/Unit/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicyTest.php new file mode 100644 index 0000000000..2e6068fb75 --- /dev/null +++ b/tests/php/Unit/Service/Policy/Provider/ObserverProfile/ObserverProfilePolicyTest.php @@ -0,0 +1,24 @@ +assertSame([ObserverProfilePolicy::KEY], $provider->keys()); + + $definition = $provider->get(ObserverProfilePolicy::KEY); + $this->assertSame(ObserverProfilePolicy::KEY, $definition->key()); + $this->assertFalse($definition->normalizeValue(0)); + $this->assertTrue($definition->normalizeValue(1)); + } +} From 9c8bb7c737d082af5d51807b25bff728feb87604 Mon Sep 17 00:00:00 2001 From: Luis Amorim Date: Sun, 30 Aug 2026 20:48:41 -0300 Subject: [PATCH 03/46] feat(api): enforce observer participant workflow in backend Persist participant roles, block observers from signing, and keep sequential signing logic scoped to actual signers only. Signed-off-by: Luis Amorim --- lib/Helper/ValidateHelper.php | 34 ++++++++++++ lib/ResponseDefinitions.php | 5 +- lib/Service/File/FileListService.php | 2 + lib/Service/File/SignersLoader.php | 6 +- lib/Service/RequestSignatureService.php | 7 ++- lib/Service/SequentialSigningService.php | 21 ++++++- .../SignRequest/SignRequestService.php | 11 +++- lib/Service/SignRequest/StatusService.php | 13 ++++- tests/php/Unit/Helper/ValidateHelperTest.php | 9 +++ .../SignRequest/StatusServiceObserverTest.php | 55 +++++++++++++++++++ 10 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 tests/php/Unit/Service/SignRequest/StatusServiceObserverTest.php diff --git a/lib/Helper/ValidateHelper.php b/lib/Helper/ValidateHelper.php index 9906732c19..4272e29e3c 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; @@ -29,6 +30,8 @@ use OCA\Libresign\Service\IdentifyMethod\RuntimeRequirementValidator; use OCA\Libresign\Service\IdentifyMethodService; use OCA\Libresign\Service\Policy\RequestSignAuthorizationService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\ObserverProfile\ObserverProfilePolicy; use OCA\Libresign\Service\SequentialSigningService; use OCA\Libresign\Service\SignerElementsService; use OCP\AppFramework\Db\DoesNotExistException; @@ -71,6 +74,7 @@ public function __construct( private DocMdpValidator $docMdpValidator, private RequestSignAuthorizationService $requestSignAuthorizationService, private RuntimeRequirementValidator $runtimeRequirementValidator, + private PolicyService $policyService, ) { } @@ -630,6 +634,27 @@ private function validateSignerData(mixed $signer): void { $this->validateSignerDisplayName($signer); $this->validateSignerIdentifyMethods($signer); + $this->validateParticipantRole($signer); + } + + private function validateParticipantRole(array $signer): 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->policyService->resolve(ObserverProfilePolicy::KEY)->getEffectiveValueAsBool(false) + ) { + // 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 validateSignerDisplayName(array $signer): void { @@ -788,6 +813,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/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 9d84593f6a..043dc95831 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -47,6 +47,7 @@ * }, * envelopeFolderId?: int, * } + * @psalm-type LibresignParticipantRole = 'signer'|'observer' * @psalm-type LibresignNewSigner = array{ * identifyMethods: list $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( @@ -501,6 +502,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/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 043ee8aa90..28e7fee016 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; @@ -488,8 +489,11 @@ private function associateToSigners(array $data, FileEntity $file): array { $fileStatus = $data['status'] ?? 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; @@ -505,6 +509,7 @@ private function associateToSigners(array $data, FileEntity $file): array { signingOrder: $signingOrder, fileStatus: $fileStatus, signerStatus: $signerStatus, + participantRole: $participantRole, ); } } diff --git a/lib/Service/SequentialSigningService.php b/lib/Service/SequentialSigningService.php index d02ccb590f..25e79ac4bd 100644 --- a/lib/Service/SequentialSigningService.php +++ b/lib/Service/SequentialSigningService.php @@ -10,6 +10,7 @@ use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Enum\ParticipantRole; use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\Enum\SignRequestStatus; @@ -120,7 +121,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 +130,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 +149,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 +183,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 +199,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/SignRequest/SignRequestService.php b/lib/Service/SignRequest/SignRequestService.php index 801e221d88..64f98bf1bc 100644 --- a/lib/Service/SignRequest/SignRequestService.php +++ b/lib/Service/SignRequest/SignRequestService.php @@ -10,6 +10,7 @@ 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\Service\IdentifyMethod\IIdentifyMethod; use OCA\Libresign\Service\IdentifyMethodService; @@ -39,6 +40,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) * @return SignRequestEntity */ public function createOrUpdateSignRequest( @@ -50,6 +52,7 @@ public function createOrUpdateSignRequest( int $signingOrder = 0, ?int $fileStatus = null, ?int $signerStatus = null, + ParticipantRole $participantRole = ParticipantRole::SIGNER, ): SignRequestEntity { $identifyMethodsInstances = $this->identifyMethodService->getByUserData($identifyMethods); if (empty($identifyMethodsInstances)) { @@ -63,7 +66,7 @@ public function createOrUpdateSignRequest( ); $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(); @@ -71,13 +74,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,9 +118,11 @@ private function populateSignRequest( 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/tests/php/Unit/Helper/ValidateHelperTest.php b/tests/php/Unit/Helper/ValidateHelperTest.php index ad9bb0ce9e..0d3239246d 100644 --- a/tests/php/Unit/Helper/ValidateHelperTest.php +++ b/tests/php/Unit/Helper/ValidateHelperTest.php @@ -27,6 +27,9 @@ use OCA\Libresign\Service\IdentifyMethod\RuntimeRequirementValidator; use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\ISignatureMethod; use OCA\Libresign\Service\IdentifyMethodService; +use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\ObserverProfile\ObserverProfilePolicy; use OCA\Libresign\Service\Policy\RequestSignAuthorizationService; use OCA\Libresign\Service\SequentialSigningService; use OCA\Libresign\Service\SignerElementsService; @@ -58,6 +61,7 @@ final class ValidateHelperTest extends \OCA\Libresign\Tests\Unit\TestCase { private DocMdpValidator&MockObject $docMdpValidator; private RequestSignAuthorizationService&MockObject $requestSignAuthorizationService; private RuntimeRequirementValidator&MockObject $runtimeRequirementValidator; + private PolicyService&MockObject $policyService; #[\Override] public function setUp(): void { @@ -81,6 +85,10 @@ public function setUp(): void { $this->docMdpValidator = $this->createMock(DocMdpValidator::class); $this->requestSignAuthorizationService = $this->createMock(RequestSignAuthorizationService::class); $this->runtimeRequirementValidator = $this->createMock(RuntimeRequirementValidator::class); + $this->policyService = $this->createMock(PolicyService::class); + $resolvedPolicy = $this->createMock(ResolvedPolicy::class); + $resolvedPolicy->method('getEffectiveValueAsBool')->willReturn(true); + $this->policyService->method('resolve')->with(ObserverProfilePolicy::KEY)->willReturn($resolvedPolicy); } private function getValidateHelper(): ValidateHelper { @@ -102,6 +110,7 @@ private function getValidateHelper(): ValidateHelper { $this->docMdpValidator, $this->requestSignAuthorizationService, $this->runtimeRequirementValidator, + $this->policyService, ); return $validateHelper; } diff --git a/tests/php/Unit/Service/SignRequest/StatusServiceObserverTest.php b/tests/php/Unit/Service/SignRequest/StatusServiceObserverTest.php new file mode 100644 index 0000000000..357961e0e6 --- /dev/null +++ b/tests/php/Unit/Service/SignRequest/StatusServiceObserverTest.php @@ -0,0 +1,55 @@ +service = new StatusService( + $this->createMock(SequentialSigningService::class), + $this->createMock(FileStatusService::class), + $this->createMock(StatusCacheService::class), + $this->createMock(StatusUpdatePolicy::class), + ); + } + + public function testObserverDraftFileRemainsDraft(): void { + $status = $this->service->determineInitialStatus( + signingOrder: 0, + fileId: 1, + fileStatus: FileStatus::DRAFT->value, + participantRole: ParticipantRole::OBSERVER, + ); + + $this->assertSame(SignRequestStatus::DRAFT, $status); + } + + public function testObserverActiveFileBecomesObserving(): void { + $status = $this->service->determineInitialStatus( + signingOrder: 0, + fileId: 1, + fileStatus: FileStatus::ABLE_TO_SIGN->value, + participantRole: ParticipantRole::OBSERVER, + ); + + $this->assertSame(SignRequestStatus::OBSERVING, $status); + } +} From f8ef798d61b5ed35d300cf306217b44b39575470 Mon Sep 17 00:00:00 2001 From: Luis Amorim Date: Sun, 30 Aug 2026 20:48:41 -0300 Subject: [PATCH 04/46] feat(ui): add observer role selection in request signature flow Replace the single add-signer action with an add dropdown, separate signers from observers in the participant list, and hide multi-signer controls when only one signer is present. Signed-off-by: Luis Amorim --- src/components/Request/IdentifySigner.vue | 27 +- .../RightSidebar/RequestSignatureTab.vue | 242 ++++++++++++------ src/components/Signers/Signers.vue | 18 +- src/constants.js | 1 + src/store/files.js | 11 +- .../components/Request/IdentifySigner.spec.ts | 1 + .../RightSidebar/RequestSignatureTab.spec.ts | 12 + src/tests/utils/participantRole.spec.ts | 32 +++ src/utils/getSignRequestStatusText.ts | 3 + src/utils/participantRole.ts | 50 ++++ 10 files changed, 313 insertions(+), 84 deletions(-) create mode 100644 src/tests/utils/participantRole.spec.ts create mode 100644 src/utils/participantRole.ts diff --git a/src/components/Request/IdentifySigner.vue b/src/components/Request/IdentifySigner.vue index 552d804d57..29a59d7e86 100644 --- a/src/components/Request/IdentifySigner.vue +++ b/src/components/Request/IdentifySigner.vue @@ -88,6 +88,7 @@ import svgTelegram from '../../../img/logo-telegram-app.svg?raw' import { SIGN_REQUEST_STATUS } from '../../constants.js' import { useFilesStore } from '../../store/files.js' import { getSignRequestStatusText } from '../../utils/getSignRequestStatusText.ts' +import { isObserverParticipant, PARTICIPANT_ROLE, type ParticipantRole } from '../../utils/participantRole.ts' import type { IdentifyAccountRecord } from '../../types' defineOptions({ @@ -100,6 +101,7 @@ const props = withDefaults(defineProps<{ placeholder?: string methods?: IdentifyMethodConfig[] disabled?: boolean + participantRole?: ParticipantRole }>(), { signerToEdit: () => ({ displayName: '', @@ -110,6 +112,7 @@ const props = withDefaults(defineProps<{ placeholder: t('libresign', 'Name'), methods: () => [], disabled: false, + participantRole: PARTICIPANT_ROLE.SIGNER, }) const iconMap = { @@ -146,18 +149,32 @@ type SignerMethodValue = { type SignerToEdit = { displayName?: string description?: string + participantRole?: ParticipantRole identifyMethods?: SignerMethodValue[] } type FilesStore = ReturnType type StoredSigner = NonNullable['signers']>[number] -// TRANSLATORS Field label for signer display name. -const signerNameLabel = t('libresign', 'Signer name') +const isObserver = computed(() => { + if (isObserverParticipant(props.signerToEdit)) { + return true + } + + return props.participantRole === PARTICIPANT_ROLE.OBSERVER +}) +const signerNameLabel = computed(() => isObserver.value + // TRANSLATORS Field label for observer display name. + ? t('libresign', 'Observer name') + // TRANSLATORS Field label for signer display name. + : t('libresign', 'Signer name')) +const customMessagePlaceholder = computed(() => isObserver.value + // TRANSLATORS Placeholder inviting user to write a personalized message for observer. + ? t('libresign', 'Add a personal message for this observer') + // TRANSLATORS Placeholder inviting user to write a personalized message for signer. + : t('libresign', 'Add a personal message for this signer')) // TRANSLATORS Field label for optional personalized message sent to signer. const customMessageLabel = t('libresign', 'Custom message') -// TRANSLATORS Placeholder inviting user to write a personalized message for signer. -const customMessagePlaceholder = t('libresign', 'Add a personal message for this signer') // TRANSLATORS Primary button label to save a newly added signer. const saveSignerButtonLabel = t('libresign', 'Save') // TRANSLATORS Primary button label to update an existing signer. @@ -250,9 +267,11 @@ async function saveSigner() { } const file = filesStore.getFile() const signers: StoredSigner[] = Array.isArray(file?.signers) ? [...file.signers] : [] + const participantRole = isObserver.value ? PARTICIPANT_ROLE.OBSERVER : PARTICIPANT_ROLE.SIGNER signers.push({ displayName: displayName.value, description: description.value.trim() || undefined, + participantRole, ...(identifyMethod.value === 'email' ? { email: identify.value } : {}), status: SIGN_REQUEST_STATUS.DRAFT, statusText: getSignRequestStatusText(SIGN_REQUEST_STATUS.DRAFT), diff --git a/src/components/RightSidebar/RequestSignatureTab.vue b/src/components/RightSidebar/RequestSignatureTab.vue index ac30dae5fa..1a07a7b09e 100644 --- a/src/components/RightSidebar/RequestSignatureTab.vue +++ b/src/components/RightSidebar/RequestSignatureTab.vue @@ -23,14 +23,37 @@ {{ t('libresign', 'A previous signing order preference was removed because it is no longer compatible with higher-level policy.') }} - - - {{ t('libresign', 'Add signer') }} - + {{ t('libresign', 'View signing order') }} - - Date: Mon, 31 Aug 2026 01:07:26 -0300 Subject: [PATCH 10/46] feat(validation): separate observers and fix observer status handling Group signers and observers in validation UI, recognize OBSERVING status, and ignore observers when checking partial or full signature completion. Signed-off-by: Luis Amorim --- .../validation/DocumentValidationDetails.vue | 61 +++++- .../validation/EnvelopeValidation.vue | 186 ++++++++++++------ src/components/validation/SignerDetails.vue | 17 +- src/components/validation/SigningProgress.vue | 6 +- src/services/validationDocument.ts | 1 + src/store/files.js | 9 +- .../DocumentValidationDetails.spec.ts | 32 ++- .../validation/EnvelopeValidation.spec.ts | 70 +++++++ .../validation/SignerDetails.spec.ts | 8 + .../validation/SigningProgress.spec.ts | 18 ++ src/tests/services/validationDocument.spec.ts | 26 +++ src/tests/store/files.spec.ts | 15 ++ src/tests/utils/participantRole.spec.ts | 12 ++ src/utils/participantRole.ts | 19 +- 14 files changed, 404 insertions(+), 76 deletions(-) diff --git a/src/components/validation/DocumentValidationDetails.vue b/src/components/validation/DocumentValidationDetails.vue index ed337688b0..295471abc9 100644 --- a/src/components/validation/DocumentValidationDetails.vue +++ b/src/components/validation/DocumentValidationDetails.vue @@ -46,9 +46,16 @@ -
    - -
+
+ +
@@ -68,6 +75,7 @@ import { import { getStatusLabel } from '../../utils/fileStatus.js' import { openDocument } from '../../utils/viewer.js' import SignerDetails from './SignerDetails.vue' +import { filterParticipantsByRole, PARTICIPANT_ROLE } from '../../utils/participantRole.ts' import type { LoadedValidationFileDocument, ValidatedChildFileRecord, @@ -90,6 +98,36 @@ const props = withDefaults(defineProps<{ const { document } = toRefs(props) +type ParticipantSectionRole = typeof PARTICIPANT_ROLE.SIGNER | typeof PARTICIPANT_ROLE.OBSERVER + +type ParticipantSection = { + role: ParticipantSectionRole + title: string + participants: NonNullable +} + +const signingParticipants = computed(() => filterParticipantsByRole(document.value.signers, PARTICIPANT_ROLE.SIGNER)) +const observerParticipants = computed(() => filterParticipantsByRole(document.value.signers, PARTICIPANT_ROLE.OBSERVER)) +const hasParticipants = computed(() => signingParticipants.value.length > 0 || observerParticipants.value.length > 0) +const participantSections = computed(() => { + const sections: ParticipantSection[] = [] + if (signingParticipants.value.length > 0) { + sections.push({ + role: PARTICIPANT_ROLE.SIGNER, + title: t('libresign', 'Signers'), + participants: signingParticipants.value, + }) + } + if (observerParticipants.value.length > 0) { + sections.push({ + role: PARTICIPANT_ROLE.OBSERVER, + title: t('libresign', 'Observers'), + participants: observerParticipants.value, + }) + } + return sections +}) + const size = computed(() => { if (document.value.size < 1024) { return document.value.size + ' B' } if (document.value.size < 1048576) { return (document.value.size / 1024).toFixed(2) + ' KB' } @@ -113,6 +151,8 @@ async function viewDocument() { defineExpose({ documentStatus, size, + hasParticipants, + participantSections, viewDocument, }) @@ -129,6 +169,21 @@ defineExpose({ } } + .participants { + margin-top: 16px; + } + + .participants-subheading { + font-size: 1rem; + font-weight: 600; + margin: 0 0 12px 0; + color: var(--color-main-text); + + &:not(:first-child) { + margin-top: 20px; + } + } + .info-document { display: flex; flex-direction: column; diff --git a/src/components/validation/EnvelopeValidation.vue b/src/components/validation/EnvelopeValidation.vue index 2302e83c95..2ffda0888f 100644 --- a/src/components/validation/EnvelopeValidation.vue +++ b/src/components/validation/EnvelopeValidation.vue @@ -95,64 +95,70 @@ - -
+ +
-

{{ t('libresign', 'Signers summary') }}

+

{{ participantsSummaryTitle }}

-

- {{ t('libresign', 'Overall progress of each signer across all documents') }} +

+ {{ t('libresign', 'Overall progress of each signer across all documents') }}

-
    -
  • - - -