diff --git a/lib/Command/Developer/Reset.php b/lib/Command/Developer/Reset.php
index 18b58dc58b..c9c4e1793a 100644
--- a/lib/Command/Developer/Reset.php
+++ b/lib/Command/Developer/Reset.php
@@ -96,6 +96,12 @@ protected function configure(): void {
mode: InputOption::VALUE_NONE,
description: 'Reset config'
)
+ ->addOption(
+ name: 'policy',
+ shortcut: null,
+ mode: InputOption::VALUE_NONE,
+ description: 'Reset policy data'
+ )
;
}
@@ -140,6 +146,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->resetConfig();
$ok = true;
}
+ if ($input->getOption('policy') || $all) {
+ $this->resetPolicy();
+ $ok = true;
+ }
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
throw $e;
@@ -254,4 +264,17 @@ private function resetConfig(): void {
} catch (\Throwable) {
}
}
+
+ private function resetPolicy(): void {
+ try {
+ $delete = $this->db->getQueryBuilder();
+ $delete->delete('libresign_permission_set_binding')
+ ->executeStatement();
+
+ $delete = $this->db->getQueryBuilder();
+ $delete->delete('libresign_permission_set')
+ ->executeStatement();
+ } catch (\Throwable) {
+ }
+ }
}
diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php
index 34c2458980..b8ebe168f6 100644
--- a/lib/Controller/AdminController.php
+++ b/lib/Controller/AdminController.php
@@ -24,6 +24,7 @@
use OCA\Libresign\Service\IdentifyMethodService;
use OCA\Libresign\Service\Install\ConfigureCheckService;
use OCA\Libresign\Service\Install\InstallService;
+use OCA\Libresign\Service\Policy\PolicyService;
use OCA\Libresign\Service\ReminderService;
use OCA\Libresign\Service\SignatureBackgroundService;
use OCA\Libresign\Service\SignatureTextService;
@@ -83,6 +84,7 @@ public function __construct(
private ReminderService $reminderService,
private FooterService $footerService,
private DocMdpConfigService $docMdpConfigService,
+ private PolicyService $policyService,
private IdentifyMethodService $identifyMethodService,
private FileMapper $fileMapper,
) {
@@ -960,57 +962,6 @@ private function saveOrDeleteConfig(string $key, ?string $value, string $default
}
}
- /**
- * Set signature flow configuration
- *
- * @param bool $enabled Whether to force a signature flow for all documents
- * @param string|null $mode Signature flow mode: 'parallel' or 'ordered_numeric' (only used when enabled is true)
- * @return DataResponse|DataResponse|DataResponse
- *
- * 200: Configuration saved successfully
- * 400: Invalid signature flow mode provided
- * 500: Internal server error
- */
- #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/signature-flow/config', requirements: ['apiVersion' => '(v1)'])]
- public function setSignatureFlowConfig(bool $enabled, ?string $mode = null): DataResponse {
- try {
- if (!$enabled) {
- $this->appConfig->deleteKey(Application::APP_ID, 'signature_flow');
- return new DataResponse([
- 'message' => $this->l10n->t('Settings saved'),
- ]);
- }
-
- if ($mode === null) {
- return new DataResponse([
- 'error' => $this->l10n->t('Mode is required when signature flow is enabled.'),
- ], Http::STATUS_BAD_REQUEST);
- }
-
- try {
- $signatureFlow = \OCA\Libresign\Enum\SignatureFlow::from($mode);
- } catch (\ValueError) {
- return new DataResponse([
- 'error' => $this->l10n->t('Invalid signature flow mode. Use "parallel" or "ordered_numeric".'),
- ], Http::STATUS_BAD_REQUEST);
- }
-
- $this->appConfig->setValueString(
- Application::APP_ID,
- 'signature_flow',
- $signatureFlow->value
- );
-
- return new DataResponse([
- 'message' => $this->l10n->t('Settings saved'),
- ]);
- } catch (\Exception $e) {
- return new DataResponse([
- 'error' => $e->getMessage(),
- ], Http::STATUS_INTERNAL_SERVER_ERROR);
- }
- }
-
/**
* Configure DocMDP signature restrictions
*
diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php
index 51d7305461..95fed81b0f 100644
--- a/lib/Controller/PageController.php
+++ b/lib/Controller/PageController.php
@@ -23,6 +23,7 @@
use OCA\Libresign\Service\FileService;
use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\TokenService;
use OCA\Libresign\Service\IdentifyMethodService;
+use OCA\Libresign\Service\Policy\PolicyService;
use OCA\Libresign\Service\RequestSignatureService;
use OCA\Libresign\Service\SessionService;
use OCA\Libresign\Service\SignerElementsService;
@@ -58,6 +59,7 @@ public function __construct(
private AccountService $accountService,
protected SignFileService $signFileService,
protected RequestSignatureService $requestSignatureService,
+ private PolicyService $policyService,
private SignerElementsService $signerElementsService,
protected IL10N $l10n,
private IdentifyMethodService $identifyMethodService,
@@ -106,7 +108,13 @@ public function index(): TemplateResponse {
$this->provideSignerSignatues();
$this->initialState->provideInitialState('identify_methods', $this->identifyMethodService->getIdentifyMethodsSettings());
- $this->initialState->provideInitialState('signature_flow', $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Enum\SignatureFlow::NONE->value));
+ $resolvedPolicies = [];
+ foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) {
+ $resolvedPolicies[$policyKey] = $resolvedPolicy->toArray();
+ }
+ $this->initialState->provideInitialState('effective_policies', [
+ 'policies' => $resolvedPolicies,
+ ]);
$this->initialState->provideInitialState('docmdp_config', $this->docMdpConfigService->getConfig());
$this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information'));
diff --git a/lib/Controller/PolicyController.php b/lib/Controller/PolicyController.php
new file mode 100644
index 0000000000..af5ee37297
--- /dev/null
+++ b/lib/Controller/PolicyController.php
@@ -0,0 +1,430 @@
+
+ *
+ * 200: OK
+ */
+ #[NoAdminRequired]
+ #[NoCSRFRequired]
+ #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/effective', requirements: ['apiVersion' => '(v1)'])]
+ public function effective(): DataResponse {
+ /** @var array $policies */
+ $policies = [];
+ foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) {
+ /** @var LibresignEffectivePolicyState $policyState */
+ $policyState = $resolvedPolicy->toArray();
+ $policies[$policyKey] = $policyState;
+ }
+
+ /** @var LibresignEffectivePoliciesResponse $data */
+ $data = [
+ 'policies' => $policies,
+ ];
+
+ return new DataResponse($data);
+ }
+
+ /**
+ * Read explicit system policy configuration
+ *
+ * @param string $policyKey Policy identifier to read from the system layer.
+ * @return DataResponse
+ *
+ * 200: OK
+ */
+ #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/system/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])]
+ public function getSystem(string $policyKey): DataResponse {
+ $policy = $this->policyService->getSystemPolicy($policyKey);
+
+ /** @var LibresignSystemPolicyResponse $data */
+ $data = [
+ 'policy' => [
+ 'policyKey' => $policyKey,
+ 'scope' => ($policy?->getScope() === 'global' ? 'global' : 'system'),
+ 'value' => $policy?->getValue(),
+ 'allowChildOverride' => $policy?->isAllowChildOverride() ?? true,
+ 'visibleToChild' => $policy?->isVisibleToChild() ?? true,
+ 'allowedValues' => $policy?->getAllowedValues() ?? [],
+ ],
+ ];
+
+ return new DataResponse($data);
+ }
+
+ /**
+ * Read a group-level policy value
+ *
+ * @param string $groupId Group identifier that receives the policy binding.
+ * @param string $policyKey Policy identifier to read for the selected group.
+ * @return DataResponse|DataResponse
+ *
+ * 200: OK
+ * 403: Forbidden
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/group/{groupId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'groupId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])]
+ public function getGroup(string $groupId, string $policyKey): DataResponse {
+ if (!$this->canManageGroupPolicy($groupId)) {
+ return $this->forbiddenGroupPolicyResponse();
+ }
+
+ $policy = $this->policyService->getGroupPolicy($policyKey, $groupId);
+
+ /** @var LibresignGroupPolicyResponse $data */
+ $data = [
+ 'policy' => $this->serializeGroupPolicy($groupId, $policyKey, $policy),
+ ];
+
+ return new DataResponse($data);
+ }
+
+ /**
+ * Read a user-level policy preference for a target user (admin scope)
+ *
+ * @param string $userId Target user identifier that receives the policy preference.
+ * @param string $policyKey Policy identifier to read for the selected user.
+ * @return DataResponse
+ *
+ * 200: OK
+ */
+ #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/user/{userId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'userId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])]
+ public function getUserPolicyForUser(string $userId, string $policyKey): DataResponse {
+ $policy = $this->policyService->getUserPreferenceForUserId($policyKey, $userId);
+
+ /** @var LibresignUserPolicyResponse $data */
+ $data = [
+ 'policy' => $this->serializeUserPolicy($userId, $policyKey, $policy),
+ ];
+
+ return new DataResponse($data);
+ }
+
+ /**
+ * Save a system-level policy value
+ *
+ * @param string $policyKey Policy identifier to persist at the system layer.
+ * @param null|bool|int|float|string $value Policy value to persist. Null resets the policy to its default system value.
+ * @param bool $allowChildOverride Whether lower layers may override this system default.
+ * @return DataResponse|DataResponse
+ *
+ * 200: OK
+ * 400: Invalid policy value
+ */
+ #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/policies/system/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])]
+ public function setSystem(string $policyKey, null|bool|int|float|string $value = null, bool $allowChildOverride = false): DataResponse {
+ $value = $this->readScalarParam('value', $value);
+ $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride);
+
+ try {
+ $policy = $this->policyService->saveSystem($policyKey, $value, $allowChildOverride);
+ /** @var LibresignSystemPolicyWriteResponse $data */
+ $data = [
+ 'message' => $this->l10n->t('Settings saved'),
+ 'policy' => $policy->toArray(),
+ ];
+
+ return new DataResponse($data);
+ } catch (\InvalidArgumentException $exception) {
+ /** @var LibresignErrorResponse $data */
+ $data = [
+ 'error' => $this->l10n->t($exception->getMessage()),
+ ];
+
+ return new DataResponse($data, Http::STATUS_BAD_REQUEST);
+ }
+ }
+
+ /**
+ * Save a group-level policy value
+ *
+ * @param string $groupId Group identifier that receives the policy binding.
+ * @param string $policyKey Policy identifier to persist at the group layer.
+ * @param null|bool|int|float|string $value Policy value to persist for the group.
+ * @param bool $allowChildOverride Whether users and requests below this group may override the group default.
+ * @return DataResponse|DataResponse|DataResponse
+ *
+ * 200: OK
+ * 400: Invalid policy value
+ * 403: Forbidden
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/group/{groupId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'groupId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])]
+ public function setGroup(string $groupId, string $policyKey, null|bool|int|float|string $value = null, bool $allowChildOverride = false): DataResponse {
+ if (!$this->canManageGroupPolicy($groupId)) {
+ return $this->forbiddenGroupPolicyResponse();
+ }
+
+ $value = $this->readScalarParam('value', $value);
+ $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride);
+
+ try {
+ $policy = $this->policyService->saveGroupPolicy($policyKey, $groupId, $value, $allowChildOverride);
+ /** @var LibresignGroupPolicyWriteResponse $data */
+ $data = [
+ 'message' => $this->l10n->t('Settings saved'),
+ 'policy' => $this->serializeGroupPolicy($groupId, $policyKey, $policy),
+ ];
+
+ return new DataResponse($data);
+ } catch (\InvalidArgumentException $exception) {
+ /** @var LibresignErrorResponse $data */
+ $data = [
+ 'error' => $this->l10n->t($exception->getMessage()),
+ ];
+
+ return new DataResponse($data, Http::STATUS_BAD_REQUEST);
+ }
+ }
+
+ /**
+ * Clear a group-level policy value
+ *
+ * @param string $groupId Group identifier that receives the policy binding.
+ * @param string $policyKey Policy identifier to clear for the selected group.
+ * @return DataResponse|DataResponse
+ *
+ * 200: OK
+ * 403: Forbidden
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/group/{groupId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'groupId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])]
+ public function clearGroup(string $groupId, string $policyKey): DataResponse {
+ if (!$this->canManageGroupPolicy($groupId)) {
+ return $this->forbiddenGroupPolicyResponse();
+ }
+
+ $policy = $this->policyService->clearGroupPolicy($policyKey, $groupId);
+ /** @var LibresignGroupPolicyWriteResponse $data */
+ $data = [
+ 'message' => $this->l10n->t('Settings saved'),
+ 'policy' => $this->serializeGroupPolicy($groupId, $policyKey, $policy),
+ ];
+
+ return new DataResponse($data);
+ }
+
+ /**
+ * Save a user policy preference
+ *
+ * @param string $policyKey Policy identifier to persist for the current user.
+ * @param null|bool|int|float|string $value Policy value to persist as the current user's default.
+ * @return DataResponse|DataResponse
+ *
+ * 200: OK
+ * 400: Invalid policy value
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/user/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])]
+ public function setUserPreference(string $policyKey, null|bool|int|float|string $value = null): DataResponse {
+ $value = $this->readScalarParam('value', $value);
+
+ try {
+ $policy = $this->policyService->saveUserPreference($policyKey, $value);
+ /** @var LibresignSystemPolicyWriteResponse $data */
+ $data = [
+ 'message' => $this->l10n->t('Settings saved'),
+ 'policy' => $policy->toArray(),
+ ];
+
+ return new DataResponse($data);
+ } catch (\InvalidArgumentException $exception) {
+ /** @var LibresignErrorResponse $data */
+ $data = [
+ 'error' => $this->l10n->t($exception->getMessage()),
+ ];
+
+ return new DataResponse($data, Http::STATUS_BAD_REQUEST);
+ }
+ }
+
+ /**
+ * Save a user policy preference for a target user (admin scope)
+ *
+ * @param string $userId Target user identifier that receives the policy preference.
+ * @param string $policyKey Policy identifier to persist for the target user.
+ * @param null|bool|int|float|string $value Policy value to persist as target user preference.
+ * @return DataResponse|DataResponse
+ *
+ * 200: OK
+ * 400: Invalid policy value
+ */
+ #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/user/{userId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'userId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])]
+ public function setUserPolicyForUser(string $userId, string $policyKey, null|bool|int|float|string $value = null): DataResponse {
+ $value = $this->readScalarParam('value', $value);
+
+ try {
+ $policy = $this->policyService->saveUserPreferenceForUserId($policyKey, $userId, $value);
+ /** @var LibresignUserPolicyWriteResponse $data */
+ $data = [
+ 'message' => $this->l10n->t('Settings saved'),
+ 'policy' => $this->serializeUserPolicy($userId, $policyKey, $policy),
+ ];
+
+ return new DataResponse($data);
+ } catch (\InvalidArgumentException $exception) {
+ /** @var LibresignErrorResponse $data */
+ $data = [
+ 'error' => $this->l10n->t($exception->getMessage()),
+ ];
+
+ return new DataResponse($data, Http::STATUS_BAD_REQUEST);
+ }
+ }
+
+ /**
+ * Clear a user policy preference
+ *
+ * @param string $policyKey Policy identifier to clear for the current user.
+ * @return DataResponse
+ *
+ * 200: OK
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/user/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])]
+ public function clearUserPreference(string $policyKey): DataResponse {
+ $policy = $this->policyService->clearUserPreference($policyKey);
+ /** @var LibresignSystemPolicyWriteResponse $data */
+ $data = [
+ 'message' => $this->l10n->t('Settings saved'),
+ 'policy' => $policy->toArray(),
+ ];
+
+ return new DataResponse($data);
+ }
+
+ /**
+ * Clear a user policy preference for a target user (admin scope)
+ *
+ * @param string $userId Target user identifier that receives the policy preference removal.
+ * @param string $policyKey Policy identifier to clear for the target user.
+ * @return DataResponse
+ *
+ * 200: OK
+ */
+ #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/user/{userId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'userId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])]
+ public function clearUserPolicyForUser(string $userId, string $policyKey): DataResponse {
+ $policy = $this->policyService->clearUserPreferenceForUserId($policyKey, $userId);
+ /** @var LibresignUserPolicyWriteResponse $data */
+ $data = [
+ 'message' => $this->l10n->t('Settings saved'),
+ 'policy' => $this->serializeUserPolicy($userId, $policyKey, $policy),
+ ];
+
+ return new DataResponse($data);
+ }
+
+ /** @return LibresignGroupPolicyState */
+ private function serializeGroupPolicy(string $groupId, string $policyKey, ?PolicyLayer $policy): array {
+ return [
+ 'policyKey' => $policyKey,
+ 'scope' => 'group',
+ 'targetId' => $groupId,
+ 'value' => $policy?->getValue(),
+ 'allowChildOverride' => $policy?->isAllowChildOverride() ?? true,
+ 'visibleToChild' => $policy?->isVisibleToChild() ?? true,
+ 'allowedValues' => $policy?->getAllowedValues() ?? [],
+ ];
+ }
+
+ /** @return LibresignUserPolicyState */
+ private function serializeUserPolicy(string $userId, string $policyKey, ?PolicyLayer $policy): array {
+ return [
+ 'policyKey' => $policyKey,
+ 'scope' => 'user',
+ 'targetId' => $userId,
+ 'value' => $policy?->getValue(),
+ ];
+ }
+
+ private function canManageGroupPolicy(string $groupId): bool {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return false;
+ }
+
+ if ($this->groupManager->isAdmin($user->getUID())) {
+ return true;
+ }
+
+ $group = $this->groupManager->get($groupId);
+ if ($group === null) {
+ return false;
+ }
+
+ return $this->subAdmin->isSubAdminOfGroup($user, $group);
+ }
+
+ private function readScalarParam(string $key, null|bool|int|float|string $default): null|bool|int|float|string {
+ $value = $this->request->getParams()[$key] ?? $default;
+ if (!is_scalar($value) && $value !== null) {
+ return $default;
+ }
+
+ return $value;
+ }
+
+ private function readBoolParam(string $key, bool $default): bool {
+ $value = $this->request->getParams()[$key] ?? $default;
+ return is_bool($value) ? $value : $default;
+ }
+
+ /** @return DataResponse */
+ private function forbiddenGroupPolicyResponse(): DataResponse {
+ /** @var LibresignErrorResponse $data */
+ $data = [
+ 'error' => $this->l10n->t('Not allowed to manage this group policy'),
+ ];
+
+ return new DataResponse($data, Http::STATUS_FORBIDDEN);
+ }
+}
diff --git a/lib/Controller/RequestSignatureController.php b/lib/Controller/RequestSignatureController.php
index 0fcc5a6de9..f52612bc5b 100644
--- a/lib/Controller/RequestSignatureController.php
+++ b/lib/Controller/RequestSignatureController.php
@@ -67,7 +67,7 @@ public function __construct(
* @param list $files Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.
* @param string|null $callback URL that will receive a POST after the document is signed
* @param integer|null $status Numeric code of status * 0 - no signers * 1 - signed * 2 - pending
- * @param string|null $signatureFlow Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration
+ * @param string|null $signatureFlow Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution.
* @return DataResponse|DataResponse
*
* 200: OK
@@ -133,7 +133,7 @@ public function request(
* @param LibresignVisibleElement[]|null $visibleElements Visible elements on document
* @param LibresignNewFile|array|null $file File object. Supports nodeId, url, base64 or path when creating a new request.
* @param integer|null $status Numeric code of status * 0 - no signers * 1 - signed * 2 - pending
- * @param string|null $signatureFlow Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration
+ * @param string|null $signatureFlow Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution.
* @param string|null $name The name of file to sign
* @param LibresignFolderSettings $settings Settings to define how and where the file should be stored
* @param list $files Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.
diff --git a/lib/Db/PermissionSet.php b/lib/Db/PermissionSet.php
new file mode 100644
index 0000000000..7b1c7b1cfc
--- /dev/null
+++ b/lib/Db/PermissionSet.php
@@ -0,0 +1,108 @@
+addType('id', Types::INTEGER);
+ $this->addType('name', Types::STRING);
+ $this->addType('description', Types::TEXT);
+ $this->addType('scopeType', Types::STRING);
+ $this->addType('enabled', Types::SMALLINT);
+ $this->addType('priority', Types::SMALLINT);
+ $this->addType('policyJson', Types::TEXT);
+ $this->addType('createdAt', Types::DATETIME);
+ $this->addType('updatedAt', Types::DATETIME);
+ }
+
+ public function isEnabled(): bool {
+ return $this->enabled === 1;
+ }
+
+ public function setEnabled(bool $enabled): void {
+ $this->setter('enabled', [$enabled ? 1 : 0]);
+ }
+
+ /**
+ * @param array $policyJson
+ */
+ public function setPolicyJson(array $policyJson): void {
+ $this->setter('policyJson', [json_encode($policyJson, JSON_THROW_ON_ERROR)]);
+ }
+
+ /**
+ * @return array
+ */
+ public function getDecodedPolicyJson(): array {
+ $decoded = json_decode($this->policyJson, true);
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ /**
+ * @param \DateTime|string $createdAt
+ */
+ public function setCreatedAt($createdAt): void {
+ if (!$createdAt instanceof \DateTime) {
+ $createdAt = new \DateTime($createdAt, new \DateTimeZone('UTC'));
+ }
+ $this->createdAt = $createdAt;
+ $this->markFieldUpdated('createdAt');
+ }
+
+ public function getCreatedAt(): ?\DateTime {
+ return $this->createdAt;
+ }
+
+ /**
+ * @param \DateTime|string $updatedAt
+ */
+ public function setUpdatedAt($updatedAt): void {
+ if (!$updatedAt instanceof \DateTime) {
+ $updatedAt = new \DateTime($updatedAt, new \DateTimeZone('UTC'));
+ }
+ $this->updatedAt = $updatedAt;
+ $this->markFieldUpdated('updatedAt');
+ }
+
+ public function getUpdatedAt(): ?\DateTime {
+ return $this->updatedAt;
+ }
+}
diff --git a/lib/Db/PermissionSetBinding.php b/lib/Db/PermissionSetBinding.php
new file mode 100644
index 0000000000..f760af1b46
--- /dev/null
+++ b/lib/Db/PermissionSetBinding.php
@@ -0,0 +1,52 @@
+addType('id', Types::INTEGER);
+ $this->addType('permissionSetId', Types::INTEGER);
+ $this->addType('targetType', Types::STRING);
+ $this->addType('targetId', Types::STRING);
+ $this->addType('createdAt', Types::DATETIME);
+ }
+
+ /**
+ * @param \DateTime|string $createdAt
+ */
+ public function setCreatedAt($createdAt): void {
+ if (!$createdAt instanceof \DateTime) {
+ $createdAt = new \DateTime($createdAt, new \DateTimeZone('UTC'));
+ }
+ $this->createdAt = $createdAt;
+ $this->markFieldUpdated('createdAt');
+ }
+
+ public function getCreatedAt(): ?\DateTime {
+ return $this->createdAt;
+ }
+}
diff --git a/lib/Db/PermissionSetBindingMapper.php b/lib/Db/PermissionSetBindingMapper.php
new file mode 100644
index 0000000000..e2363b9bb6
--- /dev/null
+++ b/lib/Db/PermissionSetBindingMapper.php
@@ -0,0 +1,83 @@
+
+ */
+class PermissionSetBindingMapper extends CachedQBMapper {
+ public function __construct(IDBConnection $db, ICacheFactory $cacheFactory) {
+ parent::__construct($db, $cacheFactory, 'libresign_permission_set_binding');
+ }
+
+ /**
+ * @throws DoesNotExistException
+ */
+ public function getById(int $id): PermissionSetBinding {
+ $cached = $this->cacheGet('id:' . $id);
+ if ($cached instanceof PermissionSetBinding) {
+ return $cached;
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
+
+ /** @var PermissionSetBinding */
+ $entity = $this->findEntity($qb);
+ $this->cacheEntity($entity);
+ return $entity;
+ }
+
+ /**
+ * @throws DoesNotExistException
+ */
+ public function getByTarget(string $targetType, string $targetId): PermissionSetBinding {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('target_type', $qb->createNamedParameter($targetType)))
+ ->andWhere($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId)));
+
+ /** @var PermissionSetBinding */
+ $entity = $this->findEntity($qb);
+ $this->cacheEntity($entity);
+ return $entity;
+ }
+
+ /**
+ * @param list $targetIds
+ * @return list
+ */
+ public function findByTargets(string $targetType, array $targetIds): array {
+ if ($targetIds === []) {
+ return [];
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('target_type', $qb->createNamedParameter($targetType)))
+ ->andWhere($qb->expr()->in('target_id', $qb->createNamedParameter($targetIds, IQueryBuilder::PARAM_STR_ARRAY)));
+
+ /** @var list */
+ $entities = $this->findEntities($qb);
+ foreach ($entities as $entity) {
+ $this->cacheEntity($entity);
+ }
+
+ return $entities;
+ }
+}
diff --git a/lib/Db/PermissionSetMapper.php b/lib/Db/PermissionSetMapper.php
new file mode 100644
index 0000000000..bafa288eb2
--- /dev/null
+++ b/lib/Db/PermissionSetMapper.php
@@ -0,0 +1,66 @@
+
+ */
+class PermissionSetMapper extends CachedQBMapper {
+ public function __construct(IDBConnection $db, ICacheFactory $cacheFactory) {
+ parent::__construct($db, $cacheFactory, 'libresign_permission_set');
+ }
+
+ /**
+ * @throws DoesNotExistException
+ */
+ public function getById(int $id): PermissionSet {
+ $cached = $this->cacheGet('id:' . $id);
+ if ($cached instanceof PermissionSet) {
+ return $cached;
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
+
+ /** @var PermissionSet */
+ $entity = $this->findEntity($qb);
+ $this->cacheEntity($entity);
+ return $entity;
+ }
+
+ /**
+ * @param list $ids
+ * @return list
+ */
+ public function findByIds(array $ids): array {
+ if ($ids === []) {
+ return [];
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)));
+
+ /** @var list */
+ $entities = $this->findEntities($qb);
+ foreach ($entities as $entity) {
+ $this->cacheEntity($entity);
+ }
+
+ return $entities;
+ }
+}
diff --git a/lib/Files/TemplateLoader.php b/lib/Files/TemplateLoader.php
index 7d3c8e3d1f..425ce46316 100644
--- a/lib/Files/TemplateLoader.php
+++ b/lib/Files/TemplateLoader.php
@@ -16,11 +16,11 @@
use OCA\Libresign\Service\AccountService;
use OCA\Libresign\Service\DocMdp\ConfigService;
use OCA\Libresign\Service\IdentifyMethodService;
+use OCA\Libresign\Service\Policy\PolicyService;
use OCP\App\IAppManager;
use OCP\AppFramework\Services\IInitialState;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
-use OCP\IAppConfig;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\Util;
@@ -37,7 +37,7 @@ public function __construct(
private ValidateHelper $validateHelper,
private IdentifyMethodService $identifyMethodService,
private CertificateEngineFactory $certificateEngineFactory,
- private IAppConfig $appConfig,
+ private PolicyService $policyService,
private IAppManager $appManager,
private ConfigService $docMdpConfigService,
) {
@@ -63,23 +63,22 @@ public function handle(Event $event): void {
}
protected function getInitialStatePayload(): array {
+ $resolvedPolicies = [];
+ foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) {
+ $resolvedPolicies[$policyKey] = $resolvedPolicy->toArray();
+ }
+
return [
'certificate_ok' => $this->certificateEngineFactory->getEngine()->isSetupOk(),
'identify_methods' => $this->identifyMethodService->getIdentifyMethodsSettings(),
- 'signature_flow' => $this->getSignatureFlow(),
+ 'effective_policies' => [
+ 'policies' => $resolvedPolicies,
+ ],
'docmdp_config' => $this->docMdpConfigService->getConfig(),
'can_request_sign' => $this->canRequestSign(),
];
}
- private function getSignatureFlow(): string {
- return $this->appConfig->getValueString(
- Application::APP_ID,
- 'signature_flow',
- \OCA\Libresign\Enum\SignatureFlow::NONE->value
- );
- }
-
private function canRequestSign(): bool {
try {
$this->validateHelper->canRequestSign($this->userSession->getUser());
diff --git a/lib/Handler/FooterHandler.php b/lib/Handler/FooterHandler.php
index fd5ea2c882..7019454a84 100644
--- a/lib/Handler/FooterHandler.php
+++ b/lib/Handler/FooterHandler.php
@@ -12,6 +12,9 @@
use OCA\Libresign\Db\File as FileEntity;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Service\File\Pdf\PdfMetadataExtractor;
+use OCA\Libresign\Service\Policy\PolicyService;
+use OCA\Libresign\Service\Policy\Provider\Footer\AddFooterPolicy;
+use OCA\Libresign\Service\Policy\Provider\Footer\SignatureFooterPolicyValue;
use OCA\Libresign\Vendor\Endroid\QrCode\Color\Color;
use OCA\Libresign\Vendor\Endroid\QrCode\Encoding\Encoding;
use OCA\Libresign\Vendor\Endroid\QrCode\ErrorCorrectionLevel;
@@ -41,17 +44,17 @@ public function __construct(
private IL10N $l10n,
private IFactory $l10nFactory,
private ITempManager $tempManager,
+ private PolicyService $policyService,
private TemplateVariables $templateVars,
) {
}
- public function getFooter(array $dimensions): string {
- $add_footer = (bool)$this->appConfig->getValueBool(Application::APP_ID, 'add_footer', true);
- if (!$add_footer) {
+ public function getFooter(array $dimensions, bool $forceEnabled = false): string {
+ if (!$forceEnabled && !$this->isFooterEnabled()) {
return '';
}
- $htmlFooter = $this->getRenderedHtmlFooter();
+ $htmlFooter = $this->getRenderedHtmlFooter($forceEnabled);
foreach ($dimensions as $dimension) {
if (!isset($pdf)) {
$pdf = new Mpdf([
@@ -94,14 +97,14 @@ public function getMetadata(File $file, FileEntity $fileEntity): array {
return $metadata;
}
- private function getRenderedHtmlFooter(): string {
+ private function getRenderedHtmlFooter(bool $forceEnabled = false): string {
try {
$twigEnvironment = new Environment(
new FilesystemLoader(),
);
return $twigEnvironment
->createTemplate($this->getTemplate())
- ->render($this->prepareTemplateVars());
+ ->render($this->prepareTemplateVars($forceEnabled));
} catch (SyntaxError $e) {
throw new LibresignException($e->getMessage());
}
@@ -112,7 +115,11 @@ public function setTemplateVar(string $name, mixed $value): self {
return $this;
}
- private function prepareTemplateVars(): array {
+ private function prepareTemplateVars(bool $forceEnabled = false): array {
+ $footerPolicy = SignatureFooterPolicyValue::normalize(
+ $this->policyService->resolve(AddFooterPolicy::KEY)->getEffectiveValue()
+ );
+
if (!$this->templateVars->getSignedBy()) {
$this->templateVars->setSignedBy(
$this->appConfig->getValueString(Application::APP_ID, 'footer_signed_by', $this->l10n->t('Digitally signed by LibreSign.'))
@@ -132,7 +139,9 @@ private function prepareTemplateVars(): array {
}
if (!$this->templateVars->getValidationSite() && $this->templateVars->getUuid()) {
- $validationSite = $this->appConfig->getValueString(Application::APP_ID, 'validation_site');
+ $validationSite = $footerPolicy['validationSite'] !== ''
+ ? $footerPolicy['validationSite']
+ : $this->appConfig->getValueString(Application::APP_ID, 'validation_site');
if ($validationSite) {
$this->templateVars->setValidationSite(
rtrim($validationSite, '/') . '/' . $this->templateVars->getUuid()
@@ -155,7 +164,7 @@ private function prepareTemplateVars(): array {
}
}
- if ($this->appConfig->getValueBool(Application::APP_ID, 'write_qrcode_on_footer', true) && $this->templateVars->getValidationSite()) {
+ if ($footerPolicy['writeQrcodeOnFooter'] && $this->templateVars->getValidationSite()) {
$this->templateVars->setQrcode($this->getQrCodeImageBase64($this->templateVars->getValidationSite()));
}
@@ -204,4 +213,10 @@ private function getQrCodeImageBase64(string $text): string {
public function getTemplateVariablesMetadata(): array {
return $this->templateVars->getVariablesMetadata();
}
+
+ private function isFooterEnabled(): bool {
+ return SignatureFooterPolicyValue::isEnabled(
+ $this->policyService->resolve(AddFooterPolicy::KEY)->getEffectiveValue()
+ );
+ }
}
diff --git a/lib/Migration/Version18000Date20260317000000.php b/lib/Migration/Version18000Date20260317000000.php
new file mode 100644
index 0000000000..a06a974ee0
--- /dev/null
+++ b/lib/Migration/Version18000Date20260317000000.php
@@ -0,0 +1,100 @@
+hasTable('libresign_permission_set')) {
+ $permissionSetTable = $schema->getTable('libresign_permission_set');
+ } else {
+ $permissionSetTable = $schema->createTable('libresign_permission_set');
+ $permissionSetTable->addColumn('id', Types::INTEGER, [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]);
+ $permissionSetTable->addColumn('name', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 255,
+ ]);
+ $permissionSetTable->addColumn('description', Types::TEXT, [
+ 'notnull' => false,
+ ]);
+ $permissionSetTable->addColumn('scope_type', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 64,
+ ]);
+ $permissionSetTable->addColumn('enabled', Types::SMALLINT, [
+ 'notnull' => true,
+ 'default' => 1,
+ ]);
+ $permissionSetTable->addColumn('priority', Types::SMALLINT, [
+ 'notnull' => true,
+ 'default' => 0,
+ ]);
+ $permissionSetTable->addColumn('policy_json', Types::TEXT, [
+ 'notnull' => true,
+ 'default' => '{}',
+ ]);
+ $permissionSetTable->addColumn('created_at', Types::DATETIME, [
+ 'notnull' => true,
+ ]);
+ $permissionSetTable->addColumn('updated_at', Types::DATETIME, [
+ 'notnull' => true,
+ ]);
+ $permissionSetTable->setPrimaryKey(['id']);
+ $permissionSetTable->addIndex(['scope_type'], 'ls_perm_set_scope_idx');
+ }
+
+ if (!$schema->hasTable('libresign_permission_set_binding')) {
+ $table = $schema->createTable('libresign_permission_set_binding');
+ $table->addColumn('id', Types::INTEGER, [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]);
+ $table->addColumn('permission_set_id', Types::INTEGER, [
+ 'notnull' => true,
+ 'unsigned' => true,
+ ]);
+ $table->addColumn('target_type', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 64,
+ ]);
+ $table->addColumn('target_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 255,
+ ]);
+ $table->addColumn('created_at', Types::DATETIME, [
+ 'notnull' => true,
+ ]);
+ $table->setPrimaryKey(['id']);
+ $table->addIndex(['permission_set_id'], 'ls_perm_bind_set_idx');
+ $table->addUniqueIndex(['target_type', 'target_id'], 'ls_perm_bind_target_uidx');
+ $table->addForeignKeyConstraint($permissionSetTable, ['permission_set_id'], ['id'], [
+ 'onDelete' => 'CASCADE',
+ ]);
+ }
+
+ return $schema;
+ }
+}
diff --git a/lib/Migration/Version18001Date20260320000000.php b/lib/Migration/Version18001Date20260320000000.php
new file mode 100644
index 0000000000..1c646acf67
--- /dev/null
+++ b/lib/Migration/Version18001Date20260320000000.php
@@ -0,0 +1,95 @@
+migrateSignatureFlowKeys();
+ $this->migrateDocMdpLevelType();
+ $this->migrateIdentifyMethodsType();
+ }
+
+ private function migrateSignatureFlowKeys(): void {
+ $newSystemKey = SignatureFlowPolicy::SYSTEM_APP_CONFIG_KEY;
+
+ $this->copyStringValueWhenDestinationEmpty(self::LEGACY_SYSTEM_KEY, $newSystemKey);
+
+ $this->appConfig->deleteKey(self::APP_ID, self::LEGACY_SYSTEM_KEY);
+ }
+
+ private function migrateDocMdpLevelType(): void {
+ $legacyValue = $this->readLegacyString(DocMdpPolicy::SYSTEM_APP_CONFIG_KEY);
+ if ($legacyValue === null || $legacyValue === self::EMPTY_STRING || !is_numeric($legacyValue)) {
+ return;
+ }
+
+ $this->appConfig->deleteKey(self::APP_ID, DocMdpPolicy::SYSTEM_APP_CONFIG_KEY);
+ $this->appConfig->setValueInt(self::APP_ID, DocMdpPolicy::SYSTEM_APP_CONFIG_KEY, (int)$legacyValue);
+ }
+
+ private function migrateIdentifyMethodsType(): void {
+ $legacyValue = $this->readLegacyString(self::IDENTIFY_METHODS_KEY);
+ if ($legacyValue === null || $legacyValue === self::EMPTY_STRING) {
+ return;
+ }
+
+ $this->appConfig->deleteKey(self::APP_ID, self::IDENTIFY_METHODS_KEY);
+ $decoded = json_decode($legacyValue, true);
+ if (!is_array($decoded)) {
+ return;
+ }
+
+ $this->appConfig->setValueArray(self::APP_ID, self::IDENTIFY_METHODS_KEY, $decoded);
+ }
+
+ private function copyStringValueWhenDestinationEmpty(string $sourceKey, string $destinationKey): void {
+ $sourceValue = $this->appConfig->getValueString(self::APP_ID, $sourceKey, self::EMPTY_STRING);
+ $destinationValue = $this->appConfig->getValueString(self::APP_ID, $destinationKey, self::EMPTY_STRING);
+ if ($sourceValue === self::EMPTY_STRING || $destinationValue !== self::EMPTY_STRING) {
+ return;
+ }
+
+ $this->appConfig->setValueString(self::APP_ID, $destinationKey, $sourceValue);
+ }
+
+ private function readLegacyString(string $key): ?string {
+ try {
+ return $this->appConfig->getValueString(self::APP_ID, $key, self::EMPTY_STRING);
+ } catch (AppConfigTypeConflictException) {
+ // The key is already stored in the target typed format
+ return null;
+ }
+ }
+
+ #[\Override]
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
+ return null;
+ }
+}
diff --git a/lib/Migration/Version18002Date20260410000000.php b/lib/Migration/Version18002Date20260410000000.php
new file mode 100644
index 0000000000..3824adcaab
--- /dev/null
+++ b/lib/Migration/Version18002Date20260410000000.php
@@ -0,0 +1,127 @@
+migrateLegacyFooterSettings();
+ }
+
+ private function migrateLegacyFooterSettings(): void {
+ $legacyAddFooter = $this->readLegacyValue('add_footer');
+ $legacyWriteQrCodeOnFooter = $this->readLegacyBool('write_qrcode_on_footer', true);
+ $legacyValidationSite = $this->readLegacyString('validation_site', '');
+ $legacyFooterTemplateIsDefault = $this->readLegacyBool('footer_template_is_default', true);
+
+ $rawFooterPolicyValue = $legacyAddFooter;
+ if (!$this->isStructuredFooterPayload($legacyAddFooter)) {
+ $rawFooterPolicyValue = [
+ 'enabled' => $this->toBool($legacyAddFooter, true),
+ 'writeQrcodeOnFooter' => $legacyWriteQrCodeOnFooter,
+ 'validationSite' => $legacyValidationSite,
+ 'customizeFooterTemplate' => !$legacyFooterTemplateIsDefault,
+ ];
+ }
+
+ $encodedFooterPolicyValue = SignatureFooterPolicyValue::encode(
+ SignatureFooterPolicyValue::normalize($rawFooterPolicyValue),
+ );
+
+ $this->appConfig->deleteKey(self::APP_ID, 'add_footer');
+ $this->appConfig->setValueString(self::APP_ID, 'add_footer', $encodedFooterPolicyValue);
+ }
+
+ private function isStructuredFooterPayload(mixed $value): bool {
+ if (!is_string($value)) {
+ return false;
+ }
+
+ $decoded = json_decode($value, true);
+ if (!is_array($decoded)) {
+ return false;
+ }
+
+ return array_key_exists('enabled', $decoded)
+ || array_key_exists('writeQrcodeOnFooter', $decoded)
+ || array_key_exists('validationSite', $decoded)
+ || array_key_exists('customizeFooterTemplate', $decoded);
+ }
+
+ private function readLegacyValue(string $key): mixed {
+ try {
+ return $this->appConfig->getValueString(self::APP_ID, $key, '');
+ } catch (AppConfigTypeConflictException) {
+ return $this->appConfig->getValueBool(self::APP_ID, $key, true);
+ }
+ }
+
+ private function readLegacyBool(string $key, bool $default): bool {
+ try {
+ $rawValue = $this->appConfig->getValueString(self::APP_ID, $key, '');
+ if ($rawValue === '') {
+ return $default;
+ }
+
+ return in_array(strtolower(trim($rawValue)), ['1', 'true', 'yes', 'on'], true);
+ } catch (AppConfigTypeConflictException) {
+ return $this->appConfig->getValueBool(self::APP_ID, $key, $default);
+ }
+ }
+
+ private function readLegacyString(string $key, string $default): string {
+ try {
+ return trim($this->appConfig->getValueString(self::APP_ID, $key, $default));
+ } catch (AppConfigTypeConflictException) {
+ return $default;
+ }
+ }
+
+ private function toBool(mixed $value, bool $default): bool {
+ if (is_bool($value)) {
+ return $value;
+ }
+
+ if (is_int($value)) {
+ return $value === 1;
+ }
+
+ if (is_string($value)) {
+ $trimmed = trim($value);
+ if ($trimmed === '') {
+ return $default;
+ }
+
+ return in_array(strtolower($trimmed), ['1', 'true', 'yes', 'on'], true);
+ }
+
+ return $default;
+ }
+
+ #[\Override]
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
+ return null;
+ }
+}
diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php
index cb386d9f76..b037ffe763 100644
--- a/lib/ResponseDefinitions.php
+++ b/lib/ResponseDefinitions.php
@@ -354,11 +354,85 @@
*
* Validation and progress contracts
*
+ * @psalm-type LibresignEffectivePolicyValue = null|bool|int|float|string
+ * @psalm-type LibresignEffectivePolicyState = array{
+ * policyKey: string,
+ * effectiveValue: LibresignEffectivePolicyValue,
+ * sourceScope: string,
+ * visible: bool,
+ * editableByCurrentActor: bool,
+ * allowedValues: list,
+ * canSaveAsUserDefault: bool,
+ * canUseAsRequestOverride: bool,
+ * preferenceWasCleared: bool,
+ * blockedBy: ?string,
+ * }
+ * @psalm-type LibresignEffectivePolicyResponse = array{
+ * policy: LibresignEffectivePolicyState,
+ * }
+ * @psalm-type LibresignEffectivePoliciesResponse = array{
+ * policies: array,
+ * }
+ * @psalm-type LibresignSystemPolicyWriteRequest = array{
+ * value: LibresignEffectivePolicyValue,
+ * }
+ * @psalm-type LibresignGroupPolicyState = array{
+ * policyKey: string,
+ * scope: 'group',
+ * targetId: string,
+ * value: null|LibresignEffectivePolicyValue,
+ * allowChildOverride: bool,
+ * visibleToChild: bool,
+ * allowedValues: list,
+ * }
+ * @psalm-type LibresignGroupPolicyResponse = array{
+ * policy: LibresignGroupPolicyState,
+ * }
+ * @psalm-type LibresignGroupPolicyWriteRequest = array{
+ * value: LibresignEffectivePolicyValue,
+ * allowChildOverride: bool,
+ * }
+ * @psalm-type LibresignSystemPolicyState = array{
+ * policyKey: string,
+ * scope: 'system'|'global',
+ * value: null|LibresignEffectivePolicyValue,
+ * allowChildOverride: bool,
+ * visibleToChild: bool,
+ * allowedValues: list,
+ * }
+ * @psalm-type LibresignSystemPolicyResponse = array{
+ * policy: LibresignSystemPolicyState,
+ * }
+ * @psalm-type LibresignUserPolicyState = array{
+ * policyKey: string,
+ * scope: 'user',
+ * targetId: string,
+ * value: null|LibresignEffectivePolicyValue,
+ * }
+ * @psalm-type LibresignUserPolicyResponse = array{
+ * policy: LibresignUserPolicyState,
+ * }
+ * @psalm-type LibresignGroupPolicyWriteResponse = LibresignMessageResponse&LibresignGroupPolicyResponse
+ * @psalm-type LibresignSystemPolicyWriteResponse = LibresignMessageResponse&LibresignEffectivePolicyResponse
+ * @psalm-type LibresignUserPolicyWriteResponse = LibresignMessageResponse&LibresignUserPolicyResponse
+ * @psalm-type LibresignPolicySnapshotEntry = array{
+ * effectiveValue: string,
+ * sourceScope: string,
+ * }
+ * @psalm-type LibresignPolicySnapshotNumericEntry = array{
+ * effectiveValue: int,
+ * sourceScope: string,
+ * }
+ * @psalm-type LibresignValidatePolicySnapshot = array{
+ * docmdp?: LibresignPolicySnapshotNumericEntry,
+ * signature_flow?: LibresignPolicySnapshotEntry,
+ * }
* @psalm-type LibresignValidateMetadata = array{
* extension: string,
* p: int,
* d?: list,
* original_file_deleted?: bool,
+ * policy_snapshot?: LibresignValidatePolicySnapshot,
* pdfVersion?: string,
* status_changed_at?: string,
* }
diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php
index f721938150..30eed63ec9 100644
--- a/lib/Service/AccountService.php
+++ b/lib/Service/AccountService.php
@@ -36,6 +36,7 @@
use OCP\Files\IMimeTypeDetector;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
+use OCP\Group\ISubAdmin;
use OCP\Http\Client\IClientService;
use OCP\IAppConfig;
use OCP\IGroupManager;
@@ -73,6 +74,7 @@ public function __construct(
private IURLGenerator $urlGenerator,
private Pkcs12Handler $pkcs12Handler,
private IGroupManager $groupManager,
+ private ISubAdmin $subAdmin,
private IdDocsService $idDocsService,
private SignerElementsService $signerElementsService,
private UserElementMapper $userElementMapper,
@@ -207,8 +209,11 @@ public function getConfig(?IUser $user = null): array {
$info['files_list_signer_identify_tab'] = $this->getUserConfigByKey('files_list_signer_identify_tab', $user);
$info['files_list_sorting_mode'] = $this->getUserConfigByKey('files_list_sorting_mode', $user) ?: 'name';
$info['files_list_sorting_direction'] = $this->getUserConfigByKey('files_list_sorting_direction', $user) ?: 'asc';
+ $info['policy_workbench_catalog_compact_view'] = $this->getUserConfigByKey('policy_workbench_catalog_compact_view', $user) === '1';
+ $info['can_manage_group_policies'] = $user !== null
+ && ($this->groupManager->isAdmin($user->getUID()) || $this->subAdmin->isSubAdmin($user));
- return array_filter($info);
+ return array_filter($info, static fn (mixed $value): bool => $value !== null && $value !== '');
}
public function getConfigFilters(?IUser $user = null): array {
diff --git a/lib/Service/DocMdp/ConfigService.php b/lib/Service/DocMdp/ConfigService.php
index 39e0689d99..d25c8b7e72 100644
--- a/lib/Service/DocMdp/ConfigService.php
+++ b/lib/Service/DocMdp/ConfigService.php
@@ -20,6 +20,7 @@
*/
class ConfigService {
private const CONFIG_KEY_LEVEL = 'docmdp_level';
+ private const DEFAULT_LEVEL = DocMdpLevel::CERTIFIED_FORM_FILLING;
public function __construct(
private IAppConfig $appConfig,
@@ -43,8 +44,8 @@ public function setEnabled(bool $enabled): void {
}
public function getLevel(): DocMdpLevel {
- $level = $this->appConfig->getValueInt(Application::APP_ID, self::CONFIG_KEY_LEVEL, DocMdpLevel::CERTIFIED_FORM_FILLING->value);
- return DocMdpLevel::tryFrom($level) ?? DocMdpLevel::CERTIFIED_FORM_FILLING;
+ $level = $this->appConfig->getValueInt(Application::APP_ID, self::CONFIG_KEY_LEVEL, self::DEFAULT_LEVEL->value);
+ return DocMdpLevel::tryFrom($level) ?? self::DEFAULT_LEVEL;
}
public function setLevel(DocMdpLevel $level): void {
@@ -71,4 +72,5 @@ private function getAvailableLevels(): array {
DocMdpLevel::cases()
);
}
+
}
diff --git a/lib/Service/FolderService.php b/lib/Service/FolderService.php
index 626193bdaf..b78b69ea8d 100644
--- a/lib/Service/FolderService.php
+++ b/lib/Service/FolderService.php
@@ -69,14 +69,8 @@ public function getUserRootFolder(): Folder {
public function getFolder(): Folder {
$path = $this->getLibreSignDefaultPath();
$containerFolder = $this->getContainerFolder();
- try {
- /** @var Folder $folder */
- $folder = $containerFolder->get($path);
- } catch (NotFoundException) {
- /** @var Folder $folder */
- $folder = $containerFolder->newFolder($path);
- }
- return $folder;
+
+ return $this->ensureFolderPathExists($containerFolder, $path);
}
/**
@@ -119,6 +113,23 @@ protected function getContainerFolder(): Folder {
return $reflectionProperty->getValue($containerFolder);
}
+ private function ensureFolderPathExists(Folder $folder, string $path): Folder {
+ $cleanPath = trim($path, '/');
+
+ if ($cleanPath === '') {
+ return $folder;
+ }
+
+ $segments = array_filter(explode('/', $cleanPath), static fn (string $segment): bool => $segment !== '');
+ $currentFolder = $folder;
+
+ foreach ($segments as $segment) {
+ $currentFolder = $currentFolder->getOrCreateFolder($segment);
+ }
+
+ return $currentFolder;
+ }
+
private function getLibreSignDefaultPath(): string {
if (!$this->userId) {
return 'unauthenticated';
diff --git a/lib/Service/IdentifyMethod/Account.php b/lib/Service/IdentifyMethod/Account.php
index 20cbb84514..cd28a7081e 100644
--- a/lib/Service/IdentifyMethod/Account.php
+++ b/lib/Service/IdentifyMethod/Account.php
@@ -8,7 +8,6 @@
namespace OCA\Libresign\Service\IdentifyMethod;
-use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Db\IdentifyMethodMapper;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Helper\JSActions;
@@ -161,10 +160,7 @@ public function getSettings(): array {
}
private function isEnabledByDefault(): bool {
- $config = $this->identifyService->getAppConfig()->getValueArray(Application::APP_ID, 'identify_methods', []);
- if (json_last_error() !== JSON_ERROR_NONE || !is_array($config)) {
- return true;
- }
+ $config = $this->identifyService->getSavedSettings();
// Remove not enabled
$config = array_filter($config, fn ($i) => isset($i['enabled']) && $i['enabled'] ? true : false);
diff --git a/lib/Service/IdentifyMethod/IdentifyService.php b/lib/Service/IdentifyMethod/IdentifyService.php
index 2329a59227..2693acb489 100644
--- a/lib/Service/IdentifyMethod/IdentifyService.php
+++ b/lib/Service/IdentifyMethod/IdentifyService.php
@@ -17,6 +17,7 @@
use OCA\Libresign\Service\SessionService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Exceptions\AppConfigTypeConflictException;
use OCP\Files\IRootFolder;
use OCP\IAppConfig;
use OCP\IL10N;
@@ -26,7 +27,7 @@
use Psr\Log\LoggerInterface;
class IdentifyService {
- private array $savedSettings = [];
+ private ?array $savedSettings = null;
public function __construct(
private IdentifyMethodMapper $identifyMethodMapper,
private SessionService $sessionService,
@@ -126,10 +127,32 @@ private function refreshIdFromDatabaseIfNecessary(IdentifyMethod $identifyMethod
}
public function getSavedSettings(): array {
- if (!empty($this->savedSettings)) {
+ if ($this->savedSettings !== null) {
return $this->savedSettings;
}
- return $this->getAppConfig()->getValueArray(Application::APP_ID, 'identify_methods', []);
+
+ $this->getAppConfig()->clearCache(true);
+ try {
+ $this->savedSettings = $this->getAppConfig()->getValueArray(Application::APP_ID, 'identify_methods', []);
+ } catch (AppConfigTypeConflictException) {
+ // Key was stored with wrong type (e.g., string written by the provisioning API).
+ // Normalize it: read the raw string, delete the key, and re-store as array type.
+ try {
+ $raw = $this->getAppConfig()->getValueString(Application::APP_ID, 'identify_methods', '');
+ } catch (AppConfigTypeConflictException) {
+ $raw = '';
+ }
+ $this->getAppConfig()->deleteKey(Application::APP_ID, 'identify_methods');
+ $decoded = json_decode($raw, true);
+ if (is_array($decoded)) {
+ $this->getAppConfig()->setValueArray(Application::APP_ID, 'identify_methods', $decoded);
+ $this->savedSettings = $decoded;
+ } else {
+ $this->savedSettings = [];
+ }
+ }
+
+ return $this->savedSettings;
}
public function getEventDispatcher(): IEventDispatcher {
diff --git a/lib/Service/Policy/Contract/IPolicyDefinition.php b/lib/Service/Policy/Contract/IPolicyDefinition.php
new file mode 100644
index 0000000000..b16fd9c7c1
--- /dev/null
+++ b/lib/Service/Policy/Contract/IPolicyDefinition.php
@@ -0,0 +1,30 @@
+ */
+ public function allowedValues(PolicyContext $context): array;
+
+ public function defaultSystemValue(): mixed;
+}
diff --git a/lib/Service/Policy/Contract/IPolicyDefinitionProvider.php b/lib/Service/Policy/Contract/IPolicyDefinitionProvider.php
new file mode 100644
index 0000000000..0a23c76c6e
--- /dev/null
+++ b/lib/Service/Policy/Contract/IPolicyDefinitionProvider.php
@@ -0,0 +1,16 @@
+ */
+ public function keys(): array;
+
+ public function get(string|\BackedEnum $policyKey): IPolicyDefinition;
+}
diff --git a/lib/Service/Policy/Contract/IPolicyResolver.php b/lib/Service/Policy/Contract/IPolicyResolver.php
new file mode 100644
index 0000000000..5f9fd8d914
--- /dev/null
+++ b/lib/Service/Policy/Contract/IPolicyResolver.php
@@ -0,0 +1,21 @@
+ $definitions
+ * @return array
+ */
+ public function resolveMany(array $definitions, PolicyContext $context): array;
+}
diff --git a/lib/Service/Policy/Contract/IPolicySource.php b/lib/Service/Policy/Contract/IPolicySource.php
new file mode 100644
index 0000000000..4d51761585
--- /dev/null
+++ b/lib/Service/Policy/Contract/IPolicySource.php
@@ -0,0 +1,38 @@
+ */
+ public function loadGroupPolicies(string $policyKey, PolicyContext $context): array;
+
+ /** @return list */
+ public function loadCirclePolicies(string $policyKey, PolicyContext $context): array;
+
+ public function loadUserPreference(string $policyKey, PolicyContext $context): ?PolicyLayer;
+
+ public function loadRequestOverride(string $policyKey, PolicyContext $context): ?PolicyLayer;
+
+ public function loadGroupPolicyConfig(string $policyKey, string $groupId): ?PolicyLayer;
+
+ public function saveSystemPolicy(string $policyKey, mixed $value, bool $allowChildOverride = false): void;
+
+ public function saveGroupPolicy(string $policyKey, string $groupId, mixed $value, bool $allowChildOverride): void;
+
+ public function clearGroupPolicy(string $policyKey, string $groupId): void;
+
+ public function saveUserPreference(string $policyKey, PolicyContext $context, mixed $value): void;
+
+ public function clearUserPreference(string $policyKey, PolicyContext $context): void;
+}
diff --git a/lib/Service/Policy/Model/PolicyContext.php b/lib/Service/Policy/Model/PolicyContext.php
new file mode 100644
index 0000000000..0e7d56f818
--- /dev/null
+++ b/lib/Service/Policy/Model/PolicyContext.php
@@ -0,0 +1,93 @@
+ */
+ private array $groups = [];
+ /** @var list */
+ private array $circles = [];
+ /** @var array|null */
+ private ?array $activeContext = null;
+ /** @var array */
+ private array $requestOverrides = [];
+ /** @var array */
+ private array $actorCapabilities = [];
+
+ public static function fromUserId(string $userId): self {
+ $context = new self();
+ $context->setUserId($userId);
+ return $context;
+ }
+
+ public function setUserId(?string $userId): self {
+ $this->userId = $userId;
+ return $this;
+ }
+
+ public function getUserId(): ?string {
+ return $this->userId;
+ }
+
+ /** @param list $groups */
+ public function setGroups(array $groups): self {
+ $this->groups = $groups;
+ return $this;
+ }
+
+ /** @return list */
+ public function getGroups(): array {
+ return $this->groups;
+ }
+
+ /** @param list $circles */
+ public function setCircles(array $circles): self {
+ $this->circles = $circles;
+ return $this;
+ }
+
+ /** @return list */
+ public function getCircles(): array {
+ return $this->circles;
+ }
+
+ /** @param array|null $activeContext */
+ public function setActiveContext(?array $activeContext): self {
+ $this->activeContext = $activeContext;
+ return $this;
+ }
+
+ /** @return array|null */
+ public function getActiveContext(): ?array {
+ return $this->activeContext;
+ }
+
+ /** @param array $requestOverrides */
+ public function setRequestOverrides(array $requestOverrides): self {
+ $this->requestOverrides = $requestOverrides;
+ return $this;
+ }
+
+ /** @return array */
+ public function getRequestOverrides(): array {
+ return $this->requestOverrides;
+ }
+
+ /** @param array $actorCapabilities */
+ public function setActorCapabilities(array $actorCapabilities): self {
+ $this->actorCapabilities = $actorCapabilities;
+ return $this;
+ }
+
+ /** @return array */
+ public function getActorCapabilities(): array {
+ return $this->actorCapabilities;
+ }
+}
diff --git a/lib/Service/Policy/Model/PolicyLayer.php b/lib/Service/Policy/Model/PolicyLayer.php
new file mode 100644
index 0000000000..16e8cdc17b
--- /dev/null
+++ b/lib/Service/Policy/Model/PolicyLayer.php
@@ -0,0 +1,78 @@
+ */
+ private array $allowedValues = [];
+ /** @var array */
+ private array $notes = [];
+
+ public function setScope(string $scope): self {
+ $this->scope = $scope;
+ return $this;
+ }
+
+ public function getScope(): string {
+ return $this->scope;
+ }
+
+ public function setValue(mixed $value): self {
+ $this->value = $value;
+ return $this;
+ }
+
+ public function getValue(): mixed {
+ return $this->value;
+ }
+
+ public function setAllowChildOverride(bool $allowChildOverride): self {
+ $this->allowChildOverride = $allowChildOverride;
+ return $this;
+ }
+
+ public function isAllowChildOverride(): bool {
+ return $this->allowChildOverride;
+ }
+
+ public function setVisibleToChild(bool $visibleToChild): self {
+ $this->visibleToChild = $visibleToChild;
+ return $this;
+ }
+
+ public function isVisibleToChild(): bool {
+ return $this->visibleToChild;
+ }
+
+ /** @param list $allowedValues */
+ public function setAllowedValues(array $allowedValues): self {
+ $this->allowedValues = $allowedValues;
+ return $this;
+ }
+
+ /** @return list */
+ public function getAllowedValues(): array {
+ return $this->allowedValues;
+ }
+
+ /** @param array $notes */
+ public function setNotes(array $notes): self {
+ $this->notes = $notes;
+ return $this;
+ }
+
+ /** @return array */
+ public function getNotes(): array {
+ return $this->notes;
+ }
+}
diff --git a/lib/Service/Policy/Model/PolicySpec.php b/lib/Service/Policy/Model/PolicySpec.php
new file mode 100644
index 0000000000..79b80e44cb
--- /dev/null
+++ b/lib/Service/Policy/Model/PolicySpec.php
@@ -0,0 +1,102 @@
+|Closure(PolicyContext): list */
+ private array|Closure $allowedValuesResolver;
+ /** @var Closure(mixed): mixed|null */
+ private ?Closure $normalizer;
+ /** @var Closure(mixed, PolicyContext): void|null */
+ private ?Closure $validator;
+
+ /**
+ * @param list|Closure(PolicyContext): list $allowedValues
+ * @param Closure(mixed): mixed|null $normalizer
+ * @param Closure(mixed, PolicyContext): void|null $validator
+ */
+ public function __construct(
+ private string $key,
+ private mixed $defaultSystemValue,
+ array|Closure $allowedValues,
+ ?Closure $normalizer = null,
+ ?Closure $validator = null,
+ private ?string $appConfigKey = null,
+ private ?string $userPreferenceKey = null,
+ private string $resolutionMode = self::RESOLUTION_MODE_RESOLVED,
+ ) {
+ $this->allowedValuesResolver = $allowedValues;
+ $this->normalizer = $normalizer;
+ $this->validator = $validator;
+ }
+
+ #[\Override]
+ public function key(): string {
+ return $this->key;
+ }
+
+ #[\Override]
+ public function resolutionMode(): string {
+ return $this->resolutionMode;
+ }
+
+ #[\Override]
+ public function getAppConfigKey(): string {
+ return $this->appConfigKey ?? $this->key;
+ }
+
+ #[\Override]
+ public function getUserPreferenceKey(): string {
+ return $this->userPreferenceKey ?? 'policy.' . $this->key;
+ }
+
+ #[\Override]
+ public function normalizeValue(mixed $rawValue): mixed {
+ if ($this->normalizer !== null) {
+ return ($this->normalizer)($rawValue);
+ }
+
+ return $rawValue;
+ }
+
+ #[\Override]
+ public function validateValue(mixed $value, PolicyContext $context): void {
+ if ($this->validator !== null) {
+ ($this->validator)($value, $context);
+ return;
+ }
+
+ if (!in_array($value, $this->allowedValues($context), true)) {
+ throw new \InvalidArgumentException(sprintf('Invalid value for %s', $this->key()));
+ }
+ }
+
+ #[\Override]
+ public function allowedValues(PolicyContext $context): array {
+ if ($this->allowedValuesResolver instanceof Closure) {
+ return ($this->allowedValuesResolver)($context);
+ }
+
+ return $this->allowedValuesResolver;
+ }
+
+ #[\Override]
+ public function defaultSystemValue(): mixed {
+ return $this->defaultSystemValue;
+ }
+}
diff --git a/lib/Service/Policy/Model/ResolvedPolicy.php b/lib/Service/Policy/Model/ResolvedPolicy.php
new file mode 100644
index 0000000000..e934c20870
--- /dev/null
+++ b/lib/Service/Policy/Model/ResolvedPolicy.php
@@ -0,0 +1,131 @@
+ */
+ private array $allowedValues = [];
+ private bool $canSaveAsUserDefault = false;
+ private bool $canUseAsRequestOverride = false;
+ private bool $preferenceWasCleared = false;
+ private ?string $blockedBy = null;
+
+ public function setPolicyKey(string $policyKey): self {
+ $this->policyKey = $policyKey;
+ return $this;
+ }
+
+ public function getPolicyKey(): string {
+ return $this->policyKey;
+ }
+
+ public function setEffectiveValue(mixed $effectiveValue): self {
+ $this->effectiveValue = $effectiveValue;
+ return $this;
+ }
+
+ public function getEffectiveValue(): mixed {
+ return $this->effectiveValue;
+ }
+
+ public function setSourceScope(string $sourceScope): self {
+ $this->sourceScope = $sourceScope;
+ return $this;
+ }
+
+ public function getSourceScope(): string {
+ return $this->sourceScope;
+ }
+
+ public function setVisible(bool $visible): self {
+ $this->visible = $visible;
+ return $this;
+ }
+
+ public function isVisible(): bool {
+ return $this->visible;
+ }
+
+ public function setEditableByCurrentActor(bool $editableByCurrentActor): self {
+ $this->editableByCurrentActor = $editableByCurrentActor;
+ return $this;
+ }
+
+ public function isEditableByCurrentActor(): bool {
+ return $this->editableByCurrentActor;
+ }
+
+ /** @param list $allowedValues */
+ public function setAllowedValues(array $allowedValues): self {
+ $this->allowedValues = $allowedValues;
+ return $this;
+ }
+
+ /** @return list */
+ public function getAllowedValues(): array {
+ return $this->allowedValues;
+ }
+
+ public function setCanSaveAsUserDefault(bool $canSaveAsUserDefault): self {
+ $this->canSaveAsUserDefault = $canSaveAsUserDefault;
+ return $this;
+ }
+
+ public function canSaveAsUserDefault(): bool {
+ return $this->canSaveAsUserDefault;
+ }
+
+ public function setCanUseAsRequestOverride(bool $canUseAsRequestOverride): self {
+ $this->canUseAsRequestOverride = $canUseAsRequestOverride;
+ return $this;
+ }
+
+ public function canUseAsRequestOverride(): bool {
+ return $this->canUseAsRequestOverride;
+ }
+
+ public function setPreferenceWasCleared(bool $preferenceWasCleared): self {
+ $this->preferenceWasCleared = $preferenceWasCleared;
+ return $this;
+ }
+
+ public function wasPreferenceCleared(): bool {
+ return $this->preferenceWasCleared;
+ }
+
+ public function setBlockedBy(?string $blockedBy): self {
+ $this->blockedBy = $blockedBy;
+ return $this;
+ }
+
+ public function getBlockedBy(): ?string {
+ return $this->blockedBy;
+ }
+
+ /** @return array */
+ public function toArray(): array {
+ return [
+ 'policyKey' => $this->getPolicyKey(),
+ 'effectiveValue' => $this->getEffectiveValue(),
+ 'sourceScope' => $this->getSourceScope(),
+ 'visible' => $this->isVisible(),
+ 'editableByCurrentActor' => $this->isEditableByCurrentActor(),
+ 'allowedValues' => $this->getAllowedValues(),
+ 'canSaveAsUserDefault' => $this->canSaveAsUserDefault(),
+ 'canUseAsRequestOverride' => $this->canUseAsRequestOverride(),
+ 'preferenceWasCleared' => $this->wasPreferenceCleared(),
+ 'blockedBy' => $this->getBlockedBy(),
+ ];
+ }
+}
diff --git a/lib/Service/Policy/PolicyService.php b/lib/Service/Policy/PolicyService.php
new file mode 100644
index 0000000000..873af0950e
--- /dev/null
+++ b/lib/Service/Policy/PolicyService.php
@@ -0,0 +1,165 @@
+resolver = new DefaultPolicyResolver($this->source);
+ }
+
+ /** @param array $requestOverrides */
+ public function resolve(string|\BackedEnum $policyKey, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy {
+ return $this->resolver->resolve(
+ $this->registry->get($policyKey),
+ $this->contextFactory->forCurrentUser($requestOverrides, $activeContext),
+ );
+ }
+
+ /** @param array $requestOverrides */
+ public function resolveForUserId(string|\BackedEnum $policyKey, ?string $userId, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy {
+ return $this->resolver->resolve(
+ $this->registry->get($policyKey),
+ $this->contextFactory->forUserId($userId, $requestOverrides, $activeContext),
+ );
+ }
+
+ /** @param array $requestOverrides */
+ public function resolveForUser(string|\BackedEnum $policyKey, ?IUser $user, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy {
+ return $this->resolver->resolve(
+ $this->registry->get($policyKey),
+ $this->contextFactory->forUser($user, $requestOverrides, $activeContext),
+ );
+ }
+
+ /** @return array */
+ public function resolveKnownPolicies(array $requestOverrides = [], ?array $activeContext = null): array {
+ $context = $this->contextFactory->forCurrentUser($requestOverrides, $activeContext);
+ $definitions = [];
+ foreach (array_keys(PolicyProviders::BY_KEY) as $policyKey) {
+ $definitions[] = $this->registry->get($policyKey);
+ }
+
+ return $this->resolver->resolveMany($definitions, $context);
+ }
+
+ public function getSystemPolicy(string|\BackedEnum $policyKey): ?PolicyLayer {
+ $definition = $this->registry->get($policyKey);
+ return $this->source->loadSystemPolicy($definition->key());
+ }
+
+ public function getUserPreferenceForUserId(string|\BackedEnum $policyKey, string $userId): ?PolicyLayer {
+ $definition = $this->registry->get($policyKey);
+ $context = $this->contextFactory->forUserId($userId);
+ return $this->source->loadUserPreference($definition->key(), $context);
+ }
+
+ public function saveSystem(string|\BackedEnum $policyKey, mixed $value, bool $allowChildOverride = false): ResolvedPolicy {
+ $context = $this->contextFactory->forCurrentUser();
+ $definition = $this->registry->get($policyKey);
+ $normalizedValue = $value === null
+ ? $definition->normalizeValue($definition->defaultSystemValue())
+ : $definition->normalizeValue($value);
+
+ $definition->validateValue($normalizedValue, $context);
+ $this->source->saveSystemPolicy($definition->key(), $normalizedValue, $allowChildOverride);
+
+ return $this->resolver->resolve($definition, $context);
+ }
+
+ public function getGroupPolicy(string|\BackedEnum $policyKey, string $groupId): ?PolicyLayer {
+ $definition = $this->registry->get($policyKey);
+ return $this->source->loadGroupPolicyConfig($definition->key(), $groupId);
+ }
+
+ public function saveGroupPolicy(string|\BackedEnum $policyKey, string $groupId, mixed $value, bool $allowChildOverride): PolicyLayer {
+ $definition = $this->registry->get($policyKey);
+ $context = $this->contextFactory->forCurrentUser();
+ $normalizedValue = $definition->normalizeValue($value);
+ $definition->validateValue($normalizedValue, $context);
+ $this->source->saveGroupPolicy($definition->key(), $groupId, $normalizedValue, $allowChildOverride);
+
+ return $this->source->loadGroupPolicyConfig($definition->key(), $groupId)
+ ?? (new PolicyLayer())
+ ->setScope('group')
+ ->setVisibleToChild(true)
+ ->setAllowChildOverride(true)
+ ->setAllowedValues([]);
+ }
+
+ public function clearGroupPolicy(string|\BackedEnum $policyKey, string $groupId): ?PolicyLayer {
+ $definition = $this->registry->get($policyKey);
+ $this->source->clearGroupPolicy($definition->key(), $groupId);
+
+ return $this->source->loadGroupPolicyConfig($definition->key(), $groupId);
+ }
+
+ public function saveUserPreference(string|\BackedEnum $policyKey, mixed $value): ResolvedPolicy {
+ $context = $this->contextFactory->forCurrentUser();
+ $definition = $this->registry->get($policyKey);
+ $resolved = $this->resolver->resolve($definition, $context);
+ if (!$resolved->canSaveAsUserDefault()) {
+ throw new \InvalidArgumentException('Saving a user preference is not allowed for ' . $definition->key());
+ }
+
+ $normalizedValue = $definition->normalizeValue($value);
+ $definition->validateValue($normalizedValue, $context);
+ $this->source->saveUserPreference($definition->key(), $context, $normalizedValue);
+
+ return $this->resolver->resolve($definition, $context);
+ }
+
+ public function clearUserPreference(string|\BackedEnum $policyKey): ResolvedPolicy {
+ $context = $this->contextFactory->forCurrentUser();
+ $definition = $this->registry->get($policyKey);
+ $this->source->clearUserPreference($definition->key(), $context);
+
+ return $this->resolver->resolve($definition, $context);
+ }
+
+ public function saveUserPreferenceForUserId(string|\BackedEnum $policyKey, string $userId, mixed $value): ?PolicyLayer {
+ $context = $this->contextFactory->forUserId($userId);
+ $definition = $this->registry->get($policyKey);
+ $resolved = $this->resolver->resolve($definition, $context);
+ if (!$resolved->canSaveAsUserDefault() && !$this->contextFactory->isCurrentActorSystemAdmin()) {
+ throw new \InvalidArgumentException('Saving a user preference is not allowed for ' . $definition->key());
+ }
+
+ $normalizedValue = $definition->normalizeValue($value);
+ $definition->validateValue($normalizedValue, $context);
+ $this->source->saveUserPreference($definition->key(), $context, $normalizedValue);
+
+ return $this->source->loadUserPreference($definition->key(), $context)
+ ?? (new PolicyLayer())
+ ->setScope('user')
+ ->setValue($normalizedValue);
+ }
+
+ public function clearUserPreferenceForUserId(string|\BackedEnum $policyKey, string $userId): ?PolicyLayer {
+ $context = $this->contextFactory->forUserId($userId);
+ $definition = $this->registry->get($policyKey);
+ $this->source->clearUserPreference($definition->key(), $context);
+
+ return $this->source->loadUserPreference($definition->key(), $context);
+ }
+}
diff --git a/lib/Service/Policy/Provider/DocMdp/DocMdpPolicy.php b/lib/Service/Policy/Provider/DocMdp/DocMdpPolicy.php
new file mode 100644
index 0000000000..c40bea3928
--- /dev/null
+++ b/lib/Service/Policy/Provider/DocMdp/DocMdpPolicy.php
@@ -0,0 +1,63 @@
+normalizePolicyKey($policyKey)) {
+ self::KEY => new PolicySpec(
+ key: self::KEY,
+ defaultSystemValue: DocMdpLevel::NOT_CERTIFIED->value,
+ allowedValues: [
+ DocMdpLevel::NOT_CERTIFIED->value,
+ DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED->value,
+ DocMdpLevel::CERTIFIED_FORM_FILLING->value,
+ DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS->value,
+ ],
+ normalizer: static function (mixed $rawValue): mixed {
+ if ($rawValue instanceof DocMdpLevel) {
+ return $rawValue->value;
+ }
+
+ if (is_int($rawValue)) {
+ return $rawValue;
+ }
+
+ return $rawValue;
+ },
+ appConfigKey: self::SYSTEM_APP_CONFIG_KEY,
+ ),
+ default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)),
+ };
+ }
+
+ private function normalizePolicyKey(string|\BackedEnum $policyKey): string {
+ if ($policyKey instanceof \BackedEnum) {
+ return (string)$policyKey->value;
+ }
+
+ return $policyKey;
+ }
+}
diff --git a/lib/Service/Policy/Provider/Footer/AddFooterPolicy.php b/lib/Service/Policy/Provider/Footer/AddFooterPolicy.php
new file mode 100644
index 0000000000..4cb77465d5
--- /dev/null
+++ b/lib/Service/Policy/Provider/Footer/AddFooterPolicy.php
@@ -0,0 +1,59 @@
+normalizePolicyKey($policyKey)) {
+ self::KEY => new PolicySpec(
+ key: self::KEY,
+ defaultSystemValue: SignatureFooterPolicyValue::encode(SignatureFooterPolicyValue::defaults()),
+ allowedValues: static fn (): array => [],
+ normalizer: static function (mixed $rawValue): mixed {
+ return SignatureFooterPolicyValue::encode(SignatureFooterPolicyValue::normalize($rawValue));
+ },
+ validator: static function (mixed $value): void {
+ if (!is_string($value) || trim($value) === '') {
+ throw new \InvalidArgumentException('Invalid value for ' . self::KEY);
+ }
+
+ $decoded = json_decode($value, true);
+ if (!is_array($decoded)) {
+ throw new \InvalidArgumentException('Invalid value for ' . self::KEY);
+ }
+ },
+ appConfigKey: self::SYSTEM_APP_CONFIG_KEY,
+ ),
+ default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)),
+ };
+ }
+
+ private function normalizePolicyKey(string|\BackedEnum $policyKey): string {
+ if ($policyKey instanceof \BackedEnum) {
+ return (string)$policyKey->value;
+ }
+
+ return $policyKey;
+ }
+}
diff --git a/lib/Service/Policy/Provider/Footer/SignatureFooterPolicyValue.php b/lib/Service/Policy/Provider/Footer/SignatureFooterPolicyValue.php
new file mode 100644
index 0000000000..2b675e5bec
--- /dev/null
+++ b/lib/Service/Policy/Provider/Footer/SignatureFooterPolicyValue.php
@@ -0,0 +1,94 @@
+ true,
+ 'writeQrcodeOnFooter' => true,
+ 'validationSite' => '',
+ 'customizeFooterTemplate' => false,
+ ];
+ }
+
+ /** @return array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool} */
+ public static function normalize(mixed $rawValue): array {
+ $defaults = self::defaults();
+
+ if (is_array($rawValue)) {
+ return [
+ 'enabled' => self::toBool($rawValue['enabled'] ?? $rawValue['addFooter'] ?? $defaults['enabled']),
+ 'writeQrcodeOnFooter' => self::toBool($rawValue['writeQrcodeOnFooter'] ?? $rawValue['write_qrcode_on_footer'] ?? $defaults['writeQrcodeOnFooter']),
+ 'validationSite' => self::toString($rawValue['validationSite'] ?? $rawValue['validation_site'] ?? $defaults['validationSite']),
+ 'customizeFooterTemplate' => self::toBool($rawValue['customizeFooterTemplate'] ?? $rawValue['customize_footer_template'] ?? $defaults['customizeFooterTemplate']),
+ ];
+ }
+
+ if (is_bool($rawValue) || is_int($rawValue)) {
+ $defaults['enabled'] = self::toBool($rawValue);
+ return $defaults;
+ }
+
+ if (is_string($rawValue)) {
+ $trimmedValue = trim($rawValue);
+ if ($trimmedValue === '') {
+ return $defaults;
+ }
+
+ $decoded = json_decode($trimmedValue, true);
+ if (is_array($decoded)) {
+ return self::normalize($decoded);
+ }
+
+ $defaults['enabled'] = self::toBool($trimmedValue);
+ return $defaults;
+ }
+
+ return $defaults;
+ }
+
+ public static function encode(array $value): string {
+ return (string)json_encode(self::normalize($value), JSON_UNESCAPED_SLASHES);
+ }
+
+ public static function isEnabled(mixed $rawValue): bool {
+ return self::normalize($rawValue)['enabled'];
+ }
+
+ public static function isQrCodeEnabled(mixed $rawValue): bool {
+ $normalized = self::normalize($rawValue);
+ return $normalized['enabled'] && $normalized['writeQrcodeOnFooter'];
+ }
+
+ private static function toBool(mixed $rawValue): bool {
+ if (is_bool($rawValue)) {
+ return $rawValue;
+ }
+
+ if (is_int($rawValue)) {
+ return $rawValue === 1;
+ }
+
+ if (is_string($rawValue)) {
+ return in_array(strtolower(trim($rawValue)), ['1', 'true', 'yes', 'on'], true);
+ }
+
+ return (bool)$rawValue;
+ }
+
+ private static function toString(mixed $rawValue): string {
+ if (!is_scalar($rawValue)) {
+ return '';
+ }
+
+ return trim((string)$rawValue);
+ }
+}
diff --git a/lib/Service/Policy/Provider/PolicyProviders.php b/lib/Service/Policy/Provider/PolicyProviders.php
new file mode 100644
index 0000000000..aa9438335f
--- /dev/null
+++ b/lib/Service/Policy/Provider/PolicyProviders.php
@@ -0,0 +1,22 @@
+ */
+ public const BY_KEY = [
+ AddFooterPolicy::KEY => AddFooterPolicy::class,
+ DocMdpPolicy::KEY => DocMdpPolicy::class,
+ SignatureFlowPolicy::KEY => SignatureFlowPolicy::class,
+ ];
+}
diff --git a/lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php b/lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php
new file mode 100644
index 0000000000..9976c158b9
--- /dev/null
+++ b/lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php
@@ -0,0 +1,59 @@
+normalizePolicyKey($policyKey)) {
+ self::KEY => new PolicySpec(
+ key: self::KEY,
+ defaultSystemValue: SignatureFlow::NONE->value,
+ allowedValues: [
+ SignatureFlow::NONE->value,
+ SignatureFlow::PARALLEL->value,
+ SignatureFlow::ORDERED_NUMERIC->value,
+ ],
+ normalizer: static function (mixed $rawValue): mixed {
+ if ($rawValue instanceof SignatureFlow) {
+ return $rawValue->value;
+ }
+
+ return $rawValue;
+ },
+ appConfigKey: self::SYSTEM_APP_CONFIG_KEY,
+ resolutionMode: PolicySpec::RESOLUTION_MODE_VALUE_CHOICE,
+ ),
+ default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)),
+ };
+ }
+
+ private function normalizePolicyKey(string|\BackedEnum $policyKey): string {
+ if ($policyKey instanceof \BackedEnum) {
+ return (string)$policyKey->value;
+ }
+
+ return $policyKey;
+ }
+}
diff --git a/lib/Service/Policy/Runtime/DefaultPolicyResolver.php b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php
new file mode 100644
index 0000000000..768bf23b50
--- /dev/null
+++ b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php
@@ -0,0 +1,332 @@
+key();
+ $resolved = (new ResolvedPolicy())
+ ->setPolicyKey($policyKey)
+ ->setAllowedValues($definition->allowedValues($context));
+
+ $systemLayer = $this->source->loadSystemPolicy($policyKey);
+ $groupLayers = $this->source->loadGroupPolicies($policyKey, $context);
+
+ $currentValue = $definition->defaultSystemValue();
+ $currentSourceScope = 'system';
+ $currentBlockedBy = null;
+ $canOverrideBelow = false;
+ $visible = true;
+
+ if ($systemLayer !== null) {
+ [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer(
+ $definition,
+ $resolved,
+ $systemLayer,
+ $context,
+ $currentValue,
+ $currentSourceScope,
+ true,
+ $visible,
+ );
+ }
+
+ if ($definition->resolutionMode() === 'value_choice') {
+ [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyValueChoiceGroupLayers(
+ $definition,
+ $resolved,
+ $groupLayers,
+ $context,
+ $currentValue,
+ $currentSourceScope,
+ $canOverrideBelow,
+ $visible,
+ );
+ } else {
+ foreach ($groupLayers as $layer) {
+ [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer(
+ $definition,
+ $resolved,
+ $layer,
+ $context,
+ $currentValue,
+ $currentSourceScope,
+ $canOverrideBelow,
+ $visible,
+ );
+ }
+ }
+
+ $userPreference = $this->source->loadUserPreference($policyKey, $context);
+ if ($userPreference !== null) {
+ if ($this->canApplyLowerLayer($definition, $resolved, $userPreference, $canOverrideBelow, $visible, $context)) {
+ $currentValue = $definition->normalizeValue($userPreference->getValue());
+ $definition->validateValue($currentValue, $context);
+ $currentSourceScope = $userPreference->getScope();
+ } else {
+ $this->source->clearUserPreference($policyKey, $context);
+ $currentBlockedBy = $currentSourceScope;
+ $resolved->setPreferenceWasCleared(true);
+ }
+ }
+
+ $requestOverride = $this->source->loadRequestOverride($policyKey, $context);
+ if ($requestOverride !== null) {
+ if ($this->canApplyLowerLayer($definition, $resolved, $requestOverride, $canOverrideBelow, $visible, $context)) {
+ $currentValue = $definition->normalizeValue($requestOverride->getValue());
+ $definition->validateValue($currentValue, $context);
+ $currentSourceScope = $requestOverride->getScope();
+ } elseif ($currentBlockedBy === null) {
+ $currentBlockedBy = $currentSourceScope;
+ }
+ }
+
+ $resolved
+ ->setEffectiveValue($currentValue)
+ ->setSourceScope($currentSourceScope)
+ ->setVisible($visible)
+ ->setEditableByCurrentActor($visible && $canOverrideBelow)
+ ->setCanSaveAsUserDefault($visible && $canOverrideBelow)
+ ->setCanUseAsRequestOverride($visible && $canOverrideBelow)
+ ->setBlockedBy($currentBlockedBy);
+
+ return $resolved;
+ }
+
+ /**
+ * @param list $layers
+ * @return array{0: mixed, 1: string, 2: bool, 3: bool}
+ */
+ private function applyValueChoiceGroupLayers(
+ IPolicyDefinition $definition,
+ ResolvedPolicy $resolved,
+ array $layers,
+ PolicyContext $context,
+ mixed $currentValue,
+ string $currentSourceScope,
+ bool $canOverrideBelow,
+ bool $visible,
+ ): array {
+ if ($layers === [] || !$visible || !$canOverrideBelow) {
+ return [$currentValue, $currentSourceScope, $canOverrideBelow, $visible];
+ }
+
+ $upstreamAllowedValues = $resolved->getAllowedValues();
+ $combinedChoices = [];
+ $groupDefaultValues = [];
+ $hasVisibleLayer = false;
+
+ foreach ($layers as $layer) {
+ if (!$layer->isVisibleToChild()) {
+ continue;
+ }
+
+ $hasVisibleLayer = true;
+ $layerChoices = $this->resolveValueChoiceLayerChoices($definition, $layer, $upstreamAllowedValues, $context);
+ $combinedChoices = $this->mergeUnionAllowedValues(
+ $definition->allowedValues($context),
+ $combinedChoices,
+ $layerChoices,
+ );
+
+ $normalizedDefault = $definition->normalizeValue($layer->getValue());
+ if ($layer->getValue() !== null && in_array($normalizedDefault, $combinedChoices, true) && !in_array($normalizedDefault, $groupDefaultValues, true)) {
+ $groupDefaultValues[] = $normalizedDefault;
+ }
+ }
+
+ if (!$hasVisibleLayer || $combinedChoices === []) {
+ return [$currentValue, $currentSourceScope, false, $visible && $hasVisibleLayer];
+ }
+
+ $resolved->setAllowedValues($combinedChoices);
+
+ return [
+ $this->pickValueChoiceDefault($definition, $currentValue, $combinedChoices, $groupDefaultValues, $context),
+ 'group',
+ count($combinedChoices) > 1,
+ true,
+ ];
+ }
+
+ #[\Override]
+ /** @param list $definitions */
+ public function resolveMany(array $definitions, PolicyContext $context): array {
+ $resolved = [];
+ foreach ($definitions as $definition) {
+ if (!$definition instanceof IPolicyDefinition) {
+ continue;
+ }
+
+ $resolved[$definition->key()] = $this->resolve($definition, $context);
+ }
+ return $resolved;
+ }
+
+ private function applyLayer(
+ IPolicyDefinition $definition,
+ ResolvedPolicy $resolved,
+ PolicyLayer $layer,
+ PolicyContext $context,
+ mixed $currentValue,
+ string $currentSourceScope,
+ bool $canOverrideBelow,
+ bool $visible,
+ ): array {
+ $visible = $visible && $layer->isVisibleToChild();
+ $resolved->setAllowedValues($this->mergeAllowedValues($resolved->getAllowedValues(), $layer->getAllowedValues()));
+
+ if ($layer->getValue() !== null && $canOverrideBelow) {
+ $currentValue = $definition->normalizeValue($layer->getValue());
+ $definition->validateValue($currentValue, $context);
+ $currentSourceScope = $layer->getScope();
+ }
+
+ $canOverrideBelow = $canOverrideBelow && $layer->isAllowChildOverride();
+
+ return [$currentValue, $currentSourceScope, $canOverrideBelow, $visible];
+ }
+
+ private function canApplyLowerLayer(
+ IPolicyDefinition $definition,
+ ResolvedPolicy $resolved,
+ PolicyLayer $layer,
+ bool $canOverrideBelow,
+ bool $visible,
+ PolicyContext $context,
+ ): bool {
+ if (!$visible || !$canOverrideBelow || $layer->getValue() === null) {
+ return false;
+ }
+
+ $value = $definition->normalizeValue($layer->getValue());
+ $allowedValues = $resolved->getAllowedValues();
+ if ($allowedValues !== [] && !in_array($value, $allowedValues, true)) {
+ return false;
+ }
+
+ $definition->validateValue($value, $context);
+ return true;
+ }
+
+ /** @param list $currentAllowedValues
+ * @param list $layerAllowedValues
+ * @return list
+ */
+ private function mergeAllowedValues(array $currentAllowedValues, array $layerAllowedValues): array {
+ if ($layerAllowedValues === []) {
+ return $currentAllowedValues;
+ }
+
+ if ($currentAllowedValues === []) {
+ return $layerAllowedValues;
+ }
+
+ return array_values(array_intersect($currentAllowedValues, $layerAllowedValues));
+ }
+
+ /**
+ * @param list $upstreamAllowedValues
+ * @return list
+ */
+ private function resolveValueChoiceLayerChoices(
+ IPolicyDefinition $definition,
+ PolicyLayer $layer,
+ array $upstreamAllowedValues,
+ PolicyContext $context,
+ ): array {
+ if ($layer->isAllowChildOverride()) {
+ $choices = $layer->getAllowedValues() === []
+ ? $upstreamAllowedValues
+ : array_values(array_intersect($upstreamAllowedValues, $layer->getAllowedValues()));
+
+ $defaultValue = $definition->normalizeValue($definition->defaultSystemValue());
+ return array_values(array_filter(
+ $choices,
+ static fn (mixed $choice): bool => $choice !== $defaultValue,
+ ));
+ }
+
+ if ($layer->getValue() === null) {
+ return [];
+ }
+
+ $value = $definition->normalizeValue($layer->getValue());
+ if ($upstreamAllowedValues !== [] && !in_array($value, $upstreamAllowedValues, true)) {
+ return [];
+ }
+
+ $definition->validateValue($value, $context);
+ return [$value];
+ }
+
+ /**
+ * @param list $canonicalOrder
+ * @param list $currentValues
+ * @param list $newValues
+ * @return list
+ */
+ private function mergeUnionAllowedValues(array $canonicalOrder, array $currentValues, array $newValues): array {
+ $merged = [];
+ foreach ($canonicalOrder as $candidate) {
+ if ((in_array($candidate, $currentValues, true) || in_array($candidate, $newValues, true)) && !in_array($candidate, $merged, true)) {
+ $merged[] = $candidate;
+ }
+ }
+
+ foreach ([$currentValues, $newValues] as $values) {
+ foreach ($values as $candidate) {
+ if (!in_array($candidate, $merged, true)) {
+ $merged[] = $candidate;
+ }
+ }
+ }
+
+ return $merged;
+ }
+
+ /**
+ * @param list $allowedValues
+ * @param list $groupDefaultValues
+ */
+ private function pickValueChoiceDefault(
+ IPolicyDefinition $definition,
+ mixed $currentValue,
+ array $allowedValues,
+ array $groupDefaultValues,
+ PolicyContext $context,
+ ): mixed {
+ $normalizedCurrentValue = $definition->normalizeValue($currentValue);
+ $defaultValue = $definition->normalizeValue($definition->defaultSystemValue());
+
+ if (count($groupDefaultValues) === 1 && in_array($groupDefaultValues[0], $allowedValues, true)) {
+ return $groupDefaultValues[0];
+ }
+
+ if ($normalizedCurrentValue !== $defaultValue && in_array($normalizedCurrentValue, $allowedValues, true)) {
+ return $normalizedCurrentValue;
+ }
+
+ $orderedAllowedValues = $this->mergeUnionAllowedValues($definition->allowedValues($context), [], $allowedValues);
+ return $orderedAllowedValues[0] ?? $normalizedCurrentValue;
+ }
+}
diff --git a/lib/Service/Policy/Runtime/PolicyContextFactory.php b/lib/Service/Policy/Runtime/PolicyContextFactory.php
new file mode 100644
index 0000000000..34f7f63c53
--- /dev/null
+++ b/lib/Service/Policy/Runtime/PolicyContextFactory.php
@@ -0,0 +1,72 @@
+ $requestOverrides */
+ public function forCurrentUser(array $requestOverrides = [], ?array $activeContext = null): PolicyContext {
+ return $this->forUser($this->userSession->getUser(), $requestOverrides, $activeContext);
+ }
+
+ public function isCurrentActorSystemAdmin(): bool {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return false;
+ }
+
+ return $this->groupManager->isAdmin($user->getUID());
+ }
+
+ /** @param array $requestOverrides */
+ public function forUser(?IUser $user, array $requestOverrides = [], ?array $activeContext = null): PolicyContext {
+ return $this->build($user?->getUID(), $user, $requestOverrides, $activeContext);
+ }
+
+ /** @param array $requestOverrides */
+ public function forUserId(?string $userId, array $requestOverrides = [], ?array $activeContext = null): PolicyContext {
+ $user = null;
+ if ($userId !== null && $userId !== '') {
+ $loadedUser = $this->userManager->get($userId);
+ if ($loadedUser instanceof IUser) {
+ $user = $loadedUser;
+ }
+ }
+
+ return $this->build($userId, $user, $requestOverrides, $activeContext);
+ }
+
+ /** @param array $requestOverrides */
+ private function build(?string $userId, ?IUser $user, array $requestOverrides = [], ?array $activeContext = null): PolicyContext {
+ $context = (new PolicyContext())
+ ->setRequestOverrides($requestOverrides)
+ ->setActiveContext($activeContext);
+
+ if ($userId !== null && $userId !== '') {
+ $context->setUserId($userId);
+ if ($user instanceof IUser) {
+ $context->setGroups($this->groupManager->getUserGroupIds($user));
+ }
+ }
+
+ return $context;
+ }
+}
diff --git a/lib/Service/Policy/Runtime/PolicyRegistry.php b/lib/Service/Policy/Runtime/PolicyRegistry.php
new file mode 100644
index 0000000000..3dff68ceed
--- /dev/null
+++ b/lib/Service/Policy/Runtime/PolicyRegistry.php
@@ -0,0 +1,57 @@
+ */
+ private array $definitions = [];
+
+ public function __construct(
+ private ContainerInterface $container,
+ ) {
+ }
+
+ public function get(string|\BackedEnum $policyKey): IPolicyDefinition {
+ $policyKeyValue = $this->normalizePolicyKey($policyKey);
+ $definition = $this->definitions[$policyKeyValue] ?? null;
+ if ($definition instanceof IPolicyDefinition) {
+ return $definition;
+ }
+
+ $providerClass = PolicyProviders::BY_KEY[$policyKeyValue] ?? null;
+ if (!is_string($providerClass) || $providerClass === '') {
+ throw new \InvalidArgumentException('Unknown policy key: ' . $policyKeyValue);
+ }
+
+ $provider = $this->container->get($providerClass);
+ if (!$provider instanceof IPolicyDefinitionProvider) {
+ throw new \UnexpectedValueException('Invalid policy provider: ' . $providerClass);
+ }
+
+ $definition = $provider->get($policyKeyValue);
+ if ($definition->key() !== $policyKeyValue) {
+ throw new \InvalidArgumentException('Policy provider returned mismatched key: ' . $definition->key());
+ }
+
+ return $this->definitions[$policyKeyValue] = $definition;
+ }
+
+ private function normalizePolicyKey(string|\BackedEnum $policyKey): string {
+ if ($policyKey instanceof \BackedEnum) {
+ return (string)$policyKey->value;
+ }
+
+ return $policyKey;
+ }
+}
diff --git a/lib/Service/Policy/Runtime/PolicySource.php b/lib/Service/Policy/Runtime/PolicySource.php
new file mode 100644
index 0000000000..643733f80a
--- /dev/null
+++ b/lib/Service/Policy/Runtime/PolicySource.php
@@ -0,0 +1,386 @@
+registry->get($policyKey);
+ $defaultValue = $definition->normalizeValue($definition->defaultSystemValue());
+ $hasExplicitSystemValue = $this->appConfig->hasAppKey($definition->getAppConfigKey());
+ $storedValue = $hasExplicitSystemValue
+ ? $this->readSystemValue($definition->getAppConfigKey(), $defaultValue)
+ : null;
+ $value = $hasExplicitSystemValue
+ ? $definition->normalizeValue($storedValue)
+ : $defaultValue;
+
+ $layer = (new PolicyLayer())
+ ->setScope($hasExplicitSystemValue ? 'global' : 'system')
+ ->setValue($value)
+ ->setVisibleToChild(true);
+
+ if (!$hasExplicitSystemValue) {
+ return $layer->setAllowChildOverride(true);
+ }
+
+ if ($value === $defaultValue) {
+ $allowChildOverride = $this->appConfig->getAppValueString(
+ $this->getSystemAllowOverrideConfigKey($definition->getAppConfigKey()),
+ '0',
+ ) === '1';
+
+ if ($allowChildOverride) {
+ // Explicitly persisted default value ("let users choose")
+ return $layer
+ ->setAllowChildOverride(true)
+ ->setAllowedValues([]);
+ }
+
+ return $layer->setAllowChildOverride(true);
+ }
+
+ $allowChildOverride = $this->appConfig->getAppValueString(
+ $this->getSystemAllowOverrideConfigKey($definition->getAppConfigKey()),
+ '0',
+ ) === '1';
+
+ return $layer
+ ->setAllowChildOverride($allowChildOverride)
+ ->setAllowedValues($allowChildOverride ? [] : [$value]);
+ }
+
+ #[\Override]
+ public function loadGroupPolicies(string $policyKey, PolicyContext $context): array {
+ $groupIds = $this->resolveGroupIds($context);
+ if ($groupIds === []) {
+ return [];
+ }
+
+ $bindingsByTargetId = [];
+ foreach ($this->bindingMapper->findByTargets('group', $groupIds) as $binding) {
+ $bindingsByTargetId[$binding->getTargetId()] = $binding;
+ }
+
+ $permissionSetIds = [];
+ foreach ($bindingsByTargetId as $binding) {
+ $permissionSetIds[] = $binding->getPermissionSetId();
+ }
+
+ $permissionSetsById = [];
+ foreach ($this->permissionSetMapper->findByIds(array_values(array_unique($permissionSetIds))) as $permissionSet) {
+ $permissionSetsById[$permissionSet->getId()] = $permissionSet;
+ }
+
+ $layers = [];
+
+ foreach ($groupIds as $groupId) {
+ $binding = $bindingsByTargetId[$groupId] ?? null;
+ if (!$binding instanceof PermissionSetBinding) {
+ continue;
+ }
+
+ $permissionSet = $permissionSetsById[$binding->getPermissionSetId()] ?? null;
+ if (!$permissionSet instanceof PermissionSet) {
+ continue;
+ }
+
+ $policyConfig = $permissionSet->getDecodedPolicyJson()[$policyKey] ?? null;
+ if (!is_array($policyConfig)) {
+ continue;
+ }
+
+ $layers[] = (new PolicyLayer())
+ ->setScope('group')
+ ->setValue($policyConfig['defaultValue'] ?? null)
+ ->setAllowChildOverride((bool)($policyConfig['allowChildOverride'] ?? false))
+ ->setVisibleToChild((bool)($policyConfig['visibleToChild'] ?? true))
+ ->setAllowedValues(is_array($policyConfig['allowedValues'] ?? null) ? $policyConfig['allowedValues'] : []);
+ }
+
+ return $layers;
+ }
+
+ #[\Override]
+ public function loadCirclePolicies(string $policyKey, PolicyContext $context): array {
+ return [];
+ }
+
+ #[\Override]
+ public function loadUserPreference(string $policyKey, PolicyContext $context): ?PolicyLayer {
+ $userId = $context->getUserId();
+ if ($userId === null || $userId === '') {
+ return null;
+ }
+
+ $definition = $this->registry->get($policyKey);
+ $value = $this->appConfig->getUserValue($userId, $definition->getUserPreferenceKey(), '');
+ if ($value === '') {
+ return null;
+ }
+
+ return (new PolicyLayer())
+ ->setScope('user')
+ ->setValue($definition->normalizeValue($value));
+ }
+
+ #[\Override]
+ public function loadRequestOverride(string $policyKey, PolicyContext $context): ?PolicyLayer {
+ $requestOverrides = $context->getRequestOverrides();
+ if (!array_key_exists($policyKey, $requestOverrides)) {
+ return null;
+ }
+
+ $definition = $this->registry->get($policyKey);
+
+ return (new PolicyLayer())
+ ->setScope('request')
+ ->setValue($definition->normalizeValue($requestOverrides[$policyKey]));
+ }
+
+ #[\Override]
+ public function loadGroupPolicyConfig(string $policyKey, string $groupId): ?PolicyLayer {
+ $permissionSet = $this->findPermissionSetByGroupId($groupId);
+ if (!$permissionSet instanceof PermissionSet) {
+ return null;
+ }
+
+ $policyConfig = $permissionSet->getDecodedPolicyJson()[$policyKey] ?? null;
+ if (!is_array($policyConfig)) {
+ return null;
+ }
+
+ return $this->createGroupPolicyLayer($policyConfig);
+ }
+
+ #[\Override]
+ public function saveSystemPolicy(string $policyKey, mixed $value, bool $allowChildOverride = false): void {
+ $definition = $this->registry->get($policyKey);
+ $normalizedValue = $definition->normalizeValue($value);
+ $defaultValue = $definition->normalizeValue($definition->defaultSystemValue());
+ $allowOverrideConfigKey = $this->getSystemAllowOverrideConfigKey($definition->getAppConfigKey());
+
+ if ($normalizedValue === $defaultValue) {
+ if ($allowChildOverride) {
+ $this->writeSystemValue($definition->getAppConfigKey(), $normalizedValue);
+ $this->appConfig->setAppValueString($allowOverrideConfigKey, '1');
+ return;
+ }
+
+ $this->appConfig->deleteAppValue($definition->getAppConfigKey());
+ $this->appConfig->deleteAppValue($allowOverrideConfigKey);
+ return;
+ }
+
+ $this->writeSystemValue($definition->getAppConfigKey(), $normalizedValue);
+ $this->appConfig->setAppValueString($allowOverrideConfigKey, $allowChildOverride ? '1' : '0');
+ }
+
+ private function readSystemValue(string $key, mixed $defaultValue): mixed {
+ if (is_int($defaultValue)) {
+ return $this->appConfig->getAppValueInt($key, $defaultValue);
+ }
+
+ if (is_bool($defaultValue)) {
+ return $this->appConfig->getAppValueBool($key, $defaultValue);
+ }
+
+ if (is_float($defaultValue)) {
+ return $this->appConfig->getAppValueFloat($key, $defaultValue);
+ }
+
+ if (is_array($defaultValue)) {
+ return $this->appConfig->getAppValueArray($key, $defaultValue);
+ }
+
+ return $this->appConfig->getAppValueString($key, (string)$defaultValue);
+ }
+
+ private function writeSystemValue(string $key, mixed $value): void {
+ if (is_int($value)) {
+ $this->appConfig->setAppValueInt($key, $value);
+ return;
+ }
+
+ if (is_bool($value)) {
+ $this->appConfig->setAppValueBool($key, $value);
+ return;
+ }
+
+ if (is_float($value)) {
+ $this->appConfig->setAppValueFloat($key, $value);
+ return;
+ }
+
+ if (is_array($value)) {
+ $this->appConfig->setAppValueArray($key, $value);
+ return;
+ }
+
+ $this->appConfig->setAppValueString($key, (string)$value);
+ }
+
+ private function getSystemAllowOverrideConfigKey(string $policyConfigKey): string {
+ return $policyConfigKey . '.allow_child_override';
+ }
+
+ #[\Override]
+ public function saveGroupPolicy(string $policyKey, string $groupId, mixed $value, bool $allowChildOverride): void {
+ $definition = $this->registry->get($policyKey);
+ $normalizedValue = $definition->normalizeValue($value);
+ $permissionSet = $this->findPermissionSetByGroupId($groupId);
+ $now = new \DateTime('now', new \DateTimeZone('UTC'));
+
+ if (!$permissionSet instanceof PermissionSet) {
+ $permissionSet = new PermissionSet();
+ $permissionSet->setName('group:' . $groupId);
+ $permissionSet->setScopeType('group');
+ $permissionSet->setCreatedAt($now);
+ }
+
+ $policyJson = $permissionSet->getDecodedPolicyJson();
+ $policyJson[$policyKey] = [
+ 'defaultValue' => $normalizedValue,
+ 'allowChildOverride' => $allowChildOverride,
+ 'visibleToChild' => true,
+ 'allowedValues' => $allowChildOverride ? [] : [$normalizedValue],
+ ];
+
+ $permissionSet->setPolicyJson($policyJson);
+ $permissionSet->setUpdatedAt($now);
+
+ if ($permissionSet->getId() > 0) {
+ $this->permissionSetMapper->update($permissionSet);
+ return;
+ }
+
+ /** @var PermissionSet $permissionSet */
+ $permissionSet = $this->permissionSetMapper->insert($permissionSet);
+
+ $binding = new PermissionSetBinding();
+ $binding->setPermissionSetId($permissionSet->getId());
+ $binding->setTargetType('group');
+ $binding->setTargetId($groupId);
+ $binding->setCreatedAt($now);
+
+ $this->bindingMapper->insert($binding);
+ }
+
+ #[\Override]
+ public function clearGroupPolicy(string $policyKey, string $groupId): void {
+ $binding = $this->findBindingByGroupId($groupId);
+ if (!$binding instanceof PermissionSetBinding) {
+ return;
+ }
+
+ $permissionSet = $this->findPermissionSetByBinding($binding);
+ if (!$permissionSet instanceof PermissionSet) {
+ return;
+ }
+
+ $policyJson = $permissionSet->getDecodedPolicyJson();
+ unset($policyJson[$policyKey]);
+
+ if ($policyJson === []) {
+ $this->bindingMapper->delete($binding);
+ $this->permissionSetMapper->delete($permissionSet);
+ return;
+ }
+
+ $permissionSet->setPolicyJson($policyJson);
+ $permissionSet->setUpdatedAt(new \DateTime('now', new \DateTimeZone('UTC')));
+ $this->permissionSetMapper->update($permissionSet);
+ }
+
+ #[\Override]
+ public function saveUserPreference(string $policyKey, PolicyContext $context, mixed $value): void {
+ $userId = $context->getUserId();
+ if ($userId === null || $userId === '') {
+ throw new \InvalidArgumentException('A signed-in user is required to save a policy preference.');
+ }
+
+ $definition = $this->registry->get($policyKey);
+ $normalizedValue = $definition->normalizeValue($value);
+ $this->appConfig->setUserValue($userId, $definition->getUserPreferenceKey(), (string)$normalizedValue);
+ }
+
+ #[\Override]
+ public function clearUserPreference(string $policyKey, PolicyContext $context): void {
+ $userId = $context->getUserId();
+ if ($userId === null || $userId === '') {
+ return;
+ }
+
+ $definition = $this->registry->get($policyKey);
+ $this->appConfig->deleteUserValue($userId, $definition->getUserPreferenceKey());
+ }
+
+ /** @return list */
+ private function resolveGroupIds(PolicyContext $context): array {
+ $activeContext = $context->getActiveContext();
+ if (($activeContext['type'] ?? null) === 'group' && is_string($activeContext['id'] ?? null)) {
+ return [$activeContext['id']];
+ }
+
+ return $context->getGroups();
+ }
+
+ /** @param array $policyConfig */
+ private function createGroupPolicyLayer(array $policyConfig): PolicyLayer {
+ return (new PolicyLayer())
+ ->setScope('group')
+ ->setValue($policyConfig['defaultValue'] ?? null)
+ ->setAllowChildOverride((bool)($policyConfig['allowChildOverride'] ?? false))
+ ->setVisibleToChild((bool)($policyConfig['visibleToChild'] ?? true))
+ ->setAllowedValues(is_array($policyConfig['allowedValues'] ?? null) ? $policyConfig['allowedValues'] : []);
+ }
+
+ private function findBindingByGroupId(string $groupId): ?PermissionSetBinding {
+ try {
+ return $this->bindingMapper->getByTarget('group', $groupId);
+ } catch (DoesNotExistException) {
+ return null;
+ }
+ }
+
+ private function findPermissionSetByBinding(PermissionSetBinding $binding): ?PermissionSet {
+ try {
+ return $this->permissionSetMapper->getById($binding->getPermissionSetId());
+ } catch (DoesNotExistException) {
+ return null;
+ }
+ }
+
+ private function findPermissionSetByGroupId(string $groupId): ?PermissionSet {
+ $binding = $this->findBindingByGroupId($groupId);
+ if (!$binding instanceof PermissionSetBinding) {
+ return null;
+ }
+
+ return $this->findPermissionSetByBinding($binding);
+ }
+}
diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php
index 78320822ec..c6beced524 100644
--- a/lib/Service/RequestSignatureService.php
+++ b/lib/Service/RequestSignatureService.php
@@ -8,13 +8,13 @@
namespace OCA\Libresign\Service;
-use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Db\File as FileEntity;
use OCA\Libresign\Db\FileElementMapper;
use OCA\Libresign\Db\FileMapper;
use OCA\Libresign\Db\IdentifyMethodMapper;
use OCA\Libresign\Db\SignRequest as SignRequestEntity;
use OCA\Libresign\Db\SignRequestMapper;
+use OCA\Libresign\Enum\DocMdpLevel;
use OCA\Libresign\Enum\FileStatus;
use OCA\Libresign\Enum\SignatureFlow;
use OCA\Libresign\Events\SignRequestCanceledEvent;
@@ -27,6 +27,10 @@
use OCA\Libresign\Service\Envelope\EnvelopeService;
use OCA\Libresign\Service\File\Pdf\PdfMetadataExtractor;
use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod;
+use OCA\Libresign\Service\Policy\Model\ResolvedPolicy;
+use OCA\Libresign\Service\Policy\PolicyService;
+use OCA\Libresign\Service\Policy\Provider\DocMdp\DocMdpPolicy;
+use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy;
use OCA\Libresign\Service\SignRequest\SignRequestService;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\IMimeTypeDetector;
@@ -67,6 +71,7 @@ public function __construct(
protected EnvelopeFileRelocator $envelopeFileRelocator,
protected FileUploadHelper $uploadHelper,
protected SignRequestService $signRequestService,
+ protected PolicyService $policyService,
) {
}
@@ -317,6 +322,7 @@ public function saveFile(array $data): FileEntity {
if (!empty($data['uuid'])) {
$file = $this->fileMapper->getByUuid($data['uuid']);
$this->updateSignatureFlowIfAllowed($file, $data);
+ $this->updateDocMdpLevelFromPolicy($file, $data);
if (!empty($data['name'])) {
$file->setName($data['name']);
$this->fileService->update($file);
@@ -333,6 +339,7 @@ public function saveFile(array $data): FileEntity {
try {
$file = $this->fileMapper->getByNodeId($fileId);
$this->updateSignatureFlowIfAllowed($file, $data);
+ $this->updateDocMdpLevelFromPolicy($file, $data);
return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0);
} catch (\Throwable) {
}
@@ -374,51 +381,109 @@ public function saveFile(array $data): FileEntity {
}
$this->setSignatureFlow($file, $data);
- $this->setDocMdpLevelFromGlobalConfig($file);
+ $this->setDocMdpLevelFromPolicy($file, $data);
$this->fileMapper->insert($file);
return $file;
}
private function updateSignatureFlowIfAllowed(FileEntity $file, array $data): void {
- $adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value);
- $adminForcedConfig = $adminFlow !== SignatureFlow::NONE->value;
+ $requestOverrides = $this->getSignatureFlowRequestOverrides($data);
+ $resolvedPolicy = $this->policyService->resolveForUserId(
+ SignatureFlowPolicy::KEY,
+ $file->getUserId(),
+ $requestOverrides,
+ );
+ $this->assertSignatureFlowOverrideAllowed($requestOverrides, $resolvedPolicy);
+ $newFlow = SignatureFlow::from((string)$resolvedPolicy->getEffectiveValue());
+ $metadataBeforeUpdate = $file->getMetadata() ?? [];
+ $this->storePolicySnapshot($file, $resolvedPolicy);
+ $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate;
+
+ if ($file->getSignatureFlowEnum() !== $newFlow || $metadataChanged) {
+ $file->setSignatureFlowEnum($newFlow);
+ $this->fileService->update($file);
+ }
+ }
- if ($adminForcedConfig) {
- $adminFlowEnum = SignatureFlow::from($adminFlow);
- if ($file->getSignatureFlowEnum() !== $adminFlowEnum) {
- $file->setSignatureFlowEnum($adminFlowEnum);
- $this->fileService->update($file);
- }
- return;
+ private function setSignatureFlow(FileEntity $file, array $data): void {
+ $user = ($data['userManager'] ?? null) instanceof IUser ? $data['userManager'] : null;
+ $requestOverrides = $this->getSignatureFlowRequestOverrides($data);
+ $resolvedPolicy = $this->policyService->resolveForUser(
+ SignatureFlowPolicy::KEY,
+ $user,
+ $requestOverrides,
+ );
+ $this->assertSignatureFlowOverrideAllowed($requestOverrides, $resolvedPolicy);
+ $file->setSignatureFlowEnum(SignatureFlow::from((string)$resolvedPolicy->getEffectiveValue()));
+ $this->storePolicySnapshot($file, $resolvedPolicy);
+ }
+
+ /** @return array */
+ private function getSignatureFlowRequestOverrides(array $data): array {
+ if (!isset($data['signatureFlow']) || empty($data['signatureFlow'])) {
+ return [];
}
- if (isset($data['signatureFlow']) && !empty($data['signatureFlow'])) {
- $newFlow = SignatureFlow::from($data['signatureFlow']);
- if ($file->getSignatureFlowEnum() !== $newFlow) {
- $file->setSignatureFlowEnum($newFlow);
- $this->fileService->update($file);
- }
+ return [SignatureFlowPolicy::KEY => (string)$data['signatureFlow']];
+ }
+
+ /** @param array $requestOverrides */
+ private function assertSignatureFlowOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void {
+ if ($requestOverrides === [] || $resolvedPolicy->canUseAsRequestOverride()) {
+ return;
}
+
+ $blockedBy = $resolvedPolicy->getBlockedBy() ?? $resolvedPolicy->getSourceScope();
+ throw new LibresignException($this->l10n->t('Signature flow override is blocked by %s.', [$blockedBy]), 422);
}
- private function setSignatureFlow(FileEntity $file, array $data): void {
- $adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value);
+ private function storePolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy): void {
+ $metadata = $file->getMetadata() ?? [];
+ $policySnapshot = $metadata['policy_snapshot'] ?? [];
+ $policySnapshot[$resolvedPolicy->getPolicyKey()] = [
+ 'effectiveValue' => $resolvedPolicy->getEffectiveValue(),
+ 'sourceScope' => $resolvedPolicy->getSourceScope(),
+ ];
+ $metadata['policy_snapshot'] = $policySnapshot;
+ $file->setMetadata($metadata);
+ }
- if (isset($data['signatureFlow']) && !empty($data['signatureFlow'])) {
- $file->setSignatureFlowEnum(SignatureFlow::from($data['signatureFlow']));
- } elseif ($adminFlow !== SignatureFlow::NONE->value) {
- $file->setSignatureFlowEnum(SignatureFlow::from($adminFlow));
- } else {
- $file->setSignatureFlowEnum(SignatureFlow::NONE);
+ private function updateDocMdpLevelFromPolicy(FileEntity $file, array $data): void {
+ $resolvedPolicy = $this->policyService->resolveForUserId(
+ DocMdpPolicy::KEY,
+ $file->getUserId(),
+ $this->getDocMdpRequestOverrides($data),
+ );
+ $newLevel = DocMdpLevel::tryFrom((int)$resolvedPolicy->getEffectiveValue()) ?? DocMdpLevel::NOT_CERTIFIED;
+ $metadataBeforeUpdate = $file->getMetadata() ?? [];
+ $this->storePolicySnapshot($file, $resolvedPolicy);
+ $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate;
+
+ if ($file->getDocmdpLevelEnum() !== $newLevel || $metadataChanged) {
+ $file->setDocmdpLevelEnum($newLevel);
+ $this->fileService->update($file);
}
}
- private function setDocMdpLevelFromGlobalConfig(FileEntity $file): void {
- if ($this->docMdpConfigService->isEnabled()) {
- $docmdpLevel = $this->docMdpConfigService->getLevel();
- $file->setDocmdpLevelEnum($docmdpLevel);
+ private function setDocMdpLevelFromPolicy(FileEntity $file, array $data): void {
+ $user = ($data['userManager'] ?? null) instanceof IUser ? $data['userManager'] : null;
+ $resolvedPolicy = $this->policyService->resolveForUser(
+ DocMdpPolicy::KEY,
+ $user,
+ $this->getDocMdpRequestOverrides($data),
+ );
+ $file->setDocmdpLevelEnum(DocMdpLevel::tryFrom((int)$resolvedPolicy->getEffectiveValue()) ?? DocMdpLevel::NOT_CERTIFIED);
+ $this->storePolicySnapshot($file, $resolvedPolicy);
+ }
+
+ /** @return array */
+ private function getDocMdpRequestOverrides(array $data): array {
+ if (!isset($data['docmdpLevel']) || $data['docmdpLevel'] === null || $data['docmdpLevel'] === '') {
+ return [];
}
+
+ return [DocMdpPolicy::KEY => (int)$data['docmdpLevel']];
}
private function getFileMetadata(\OCP\Files\Node $node): array {
diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php
index 93f243bdb2..527f189c17 100644
--- a/lib/Service/SignFileService.php
+++ b/lib/Service/SignFileService.php
@@ -594,6 +594,22 @@ private function addCredentialsToJobArgs(array $args, SignRequestEntity $signReq
return $args;
}
+ private function runWithVolatileActiveUser(?IUser $user, callable $callback): mixed {
+ $currentUser = $this->userSession->getUser();
+
+ if ($user === null || $currentUser?->getUID() === $user->getUID()) {
+ return $callback();
+ }
+
+ $this->userSession->setVolatileActiveUser($user);
+
+ try {
+ return $callback();
+ } finally {
+ $this->userSession->setVolatileActiveUser($currentUser);
+ }
+ }
+
/**
* @return DateTimeInterface|null Last signed date
*/
@@ -614,7 +630,11 @@ private function signSequentially(array $signRequests): ?DateTimeInterface {
$this->validateDocMdpAllowsSignatures();
try {
- $signedFile = $this->getEngine()->sign();
+ $engine = $this->getEngine();
+ $signedFile = $this->runWithVolatileActiveUser(
+ $this->fileToSign?->getOwner(),
+ fn (): File => $engine->sign(),
+ );
} catch (LibresignException|Exception $e) {
$this->cleanupUnsignedSignedFile();
$this->recordSignatureAttempt($e);
@@ -1439,7 +1459,8 @@ private function createSignedFile(File $originalFile, string $content): File {
$this->l10n->t('signed') . '.' . $originalFile->getExtension(),
basename($originalFile->getPath())
);
- $owner = $originalFile->getOwner()->getUID();
+ $owner = $originalFile->getOwner();
+ $ownerUid = $owner->getUID();
$fileId = $this->libreSignFile->getId();
$extension = $originalFile->getExtension();
@@ -1447,9 +1468,12 @@ private function createSignedFile(File $originalFile, string $content): File {
try {
/** @var \OCP\Files\Folder */
- $parentFolder = $this->root->getUserFolder($owner)->getFirstNodeById($originalFile->getParentId());
+ $parentFolder = $this->root->getUserFolder($ownerUid)->getFirstNodeById($originalFile->getParentId());
- $this->createdSignedFile = $parentFolder->newFile($uniqueFilename, $content);
+ $this->createdSignedFile = $this->runWithVolatileActiveUser(
+ $owner,
+ fn (): File => $parentFolder->newFile($uniqueFilename, $content),
+ );
return $this->createdSignedFile;
} catch (NotPermittedException) {
diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php
index 411b28d16a..ac1458128e 100644
--- a/lib/Settings/Admin.php
+++ b/lib/Settings/Admin.php
@@ -11,15 +11,18 @@
use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
+use OCA\Libresign\Service\AccountService;
use OCA\Libresign\Service\CertificatePolicyService;
use OCA\Libresign\Service\DocMdp\ConfigService as DocMdpConfigService;
use OCA\Libresign\Service\FooterService;
use OCA\Libresign\Service\IdentifyMethodService;
+use OCA\Libresign\Service\Policy\PolicyService;
use OCA\Libresign\Service\SignatureBackgroundService;
use OCA\Libresign\Service\SignatureTextService;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IAppConfig;
+use OCP\IUserSession;
use OCP\Settings\ISettings;
use OCP\Util;
@@ -33,6 +36,8 @@ class Admin implements ISettings {
public function __construct(
private IInitialState $initialState,
+ private AccountService $accountService,
+ private IUserSession $userSession,
private IdentifyMethodService $identifyMethodService,
private CertificateEngineFactory $certificateEngineFactory,
private CertificatePolicyService $certificatePolicyService,
@@ -41,12 +46,14 @@ public function __construct(
private SignatureBackgroundService $signatureBackgroundService,
private FooterService $footerService,
private DocMdpConfigService $docMdpConfigService,
+ private PolicyService $policyService,
) {
}
#[\Override]
public function getForm(): TemplateResponse {
Util::addScript(Application::APP_ID, 'libresign-settings');
Util::addStyle(Application::APP_ID, 'libresign-settings');
+ $this->initialState->provideInitialState('config', $this->accountService->getConfig($this->userSession->getUser()));
try {
$signatureParsed = $this->signatureTextService->parse();
$this->initialState->provideInitialState('signature_text_parsed', $signatureParsed['parsed']);
@@ -87,7 +94,13 @@ public function getForm(): TemplateResponse {
$this->initialState->provideInitialState('tsa_username', $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', ''));
$this->initialState->provideInitialState('tsa_password', $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', self::PASSWORD_PLACEHOLDER));
$this->initialState->provideInitialState('docmdp_config', $this->docMdpConfigService->getConfig());
- $this->initialState->provideInitialState('signature_flow', $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Enum\SignatureFlow::NONE->value));
+ $resolvedPolicies = [];
+ foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) {
+ $resolvedPolicies[$policyKey] = $resolvedPolicy->toArray();
+ }
+ $this->initialState->provideInitialState('effective_policies', [
+ 'policies' => $resolvedPolicies,
+ ]);
$this->initialState->provideInitialState('signing_mode', $this->getSigningModeInitialState());
$this->initialState->provideInitialState('worker_type', $this->getWorkerTypeInitialState());
$this->initialState->provideInitialState('identification_documents', $this->appConfig->getValueBool(Application::APP_ID, 'identification_documents', false));
diff --git a/openapi-administration.json b/openapi-administration.json
index 849c63dc64..48e5a2bfa7 100644
--- a/openapi-administration.json
+++ b/openapi-administration.json
@@ -374,6 +374,87 @@
}
}
},
+ "EffectivePolicyResponse": {
+ "type": "object",
+ "required": [
+ "policy"
+ ],
+ "properties": {
+ "policy": {
+ "$ref": "#/components/schemas/EffectivePolicyState"
+ }
+ }
+ },
+ "EffectivePolicyState": {
+ "type": "object",
+ "required": [
+ "policyKey",
+ "effectiveValue",
+ "sourceScope",
+ "visible",
+ "editableByCurrentActor",
+ "allowedValues",
+ "canSaveAsUserDefault",
+ "canUseAsRequestOverride",
+ "preferenceWasCleared",
+ "blockedBy"
+ ],
+ "properties": {
+ "policyKey": {
+ "type": "string"
+ },
+ "effectiveValue": {
+ "$ref": "#/components/schemas/EffectivePolicyValue"
+ },
+ "sourceScope": {
+ "type": "string"
+ },
+ "visible": {
+ "type": "boolean"
+ },
+ "editableByCurrentActor": {
+ "type": "boolean"
+ },
+ "allowedValues": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EffectivePolicyValue"
+ }
+ },
+ "canSaveAsUserDefault": {
+ "type": "boolean"
+ },
+ "canUseAsRequestOverride": {
+ "type": "boolean"
+ },
+ "preferenceWasCleared": {
+ "type": "boolean"
+ },
+ "blockedBy": {
+ "type": "string",
+ "nullable": true
+ }
+ }
+ },
+ "EffectivePolicyValue": {
+ "nullable": true,
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "type": "number",
+ "format": "double"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
"EngineHandler": {
"type": "object",
"required": [
@@ -799,6 +880,114 @@
]
}
}
+ },
+ "SystemPolicyResponse": {
+ "type": "object",
+ "required": [
+ "policy"
+ ],
+ "properties": {
+ "policy": {
+ "$ref": "#/components/schemas/SystemPolicyState"
+ }
+ }
+ },
+ "SystemPolicyState": {
+ "type": "object",
+ "required": [
+ "policyKey",
+ "scope",
+ "value",
+ "allowChildOverride",
+ "visibleToChild",
+ "allowedValues"
+ ],
+ "properties": {
+ "policyKey": {
+ "type": "string"
+ },
+ "scope": {
+ "type": "string",
+ "enum": [
+ "system",
+ "global"
+ ]
+ },
+ "value": {
+ "$ref": "#/components/schemas/EffectivePolicyValue",
+ "nullable": true
+ },
+ "allowChildOverride": {
+ "type": "boolean"
+ },
+ "visibleToChild": {
+ "type": "boolean"
+ },
+ "allowedValues": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EffectivePolicyValue"
+ }
+ }
+ }
+ },
+ "SystemPolicyWriteResponse": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/MessageResponse"
+ },
+ {
+ "$ref": "#/components/schemas/EffectivePolicyResponse"
+ }
+ ]
+ },
+ "UserPolicyResponse": {
+ "type": "object",
+ "required": [
+ "policy"
+ ],
+ "properties": {
+ "policy": {
+ "$ref": "#/components/schemas/UserPolicyState"
+ }
+ }
+ },
+ "UserPolicyState": {
+ "type": "object",
+ "required": [
+ "policyKey",
+ "scope",
+ "targetId",
+ "value"
+ ],
+ "properties": {
+ "policyKey": {
+ "type": "string"
+ },
+ "scope": {
+ "type": "string",
+ "enum": [
+ "user"
+ ]
+ },
+ "targetId": {
+ "type": "string"
+ },
+ "value": {
+ "$ref": "#/components/schemas/EffectivePolicyValue",
+ "nullable": true
+ }
+ }
+ },
+ "UserPolicyWriteResponse": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/MessageResponse"
+ },
+ {
+ "$ref": "#/components/schemas/UserPolicyResponse"
+ }
+ ]
}
}
},
@@ -3315,10 +3504,10 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-flow/config": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": {
"post": {
- "operationId": "admin-set-signature-flow-config",
- "summary": "Set signature flow configuration",
+ "operationId": "admin-set-doc-mdp-config",
+ "summary": "Configure DocMDP signature restrictions",
"description": "This endpoint requires admin access",
"tags": [
"admin"
@@ -3343,12 +3532,13 @@
"properties": {
"enabled": {
"type": "boolean",
- "description": "Whether to force a signature flow for all documents"
+ "description": "Whether to enable DocMDP restrictions"
},
- "mode": {
- "type": "string",
- "nullable": true,
- "description": "Signature flow mode: 'parallel' or 'ordered_numeric' (only used when enabled is true)"
+ "defaultLevel": {
+ "type": "integer",
+ "format": "int64",
+ "default": 2,
+ "description": "DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations)"
}
}
}
@@ -3411,7 +3601,7 @@
}
},
"400": {
- "description": "Invalid signature flow mode provided",
+ "description": "Invalid DocMDP level provided",
"content": {
"application/json": {
"schema": {
@@ -3473,10 +3663,10 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": {
- "post": {
- "operationId": "admin-set-doc-mdp-config",
- "summary": "Configure DocMDP signature restrictions",
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/active-signings": {
+ "get": {
+ "operationId": "admin-get-active-signings",
+ "summary": "Get list of files currently being signed (status = SIGNING_IN_PROGRESS)",
"description": "This endpoint requires admin access",
"tags": [
"admin"
@@ -3489,31 +3679,6 @@
"basic_auth": []
}
],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "enabled"
- ],
- "properties": {
- "enabled": {
- "type": "boolean",
- "description": "Whether to enable DocMDP restrictions"
- },
- "defaultLevel": {
- "type": "integer",
- "format": "int64",
- "default": 2,
- "description": "DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations)"
- }
- }
- }
- }
- }
- },
"parameters": [
{
"name": "apiVersion",
@@ -3540,37 +3705,7 @@
],
"responses": {
"200": {
- "description": "Configuration saved successfully",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "$ref": "#/components/schemas/MessageResponse"
- }
- }
- }
- }
- }
- }
- }
- },
- "400": {
- "description": "Invalid DocMDP level provided",
+ "description": "List of active signings",
"content": {
"application/json": {
"schema": {
@@ -3590,7 +3725,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ErrorResponse"
+ "$ref": "#/components/schemas/ActiveSigningsResponse"
}
}
}
@@ -3600,7 +3735,7 @@
}
},
"500": {
- "description": "Internal server error",
+ "description": "",
"content": {
"application/json": {
"schema": {
@@ -3632,13 +3767,13 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/active-signings": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/list": {
"get": {
- "operationId": "admin-get-active-signings",
- "summary": "Get list of files currently being signed (status = SIGNING_IN_PROGRESS)",
+ "operationId": "crl_api-list",
+ "summary": "List CRL entries with pagination and filters",
"description": "This endpoint requires admin access",
"tags": [
- "admin"
+ "crl_api"
],
"security": [
{
@@ -3662,117 +3797,13 @@
}
},
{
- "name": "OCS-APIRequest",
- "in": "header",
- "description": "Required to be true for the API request to pass",
- "required": true,
+ "name": "page",
+ "in": "query",
+ "description": "Page number (1-based)",
"schema": {
- "type": "boolean",
- "default": true
- }
- }
- ],
- "responses": {
- "200": {
- "description": "List of active signings",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "$ref": "#/components/schemas/ActiveSigningsResponse"
- }
- }
- }
- }
- }
- }
- }
- },
- "500": {
- "description": "",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- }
- }
- }
- }
- }
- }
- }
- },
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/list": {
- "get": {
- "operationId": "crl_api-list",
- "summary": "List CRL entries with pagination and filters",
- "description": "This endpoint requires admin access",
- "tags": [
- "crl_api"
- ],
- "security": [
- {
- "bearer_auth": []
- },
- {
- "basic_auth": []
- }
- ],
- "parameters": [
- {
- "name": "apiVersion",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "enum": [
- "v1"
- ],
- "default": "v1"
- }
- },
- {
- "name": "page",
- "in": "query",
- "description": "Page number (1-based)",
- "schema": {
- "type": "integer",
- "format": "int64",
- "nullable": true
+ "type": "integer",
+ "format": "int64",
+ "nullable": true
}
},
{
@@ -4076,6 +4107,579 @@
}
}
},
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": {
+ "get": {
+ "operationId": "policy-get-system",
+ "summary": "Read explicit system policy configuration",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "policy"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to read from the system layer.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/SystemPolicyResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "operationId": "policy-set-system",
+ "summary": "Save a system-level policy value",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "policy"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "nullable": true,
+ "description": "Policy value to persist. Null resets the policy to its default system value.",
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "type": "number",
+ "format": "double"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
+ "allowChildOverride": {
+ "type": "boolean",
+ "default": false,
+ "description": "Whether lower layers may override this system default."
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to persist at the system layer.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/SystemPolicyWriteResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid policy value",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{userId}/{policyKey}": {
+ "get": {
+ "operationId": "policy-get-user-policy-for-user",
+ "summary": "Read a user-level policy preference for a target user (admin scope)",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "policy"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "userId",
+ "in": "path",
+ "description": "Target user identifier that receives the policy preference.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[^/]+$"
+ }
+ },
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to read for the selected user.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/UserPolicyResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "operationId": "policy-set-user-policy-for-user",
+ "summary": "Save a user policy preference for a target user (admin scope)",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "policy"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "nullable": true,
+ "description": "Policy value to persist as target user preference.",
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "type": "number",
+ "format": "double"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "userId",
+ "in": "path",
+ "description": "Target user identifier that receives the policy preference.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[^/]+$"
+ }
+ },
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to persist for the target user.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/UserPolicyWriteResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid policy value",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "operationId": "policy-clear-user-policy-for-user",
+ "summary": "Clear a user policy preference for a target user (admin scope)",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "policy"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "userId",
+ "in": "path",
+ "description": "Target user identifier that receives the policy preference removal.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[^/]+$"
+ }
+ },
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to clear for the target user.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/UserPolicyWriteResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/ocs/v2.php/apps/libresign/api/{apiVersion}/setting/has-root-cert": {
"get": {
"operationId": "setting-has-root-cert",
diff --git a/openapi-full.json b/openapi-full.json
index 157caffe52..044ceafec5 100644
--- a/openapi-full.json
+++ b/openapi-full.json
@@ -947,6 +947,101 @@
}
}
},
+ "EffectivePoliciesResponse": {
+ "type": "object",
+ "required": [
+ "policies"
+ ],
+ "properties": {
+ "policies": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/components/schemas/EffectivePolicyState"
+ }
+ }
+ }
+ },
+ "EffectivePolicyResponse": {
+ "type": "object",
+ "required": [
+ "policy"
+ ],
+ "properties": {
+ "policy": {
+ "$ref": "#/components/schemas/EffectivePolicyState"
+ }
+ }
+ },
+ "EffectivePolicyState": {
+ "type": "object",
+ "required": [
+ "policyKey",
+ "effectiveValue",
+ "sourceScope",
+ "visible",
+ "editableByCurrentActor",
+ "allowedValues",
+ "canSaveAsUserDefault",
+ "canUseAsRequestOverride",
+ "preferenceWasCleared",
+ "blockedBy"
+ ],
+ "properties": {
+ "policyKey": {
+ "type": "string"
+ },
+ "effectiveValue": {
+ "$ref": "#/components/schemas/EffectivePolicyValue"
+ },
+ "sourceScope": {
+ "type": "string"
+ },
+ "visible": {
+ "type": "boolean"
+ },
+ "editableByCurrentActor": {
+ "type": "boolean"
+ },
+ "allowedValues": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EffectivePolicyValue"
+ }
+ },
+ "canSaveAsUserDefault": {
+ "type": "boolean"
+ },
+ "canUseAsRequestOverride": {
+ "type": "boolean"
+ },
+ "preferenceWasCleared": {
+ "type": "boolean"
+ },
+ "blockedBy": {
+ "type": "string",
+ "nullable": true
+ }
+ }
+ },
+ "EffectivePolicyValue": {
+ "nullable": true,
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "type": "number",
+ "format": "double"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
"EngineHandler": {
"type": "object",
"required": [
@@ -1447,6 +1542,84 @@
}
}
},
+ "GroupPolicyResponse": {
+ "type": "object",
+ "required": [
+ "policy"
+ ],
+ "properties": {
+ "policy": {
+ "$ref": "#/components/schemas/GroupPolicyState"
+ }
+ }
+ },
+ "GroupPolicyState": {
+ "type": "object",
+ "required": [
+ "policyKey",
+ "scope",
+ "targetId",
+ "value",
+ "allowChildOverride",
+ "visibleToChild",
+ "allowedValues"
+ ],
+ "properties": {
+ "policyKey": {
+ "type": "string"
+ },
+ "scope": {
+ "type": "string",
+ "enum": [
+ "group"
+ ]
+ },
+ "targetId": {
+ "type": "string"
+ },
+ "value": {
+ "$ref": "#/components/schemas/EffectivePolicyValue",
+ "nullable": true
+ },
+ "allowChildOverride": {
+ "type": "boolean"
+ },
+ "visibleToChild": {
+ "type": "boolean"
+ },
+ "allowedValues": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EffectivePolicyValue"
+ }
+ }
+ }
+ },
+ "GroupPolicyWriteRequest": {
+ "type": "object",
+ "required": [
+ "value",
+ "allowChildOverride"
+ ],
+ "properties": {
+ "value": {
+ "$ref": "#/components/schemas/EffectivePolicyValue"
+ },
+ "allowChildOverride": {
+ "type": "boolean"
+ }
+ }
+ },
+ "GroupPolicyWriteResponse": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/MessageResponse"
+ },
+ {
+ "$ref": "#/components/schemas/GroupPolicyResponse"
+ }
+ ]
+ },
"HasRootCertResponse": {
"type": "object",
"required": [
@@ -1862,6 +2035,37 @@
}
}
},
+ "PolicySnapshotEntry": {
+ "type": "object",
+ "required": [
+ "effectiveValue",
+ "sourceScope"
+ ],
+ "properties": {
+ "effectiveValue": {
+ "type": "string"
+ },
+ "sourceScope": {
+ "type": "string"
+ }
+ }
+ },
+ "PolicySnapshotNumericEntry": {
+ "type": "object",
+ "required": [
+ "effectiveValue",
+ "sourceScope"
+ ],
+ "properties": {
+ "effectiveValue": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "sourceScope": {
+ "type": "string"
+ }
+ }
+ },
"ProgressError": {
"type": "object",
"required": [
@@ -2516,6 +2720,77 @@
}
}
},
+ "SystemPolicyResponse": {
+ "type": "object",
+ "required": [
+ "policy"
+ ],
+ "properties": {
+ "policy": {
+ "$ref": "#/components/schemas/SystemPolicyState"
+ }
+ }
+ },
+ "SystemPolicyState": {
+ "type": "object",
+ "required": [
+ "policyKey",
+ "scope",
+ "value",
+ "allowChildOverride",
+ "visibleToChild",
+ "allowedValues"
+ ],
+ "properties": {
+ "policyKey": {
+ "type": "string"
+ },
+ "scope": {
+ "type": "string",
+ "enum": [
+ "system",
+ "global"
+ ]
+ },
+ "value": {
+ "$ref": "#/components/schemas/EffectivePolicyValue",
+ "nullable": true
+ },
+ "allowChildOverride": {
+ "type": "boolean"
+ },
+ "visibleToChild": {
+ "type": "boolean"
+ },
+ "allowedValues": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EffectivePolicyValue"
+ }
+ }
+ }
+ },
+ "SystemPolicyWriteRequest": {
+ "type": "object",
+ "required": [
+ "value"
+ ],
+ "properties": {
+ "value": {
+ "$ref": "#/components/schemas/EffectivePolicyValue"
+ }
+ }
+ },
+ "SystemPolicyWriteResponse": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/MessageResponse"
+ },
+ {
+ "$ref": "#/components/schemas/EffectivePolicyResponse"
+ }
+ ]
+ },
"UserElement": {
"type": "object",
"required": [
@@ -2598,6 +2873,54 @@
}
}
},
+ "UserPolicyResponse": {
+ "type": "object",
+ "required": [
+ "policy"
+ ],
+ "properties": {
+ "policy": {
+ "$ref": "#/components/schemas/UserPolicyState"
+ }
+ }
+ },
+ "UserPolicyState": {
+ "type": "object",
+ "required": [
+ "policyKey",
+ "scope",
+ "targetId",
+ "value"
+ ],
+ "properties": {
+ "policyKey": {
+ "type": "string"
+ },
+ "scope": {
+ "type": "string",
+ "enum": [
+ "user"
+ ]
+ },
+ "targetId": {
+ "type": "string"
+ },
+ "value": {
+ "$ref": "#/components/schemas/EffectivePolicyValue",
+ "nullable": true
+ }
+ }
+ },
+ "UserPolicyWriteResponse": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/MessageResponse"
+ },
+ {
+ "$ref": "#/components/schemas/UserPolicyResponse"
+ }
+ ]
+ },
"ValidateMetadata": {
"type": "object",
"required": [
@@ -2635,6 +2958,9 @@
"original_file_deleted": {
"type": "boolean"
},
+ "policy_snapshot": {
+ "$ref": "#/components/schemas/ValidatePolicySnapshot"
+ },
"pdfVersion": {
"type": "string"
},
@@ -2643,6 +2969,17 @@
}
}
},
+ "ValidatePolicySnapshot": {
+ "type": "object",
+ "properties": {
+ "docmdp": {
+ "$ref": "#/components/schemas/PolicySnapshotNumericEntry"
+ },
+ "signature_flow": {
+ "$ref": "#/components/schemas/PolicySnapshotEntry"
+ }
+ }
+ },
"ValidatedChildFile": {
"type": "object",
"required": [
@@ -8019,13 +8356,12 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": {
- "post": {
- "operationId": "request_signature-request",
- "summary": "Request signature",
- "description": "Request that a file be signed by a list of signers. Each signer in the signers array can optionally include a 'signingOrder' field to control the order of signatures when ordered signing flow is enabled. The returned `data` always includes `filesCount` and `files`. For `nodeType=file`, `filesCount=1` and `files` contains the current file. For `nodeType=envelope`, `files` contains envelope child files.",
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/effective": {
+ "get": {
+ "operationId": "policy-effective",
+ "summary": "Effective policies bootstrap",
"tags": [
- "request_signature"
+ "policy"
],
"security": [
{
@@ -8035,66 +8371,6 @@
"basic_auth": []
}
],
- "requestBody": {
- "required": false,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "signers": {
- "type": "array",
- "default": [],
- "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format. Other supported fields: displayName, description, notify, signingOrder, status",
- "items": {
- "$ref": "#/components/schemas/NewSigner"
- }
- },
- "name": {
- "type": "string",
- "default": "",
- "description": "The name of file to sign"
- },
- "settings": {
- "$ref": "#/components/schemas/FolderSettings",
- "default": [],
- "description": "Settings to define how and where the file should be stored"
- },
- "file": {
- "$ref": "#/components/schemas/NewFile",
- "default": [],
- "description": "File object. Supports nodeId, url, base64 or path."
- },
- "files": {
- "type": "array",
- "default": [],
- "description": "Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.",
- "items": {
- "$ref": "#/components/schemas/NewFile"
- }
- },
- "callback": {
- "type": "string",
- "nullable": true,
- "description": "URL that will receive a POST after the document is signed"
- },
- "status": {
- "type": "integer",
- "format": "int64",
- "nullable": true,
- "default": 1,
- "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending"
- },
- "signatureFlow": {
- "type": "string",
- "nullable": true,
- "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration"
- }
- }
- }
- }
- }
- },
"parameters": [
{
"name": "apiVersion",
@@ -8141,44 +8417,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/DetailedFileResponse"
- }
- }
- }
- }
- }
- }
- }
- },
- "422": {
- "description": "Unauthorized",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "anyOf": [
- {
- "$ref": "#/components/schemas/MessageResponse"
- },
- {
- "$ref": "#/components/schemas/ActionErrorResponse"
- }
- ]
+ "$ref": "#/components/schemas/EffectivePoliciesResponse"
}
}
}
@@ -8188,13 +8427,14 @@
}
}
}
- },
- "patch": {
- "operationId": "request_signature-update-sign",
- "summary": "Updates signatures data",
- "description": "It is necessary to inform the UUID of the file and a list of signers.",
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/group/{groupId}/{policyKey}": {
+ "get": {
+ "operationId": "policy-get-group",
+ "summary": "Read a group-level policy value",
"tags": [
- "request_signature"
+ "policy"
],
"security": [
{
@@ -8204,83 +8444,6 @@
"basic_auth": []
}
],
- "requestBody": {
- "required": false,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "signers": {
- "type": "array",
- "nullable": true,
- "default": [],
- "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format.",
- "items": {
- "$ref": "#/components/schemas/NewSigner"
- }
- },
- "uuid": {
- "type": "string",
- "nullable": true,
- "description": "UUID of sign request. The signer UUID is what the person receives via email when asked to sign. This is not the file UUID."
- },
- "visibleElements": {
- "type": "array",
- "nullable": true,
- "description": "Visible elements on document",
- "items": {
- "$ref": "#/components/schemas/VisibleElement"
- }
- },
- "file": {
- "nullable": true,
- "default": [],
- "description": "File object. Supports nodeId, url, base64 or path when creating a new request.",
- "anyOf": [
- {
- "$ref": "#/components/schemas/NewFile"
- },
- {
- "type": "array",
- "maxItems": 0
- }
- ]
- },
- "status": {
- "type": "integer",
- "format": "int64",
- "nullable": true,
- "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending"
- },
- "signatureFlow": {
- "type": "string",
- "nullable": true,
- "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration"
- },
- "name": {
- "type": "string",
- "nullable": true,
- "description": "The name of file to sign"
- },
- "settings": {
- "$ref": "#/components/schemas/FolderSettings",
- "default": [],
- "description": "Settings to define how and where the file should be stored"
- },
- "files": {
- "type": "array",
- "default": [],
- "description": "Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.",
- "items": {
- "$ref": "#/components/schemas/NewFile"
- }
- }
- }
- }
- }
- }
- },
"parameters": [
{
"name": "apiVersion",
@@ -8294,6 +8457,26 @@
"default": "v1"
}
},
+ {
+ "name": "groupId",
+ "in": "path",
+ "description": "Group identifier that receives the policy binding.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[^/]+$"
+ }
+ },
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to read for the selected group.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -8327,7 +8510,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/DetailedFileResponse"
+ "$ref": "#/components/schemas/GroupPolicyResponse"
}
}
}
@@ -8336,8 +8519,8 @@
}
}
},
- "422": {
- "description": "Unauthorized",
+ "403": {
+ "description": "Forbidden",
"content": {
"application/json": {
"schema": {
@@ -8357,14 +8540,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "anyOf": [
- {
- "$ref": "#/components/schemas/MessageResponse"
- },
- {
- "$ref": "#/components/schemas/ActionErrorResponse"
- }
- ]
+ "$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -8374,15 +8550,12 @@
}
}
}
- }
- },
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/{signRequestId}": {
- "delete": {
- "operationId": "request_signature-delete-one-request-signature-using-file-id",
- "summary": "Delete sign request",
- "description": "You can only request exclusion as any sign",
+ },
+ "put": {
+ "operationId": "policy-set-group",
+ "summary": "Save a group-level policy value",
"tags": [
- "request_signature"
+ "policy"
],
"security": [
{
@@ -8392,6 +8565,43 @@
"basic_auth": []
}
],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "nullable": true,
+ "description": "Policy value to persist for the group.",
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "type": "number",
+ "format": "double"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
+ "allowChildOverride": {
+ "type": "boolean",
+ "default": false,
+ "description": "Whether users and requests below this group may override the group default."
+ }
+ }
+ }
+ }
+ }
+ },
"parameters": [
{
"name": "apiVersion",
@@ -8406,23 +8616,23 @@
}
},
{
- "name": "fileId",
+ "name": "groupId",
"in": "path",
- "description": "LibreSign file ID",
+ "description": "Group identifier that receives the policy binding.",
"required": true,
"schema": {
- "type": "integer",
- "format": "int64"
+ "type": "string",
+ "pattern": "^[^/]+$"
}
},
{
- "name": "signRequestId",
+ "name": "policyKey",
"in": "path",
- "description": "The sign request id",
+ "description": "Policy identifier to persist at the group layer.",
"required": true,
"schema": {
- "type": "integer",
- "format": "int64"
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
}
},
{
@@ -8458,7 +8668,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/MessageResponse"
+ "$ref": "#/components/schemas/GroupPolicyWriteResponse"
}
}
}
@@ -8467,8 +8677,8 @@
}
}
},
- "401": {
- "description": "Failed",
+ "400": {
+ "description": "Invalid policy value",
"content": {
"application/json": {
"schema": {
@@ -8488,7 +8698,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/MessageResponse"
+ "$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -8497,8 +8707,8 @@
}
}
},
- "422": {
- "description": "Failed",
+ "403": {
+ "description": "Forbidden",
"content": {
"application/json": {
"schema": {
@@ -8518,7 +8728,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ActionErrorResponse"
+ "$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -8528,15 +8738,12 @@
}
}
}
- }
- },
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}": {
+ },
"delete": {
- "operationId": "request_signature-delete-all-request-signature-using-file-id",
- "summary": "Delete sign request",
- "description": "You can only request exclusion as any sign",
+ "operationId": "policy-clear-group",
+ "summary": "Clear a group-level policy value",
"tags": [
- "request_signature"
+ "policy"
],
"security": [
{
@@ -8560,19 +8767,29 @@
}
},
{
- "name": "fileId",
+ "name": "groupId",
"in": "path",
- "description": "LibreSign file ID",
+ "description": "Group identifier that receives the policy binding.",
"required": true,
"schema": {
- "type": "integer",
- "format": "int64"
+ "type": "string",
+ "pattern": "^[^/]+$"
}
},
{
- "name": "OCS-APIRequest",
- "in": "header",
- "description": "Required to be true for the API request to pass",
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to clear for the selected group.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
@@ -8602,37 +8819,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/MessageResponse"
- }
- }
- }
- }
- }
- }
- }
- },
- "401": {
- "description": "Failed",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "$ref": "#/components/schemas/MessageResponse"
+ "$ref": "#/components/schemas/GroupPolicyWriteResponse"
}
}
}
@@ -8641,8 +8828,8 @@
}
}
},
- "422": {
- "description": "Failed",
+ "403": {
+ "description": "Forbidden",
"content": {
"application/json": {
"schema": {
@@ -8662,7 +8849,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ActionErrorResponse"
+ "$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -8672,15 +8859,16 @@
}
}
}
- },
- "post": {
- "operationId": "sign_file-sign-using-file-id",
- "summary": "Sign a file using file Id",
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{policyKey}": {
+ "put": {
+ "operationId": "policy-set-user-preference",
+ "summary": "Save a user policy preference",
"tags": [
- "sign_file"
+ "policy"
],
"security": [
- {},
{
"bearer_auth": []
},
@@ -8689,41 +8877,31 @@
}
],
"requestBody": {
- "required": true,
+ "required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
- "required": [
- "method"
- ],
"properties": {
- "method": {
- "type": "string",
- "description": "Signature method"
- },
- "elements": {
- "type": "object",
- "default": {},
- "description": "List of visible elements",
- "additionalProperties": {
- "type": "object"
- }
- },
- "identifyValue": {
- "type": "string",
- "default": "",
- "description": "Identify value"
- },
- "token": {
- "type": "string",
- "default": "",
- "description": "Token, commonly send by email"
- },
- "async": {
- "type": "boolean",
- "default": false,
- "description": "Execute signing asynchronously when possible"
+ "value": {
+ "nullable": true,
+ "description": "Policy value to persist as the current user's default.",
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "type": "number",
+ "format": "double"
+ },
+ {
+ "type": "string"
+ }
+ ]
}
}
}
@@ -8744,13 +8922,13 @@
}
},
{
- "name": "fileId",
+ "name": "policyKey",
"in": "path",
- "description": "Id of LibreSign file",
+ "description": "Policy identifier to persist for the current user.",
"required": true,
"schema": {
- "type": "integer",
- "format": "int64"
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
}
},
{
@@ -8786,7 +8964,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/SignActionResponse"
+ "$ref": "#/components/schemas/SystemPolicyWriteResponse"
}
}
}
@@ -8795,8 +8973,8 @@
}
}
},
- "422": {
- "description": "Error",
+ "400": {
+ "description": "Invalid policy value",
"content": {
"application/json": {
"schema": {
@@ -8816,7 +8994,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/SignActionErrorResponse"
+ "$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -8826,17 +9004,14 @@
}
}
}
- }
- },
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}": {
- "post": {
- "operationId": "sign_file-sign-using-uuid",
- "summary": "Sign a file using file UUID",
+ },
+ "delete": {
+ "operationId": "policy-clear-user-preference",
+ "summary": "Clear a user policy preference",
"tags": [
- "sign_file"
+ "policy"
],
"security": [
- {},
{
"bearer_auth": []
},
@@ -8844,48 +9019,6 @@
"basic_auth": []
}
],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "method"
- ],
- "properties": {
- "method": {
- "type": "string",
- "description": "Signature method"
- },
- "elements": {
- "type": "object",
- "default": {},
- "description": "List of visible elements",
- "additionalProperties": {
- "type": "object"
- }
- },
- "identifyValue": {
- "type": "string",
- "default": "",
- "description": "Identify value"
- },
- "token": {
- "type": "string",
- "default": "",
- "description": "Token, commonly send by email"
- },
- "async": {
- "type": "boolean",
- "default": false,
- "description": "Execute signing asynchronously when possible"
- }
- }
- }
- }
- }
- },
"parameters": [
{
"name": "apiVersion",
@@ -8900,12 +9033,13 @@
}
},
{
- "name": "uuid",
+ "name": "policyKey",
"in": "path",
- "description": "UUID of LibreSign file",
+ "description": "Policy identifier to clear for the current user.",
"required": true,
"schema": {
- "type": "string"
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
}
},
{
@@ -8941,37 +9075,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/SignActionResponse"
- }
- }
- }
- }
- }
- }
- }
- },
- "422": {
- "description": "Error",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "$ref": "#/components/schemas/SignActionErrorResponse"
+ "$ref": "#/components/schemas/SystemPolicyWriteResponse"
}
}
}
@@ -8983,106 +9087,15 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/renew/{method}": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": {
"post": {
- "operationId": "sign_file-sign-renew",
- "summary": "Renew the signature method",
+ "operationId": "request_signature-request",
+ "summary": "Request signature",
+ "description": "Request that a file be signed by a list of signers. Each signer in the signers array can optionally include a 'signingOrder' field to control the order of signatures when ordered signing flow is enabled. The returned `data` always includes `filesCount` and `files`. For `nodeType=file`, `filesCount=1` and `files` contains the current file. For `nodeType=envelope`, `files` contains envelope child files.",
"tags": [
- "sign_file"
+ "request_signature"
],
"security": [
- {},
- {
- "bearer_auth": []
- },
- {
- "basic_auth": []
- }
- ],
- "parameters": [
- {
- "name": "apiVersion",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "enum": [
- "v1"
- ],
- "default": "v1"
- }
- },
- {
- "name": "uuid",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string"
- }
- },
- {
- "name": "method",
- "in": "path",
- "description": "Signature method",
- "required": true,
- "schema": {
- "type": "string"
- }
- },
- {
- "name": "OCS-APIRequest",
- "in": "header",
- "description": "Required to be true for the API request to pass",
- "required": true,
- "schema": {
- "type": "boolean",
- "default": true
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "$ref": "#/components/schemas/MessageResponse"
- }
- }
- }
- }
- }
- }
- }
- }
- }
- }
- },
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/code": {
- "post": {
- "operationId": "sign_file-get-code-using-uuid",
- "summary": "Get code to sign the document using UUID",
- "tags": [
- "sign_file"
- ],
- "security": [
- {},
{
"bearer_auth": []
},
@@ -9097,24 +9110,53 @@
"schema": {
"type": "object",
"properties": {
- "identifyMethod": {
+ "signers": {
+ "type": "array",
+ "default": [],
+ "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format. Other supported fields: displayName, description, notify, signingOrder, status",
+ "items": {
+ "$ref": "#/components/schemas/NewSigner"
+ }
+ },
+ "name": {
"type": "string",
- "nullable": true,
- "enum": [
- "account",
- "email"
- ],
- "description": "Identify signer method"
+ "default": "",
+ "description": "The name of file to sign"
},
- "signMethod": {
+ "settings": {
+ "$ref": "#/components/schemas/FolderSettings",
+ "default": [],
+ "description": "Settings to define how and where the file should be stored"
+ },
+ "file": {
+ "$ref": "#/components/schemas/NewFile",
+ "default": [],
+ "description": "File object. Supports nodeId, url, base64 or path."
+ },
+ "files": {
+ "type": "array",
+ "default": [],
+ "description": "Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.",
+ "items": {
+ "$ref": "#/components/schemas/NewFile"
+ }
+ },
+ "callback": {
"type": "string",
"nullable": true,
- "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken"
+ "description": "URL that will receive a POST after the document is signed"
},
- "identify": {
+ "status": {
+ "type": "integer",
+ "format": "int64",
+ "nullable": true,
+ "default": 1,
+ "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending"
+ },
+ "signatureFlow": {
"type": "string",
"nullable": true,
- "description": "Identify value, i.e. the signer email, account or phone number"
+ "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution."
}
}
}
@@ -9134,15 +9176,6 @@
"default": "v1"
}
},
- {
- "name": "uuid",
- "in": "path",
- "description": "UUID of LibreSign file",
- "required": true,
- "schema": {
- "type": "string"
- }
- },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -9176,7 +9209,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/MessageResponse"
+ "$ref": "#/components/schemas/DetailedFileResponse"
}
}
}
@@ -9186,7 +9219,7 @@
}
},
"422": {
- "description": "Error",
+ "description": "Unauthorized",
"content": {
"application/json": {
"schema": {
@@ -9206,7 +9239,14 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/MessageResponse"
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MessageResponse"
+ },
+ {
+ "$ref": "#/components/schemas/ActionErrorResponse"
+ }
+ ]
}
}
}
@@ -9216,17 +9256,15 @@
}
}
}
- }
- },
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/code": {
- "post": {
- "operationId": "sign_file-get-code-using-file-id",
- "summary": "Get code to sign the document using FileID",
+ },
+ "patch": {
+ "operationId": "request_signature-update-sign",
+ "summary": "Updates signatures data",
+ "description": "It is necessary to inform the UUID of the file and a list of signers.",
"tags": [
- "sign_file"
+ "request_signature"
],
"security": [
- {},
{
"bearer_auth": []
},
@@ -9241,24 +9279,70 @@
"schema": {
"type": "object",
"properties": {
- "identifyMethod": {
+ "signers": {
+ "type": "array",
+ "nullable": true,
+ "default": [],
+ "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format.",
+ "items": {
+ "$ref": "#/components/schemas/NewSigner"
+ }
+ },
+ "uuid": {
"type": "string",
"nullable": true,
- "enum": [
- "account",
- "email"
- ],
- "description": "Identify signer method"
+ "description": "UUID of sign request. The signer UUID is what the person receives via email when asked to sign. This is not the file UUID."
},
- "signMethod": {
+ "visibleElements": {
+ "type": "array",
+ "nullable": true,
+ "description": "Visible elements on document",
+ "items": {
+ "$ref": "#/components/schemas/VisibleElement"
+ }
+ },
+ "file": {
+ "nullable": true,
+ "default": [],
+ "description": "File object. Supports nodeId, url, base64 or path when creating a new request.",
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/NewFile"
+ },
+ {
+ "type": "array",
+ "maxItems": 0
+ }
+ ]
+ },
+ "status": {
+ "type": "integer",
+ "format": "int64",
+ "nullable": true,
+ "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending"
+ },
+ "signatureFlow": {
"type": "string",
"nullable": true,
- "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken"
+ "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution."
},
- "identify": {
+ "name": {
"type": "string",
"nullable": true,
- "description": "Identify value, i.e. the signer email, account or phone number"
+ "description": "The name of file to sign"
+ },
+ "settings": {
+ "$ref": "#/components/schemas/FolderSettings",
+ "default": [],
+ "description": "Settings to define how and where the file should be stored"
+ },
+ "files": {
+ "type": "array",
+ "default": [],
+ "description": "Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.",
+ "items": {
+ "$ref": "#/components/schemas/NewFile"
+ }
}
}
}
@@ -9278,16 +9362,6 @@
"default": "v1"
}
},
- {
- "name": "fileId",
- "in": "path",
- "description": "Id of LibreSign file",
- "required": true,
- "schema": {
- "type": "integer",
- "format": "int64"
- }
- },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -9321,7 +9395,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/MessageResponse"
+ "$ref": "#/components/schemas/DetailedFileResponse"
}
}
}
@@ -9331,7 +9405,7 @@
}
},
"422": {
- "description": "Error",
+ "description": "Unauthorized",
"content": {
"application/json": {
"schema": {
@@ -9351,7 +9425,14 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/MessageResponse"
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MessageResponse"
+ },
+ {
+ "$ref": "#/components/schemas/ActionErrorResponse"
+ }
+ ]
}
}
}
@@ -9363,15 +9444,15 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements": {
- "post": {
- "operationId": "signature_elements-create-signature-element",
- "summary": "Create signature element",
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/{signRequestId}": {
+ "delete": {
+ "operationId": "request_signature-delete-one-request-signature-using-file-id",
+ "summary": "Delete sign request",
+ "description": "You can only request exclusion as any sign",
"tags": [
- "signature_elements"
+ "request_signature"
],
"security": [
- {},
{
"bearer_auth": []
},
@@ -9379,28 +9460,6 @@
"basic_auth": []
}
],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "elements"
- ],
- "properties": {
- "elements": {
- "type": "object",
- "description": "Element object",
- "additionalProperties": {
- "type": "object"
- }
- }
- }
- }
- }
- }
- },
"parameters": [
{
"name": "apiVersion",
@@ -9415,9 +9474,29 @@
}
},
{
- "name": "OCS-APIRequest",
- "in": "header",
- "description": "Required to be true for the API request to pass",
+ "name": "fileId",
+ "in": "path",
+ "description": "LibreSign file ID",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ {
+ "name": "signRequestId",
+ "in": "path",
+ "description": "The sign request id",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
@@ -9428,36 +9507,6 @@
"responses": {
"200": {
"description": "OK",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "$ref": "#/components/schemas/UserElementsMessageResponse"
- }
- }
- }
- }
- }
- }
- }
- },
- "422": {
- "description": "Invalid data",
"content": {
"application/json": {
"schema": {
@@ -9485,51 +9534,9 @@
}
}
}
- }
- }
- },
- "get": {
- "operationId": "signature_elements-get-signature-elements",
- "summary": "Get signature elements",
- "tags": [
- "signature_elements"
- ],
- "security": [
- {},
- {
- "bearer_auth": []
- },
- {
- "basic_auth": []
- }
- ],
- "parameters": [
- {
- "name": "apiVersion",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "enum": [
- "v1"
- ],
- "default": "v1"
- }
},
- {
- "name": "OCS-APIRequest",
- "in": "header",
- "description": "Required to be true for the API request to pass",
- "required": true,
- "schema": {
- "type": "boolean",
- "default": true
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
+ "401": {
+ "description": "Failed",
"content": {
"application/json": {
"schema": {
@@ -9549,7 +9556,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/UserElementsResponse"
+ "$ref": "#/components/schemas/MessageResponse"
}
}
}
@@ -9558,8 +9565,8 @@
}
}
},
- "404": {
- "description": "Invalid data",
+ "422": {
+ "description": "Failed",
"content": {
"application/json": {
"schema": {
@@ -9579,7 +9586,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/MessageResponse"
+ "$ref": "#/components/schemas/ActionErrorResponse"
}
}
}
@@ -9591,15 +9598,15 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/preview/{nodeId}": {
- "get": {
- "operationId": "signature_elements-get-signature-element-preview",
- "summary": "Get preview of signature elements of",
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}": {
+ "delete": {
+ "operationId": "request_signature-delete-all-request-signature-using-file-id",
+ "summary": "Delete sign request",
+ "description": "You can only request exclusion as any sign",
"tags": [
- "signature_elements"
+ "request_signature"
],
"security": [
- {},
{
"bearer_auth": []
},
@@ -9621,9 +9628,9 @@
}
},
{
- "name": "nodeId",
+ "name": "fileId",
"in": "path",
- "description": "Node id of a Nextcloud file",
+ "description": "LibreSign file ID",
"required": true,
"schema": {
"type": "integer",
@@ -9644,17 +9651,6 @@
"responses": {
"200": {
"description": "OK",
- "content": {
- "*/*": {
- "schema": {
- "type": "string",
- "format": "binary"
- }
- }
- }
- },
- "404": {
- "description": "Invalid data",
"content": {
"application/json": {
"schema": {
@@ -9674,7 +9670,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "type": "object"
+ "$ref": "#/components/schemas/MessageResponse"
}
}
}
@@ -9682,62 +9678,9 @@
}
}
}
- }
- }
- }
- },
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/{nodeId}": {
- "get": {
- "operationId": "signature_elements-get-signature-element",
- "summary": "Get signature element of signer",
- "tags": [
- "signature_elements"
- ],
- "security": [
- {
- "bearer_auth": []
- },
- {
- "basic_auth": []
- }
- ],
- "parameters": [
- {
- "name": "apiVersion",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "enum": [
- "v1"
- ],
- "default": "v1"
- }
- },
- {
- "name": "nodeId",
- "in": "path",
- "description": "Node id of a Nextcloud file",
- "required": true,
- "schema": {
- "type": "integer",
- "format": "int64"
- }
},
- {
- "name": "OCS-APIRequest",
- "in": "header",
- "description": "Required to be true for the API request to pass",
- "required": true,
- "schema": {
- "type": "boolean",
- "default": true
- }
- }
- ],
- "responses": {
- "200": {
- "description": "OK",
+ "401": {
+ "description": "Failed",
"content": {
"application/json": {
"schema": {
@@ -9757,7 +9700,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/UserElement"
+ "$ref": "#/components/schemas/MessageResponse"
}
}
}
@@ -9766,8 +9709,8 @@
}
}
},
- "404": {
- "description": "Invalid data",
+ "422": {
+ "description": "Failed",
"content": {
"application/json": {
"schema": {
@@ -9787,7 +9730,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/MessageResponse"
+ "$ref": "#/components/schemas/ActionErrorResponse"
}
}
}
@@ -9798,11 +9741,11 @@
}
}
},
- "patch": {
- "operationId": "signature_elements-patch-signature-element",
- "summary": "Update signature element",
+ "post": {
+ "operationId": "sign_file-sign-using-file-id",
+ "summary": "Sign a file using file Id",
"tags": [
- "signature_elements"
+ "sign_file"
],
"security": [
{},
@@ -9814,24 +9757,41 @@
}
],
"requestBody": {
- "required": false,
+ "required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
+ "required": [
+ "method"
+ ],
"properties": {
- "type": {
+ "method": {
"type": "string",
- "default": "",
- "description": "The type of signature element"
+ "description": "Signature method"
},
- "file": {
+ "elements": {
"type": "object",
"default": {},
- "description": "Element object",
+ "description": "List of visible elements",
"additionalProperties": {
"type": "object"
}
+ },
+ "identifyValue": {
+ "type": "string",
+ "default": "",
+ "description": "Identify value"
+ },
+ "token": {
+ "type": "string",
+ "default": "",
+ "description": "Token, commonly send by email"
+ },
+ "async": {
+ "type": "boolean",
+ "default": false,
+ "description": "Execute signing asynchronously when possible"
}
}
}
@@ -9852,9 +9812,9 @@
}
},
{
- "name": "nodeId",
+ "name": "fileId",
"in": "path",
- "description": "Node id of a Nextcloud file",
+ "description": "Id of LibreSign file",
"required": true,
"schema": {
"type": "integer",
@@ -9894,7 +9854,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/UserElementsMessageResponse"
+ "$ref": "#/components/schemas/SignActionResponse"
}
}
}
@@ -9924,7 +9884,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/MessageResponse"
+ "$ref": "#/components/schemas/SignActionErrorResponse"
}
}
}
@@ -9934,12 +9894,14 @@
}
}
}
- },
- "delete": {
- "operationId": "signature_elements-delete-signature-element",
- "summary": "Delete signature element",
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}": {
+ "post": {
+ "operationId": "sign_file-sign-using-uuid",
+ "summary": "Sign a file using file UUID",
"tags": [
- "signature_elements"
+ "sign_file"
],
"security": [
{},
@@ -9950,7 +9912,49 @@
"basic_auth": []
}
],
- "parameters": [
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "method"
+ ],
+ "properties": {
+ "method": {
+ "type": "string",
+ "description": "Signature method"
+ },
+ "elements": {
+ "type": "object",
+ "default": {},
+ "description": "List of visible elements",
+ "additionalProperties": {
+ "type": "object"
+ }
+ },
+ "identifyValue": {
+ "type": "string",
+ "default": "",
+ "description": "Identify value"
+ },
+ "token": {
+ "type": "string",
+ "default": "",
+ "description": "Token, commonly send by email"
+ },
+ "async": {
+ "type": "boolean",
+ "default": false,
+ "description": "Execute signing asynchronously when possible"
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
{
"name": "apiVersion",
"in": "path",
@@ -9964,13 +9968,12 @@
}
},
{
- "name": "nodeId",
+ "name": "uuid",
"in": "path",
- "description": "Node id of a Nextcloud file",
+ "description": "UUID of LibreSign file",
"required": true,
"schema": {
- "type": "integer",
- "format": "int64"
+ "type": "string"
}
},
{
@@ -10006,7 +10009,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/MessageResponse"
+ "$ref": "#/components/schemas/SignActionResponse"
}
}
}
@@ -10015,8 +10018,8 @@
}
}
},
- "404": {
- "description": "Not found",
+ "422": {
+ "description": "Error",
"content": {
"application/json": {
"schema": {
@@ -10036,7 +10039,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/MessageResponse"
+ "$ref": "#/components/schemas/SignActionErrorResponse"
}
}
}
@@ -10048,15 +10051,15 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/cfssl": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/renew/{method}": {
"post": {
- "operationId": "admin-generate-certificate-cfssl",
- "summary": "Generate certificate using CFSSL engine",
- "description": "This endpoint requires admin access",
+ "operationId": "sign_file-sign-renew",
+ "summary": "Renew the signature method",
"tags": [
- "admin"
+ "sign_file"
],
"security": [
+ {},
{
"bearer_auth": []
},
@@ -10064,68 +10067,6 @@
"basic_auth": []
}
],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "rootCert"
- ],
- "properties": {
- "rootCert": {
- "type": "object",
- "description": "fields of root certificate",
- "required": [
- "commonName",
- "names"
- ],
- "properties": {
- "commonName": {
- "type": "string"
- },
- "names": {
- "type": "object",
- "additionalProperties": {
- "type": "object",
- "required": [
- "value"
- ],
- "properties": {
- "value": {
- "oneOf": [
- {
- "type": "string"
- },
- {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- ]
- }
- }
- }
- }
- }
- },
- "cfsslUri": {
- "type": "string",
- "default": "",
- "description": "URI of CFSSL API"
- },
- "configPath": {
- "type": "string",
- "default": "",
- "description": "Path of config files of CFSSL"
- }
- }
- }
- }
- }
- },
"parameters": [
{
"name": "apiVersion",
@@ -10139,6 +10080,23 @@
"default": "v1"
}
},
+ {
+ "name": "uuid",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "method",
+ "in": "path",
+ "description": "Signature method",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -10153,36 +10111,6 @@
"responses": {
"200": {
"description": "OK",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "$ref": "#/components/schemas/EngineHandlerResponse"
- }
- }
- }
- }
- }
- }
- }
- },
- "401": {
- "description": "Account not found",
"content": {
"application/json": {
"schema": {
@@ -10214,15 +10142,15 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/openssl": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/code": {
"post": {
- "operationId": "admin-generate-certificate-open-ssl",
- "summary": "Generate certificate using OpenSSL engine",
- "description": "This endpoint requires admin access",
+ "operationId": "sign_file-get-code-using-uuid",
+ "summary": "Get code to sign the document using UUID",
"tags": [
- "admin"
+ "sign_file"
],
"security": [
+ {},
{
"bearer_auth": []
},
@@ -10231,56 +10159,30 @@
}
],
"requestBody": {
- "required": true,
+ "required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
- "required": [
- "rootCert"
- ],
"properties": {
- "rootCert": {
- "type": "object",
- "description": "fields of root certificate",
- "required": [
- "commonName",
- "names"
+ "identifyMethod": {
+ "type": "string",
+ "nullable": true,
+ "enum": [
+ "account",
+ "email"
],
- "properties": {
- "commonName": {
- "type": "string"
- },
- "names": {
- "type": "object",
- "additionalProperties": {
- "type": "object",
- "required": [
- "value"
- ],
- "properties": {
- "value": {
- "oneOf": [
- {
- "type": "string"
- },
- {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- ]
- }
- }
- }
- }
- }
+ "description": "Identify signer method"
},
- "configPath": {
+ "signMethod": {
"type": "string",
- "default": "",
- "description": "Path of config files of CFSSL"
+ "nullable": true,
+ "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken"
+ },
+ "identify": {
+ "type": "string",
+ "nullable": true,
+ "description": "Identify value, i.e. the signer email, account or phone number"
}
}
}
@@ -10300,6 +10202,15 @@
"default": "v1"
}
},
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of LibreSign file",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -10333,7 +10244,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/EngineHandlerResponse"
+ "$ref": "#/components/schemas/MessageResponse"
}
}
}
@@ -10342,8 +10253,8 @@
}
}
},
- "401": {
- "description": "Account not found",
+ "422": {
+ "description": "Error",
"content": {
"application/json": {
"schema": {
@@ -10375,15 +10286,15 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/engine": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/code": {
"post": {
- "operationId": "admin-set-certificate-engine",
- "summary": "Set certificate engine",
- "description": "Sets the certificate engine (openssl, cfssl, or none) and automatically configures identify_methods when needed\nThis endpoint requires admin access",
+ "operationId": "sign_file-get-code-using-file-id",
+ "summary": "Get code to sign the document using FileID",
"tags": [
- "admin"
+ "sign_file"
],
"security": [
+ {},
{
"bearer_auth": []
},
@@ -10392,18 +10303,30 @@
}
],
"requestBody": {
- "required": true,
+ "required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
- "required": [
- "engine"
- ],
"properties": {
- "engine": {
+ "identifyMethod": {
"type": "string",
- "description": "The certificate engine to use (openssl, cfssl, or none)"
+ "nullable": true,
+ "enum": [
+ "account",
+ "email"
+ ],
+ "description": "Identify signer method"
+ },
+ "signMethod": {
+ "type": "string",
+ "nullable": true,
+ "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken"
+ },
+ "identify": {
+ "type": "string",
+ "nullable": true,
+ "description": "Identify value, i.e. the signer email, account or phone number"
}
}
}
@@ -10423,6 +10346,16 @@
"default": "v1"
}
},
+ {
+ "name": "fileId",
+ "in": "path",
+ "description": "Id of LibreSign file",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -10456,7 +10389,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/CertificateEngineConfigResponse"
+ "$ref": "#/components/schemas/MessageResponse"
}
}
}
@@ -10465,8 +10398,8 @@
}
}
},
- "400": {
- "description": "Invalid engine",
+ "422": {
+ "description": "Error",
"content": {
"application/json": {
"schema": {
@@ -10498,15 +10431,15 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate": {
- "get": {
- "operationId": "admin-load-certificate",
- "summary": "Load certificate data",
- "description": "Return all data of root certificate and a field called `generated` with a boolean value.\nThis endpoint requires admin access",
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements": {
+ "post": {
+ "operationId": "signature_elements-create-signature-element",
+ "summary": "Create signature element",
"tags": [
- "admin"
+ "signature_elements"
],
"security": [
+ {},
{
"bearer_auth": []
},
@@ -10514,6 +10447,28 @@
"basic_auth": []
}
],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "elements"
+ ],
+ "properties": {
+ "elements": {
+ "type": "object",
+ "description": "Element object",
+ "additionalProperties": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"parameters": [
{
"name": "apiVersion",
@@ -10560,7 +10515,37 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/CetificateDataGenerated"
+ "$ref": "#/components/schemas/UserElementsMessageResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Invalid data",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/MessageResponse"
}
}
}
@@ -10570,17 +10555,15 @@
}
}
}
- }
- },
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/configure-check": {
+ },
"get": {
- "operationId": "admin-configure-check",
- "summary": "Check the configuration of LibreSign",
- "description": "Return the status of necessary configuration and tips to fix the problems.\nThis endpoint requires admin access",
+ "operationId": "signature_elements-get-signature-elements",
+ "summary": "Get signature elements",
"tags": [
- "admin"
+ "signature_elements"
],
"security": [
+ {},
{
"bearer_auth": []
},
@@ -10634,7 +10617,37 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ConfigureChecksResponse"
+ "$ref": "#/components/schemas/UserElementsResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Invalid data",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/MessageResponse"
}
}
}
@@ -10646,15 +10659,15 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/disable-hate-limit": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/preview/{nodeId}": {
"get": {
- "operationId": "admin-disable-hate-limit",
- "summary": "Disable hate limit to current session",
- "description": "This will disable hate limit to current session.\nThis endpoint requires admin access",
+ "operationId": "signature_elements-get-signature-element-preview",
+ "summary": "Get preview of signature elements of",
"tags": [
- "admin"
+ "signature_elements"
],
"security": [
+ {},
{
"bearer_auth": []
},
@@ -10675,6 +10688,16 @@
"default": "v1"
}
},
+ {
+ "name": "nodeId",
+ "in": "path",
+ "description": "Node id of a Nextcloud file",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -10689,6 +10712,17 @@
"responses": {
"200": {
"description": "OK",
+ "content": {
+ "*/*": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Invalid data",
"content": {
"application/json": {
"schema": {
@@ -10707,7 +10741,9 @@
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
- "data": {}
+ "data": {
+ "type": "object"
+ }
}
}
}
@@ -10718,13 +10754,12 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-background": {
- "post": {
- "operationId": "admin-signature-background-save",
- "summary": "Add custom background image",
- "description": "This endpoint requires admin access",
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/{nodeId}": {
+ "get": {
+ "operationId": "signature_elements-get-signature-element",
+ "summary": "Get signature element of signer",
"tags": [
- "admin"
+ "signature_elements"
],
"security": [
{
@@ -10747,6 +10782,16 @@
"default": "v1"
}
},
+ {
+ "name": "nodeId",
+ "in": "path",
+ "description": "Node id of a Nextcloud file",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -10780,7 +10825,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/SuccessStatusResponse"
+ "$ref": "#/components/schemas/UserElement"
}
}
}
@@ -10789,8 +10834,8 @@
}
}
},
- "422": {
- "description": "Error",
+ "404": {
+ "description": "Invalid data",
"content": {
"application/json": {
"schema": {
@@ -10810,7 +10855,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/FailureStatusResponse"
+ "$ref": "#/components/schemas/MessageResponse"
}
}
}
@@ -10821,14 +10866,14 @@
}
}
},
- "get": {
- "operationId": "admin-signature-background-get",
- "summary": "Get custom background image",
- "description": "This endpoint requires admin access",
+ "patch": {
+ "operationId": "signature_elements-patch-signature-element",
+ "summary": "Update signature element",
"tags": [
- "admin"
+ "signature_elements"
],
"security": [
+ {},
{
"bearer_auth": []
},
@@ -10836,59 +10881,31 @@
"basic_auth": []
}
],
- "parameters": [
- {
- "name": "apiVersion",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "enum": [
- "v1"
- ],
- "default": "v1"
- }
- },
- {
- "name": "OCS-APIRequest",
- "in": "header",
- "description": "Required to be true for the API request to pass",
- "required": true,
- "schema": {
- "type": "boolean",
- "default": true
- }
- }
- ],
- "responses": {
- "200": {
- "description": "Image returned",
- "content": {
- "*/*": {
- "schema": {
- "type": "string",
- "format": "binary"
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "default": "",
+ "description": "The type of signature element"
+ },
+ "file": {
+ "type": "object",
+ "default": {},
+ "description": "Element object",
+ "additionalProperties": {
+ "type": "object"
+ }
+ }
}
}
}
}
- }
- },
- "patch": {
- "operationId": "admin-signature-background-reset",
- "summary": "Reset the background image to be the default of LibreSign",
- "description": "This endpoint requires admin access",
- "tags": [
- "admin"
- ],
- "security": [
- {
- "bearer_auth": []
- },
- {
- "basic_auth": []
- }
- ],
+ },
"parameters": [
{
"name": "apiVersion",
@@ -10902,6 +10919,16 @@
"default": "v1"
}
},
+ {
+ "name": "nodeId",
+ "in": "path",
+ "description": "Node id of a Nextcloud file",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -10915,7 +10942,7 @@
],
"responses": {
"200": {
- "description": "Image reseted to default",
+ "description": "OK",
"content": {
"application/json": {
"schema": {
@@ -10935,7 +10962,37 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/SuccessStatusResponse"
+ "$ref": "#/components/schemas/UserElementsMessageResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/MessageResponse"
}
}
}
@@ -10947,13 +11004,13 @@
}
},
"delete": {
- "operationId": "admin-signature-background-delete",
- "summary": "Delete background image",
- "description": "This endpoint requires admin access",
+ "operationId": "signature_elements-delete-signature-element",
+ "summary": "Delete signature element",
"tags": [
- "admin"
+ "signature_elements"
],
"security": [
+ {},
{
"bearer_auth": []
},
@@ -10974,6 +11031,16 @@
"default": "v1"
}
},
+ {
+ "name": "nodeId",
+ "in": "path",
+ "description": "Node id of a Nextcloud file",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -10987,7 +11054,7 @@
],
"responses": {
"200": {
- "description": "Deleted with success",
+ "description": "OK",
"content": {
"application/json": {
"schema": {
@@ -11007,7 +11074,37 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/SuccessStatusResponse"
+ "$ref": "#/components/schemas/MessageResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/MessageResponse"
}
}
}
@@ -11019,10 +11116,10 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-text": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/cfssl": {
"post": {
- "operationId": "admin-signature-text-save",
- "summary": "Save signature text service",
+ "operationId": "admin-generate-certificate-cfssl",
+ "summary": "Generate certificate using CFSSL engine",
"description": "This endpoint requires admin access",
"tags": [
"admin"
@@ -11042,41 +11139,55 @@
"schema": {
"type": "object",
"required": [
- "template"
+ "rootCert"
],
"properties": {
- "template": {
- "type": "string",
- "description": "Template to signature text"
- },
- "templateFontSize": {
- "type": "number",
- "format": "double",
- "default": 10,
- "description": "Font size used when print the parsed text of this template at PDF file"
- },
- "signatureFontSize": {
- "type": "number",
- "format": "double",
- "default": 20,
- "description": "Font size used when the signature mode is SIGNAME_AND_DESCRIPTION"
- },
- "signatureWidth": {
- "type": "number",
- "format": "double",
- "default": 350,
- "description": "Signature box width, minimum 1"
+ "rootCert": {
+ "type": "object",
+ "description": "fields of root certificate",
+ "required": [
+ "commonName",
+ "names"
+ ],
+ "properties": {
+ "commonName": {
+ "type": "string"
+ },
+ "names": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "required": [
+ "value"
+ ],
+ "properties": {
+ "value": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
},
- "signatureHeight": {
- "type": "number",
- "format": "double",
- "default": 100,
- "description": "Signature box height, minimum 1"
+ "cfsslUri": {
+ "type": "string",
+ "default": "",
+ "description": "URI of CFSSL API"
},
- "renderMode": {
+ "configPath": {
"type": "string",
- "default": "GRAPHIC_AND_DESCRIPTION",
- "description": "Signature render mode"
+ "default": "",
+ "description": "Path of config files of CFSSL"
}
}
}
@@ -11129,7 +11240,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/SignatureTextSettingsResponse"
+ "$ref": "#/components/schemas/EngineHandlerResponse"
}
}
}
@@ -11138,8 +11249,8 @@
}
}
},
- "400": {
- "description": "Bad request",
+ "401": {
+ "description": "Account not found",
"content": {
"application/json": {
"schema": {
@@ -11159,7 +11270,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ErrorResponse"
+ "$ref": "#/components/schemas/MessageResponse"
}
}
}
@@ -11169,10 +11280,12 @@
}
}
}
- },
- "get": {
- "operationId": "admin-signature-text-get",
- "summary": "Get parsed signature text service",
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/openssl": {
+ "post": {
+ "operationId": "admin-generate-certificate-open-ssl",
+ "summary": "Generate certificate using OpenSSL engine",
"description": "This endpoint requires admin access",
"tags": [
"admin"
@@ -11185,35 +11298,74 @@
"basic_auth": []
}
],
- "parameters": [
- {
- "name": "apiVersion",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string",
- "enum": [
- "v1"
- ],
- "default": "v1"
- }
- },
- {
- "name": "template",
- "in": "query",
- "description": "Template to signature text",
- "schema": {
- "type": "string",
- "default": ""
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "rootCert"
+ ],
+ "properties": {
+ "rootCert": {
+ "type": "object",
+ "description": "fields of root certificate",
+ "required": [
+ "commonName",
+ "names"
+ ],
+ "properties": {
+ "commonName": {
+ "type": "string"
+ },
+ "names": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "required": [
+ "value"
+ ],
+ "properties": {
+ "value": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "configPath": {
+ "type": "string",
+ "default": "",
+ "description": "Path of config files of CFSSL"
+ }
+ }
+ }
}
- },
+ }
+ },
+ "parameters": [
{
- "name": "context",
- "in": "query",
- "description": "Context for parsing the template",
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
"schema": {
"type": "string",
- "default": ""
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
}
},
{
@@ -11249,7 +11401,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/SignatureTextSettingsResponse"
+ "$ref": "#/components/schemas/EngineHandlerResponse"
}
}
}
@@ -11258,8 +11410,8 @@
}
}
},
- "400": {
- "description": "Bad request",
+ "401": {
+ "description": "Account not found",
"content": {
"application/json": {
"schema": {
@@ -11279,7 +11431,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ErrorResponse"
+ "$ref": "#/components/schemas/MessageResponse"
}
}
}
@@ -11291,11 +11443,11 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-settings": {
- "get": {
- "operationId": "admin-get-signature-settings",
- "summary": "Get signature settings",
- "description": "This endpoint requires admin access",
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/engine": {
+ "post": {
+ "operationId": "admin-set-certificate-engine",
+ "summary": "Set certificate engine",
+ "description": "Sets the certificate engine (openssl, cfssl, or none) and automatically configures identify_methods when needed\nThis endpoint requires admin access",
"tags": [
"admin"
],
@@ -11307,6 +11459,25 @@
"basic_auth": []
}
],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "engine"
+ ],
+ "properties": {
+ "engine": {
+ "type": "string",
+ "description": "The certificate engine to use (openssl, cfssl, or none)"
+ }
+ }
+ }
+ }
+ }
+ },
"parameters": [
{
"name": "apiVersion",
@@ -11353,7 +11524,37 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/SignatureTemplateSettingsResponse"
+ "$ref": "#/components/schemas/CertificateEngineConfigResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid engine",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/MessageResponse"
}
}
}
@@ -11365,11 +11566,11 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signer-name": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate": {
"get": {
- "operationId": "admin-signer-name",
- "summary": "Convert signer name as image",
- "description": "This endpoint requires admin access",
+ "operationId": "admin-load-certificate",
+ "summary": "Load certificate data",
+ "description": "Return all data of root certificate and a field called `generated` with a boolean value.\nThis endpoint requires admin access",
"tags": [
"admin"
],
@@ -11394,67 +11595,6 @@
"default": "v1"
}
},
- {
- "name": "width",
- "in": "query",
- "description": "Image width,",
- "required": true,
- "schema": {
- "type": "integer",
- "format": "int64"
- }
- },
- {
- "name": "height",
- "in": "query",
- "description": "Image height",
- "required": true,
- "schema": {
- "type": "integer",
- "format": "int64"
- }
- },
- {
- "name": "text",
- "in": "query",
- "description": "Text to be added to image",
- "required": true,
- "schema": {
- "type": "string"
- }
- },
- {
- "name": "fontSize",
- "in": "query",
- "description": "Font size of text",
- "required": true,
- "schema": {
- "type": "number",
- "format": "double"
- }
- },
- {
- "name": "isDarkTheme",
- "in": "query",
- "description": "Color of text, white if is tark theme and black if not",
- "required": true,
- "schema": {
- "type": "integer",
- "enum": [
- 0,
- 1
- ]
- }
- },
- {
- "name": "align",
- "in": "query",
- "description": "Align of text: left, center or right",
- "required": true,
- "schema": {
- "type": "string"
- }
- },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -11469,27 +11609,6 @@
"responses": {
"200": {
"description": "OK",
- "headers": {
- "Content-Disposition": {
- "schema": {
- "type": "string",
- "enum": [
- "inline; filename=\"signer-name.png\""
- ]
- }
- }
- },
- "content": {
- "image/png": {
- "schema": {
- "type": "string",
- "format": "binary"
- }
- }
- }
- },
- "400": {
- "description": "Bad request",
"content": {
"application/json": {
"schema": {
@@ -11509,7 +11628,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ErrorResponse"
+ "$ref": "#/components/schemas/CetificateDataGenerated"
}
}
}
@@ -11521,11 +11640,11 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy": {
- "post": {
- "operationId": "admin-save-certificate-policy",
- "summary": "Update certificate policy of this instance",
- "description": "This endpoint requires admin access",
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/configure-check": {
+ "get": {
+ "operationId": "admin-configure-check",
+ "summary": "Check the configuration of LibreSign",
+ "description": "Return the status of necessary configuration and tips to fix the problems.\nThis endpoint requires admin access",
"tags": [
"admin"
],
@@ -11583,37 +11702,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/CertificatePolicyResponse"
- }
- }
- }
- }
- }
- }
- }
- },
- "422": {
- "description": "Not found",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "$ref": "#/components/schemas/FailureStatusResponse"
+ "$ref": "#/components/schemas/ConfigureChecksResponse"
}
}
}
@@ -11623,11 +11712,13 @@
}
}
}
- },
- "delete": {
- "operationId": "admin-delete-certificate-policy",
- "summary": "Delete certificate policy of this instance",
- "description": "This endpoint requires admin access",
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/disable-hate-limit": {
+ "get": {
+ "operationId": "admin-disable-hate-limit",
+ "summary": "Disable hate limit to current session",
+ "description": "This will disable hate limit to current session.\nThis endpoint requires admin access",
"tags": [
"admin"
],
@@ -11684,9 +11775,7 @@
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
- "data": {
- "type": "object"
- }
+ "data": {}
}
}
}
@@ -11697,10 +11786,10 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-background": {
"post": {
- "operationId": "admin-updateoid",
- "summary": "Update OID",
+ "operationId": "admin-signature-background-save",
+ "summary": "Add custom background image",
"description": "This endpoint requires admin access",
"tags": [
"admin"
@@ -11713,25 +11802,6 @@
"basic_auth": []
}
],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "oid"
- ],
- "properties": {
- "oid": {
- "type": "string",
- "description": "OID is a unique numeric identifier for certificate policies in digital certificates."
- }
- }
- }
- }
- }
- },
"parameters": [
{
"name": "apiVersion",
@@ -11788,7 +11858,7 @@
}
},
"422": {
- "description": "Validation error",
+ "description": "Error",
"content": {
"application/json": {
"schema": {
@@ -11818,12 +11888,10 @@
}
}
}
- }
- },
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/reminder": {
+ },
"get": {
- "operationId": "admin-reminder-fetch",
- "summary": "Get reminder settings",
+ "operationId": "admin-signature-background-get",
+ "summary": "Get custom background image",
"description": "This endpoint requires admin access",
"tags": [
"admin"
@@ -11862,7 +11930,60 @@
],
"responses": {
"200": {
- "description": "OK",
+ "description": "Image returned",
+ "content": {
+ "*/*": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ }
+ }
+ },
+ "patch": {
+ "operationId": "admin-signature-background-reset",
+ "summary": "Reset the background image to be the default of LibreSign",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "admin"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Image reseted to default",
"content": {
"application/json": {
"schema": {
@@ -11882,7 +12003,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ReminderSettings"
+ "$ref": "#/components/schemas/SuccessStatusResponse"
}
}
}
@@ -11893,9 +12014,9 @@
}
}
},
- "post": {
- "operationId": "admin-reminder-save",
- "summary": "Save reminder",
+ "delete": {
+ "operationId": "admin-signature-background-delete",
+ "summary": "Delete background image",
"description": "This endpoint requires admin access",
"tags": [
"admin"
@@ -11908,43 +12029,6 @@
"basic_auth": []
}
],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "daysBefore",
- "daysBetween",
- "max",
- "sendTimer"
- ],
- "properties": {
- "daysBefore": {
- "type": "integer",
- "format": "int64",
- "description": "First reminder after (days)"
- },
- "daysBetween": {
- "type": "integer",
- "format": "int64",
- "description": "Days between reminders"
- },
- "max": {
- "type": "integer",
- "format": "int64",
- "description": "Max reminders per signer"
- },
- "sendTimer": {
- "type": "string",
- "description": "Send time (HH:mm)"
- }
- }
- }
- }
- }
- },
"parameters": [
{
"name": "apiVersion",
@@ -11971,7 +12055,7 @@
],
"responses": {
"200": {
- "description": "OK",
+ "description": "Deleted with success",
"content": {
"application/json": {
"schema": {
@@ -11991,7 +12075,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ReminderSettings"
+ "$ref": "#/components/schemas/SuccessStatusResponse"
}
}
}
@@ -12003,11 +12087,11 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/tsa": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-text": {
"post": {
- "operationId": "admin-set-tsa-config",
- "summary": "Set TSA configuration values with proper sensitive data handling",
- "description": "Only saves configuration if tsa_url is provided. Automatically manages username/password fields based on authentication type.\nThis endpoint requires admin access",
+ "operationId": "admin-signature-text-save",
+ "summary": "Save signature text service",
+ "description": "This endpoint requires admin access",
"tags": [
"admin"
],
@@ -12020,36 +12104,47 @@
}
],
"requestBody": {
- "required": false,
+ "required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
+ "required": [
+ "template"
+ ],
"properties": {
- "tsa_url": {
+ "template": {
"type": "string",
- "nullable": true,
- "description": "TSA server URL (required for saving)"
+ "description": "Template to signature text"
},
- "tsa_policy_oid": {
- "type": "string",
- "nullable": true,
- "description": "TSA policy OID"
+ "templateFontSize": {
+ "type": "number",
+ "format": "double",
+ "default": 10,
+ "description": "Font size used when print the parsed text of this template at PDF file"
},
- "tsa_auth_type": {
- "type": "string",
- "nullable": true,
- "description": "Authentication type (none|basic), defaults to 'none'"
+ "signatureFontSize": {
+ "type": "number",
+ "format": "double",
+ "default": 20,
+ "description": "Font size used when the signature mode is SIGNAME_AND_DESCRIPTION"
},
- "tsa_username": {
- "type": "string",
- "nullable": true,
- "description": "Username for basic authentication"
+ "signatureWidth": {
+ "type": "number",
+ "format": "double",
+ "default": 350,
+ "description": "Signature box width, minimum 1"
},
- "tsa_password": {
+ "signatureHeight": {
+ "type": "number",
+ "format": "double",
+ "default": 100,
+ "description": "Signature box height, minimum 1"
+ },
+ "renderMode": {
"type": "string",
- "nullable": true,
- "description": "Password for basic authentication (stored as sensitive data)"
+ "default": "GRAPHIC_AND_DESCRIPTION",
+ "description": "Signature render mode"
}
}
}
@@ -12102,7 +12197,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/SuccessStatusResponse"
+ "$ref": "#/components/schemas/SignatureTextSettingsResponse"
}
}
}
@@ -12112,7 +12207,7 @@
}
},
"400": {
- "description": "Validation error",
+ "description": "Bad request",
"content": {
"application/json": {
"schema": {
@@ -12132,7 +12227,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ErrorStatusResponse"
+ "$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -12143,10 +12238,10 @@
}
}
},
- "delete": {
- "operationId": "admin-delete-tsa-config",
- "summary": "Delete TSA configuration",
- "description": "Delete all TSA configuration fields from the application settings.\nThis endpoint requires admin access",
+ "get": {
+ "operationId": "admin-signature-text-get",
+ "summary": "Get parsed signature text service",
+ "description": "This endpoint requires admin access",
"tags": [
"admin"
],
@@ -12172,10 +12267,28 @@
}
},
{
- "name": "OCS-APIRequest",
- "in": "header",
- "description": "Required to be true for the API request to pass",
- "required": true,
+ "name": "template",
+ "in": "query",
+ "description": "Template to signature text",
+ "schema": {
+ "type": "string",
+ "default": ""
+ }
+ },
+ {
+ "name": "context",
+ "in": "query",
+ "description": "Context for parsing the template",
+ "schema": {
+ "type": "string",
+ "default": ""
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
"schema": {
"type": "boolean",
"default": true
@@ -12204,7 +12317,37 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/SuccessStatusResponse"
+ "$ref": "#/components/schemas/SignatureTextSettingsResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -12216,11 +12359,11 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/footer-template": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-settings": {
"get": {
- "operationId": "admin-get-footer-template",
- "summary": "Get footer template",
- "description": "Returns the current footer template if set, otherwise returns the default template.\nThis endpoint requires admin access",
+ "operationId": "admin-get-signature-settings",
+ "summary": "Get signature settings",
+ "description": "This endpoint requires admin access",
"tags": [
"admin"
],
@@ -12278,7 +12421,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/FooterTemplateResponse"
+ "$ref": "#/components/schemas/SignatureTemplateSettingsResponse"
}
}
}
@@ -12288,11 +12431,13 @@
}
}
}
- },
- "post": {
- "operationId": "admin-save-footer-template",
- "summary": "Save footer template and render preview",
- "description": "Saves the footer template and returns the rendered PDF preview.\nThis endpoint requires admin access",
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signer-name": {
+ "get": {
+ "operationId": "admin-signer-name",
+ "summary": "Convert signer name as image",
+ "description": "This endpoint requires admin access",
"tags": [
"admin"
],
@@ -12304,35 +12449,6 @@
"basic_auth": []
}
],
- "requestBody": {
- "required": false,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "template": {
- "type": "string",
- "default": "",
- "description": "The Twig template to save (empty to reset to default)"
- },
- "width": {
- "type": "integer",
- "format": "int64",
- "default": 595,
- "description": "Width of preview in points (default: 595 - A4 width)"
- },
- "height": {
- "type": "integer",
- "format": "int64",
- "default": 50,
- "description": "Height of preview in points (default: 50)"
- }
- }
- }
- }
- }
- },
"parameters": [
{
"name": "apiVersion",
@@ -12346,6 +12462,67 @@
"default": "v1"
}
},
+ {
+ "name": "width",
+ "in": "query",
+ "description": "Image width,",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ {
+ "name": "height",
+ "in": "query",
+ "description": "Image height",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ {
+ "name": "text",
+ "in": "query",
+ "description": "Text to be added to image",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "fontSize",
+ "in": "query",
+ "description": "Font size of text",
+ "required": true,
+ "schema": {
+ "type": "number",
+ "format": "double"
+ }
+ },
+ {
+ "name": "isDarkTheme",
+ "in": "query",
+ "description": "Color of text, white if is tark theme and black if not",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "enum": [
+ 0,
+ 1
+ ]
+ }
+ },
+ {
+ "name": "align",
+ "in": "query",
+ "description": "Align of text: left, center or right",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -12360,8 +12537,18 @@
"responses": {
"200": {
"description": "OK",
+ "headers": {
+ "Content-Disposition": {
+ "schema": {
+ "type": "string",
+ "enum": [
+ "inline; filename=\"signer-name.png\""
+ ]
+ }
+ }
+ },
"content": {
- "application/pdf": {
+ "image/png": {
"schema": {
"type": "string",
"format": "binary"
@@ -12402,11 +12589,11 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signing-mode/config": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy": {
"post": {
- "operationId": "admin-set-signing-mode-config",
- "summary": "Set signing mode configuration",
- "description": "Configure whether document signing should be synchronous or asynchronous\nThis endpoint requires admin access",
+ "operationId": "admin-save-certificate-policy",
+ "summary": "Update certificate policy of this instance",
+ "description": "This endpoint requires admin access",
"tags": [
"admin"
],
@@ -12418,41 +12605,1421 @@
"basic_auth": []
}
],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "mode"
- ],
- "properties": {
- "mode": {
- "type": "string",
- "description": "Signing mode: \"sync\" or \"async\""
- },
- "workerType": {
- "type": "string",
- "nullable": true,
- "description": "Worker type when async: \"local\" or \"external\" (optional)"
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/CertificatePolicyResponse"
+ }
+ }
+ }
}
}
}
}
- }
- },
- "parameters": [
+ },
+ "422": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/FailureStatusResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "operationId": "admin-delete-certificate-policy",
+ "summary": "Delete certificate policy of this instance",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "admin"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": {
+ "post": {
+ "operationId": "admin-updateoid",
+ "summary": "Update OID",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "admin"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "oid"
+ ],
+ "properties": {
+ "oid": {
+ "type": "string",
+ "description": "OID is a unique numeric identifier for certificate policies in digital certificates."
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/SuccessStatusResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/FailureStatusResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/reminder": {
+ "get": {
+ "operationId": "admin-reminder-fetch",
+ "summary": "Get reminder settings",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "admin"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ReminderSettings"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "operationId": "admin-reminder-save",
+ "summary": "Save reminder",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "admin"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "daysBefore",
+ "daysBetween",
+ "max",
+ "sendTimer"
+ ],
+ "properties": {
+ "daysBefore": {
+ "type": "integer",
+ "format": "int64",
+ "description": "First reminder after (days)"
+ },
+ "daysBetween": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Days between reminders"
+ },
+ "max": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Max reminders per signer"
+ },
+ "sendTimer": {
+ "type": "string",
+ "description": "Send time (HH:mm)"
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ReminderSettings"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/tsa": {
+ "post": {
+ "operationId": "admin-set-tsa-config",
+ "summary": "Set TSA configuration values with proper sensitive data handling",
+ "description": "Only saves configuration if tsa_url is provided. Automatically manages username/password fields based on authentication type.\nThis endpoint requires admin access",
+ "tags": [
+ "admin"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "tsa_url": {
+ "type": "string",
+ "nullable": true,
+ "description": "TSA server URL (required for saving)"
+ },
+ "tsa_policy_oid": {
+ "type": "string",
+ "nullable": true,
+ "description": "TSA policy OID"
+ },
+ "tsa_auth_type": {
+ "type": "string",
+ "nullable": true,
+ "description": "Authentication type (none|basic), defaults to 'none'"
+ },
+ "tsa_username": {
+ "type": "string",
+ "nullable": true,
+ "description": "Username for basic authentication"
+ },
+ "tsa_password": {
+ "type": "string",
+ "nullable": true,
+ "description": "Password for basic authentication (stored as sensitive data)"
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/SuccessStatusResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Validation error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorStatusResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "operationId": "admin-delete-tsa-config",
+ "summary": "Delete TSA configuration",
+ "description": "Delete all TSA configuration fields from the application settings.\nThis endpoint requires admin access",
+ "tags": [
+ "admin"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/SuccessStatusResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/footer-template": {
+ "get": {
+ "operationId": "admin-get-footer-template",
+ "summary": "Get footer template",
+ "description": "Returns the current footer template if set, otherwise returns the default template.\nThis endpoint requires admin access",
+ "tags": [
+ "admin"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/FooterTemplateResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "operationId": "admin-save-footer-template",
+ "summary": "Save footer template and render preview",
+ "description": "Saves the footer template and returns the rendered PDF preview.\nThis endpoint requires admin access",
+ "tags": [
+ "admin"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "template": {
+ "type": "string",
+ "default": "",
+ "description": "The Twig template to save (empty to reset to default)"
+ },
+ "width": {
+ "type": "integer",
+ "format": "int64",
+ "default": 595,
+ "description": "Width of preview in points (default: 595 - A4 width)"
+ },
+ "height": {
+ "type": "integer",
+ "format": "int64",
+ "default": 50,
+ "description": "Height of preview in points (default: 50)"
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/pdf": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signing-mode/config": {
+ "post": {
+ "operationId": "admin-set-signing-mode-config",
+ "summary": "Set signing mode configuration",
+ "description": "Configure whether document signing should be synchronous or asynchronous\nThis endpoint requires admin access",
+ "tags": [
+ "admin"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "mode"
+ ],
+ "properties": {
+ "mode": {
+ "type": "string",
+ "description": "Signing mode: \"sync\" or \"async\""
+ },
+ "workerType": {
+ "type": "string",
+ "nullable": true,
+ "description": "Worker type when async: \"local\" or \"external\" (optional)"
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Settings saved",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/MessageResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid parameters",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": {
+ "post": {
+ "operationId": "admin-set-doc-mdp-config",
+ "summary": "Configure DocMDP signature restrictions",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "admin"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "enabled"
+ ],
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "description": "Whether to enable DocMDP restrictions"
+ },
+ "defaultLevel": {
+ "type": "integer",
+ "format": "int64",
+ "default": 2,
+ "description": "DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations)"
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Configuration saved successfully",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/MessageResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid DocMDP level provided",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/active-signings": {
+ "get": {
+ "operationId": "admin-get-active-signings",
+ "summary": "Get list of files currently being signed (status = SIGNING_IN_PROGRESS)",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "admin"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of active signings",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ActiveSigningsResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/list": {
+ "get": {
+ "operationId": "crl_api-list",
+ "summary": "List CRL entries with pagination and filters",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "crl_api"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "page",
+ "in": "query",
+ "description": "Page number (1-based)",
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "nullable": true
+ }
+ },
+ {
+ "name": "length",
+ "in": "query",
+ "description": "Number of items per page",
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "nullable": true
+ }
+ },
+ {
+ "name": "status",
+ "in": "query",
+ "description": "Filter by status (issued, revoked, expired)",
+ "schema": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ {
+ "name": "engine",
+ "in": "query",
+ "description": "Filter by engine type",
+ "schema": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ {
+ "name": "instanceId",
+ "in": "query",
+ "description": "Filter by instance ID",
+ "schema": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ {
+ "name": "generation",
+ "in": "query",
+ "description": "Filter by generation",
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "nullable": true
+ }
+ },
+ {
+ "name": "owner",
+ "in": "query",
+ "description": "Filter by owner",
+ "schema": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ {
+ "name": "serialNumber",
+ "in": "query",
+ "description": "Filter by serial number (partial match)",
+ "schema": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ {
+ "name": "revokedBy",
+ "in": "query",
+ "description": "Filter by who revoked the certificate",
+ "schema": {
+ "type": "string",
+ "nullable": true
+ }
+ },
{
- "name": "apiVersion",
- "in": "path",
- "required": true,
+ "name": "sortBy",
+ "in": "query",
+ "description": "Sort field (e.g., 'revoked_at', 'issued_at', 'serial_number')",
"schema": {
"type": "string",
- "enum": [
- "v1"
- ],
- "default": "v1"
+ "nullable": true
+ }
+ },
+ {
+ "name": "sortOrder",
+ "in": "query",
+ "description": "Sort order (ASC or DESC)",
+ "schema": {
+ "type": "string",
+ "nullable": true
}
},
{
@@ -12468,67 +14035,7 @@
],
"responses": {
"200": {
- "description": "Settings saved",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "$ref": "#/components/schemas/MessageResponse"
- }
- }
- }
- }
- }
- }
- }
- },
- "400": {
- "description": "Invalid parameters",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
- }
- }
- }
- }
- }
- }
- },
- "500": {
- "description": "Internal server error",
+ "description": "CRL entries retrieved successfully",
"content": {
"application/json": {
"schema": {
@@ -12548,7 +14055,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ErrorResponse"
+ "$ref": "#/components/schemas/CrlListResponse"
}
}
}
@@ -12560,13 +14067,13 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-flow/config": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/revoke": {
"post": {
- "operationId": "admin-set-signature-flow-config",
- "summary": "Set signature flow configuration",
+ "operationId": "crl_api-revoke",
+ "summary": "Revoke a certificate by serial number",
"description": "This endpoint requires admin access",
"tags": [
- "admin"
+ "crl_api"
],
"security": [
{
@@ -12583,17 +14090,23 @@
"schema": {
"type": "object",
"required": [
- "enabled"
+ "serialNumber"
],
"properties": {
- "enabled": {
- "type": "boolean",
- "description": "Whether to force a signature flow for all documents"
+ "serialNumber": {
+ "type": "string",
+ "description": "Certificate serial number to revoke"
},
- "mode": {
+ "reasonCode": {
+ "type": "integer",
+ "format": "int64",
+ "nullable": true,
+ "description": "Revocation reason code (0-10, see RFC 5280)"
+ },
+ "reasonText": {
"type": "string",
"nullable": true,
- "description": "Signature flow mode: 'parallel' or 'ordered_numeric' (only used when enabled is true)"
+ "description": "Optional text describing the reason"
}
}
}
@@ -12626,7 +14139,7 @@
],
"responses": {
"200": {
- "description": "Configuration saved successfully",
+ "description": "Certificate revoked successfully",
"content": {
"application/json": {
"schema": {
@@ -12646,7 +14159,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/MessageResponse"
+ "$ref": "#/components/schemas/CrlRevokeResponse"
}
}
}
@@ -12656,7 +14169,7 @@
}
},
"400": {
- "description": "Invalid signature flow mode provided",
+ "description": "Invalid parameters",
"content": {
"application/json": {
"schema": {
@@ -12676,7 +14189,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ErrorResponse"
+ "$ref": "#/components/schemas/CrlRevokeResponse"
}
}
}
@@ -12685,8 +14198,8 @@
}
}
},
- "500": {
- "description": "Internal server error",
+ "404": {
+ "description": "Certificate not found",
"content": {
"application/json": {
"schema": {
@@ -12706,7 +14219,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ErrorResponse"
+ "$ref": "#/components/schemas/CrlRevokeResponse"
}
}
}
@@ -12718,13 +14231,13 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": {
- "post": {
- "operationId": "admin-set-doc-mdp-config",
- "summary": "Configure DocMDP signature restrictions",
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": {
+ "get": {
+ "operationId": "policy-get-system",
+ "summary": "Read explicit system policy configuration",
"description": "This endpoint requires admin access",
"tags": [
- "admin"
+ "policy"
],
"security": [
{
@@ -12734,31 +14247,6 @@
"basic_auth": []
}
],
- "requestBody": {
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "enabled"
- ],
- "properties": {
- "enabled": {
- "type": "boolean",
- "description": "Whether to enable DocMDP restrictions"
- },
- "defaultLevel": {
- "type": "integer",
- "format": "int64",
- "default": 2,
- "description": "DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations)"
- }
- }
- }
- }
- }
- },
"parameters": [
{
"name": "apiVersion",
@@ -12772,6 +14260,16 @@
"default": "v1"
}
},
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to read from the system layer.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -12785,37 +14283,7 @@
],
"responses": {
"200": {
- "description": "Configuration saved successfully",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "$ref": "#/components/schemas/MessageResponse"
- }
- }
- }
- }
- }
- }
- }
- },
- "400": {
- "description": "Invalid DocMDP level provided",
+ "description": "OK",
"content": {
"application/json": {
"schema": {
@@ -12835,7 +14303,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ErrorResponse"
+ "$ref": "#/components/schemas/SystemPolicyResponse"
}
}
}
@@ -12843,56 +14311,61 @@
}
}
}
+ }
+ }
+ },
+ "post": {
+ "operationId": "policy-set-system",
+ "summary": "Save a system-level policy value",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "policy"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
},
- "500": {
- "description": "Internal server error",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "ocs"
- ],
- "properties": {
- "ocs": {
- "type": "object",
- "required": [
- "meta",
- "data"
- ],
- "properties": {
- "meta": {
- "$ref": "#/components/schemas/OCSMeta"
- },
- "data": {
- "$ref": "#/components/schemas/ErrorResponse"
- }
+ {
+ "basic_auth": []
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "nullable": true,
+ "description": "Policy value to persist. Null resets the policy to its default system value.",
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "type": "number",
+ "format": "double"
+ },
+ {
+ "type": "string"
}
- }
+ ]
+ },
+ "allowChildOverride": {
+ "type": "boolean",
+ "default": false,
+ "description": "Whether lower layers may override this system default."
}
}
}
}
}
- }
- }
- },
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/active-signings": {
- "get": {
- "operationId": "admin-get-active-signings",
- "summary": "Get list of files currently being signed (status = SIGNING_IN_PROGRESS)",
- "description": "This endpoint requires admin access",
- "tags": [
- "admin"
- ],
- "security": [
- {
- "bearer_auth": []
- },
- {
- "basic_auth": []
- }
- ],
+ },
"parameters": [
{
"name": "apiVersion",
@@ -12906,6 +14379,16 @@
"default": "v1"
}
},
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to persist at the system layer.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -12919,7 +14402,7 @@
],
"responses": {
"200": {
- "description": "List of active signings",
+ "description": "OK",
"content": {
"application/json": {
"schema": {
@@ -12939,7 +14422,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/ActiveSigningsResponse"
+ "$ref": "#/components/schemas/SystemPolicyWriteResponse"
}
}
}
@@ -12948,8 +14431,8 @@
}
}
},
- "500": {
- "description": "",
+ "400": {
+ "description": "Invalid policy value",
"content": {
"application/json": {
"schema": {
@@ -12981,13 +14464,13 @@
}
}
},
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/list": {
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{userId}/{policyKey}": {
"get": {
- "operationId": "crl_api-list",
- "summary": "List CRL entries with pagination and filters",
+ "operationId": "policy-get-user-policy-for-user",
+ "summary": "Read a user-level policy preference for a target user (admin scope)",
"description": "This endpoint requires admin access",
"tags": [
- "crl_api"
+ "policy"
],
"security": [
{
@@ -13011,105 +14494,23 @@
}
},
{
- "name": "page",
- "in": "query",
- "description": "Page number (1-based)",
- "schema": {
- "type": "integer",
- "format": "int64",
- "nullable": true
- }
- },
- {
- "name": "length",
- "in": "query",
- "description": "Number of items per page",
- "schema": {
- "type": "integer",
- "format": "int64",
- "nullable": true
- }
- },
- {
- "name": "status",
- "in": "query",
- "description": "Filter by status (issued, revoked, expired)",
- "schema": {
- "type": "string",
- "nullable": true
- }
- },
- {
- "name": "engine",
- "in": "query",
- "description": "Filter by engine type",
- "schema": {
- "type": "string",
- "nullable": true
- }
- },
- {
- "name": "instanceId",
- "in": "query",
- "description": "Filter by instance ID",
- "schema": {
- "type": "string",
- "nullable": true
- }
- },
- {
- "name": "generation",
- "in": "query",
- "description": "Filter by generation",
- "schema": {
- "type": "integer",
- "format": "int64",
- "nullable": true
- }
- },
- {
- "name": "owner",
- "in": "query",
- "description": "Filter by owner",
- "schema": {
- "type": "string",
- "nullable": true
- }
- },
- {
- "name": "serialNumber",
- "in": "query",
- "description": "Filter by serial number (partial match)",
- "schema": {
- "type": "string",
- "nullable": true
- }
- },
- {
- "name": "revokedBy",
- "in": "query",
- "description": "Filter by who revoked the certificate",
- "schema": {
- "type": "string",
- "nullable": true
- }
- },
- {
- "name": "sortBy",
- "in": "query",
- "description": "Sort field (e.g., 'revoked_at', 'issued_at', 'serial_number')",
+ "name": "userId",
+ "in": "path",
+ "description": "Target user identifier that receives the policy preference.",
+ "required": true,
"schema": {
"type": "string",
- "nullable": true
+ "pattern": "^[^/]+$"
}
},
{
- "name": "sortOrder",
- "in": "query",
- "description": "Sort order (ASC or DESC)",
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to read for the selected user.",
+ "required": true,
"schema": {
"type": "string",
- "nullable": true
+ "pattern": "^[a-z0-9_]+$"
}
},
{
@@ -13125,7 +14526,7 @@
],
"responses": {
"200": {
- "description": "CRL entries retrieved successfully",
+ "description": "OK",
"content": {
"application/json": {
"schema": {
@@ -13145,7 +14546,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/CrlListResponse"
+ "$ref": "#/components/schemas/UserPolicyResponse"
}
}
}
@@ -13155,15 +14556,13 @@
}
}
}
- }
- },
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/revoke": {
- "post": {
- "operationId": "crl_api-revoke",
- "summary": "Revoke a certificate by serial number",
+ },
+ "put": {
+ "operationId": "policy-set-user-policy-for-user",
+ "summary": "Save a user policy preference for a target user (admin scope)",
"description": "This endpoint requires admin access",
"tags": [
- "crl_api"
+ "policy"
],
"security": [
{
@@ -13174,29 +14573,31 @@
}
],
"requestBody": {
- "required": true,
+ "required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
- "required": [
- "serialNumber"
- ],
"properties": {
- "serialNumber": {
- "type": "string",
- "description": "Certificate serial number to revoke"
- },
- "reasonCode": {
- "type": "integer",
- "format": "int64",
- "nullable": true,
- "description": "Revocation reason code (0-10, see RFC 5280)"
- },
- "reasonText": {
- "type": "string",
+ "value": {
"nullable": true,
- "description": "Optional text describing the reason"
+ "description": "Policy value to persist as target user preference.",
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "type": "number",
+ "format": "double"
+ },
+ {
+ "type": "string"
+ }
+ ]
}
}
}
@@ -13216,6 +14617,26 @@
"default": "v1"
}
},
+ {
+ "name": "userId",
+ "in": "path",
+ "description": "Target user identifier that receives the policy preference.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[^/]+$"
+ }
+ },
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to persist for the target user.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
{
"name": "OCS-APIRequest",
"in": "header",
@@ -13229,7 +14650,7 @@
],
"responses": {
"200": {
- "description": "Certificate revoked successfully",
+ "description": "OK",
"content": {
"application/json": {
"schema": {
@@ -13249,7 +14670,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/CrlRevokeResponse"
+ "$ref": "#/components/schemas/UserPolicyWriteResponse"
}
}
}
@@ -13259,7 +14680,7 @@
}
},
"400": {
- "description": "Invalid parameters",
+ "description": "Invalid policy value",
"content": {
"application/json": {
"schema": {
@@ -13279,7 +14700,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/CrlRevokeResponse"
+ "$ref": "#/components/schemas/ErrorResponse"
}
}
}
@@ -13287,9 +14708,71 @@
}
}
}
+ }
+ }
+ },
+ "delete": {
+ "operationId": "policy-clear-user-policy-for-user",
+ "summary": "Clear a user policy preference for a target user (admin scope)",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "policy"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
},
- "404": {
- "description": "Certificate not found",
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "userId",
+ "in": "path",
+ "description": "Target user identifier that receives the policy preference removal.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[^/]+$"
+ }
+ },
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to clear for the target user.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
"content": {
"application/json": {
"schema": {
@@ -13309,7 +14792,7 @@
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
- "$ref": "#/components/schemas/CrlRevokeResponse"
+ "$ref": "#/components/schemas/UserPolicyWriteResponse"
}
}
}
diff --git a/openapi.json b/openapi.json
index e3fac16bee..c61a39b860 100644
--- a/openapi.json
+++ b/openapi.json
@@ -620,6 +620,101 @@
}
]
},
+ "EffectivePoliciesResponse": {
+ "type": "object",
+ "required": [
+ "policies"
+ ],
+ "properties": {
+ "policies": {
+ "type": "object",
+ "additionalProperties": {
+ "$ref": "#/components/schemas/EffectivePolicyState"
+ }
+ }
+ }
+ },
+ "EffectivePolicyResponse": {
+ "type": "object",
+ "required": [
+ "policy"
+ ],
+ "properties": {
+ "policy": {
+ "$ref": "#/components/schemas/EffectivePolicyState"
+ }
+ }
+ },
+ "EffectivePolicyState": {
+ "type": "object",
+ "required": [
+ "policyKey",
+ "effectiveValue",
+ "sourceScope",
+ "visible",
+ "editableByCurrentActor",
+ "allowedValues",
+ "canSaveAsUserDefault",
+ "canUseAsRequestOverride",
+ "preferenceWasCleared",
+ "blockedBy"
+ ],
+ "properties": {
+ "policyKey": {
+ "type": "string"
+ },
+ "effectiveValue": {
+ "$ref": "#/components/schemas/EffectivePolicyValue"
+ },
+ "sourceScope": {
+ "type": "string"
+ },
+ "visible": {
+ "type": "boolean"
+ },
+ "editableByCurrentActor": {
+ "type": "boolean"
+ },
+ "allowedValues": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EffectivePolicyValue"
+ }
+ },
+ "canSaveAsUserDefault": {
+ "type": "boolean"
+ },
+ "canUseAsRequestOverride": {
+ "type": "boolean"
+ },
+ "preferenceWasCleared": {
+ "type": "boolean"
+ },
+ "blockedBy": {
+ "type": "string",
+ "nullable": true
+ }
+ }
+ },
+ "EffectivePolicyValue": {
+ "nullable": true,
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "type": "number",
+ "format": "double"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
"ErrorItem": {
"type": "object",
"required": [
@@ -1023,6 +1118,69 @@
}
}
},
+ "GroupPolicyResponse": {
+ "type": "object",
+ "required": [
+ "policy"
+ ],
+ "properties": {
+ "policy": {
+ "$ref": "#/components/schemas/GroupPolicyState"
+ }
+ }
+ },
+ "GroupPolicyState": {
+ "type": "object",
+ "required": [
+ "policyKey",
+ "scope",
+ "targetId",
+ "value",
+ "allowChildOverride",
+ "visibleToChild",
+ "allowedValues"
+ ],
+ "properties": {
+ "policyKey": {
+ "type": "string"
+ },
+ "scope": {
+ "type": "string",
+ "enum": [
+ "group"
+ ]
+ },
+ "targetId": {
+ "type": "string"
+ },
+ "value": {
+ "$ref": "#/components/schemas/EffectivePolicyValue",
+ "nullable": true
+ },
+ "allowChildOverride": {
+ "type": "boolean"
+ },
+ "visibleToChild": {
+ "type": "boolean"
+ },
+ "allowedValues": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EffectivePolicyValue"
+ }
+ }
+ }
+ },
+ "GroupPolicyWriteResponse": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/MessageResponse"
+ },
+ {
+ "$ref": "#/components/schemas/GroupPolicyResponse"
+ }
+ ]
+ },
"IdDocs": {
"type": "object",
"required": [
@@ -1386,6 +1544,37 @@
}
}
},
+ "PolicySnapshotEntry": {
+ "type": "object",
+ "required": [
+ "effectiveValue",
+ "sourceScope"
+ ],
+ "properties": {
+ "effectiveValue": {
+ "type": "string"
+ },
+ "sourceScope": {
+ "type": "string"
+ }
+ }
+ },
+ "PolicySnapshotNumericEntry": {
+ "type": "object",
+ "required": [
+ "effectiveValue",
+ "sourceScope"
+ ],
+ "properties": {
+ "effectiveValue": {
+ "type": "integer",
+ "format": "int64"
+ },
+ "sourceScope": {
+ "type": "string"
+ }
+ }
+ },
"ProgressError": {
"type": "object",
"required": [
@@ -1904,6 +2093,16 @@
}
}
},
+ "SystemPolicyWriteResponse": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/MessageResponse"
+ },
+ {
+ "$ref": "#/components/schemas/EffectivePolicyResponse"
+ }
+ ]
+ },
"UserElement": {
"type": "object",
"required": [
@@ -2023,6 +2222,9 @@
"original_file_deleted": {
"type": "boolean"
},
+ "policy_snapshot": {
+ "$ref": "#/components/schemas/ValidatePolicySnapshot"
+ },
"pdfVersion": {
"type": "string"
},
@@ -2031,6 +2233,17 @@
}
}
},
+ "ValidatePolicySnapshot": {
+ "type": "object",
+ "properties": {
+ "docmdp": {
+ "$ref": "#/components/schemas/PolicySnapshotNumericEntry"
+ },
+ "signature_flow": {
+ "$ref": "#/components/schemas/PolicySnapshotEntry"
+ }
+ }
+ },
"ValidatedChildFile": {
"type": "object",
"required": [
@@ -7407,6 +7620,737 @@
}
}
},
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/effective": {
+ "get": {
+ "operationId": "policy-effective",
+ "summary": "Effective policies bootstrap",
+ "tags": [
+ "policy"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/EffectivePoliciesResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/group/{groupId}/{policyKey}": {
+ "get": {
+ "operationId": "policy-get-group",
+ "summary": "Read a group-level policy value",
+ "tags": [
+ "policy"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "groupId",
+ "in": "path",
+ "description": "Group identifier that receives the policy binding.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[^/]+$"
+ }
+ },
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to read for the selected group.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/GroupPolicyResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "operationId": "policy-set-group",
+ "summary": "Save a group-level policy value",
+ "tags": [
+ "policy"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "nullable": true,
+ "description": "Policy value to persist for the group.",
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "type": "number",
+ "format": "double"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ },
+ "allowChildOverride": {
+ "type": "boolean",
+ "default": false,
+ "description": "Whether users and requests below this group may override the group default."
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "groupId",
+ "in": "path",
+ "description": "Group identifier that receives the policy binding.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[^/]+$"
+ }
+ },
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to persist at the group layer.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/GroupPolicyWriteResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid policy value",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "operationId": "policy-clear-group",
+ "summary": "Clear a group-level policy value",
+ "tags": [
+ "policy"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "groupId",
+ "in": "path",
+ "description": "Group identifier that receives the policy binding.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[^/]+$"
+ }
+ },
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to clear for the selected group.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/GroupPolicyWriteResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{policyKey}": {
+ "put": {
+ "operationId": "policy-set-user-preference",
+ "summary": "Save a user policy preference",
+ "tags": [
+ "policy"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "value": {
+ "nullable": true,
+ "description": "Policy value to persist as the current user's default.",
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "integer",
+ "format": "int64"
+ },
+ {
+ "type": "number",
+ "format": "double"
+ },
+ {
+ "type": "string"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to persist for the current user.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/SystemPolicyWriteResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid policy value",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "operationId": "policy-clear-user-preference",
+ "summary": "Clear a user policy preference",
+ "tags": [
+ "policy"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "policyKey",
+ "in": "path",
+ "description": "Policy identifier to clear for the current user.",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "pattern": "^[a-z0-9_]+$"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "$ref": "#/components/schemas/SystemPolicyWriteResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": {
"post": {
"operationId": "request_signature-request",
@@ -7476,7 +8420,7 @@
"signatureFlow": {
"type": "string",
"nullable": true,
- "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration"
+ "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution."
}
}
}
@@ -7644,7 +8588,7 @@
"signatureFlow": {
"type": "string",
"nullable": true,
- "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration"
+ "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution."
},
"name": {
"type": "string",
diff --git a/playwright/e2e/policy-workbench-personas-permissions.spec.ts b/playwright/e2e/policy-workbench-personas-permissions.spec.ts
new file mode 100644
index 0000000000..3975074d84
--- /dev/null
+++ b/playwright/e2e/policy-workbench-personas-permissions.spec.ts
@@ -0,0 +1,251 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect, request, test, type APIRequestContext } from '@playwright/test'
+import {
+ ensureGroupExists,
+ ensureSubadminOfGroup,
+ ensureUserExists,
+ ensureUserInGroup,
+} from '../support/nc-provisioning'
+
+test.describe.configure({ retries: 0, timeout: 90000 })
+
+const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin'
+const ADMIN_PASSWORD = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin'
+const DEFAULT_TEST_PASSWORD = '123456'
+
+const GROUP_ID = 'policy-e2e-group'
+const GROUP_ADMIN_USER = 'policy-e2e-group-admin'
+const END_USER = 'policy-e2e-end-user'
+const INSTANCE_RESET_USER = 'policy-e2e-instance-reset-user'
+const POLICY_KEY = 'signature_flow'
+
+type OcsPolicyResponse = {
+ ocs?: {
+ meta?: {
+ statuscode?: number
+ message?: string
+ }
+ data?: Record
+ }
+}
+
+async function policyRequest(
+ requestContext: APIRequestContext,
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE',
+ path: string,
+ body?: Record,
+) {
+ const requestUrl = `./ocs/v2.php${path}`
+ const requestOptions = {
+ data: body,
+ failOnStatusCode: false,
+ }
+
+ const response = method === 'GET'
+ ? await requestContext.get(requestUrl, requestOptions)
+ : method === 'POST'
+ ? await requestContext.post(requestUrl, requestOptions)
+ : method === 'PUT'
+ ? await requestContext.put(requestUrl, requestOptions)
+ : await requestContext.delete(requestUrl, requestOptions)
+
+ const text = await response.text()
+ const parsed = text ? JSON.parse(text) as OcsPolicyResponse : { ocs: { data: {} } }
+
+ return {
+ httpStatus: response.status(),
+ statusCode: parsed.ocs?.meta?.statuscode ?? response.status(),
+ message: parsed.ocs?.meta?.message ?? '',
+ data: parsed.ocs?.data ?? {},
+ }
+}
+
+async function getEffectivePolicy(
+ requestContext: APIRequestContext,
+) {
+ const result = await policyRequest(requestContext, 'GET', `/apps/libresign/api/v1/policies/effective`)
+ const policies = (result.data.policies ?? {}) as Record
+
+ return policies[POLICY_KEY] ?? null
+}
+
+async function clearOwnUserPreference(
+ requestContext: APIRequestContext,
+) {
+ const result = await policyRequest(requestContext, 'DELETE', `/apps/libresign/api/v1/policies/user/${POLICY_KEY}`)
+ expect([200, 500]).toContain(result.httpStatus)
+}
+
+async function createAuthenticatedRequestContext(authUser: string, authPassword: string): Promise {
+ const auth = 'Basic ' + Buffer.from(`${authUser}:${authPassword}`).toString('base64')
+
+ return request.newContext({
+ baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost',
+ ignoreHTTPSErrors: true,
+ extraHTTPHeaders: {
+ 'OCS-ApiRequest': 'true',
+ Accept: 'application/json',
+ Authorization: auth,
+ 'Content-Type': 'application/json',
+ },
+ })
+}
+
+test('personas can manage policies according to permissions and override toggles', async ({ page }) => {
+ await ensureUserExists(page.request, GROUP_ADMIN_USER, DEFAULT_TEST_PASSWORD)
+ await ensureUserExists(page.request, END_USER, DEFAULT_TEST_PASSWORD)
+ await ensureGroupExists(page.request, GROUP_ID)
+ await ensureUserInGroup(page.request, GROUP_ADMIN_USER, GROUP_ID)
+ await ensureUserInGroup(page.request, END_USER, GROUP_ID)
+ await ensureSubadminOfGroup(page.request, GROUP_ADMIN_USER, GROUP_ID)
+
+ const adminRequest = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD)
+ const groupAdminRequest = await createAuthenticatedRequestContext(GROUP_ADMIN_USER, DEFAULT_TEST_PASSWORD)
+ const endUserRequest = await createAuthenticatedRequestContext(END_USER, DEFAULT_TEST_PASSWORD)
+
+ try {
+
+ // Normalize user-level state before assertions.
+ await clearOwnUserPreference(groupAdminRequest)
+ await clearOwnUserPreference(endUserRequest)
+
+ // Global admin defines baseline and group policy with override enabled.
+ let result = await policyRequest(
+ adminRequest,
+ 'POST',
+ `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`,
+ { value: 'parallel', allowChildOverride: true },
+ )
+ expect(result.httpStatus).toBe(200)
+
+ result = await policyRequest(
+ adminRequest,
+ 'PUT',
+ `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`,
+ { value: 'ordered_numeric', allowChildOverride: true },
+ )
+ expect(result.httpStatus).toBe(200)
+
+ // Group admin can edit own group rule.
+ result = await policyRequest(
+ groupAdminRequest,
+ 'PUT',
+ `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`,
+ { value: 'ordered_numeric', allowChildOverride: false },
+ )
+ expect(result.httpStatus).toBe(200)
+
+ const groupPolicyReadback = await policyRequest(
+ groupAdminRequest,
+ 'GET',
+ `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`,
+ )
+ expect(groupPolicyReadback.httpStatus).toBe(200)
+ expect(groupPolicyReadback.data?.policy).toMatchObject({
+ targetId: GROUP_ID,
+ policyKey: POLICY_KEY,
+ value: 'ordered_numeric',
+ allowChildOverride: false,
+ })
+
+ // End user cannot manage group policy and cannot save user preference while group blocks lower layers.
+ result = await policyRequest(
+ endUserRequest,
+ 'PUT',
+ `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`,
+ { value: 'parallel', allowChildOverride: true },
+ )
+ expect(result.httpStatus).toBe(403)
+
+ result = await policyRequest(
+ endUserRequest,
+ 'PUT',
+ `/apps/libresign/api/v1/policies/user/${POLICY_KEY}`,
+ { value: 'parallel' },
+ )
+ expect(result.httpStatus).toBe(400)
+
+ let endUserEffective = await getEffectivePolicy(endUserRequest)
+ expect(endUserEffective?.effectiveValue).toBe('ordered_numeric')
+ expect(endUserEffective?.canSaveAsUserDefault).toBe(false)
+
+ // Group admin enables lower-layer overrides again.
+ result = await policyRequest(
+ groupAdminRequest,
+ 'PUT',
+ `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`,
+ { value: 'ordered_numeric', allowChildOverride: true },
+ )
+ expect(result.httpStatus).toBe(200)
+
+ // End user can now save personal preference and it becomes effective.
+ result = await policyRequest(
+ endUserRequest,
+ 'PUT',
+ `/apps/libresign/api/v1/policies/user/${POLICY_KEY}`,
+ { value: 'parallel' },
+ )
+ expect(result.httpStatus).toBe(200)
+
+ endUserEffective = await getEffectivePolicy(endUserRequest)
+ expect(endUserEffective?.effectiveValue).toBe('parallel')
+ expect(endUserEffective?.sourceScope).toBe('user')
+ expect(endUserEffective?.canSaveAsUserDefault).toBe(true)
+ } finally {
+ await Promise.all([
+ adminRequest.dispose(),
+ groupAdminRequest.dispose(),
+ endUserRequest.dispose(),
+ ])
+ }
+})
+
+test('admin can remove explicit instance policy and restore system baseline', async ({ page }) => {
+ await ensureUserExists(page.request, INSTANCE_RESET_USER, DEFAULT_TEST_PASSWORD)
+
+ const adminRequest = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD)
+ const instanceResetUserRequest = await createAuthenticatedRequestContext(INSTANCE_RESET_USER, DEFAULT_TEST_PASSWORD)
+
+ try {
+ await clearOwnUserPreference(instanceResetUserRequest)
+
+ let result = await policyRequest(
+ adminRequest,
+ 'POST',
+ `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`,
+ { value: 'parallel', allowChildOverride: true },
+ )
+ expect(result.httpStatus).toBe(200)
+
+ let effectivePolicy = await getEffectivePolicy(instanceResetUserRequest)
+ expect(effectivePolicy?.effectiveValue).toBe('parallel')
+ expect(effectivePolicy?.sourceScope).toBe('global')
+
+ result = await policyRequest(
+ adminRequest,
+ 'POST',
+ `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`,
+ { value: null, allowChildOverride: false },
+ )
+ expect(result.httpStatus).toBe(200)
+
+ effectivePolicy = await getEffectivePolicy(instanceResetUserRequest)
+ expect(effectivePolicy?.effectiveValue).toBe('none')
+ expect(effectivePolicy?.sourceScope).toBe('system')
+ } finally {
+ await Promise.all([
+ adminRequest.dispose(),
+ instanceResetUserRequest.dispose(),
+ ])
+ }
+})
diff --git a/playwright/e2e/policy-workbench-system-default-persistence.spec.ts b/playwright/e2e/policy-workbench-system-default-persistence.spec.ts
new file mode 100644
index 0000000000..2cb0832c9b
--- /dev/null
+++ b/playwright/e2e/policy-workbench-system-default-persistence.spec.ts
@@ -0,0 +1,441 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect, test } from '@playwright/test'
+import type { Locator, Page } from '@playwright/test'
+import { login } from '../support/nc-login'
+import { ensureUserExists } from '../support/nc-provisioning'
+
+test.describe.configure({ mode: 'serial', retries: 0, timeout: 45000 })
+
+const openPolicyButtonName = /Manage signing order|Manage this setting|Manage setting|Open policy|Open setting policy/i
+const changeDefaultButtonName = /^Change$/i
+const removeExceptionButtonName = /Remove exception|Remove rule/i
+const userRuleTargetLabel = 'policy-e2e-user'
+const instanceWideTargetLabel = 'Default (instance-wide)'
+const ruleDialogName = /Create rule|Edit rule|What do you want to create\?/i
+
+async function getActiveRuleDialog(page: Page): Promise {
+ const roleDialog = page.getByRole('dialog', { name: ruleDialogName }).last()
+ if (await roleDialog.isVisible().catch(() => false)) {
+ return roleDialog
+ }
+
+ const headingDialog = page.locator('[role="dialog"]').filter({
+ has: page.getByRole('heading', { name: ruleDialogName }),
+ }).last()
+ await expect(headingDialog).toBeVisible({ timeout: 8000 })
+ return headingDialog
+}
+
+async function openSigningOrderDialog(page: Page) {
+ const manageButtonsByClass = page.locator('.policy-workbench__manage-button')
+ if (await manageButtonsByClass.count()) {
+ await expect(manageButtonsByClass.first()).toBeVisible({ timeout: 20000 })
+ await manageButtonsByClass.first().click()
+ } else {
+ const manageButtonsByName = page.getByRole('button', { name: openPolicyButtonName })
+ await expect(manageButtonsByName.first()).toBeVisible({ timeout: 20000 })
+ await manageButtonsByName.first().click()
+ }
+ await expect(page.getByLabel('Signing order')).toBeVisible()
+}
+
+async function getSigningOrderDialog(page: Page): Promise {
+ const dialog = page.getByLabel('Signing order')
+ await expect(dialog).toBeVisible()
+ return dialog
+}
+
+async function waitForEditorIdle(dialog: Locator) {
+ const savingOverlays = dialog.page().locator('[aria-busy="true"]')
+ await savingOverlays.first().waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {})
+}
+
+async function setSigningFlow(dialog: Locator, flow: 'parallel' | 'ordered_numeric' | 'none'): Promise {
+ const label = flow === 'parallel'
+ ? /Simultaneous \(Parallel\)/i
+ : flow === 'ordered_numeric'
+ ? /Sequential/i
+ : /Let users choose/i
+ const page = dialog.page()
+ const activeDialog = await getActiveRuleDialog(page).catch(() => null)
+ const root = activeDialog ?? dialog
+ const flowRadio = root.getByRole('radio', { name: label }).first()
+
+ if (!(await flowRadio.count())) {
+ return false
+ }
+
+ if (!(await flowRadio.isChecked())) {
+ await flowRadio.click({ force: true })
+ if (!(await flowRadio.isChecked())) {
+ const optionRow = root.locator('.checkbox-radio-switch').filter({ hasText: label }).first()
+ if (await optionRow.count()) {
+ await optionRow.click({ force: true })
+ }
+ }
+ }
+ return true
+}
+
+async function submitRule(dialog: Locator) {
+ await waitForEditorIdle(dialog)
+ const page = dialog.page()
+ const activeDialog = await getActiveRuleDialog(page).catch(() => null)
+ const root = activeDialog ?? dialog
+
+ const createButton = root.getByRole('button', { name: /Create rule|Create policy rule/i }).last()
+ if (await createButton.isVisible().catch(() => false)) {
+ await expect(createButton).toBeEnabled({ timeout: 8000 })
+ await createButton.click()
+ await waitForEditorIdle(dialog)
+ return
+ }
+
+ const saveButton = root.getByRole('button', { name: /Save changes|Save policy rule changes|Save rule changes/i }).last()
+ await expect(saveButton).toBeVisible({ timeout: 8000 })
+ await expect(saveButton).toBeEnabled({ timeout: 8000 })
+ await saveButton.click()
+ await waitForEditorIdle(dialog)
+}
+
+async function submitSystemRuleAndWait(dialog: Locator) {
+ const page = dialog.page()
+ const saveSystemPolicyResponse = page.waitForResponse((response) => {
+ return ['POST', 'PUT', 'PATCH'].includes(response.request().method())
+ && response.url().includes('/apps/libresign/api/v1/policies/system/signature_flow')
+ })
+
+ await submitRule(dialog)
+ const response = await saveSystemPolicyResponse
+ expect(response.status(), 'Expected system policy save request to succeed').toBe(200)
+}
+
+async function getSystemSignatureFlowValue(page: Page): Promise {
+ const response = await page.request.get('./ocs/v2.php/apps/libresign/api/v1/policies/system/signature_flow', {
+ headers: {
+ 'OCS-ApiRequest': 'true',
+ Accept: 'application/json',
+ },
+ })
+ expect(response.status(), 'Expected system policy fetch request to succeed').toBe(200)
+ const data = await response.json() as {
+ ocs?: {
+ data?: {
+ policy?: {
+ value?: unknown
+ }
+ }
+ }
+ }
+
+ return data.ocs?.data?.policy?.value ?? null
+}
+
+async function clearSystemSignatureFlowValue(page: Page): Promise {
+ const response = await page.request.post('./ocs/v2.php/apps/libresign/api/v1/policies/system/signature_flow', {
+ headers: {
+ 'OCS-ApiRequest': 'true',
+ Accept: 'application/json',
+ },
+ data: {
+ value: null,
+ allowChildOverride: true,
+ },
+ })
+ expect(response.status(), 'Expected system policy reset request to succeed').toBe(200)
+}
+
+function getRuleRow(dialog: Locator, _scope: 'Instance' | 'Group' | 'User', targetLabel: string) {
+ return dialog.locator('tbody tr').filter({
+ hasText: targetLabel,
+ }).first()
+}
+
+async function openSystemDefaultEditor(dialog: Locator) {
+ await dialog.getByRole('button', { name: changeDefaultButtonName }).first().click()
+ await getActiveRuleDialog(dialog.page())
+}
+
+async function getCreateScopeDialog(page: Page): Promise {
+ const dialog = await getActiveRuleDialog(page)
+ await expect(dialog.getByRole('heading', { name: /What do you want to create\?/i })).toBeVisible()
+ return dialog
+}
+
+async function getCreateScopeOption(page: Page, scopeLabel: 'User' | 'Group' | 'Instance') {
+ const dialog = await getCreateScopeDialog(page)
+ return dialog.getByRole('option', { name: new RegExp(`^${scopeLabel}\\b`, 'i') }).first()
+}
+
+async function openRuleActions(dialog: Locator, scope: 'Instance' | 'Group' | 'User', targetLabel: string) {
+ const row = getRuleRow(dialog, scope, targetLabel)
+ await expect(row).toBeVisible({ timeout: 8000 })
+ await row.getByRole('button', { name: 'Rule actions' }).first().click()
+ return row
+}
+
+async function clickRuleMenuAction(dialog: Locator, actionName: 'Edit' | 'Remove'): Promise {
+ const page = dialog.page()
+ const actionItem = page
+ .locator('.action-item:visible, [role="menuitem"]:visible, li.action:visible')
+ .filter({ hasText: new RegExp(`^${actionName}$`, 'i') })
+ .first()
+
+ if (!(await actionItem.isVisible().catch(() => false))) {
+ return false
+ }
+
+ await actionItem.click()
+ return true
+}
+
+async function editRule(dialog: Locator, scope: 'Instance' | 'Group' | 'User', targetLabel: string) {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ await openRuleActions(dialog, scope, targetLabel)
+ if (await clickRuleMenuAction(dialog, 'Edit')) {
+ return
+ }
+ await dialog.page().waitForTimeout(200)
+ }
+
+ expect(false, 'Expected Edit action to be visible in rule menu').toBe(true)
+}
+
+async function removeRule(dialog: Locator, scope: 'Instance' | 'Group' | 'User', targetLabel: string) {
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ await openRuleActions(dialog, scope, targetLabel)
+ if (await clickRuleMenuAction(dialog, 'Remove')) {
+ const page = dialog.page()
+ const removeExceptionButton = page.getByRole('button', { name: removeExceptionButtonName }).first()
+ if (await removeExceptionButton.isVisible().catch(() => false)) {
+ await removeExceptionButton.click()
+ } else {
+ const removeExceptionText = page.getByText(/^Remove exception$/i).first()
+ if (await removeExceptionText.isVisible().catch(() => false)) {
+ await removeExceptionText.click()
+ }
+ }
+ await waitForEditorIdle(dialog)
+ await dialog.page().waitForTimeout(150)
+ return
+ }
+ await dialog.page().waitForTimeout(200)
+ }
+
+ expect(false, 'Expected Remove action to be visible in rule menu').toBe(true)
+}
+
+async function chooseTarget(dialog: Locator, ariaLabel: 'Target groups' | 'Target users', optionText: string) {
+ await waitForEditorIdle(dialog)
+ const page = dialog.page()
+ const activeDialog = await getActiveRuleDialog(page).catch(() => null)
+ const root = activeDialog ?? dialog
+
+ const combobox = root.getByRole('combobox', { name: ariaLabel }).first()
+ const labeledInput = root.getByLabel(ariaLabel).first()
+ const targetInput = await combobox.count() ? combobox : labeledInput
+
+ await expect(targetInput).toBeVisible({ timeout: 8000 })
+ await targetInput.click()
+
+ const searchInput = targetInput.locator('input').first()
+ if (await searchInput.count()) {
+ await searchInput.fill(optionText)
+ await page.waitForTimeout(250)
+ const matchingOption = page.getByRole('option', { name: new RegExp(optionText, 'i') }).first()
+ const matchingVisible = await matchingOption.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false)
+ if (matchingVisible) {
+ await matchingOption.click()
+ await searchInput.press('Tab').catch(() => {})
+ return
+ }
+
+ const exactTextOption = page.getByText(new RegExp(`^${optionText}$`, 'i')).last()
+ const exactTextVisible = await exactTextOption.waitFor({ state: 'visible', timeout: 1500 }).then(() => true).catch(() => false)
+ if (exactTextVisible) {
+ await exactTextOption.click()
+ await searchInput.press('Tab').catch(() => {})
+ return
+ }
+
+ const anyOption = page.getByRole('option').first()
+ const anyVisible = await anyOption.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false)
+ if (anyVisible) {
+ await anyOption.click()
+ await searchInput.press('Tab').catch(() => {})
+ return
+ }
+
+ await searchInput.press('ArrowDown')
+ await searchInput.press('Enter')
+ await searchInput.press('Tab').catch(() => {})
+ } else {
+ const fallbackTextbox = root.getByRole('textbox').first()
+ await fallbackTextbox.fill(optionText)
+ await fallbackTextbox.press('ArrowDown')
+ await fallbackTextbox.press('Enter')
+ await fallbackTextbox.press('Tab').catch(() => {})
+ }
+}
+
+async function resetSystemRuleToBaseline(dialog: Locator) {
+ await clearSystemSignatureFlowValue(dialog.page())
+}
+
+async function clearExistingRules(dialog: Locator) {
+ const page = dialog.page()
+
+ for (let round = 0; round < 6; round += 1) {
+ let removedInRound = false
+ const actions = dialog.getByRole('button', { name: 'Rule actions' })
+
+ while ((await actions.count()) > 0) {
+ const firstAction = actions.first()
+ if (!(await firstAction.isVisible().catch(() => false))) {
+ break
+ }
+
+ const clickedAction = await firstAction.click({ timeout: 1500 }).then(() => true).catch(() => false)
+ if (!clickedAction) {
+ await page.waitForTimeout(150)
+ continue
+ }
+ const hasRemoveAction = await clickRuleMenuAction(dialog, 'Remove')
+ if (!hasRemoveAction) {
+ break
+ }
+
+ const removeExceptionButton = page.getByRole('button', { name: removeExceptionButtonName }).first()
+ if (await removeExceptionButton.isVisible().catch(() => false)) {
+ await removeExceptionButton.click()
+ } else {
+ const removeExceptionText = page.getByText(/^Remove exception$/i).first()
+ if (await removeExceptionText.isVisible().catch(() => false)) {
+ await removeExceptionText.click()
+ }
+ }
+ await waitForEditorIdle(dialog)
+ await page.waitForTimeout(150)
+ removedInRound = true
+ }
+
+ if (!removedInRound) {
+ await page.waitForTimeout(700)
+ if ((await actions.count()) === 0) {
+ break
+ }
+ }
+ }
+
+ if (await dialog.getByText(/\(custom\)/i).first().isVisible().catch(() => false)) {
+ await resetSystemRuleToBaseline(dialog)
+ }
+
+ await expect(dialog).toBeVisible()
+}
+
+test('system default persists across edit cycles and can be reset to the system baseline', async ({ page }) => {
+ await login(
+ page.request,
+ process.env.NEXTCLOUD_ADMIN_USER ?? 'admin',
+ process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin',
+ )
+
+ await page.goto('./settings/admin/libresign')
+
+ await openSigningOrderDialog(page)
+
+ const signingOrderDialog = await getSigningOrderDialog(page)
+ await clearExistingRules(signingOrderDialog)
+
+ await page.reload()
+ await openSigningOrderDialog(page)
+ const stableDialog = await getSigningOrderDialog(page)
+
+ await openSystemDefaultEditor(stableDialog)
+ expect(await setSigningFlow(stableDialog, 'ordered_numeric'), 'Expected signing-flow radios in system editor').toBe(true)
+ await submitSystemRuleAndWait(stableDialog)
+ expect(await getSystemSignatureFlowValue(page)).toBe('ordered_numeric')
+
+ await page.reload()
+ await openSigningOrderDialog(page)
+ const reloadedDialog = await getSigningOrderDialog(page)
+ expect(await getSystemSignatureFlowValue(page)).toBe('ordered_numeric')
+
+ await openSystemDefaultEditor(reloadedDialog)
+ expect(await setSigningFlow(reloadedDialog, 'parallel'), 'Expected signing-flow radios in system editor').toBe(true)
+ await submitSystemRuleAndWait(reloadedDialog)
+ expect(await getSystemSignatureFlowValue(page)).toBe('parallel')
+
+ await resetSystemRuleToBaseline(reloadedDialog)
+ expect([null, 'none']).toContain(await getSystemSignatureFlowValue(page))
+})
+
+test('admin can manage instance, group, and user rules when system default is fixed', async ({ page }) => {
+ const userTarget = userRuleTargetLabel
+
+ await ensureUserExists(page.request, userTarget)
+
+ await login(
+ page.request,
+ process.env.NEXTCLOUD_ADMIN_USER ?? 'admin',
+ process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin',
+ )
+
+ await page.goto('./settings/admin/libresign')
+ await openSigningOrderDialog(page)
+
+ const dialog = await getSigningOrderDialog(page)
+ await clearExistingRules(dialog)
+
+ await page.reload()
+ await openSigningOrderDialog(page)
+ const stableDialog = await getSigningOrderDialog(page)
+
+ // Global rule: edit
+ await openSystemDefaultEditor(stableDialog)
+ expect(await setSigningFlow(stableDialog, 'ordered_numeric'), 'Expected signing-flow radios in global editor').toBe(true)
+ await submitSystemRuleAndWait(stableDialog)
+ expect(await getSystemSignatureFlowValue(page)).toBe('ordered_numeric')
+
+ // Instance admins can still create group-level exceptions even when the system default is fixed.
+ await stableDialog.getByRole('button', { name: 'Create rule' }).first().click()
+ const groupScopeOption = await getCreateScopeOption(stableDialog.page(), 'Group')
+ await expect(groupScopeOption).toBeEnabled()
+
+ // User rule: create
+ const userScopeOption = await getCreateScopeOption(stableDialog.page(), 'User')
+ await expect(userScopeOption).toBeEnabled()
+ await userScopeOption.click()
+ await chooseTarget(stableDialog, 'Target users', userTarget)
+ expect(await setSigningFlow(stableDialog, 'parallel'), 'Expected signing-flow radios in user editor').toBe(true)
+ await submitRule(stableDialog)
+ await expect(stableDialog).toContainText(userTarget)
+ await expect(stableDialog).toContainText('Simultaneous (Parallel)')
+
+ // User rule: edit
+ await editRule(stableDialog, 'User', userTarget)
+ expect(await setSigningFlow(stableDialog, 'ordered_numeric'), 'Expected signing-flow radios in user editor').toBe(true)
+ await submitRule(stableDialog)
+ await expect(stableDialog).toContainText(userTarget)
+ await expect(stableDialog).toContainText('Sequential')
+
+ await page.reload()
+ await openSigningOrderDialog(page)
+ const reloadedDialog = await getSigningOrderDialog(page)
+ expect(await getSystemSignatureFlowValue(page)).toBe('ordered_numeric')
+ await expect(reloadedDialog).toContainText(userTarget)
+ await expect(reloadedDialog).toContainText('Sequential')
+
+ // User rule: delete
+ await removeRule(reloadedDialog, 'User', userTarget)
+ await expect(reloadedDialog).not.toContainText(userTarget)
+
+ // Global rule: reset to explicit "let users choose" baseline
+ await resetSystemRuleToBaseline(reloadedDialog)
+ expect([null, 'none']).toContain(await getSystemSignatureFlowValue(page))
+})
diff --git a/playwright/e2e/send-reminder.spec.ts b/playwright/e2e/send-reminder.spec.ts
index 040c5b5133..fe53050b3b 100644
--- a/playwright/e2e/send-reminder.spec.ts
+++ b/playwright/e2e/send-reminder.spec.ts
@@ -67,7 +67,9 @@ test('admin can send a reminder to a pending signer', async ({ page }) => {
// The signer row renders as NcListItem with force-display-actions, so the
// three-dots NcActions toggle is always visible (aria-label="Actions").
await page.locator('li').filter({ hasText: 'Signer 01' }).getByRole('button', { name: 'Actions' }).click()
- await page.getByRole('menuitem', { name: 'Send reminder' }).click()
+ const sendReminderAction = page.locator('[role="menuitem"], [role="dialog"] button').filter({ hasText: /^Send reminder$/i }).first()
+ await expect(sendReminderAction).toBeVisible({ timeout: 8000 })
+ await sendReminderAction.click()
// The reminder uses a different subject: "LibreSign: Changes into a file for you to sign".
await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: Changes into a file for you to sign')
diff --git a/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts b/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts
new file mode 100644
index 0000000000..81867fe7e0
--- /dev/null
+++ b/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts
@@ -0,0 +1,278 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { expect, request, test, type APIRequestContext, type Page } from '@playwright/test'
+import { login } from '../support/nc-login'
+import {
+ configureOpenSsl,
+ ensureGroupExists,
+ ensureSubadminOfGroup,
+ ensureUserExists,
+ ensureUserInGroup,
+ setAppConfig,
+} from '../support/nc-provisioning'
+
+const POLICY_KEY = 'signature_flow'
+const GROUP_ADMIN_USER = 'signature-flow-e2e-group-admin'
+const GROUP_ADMIN_PASSWORD = '123456'
+const GROUP_ADMIN_GROUP = 'signature-flow-e2e-group'
+
+test.setTimeout(120_000)
+test.describe.configure({ mode: 'serial' })
+
+type OcsPolicyResponse = {
+ ocs?: {
+ meta?: {
+ statuscode?: number
+ message?: string
+ }
+ data?: Record
+ }
+}
+
+async function createAuthenticatedRequestContext(authUser: string, authPassword: string): Promise {
+ const auth = 'Basic ' + Buffer.from(`${authUser}:${authPassword}`).toString('base64')
+
+ return request.newContext({
+ baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost',
+ ignoreHTTPSErrors: true,
+ extraHTTPHeaders: {
+ 'OCS-ApiRequest': 'true',
+ Accept: 'application/json',
+ Authorization: auth,
+ },
+ })
+}
+
+async function policyRequest(
+ requestContext: APIRequestContext,
+ method: 'POST' | 'DELETE',
+ path: string,
+ body?: Record,
+) {
+ const response = method === 'POST'
+ ? await requestContext.post(`./ocs/v2.php${path}`, {
+ data: body,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ failOnStatusCode: false,
+ })
+ : await requestContext.delete(`./ocs/v2.php${path}`, { failOnStatusCode: false })
+
+ const text = await response.text()
+ const parsed = text ? JSON.parse(text) as OcsPolicyResponse : { ocs: { data: {} } }
+
+ return {
+ httpStatus: response.status(),
+ statusCode: parsed.ocs?.meta?.statuscode ?? response.status(),
+ message: parsed.ocs?.meta?.message ?? '',
+ }
+}
+
+async function setSystemSignatureFlowPolicy(
+ requestContext: APIRequestContext,
+ value: 'none' | 'parallel' | 'ordered_numeric',
+ allowChildOverride: boolean,
+) {
+ const result = await policyRequest(
+ requestContext,
+ 'POST',
+ `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`,
+ { value, allowChildOverride },
+ )
+
+ expect(result.httpStatus, `Failed to set system signature flow policy: ${result.message}`).toBe(200)
+}
+
+async function clearOwnPreference(requestContext: APIRequestContext) {
+ const result = await policyRequest(
+ requestContext,
+ 'DELETE',
+ `/apps/libresign/api/v1/policies/user/${POLICY_KEY}`,
+ )
+ // Can be 200 (cleared) or 500 when preference doesn't exist in some environments.
+ expect([200, 500]).toContain(result.httpStatus)
+}
+
+async function addEmailSigner(page: Page, email: string, name: string) {
+ 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) => {
+ return response.url().includes('/apps/libresign/api/v1/request-signature')
+ && ['POST', 'PATCH'].includes(response.request().method())
+ })
+
+ await dialog.getByRole('button', { name: 'Save' }).click()
+ const saveSignerResponse = await saveSignerResponsePromise
+ expect(saveSignerResponse.status()).toBe(200)
+ await expect(dialog).toBeHidden()
+}
+
+test('request sidebar persists signature flow preference through policies endpoint', async ({ page }) => {
+ const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin'
+ const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin'
+ const adminRequest = await createAuthenticatedRequestContext(adminUser, adminPassword)
+
+ await login(page.request, adminUser, adminPassword)
+
+ await configureOpenSsl(adminRequest, 'LibreSign Test', {
+ C: 'BR',
+ OU: ['Organization Unit'],
+ ST: 'Rio de Janeiro',
+ O: 'LibreSign',
+ L: 'Rio de Janeiro',
+ })
+
+ await setAppConfig(
+ adminRequest,
+ 'libresign',
+ 'identify_methods',
+ JSON.stringify([
+ { name: 'account', enabled: false, mandatory: false },
+ { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false },
+ ]),
+ )
+
+ try {
+ await setSystemSignatureFlowPolicy(adminRequest, 'parallel', true)
+ await clearOwnPreference(adminRequest)
+
+ 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 addEmailSigner(page, 'signer01@libresign.coop', 'Signer 01')
+ await addEmailSigner(page, 'signer02@libresign.coop', 'Signer 02')
+
+ // Enable remember preference first, then switch to ordered mode.
+ // The second action must persist ordered_numeric via policies endpoint.
+ await expect(page.getByLabel('Use this as my default signing order')).toBeVisible()
+ await page.getByText('Use this as my default signing order').click()
+
+ const saveOrderedPreference = page.waitForResponse((response) => {
+ const req = response.request()
+ return req.method() === 'PUT'
+ && req.url().includes('/apps/libresign/api/v1/policies/user/signature_flow')
+ && (req.postData() ?? '').includes('ordered_numeric')
+ })
+
+ await expect(page.getByLabel('Sign in order')).toBeVisible()
+ await page.getByText('Sign in order').click()
+ await expect(page.getByLabel('Sign in order')).toBeChecked()
+
+ const saveOrderedPreferenceResponse = await saveOrderedPreference
+ expect(saveOrderedPreferenceResponse.status()).toBe(200)
+ } finally {
+ await clearOwnPreference(adminRequest)
+ await setSystemSignatureFlowPolicy(adminRequest, 'none', true)
+ await adminRequest.dispose()
+ }
+})
+
+for (const systemFlow of ['ordered_numeric', 'parallel'] as const) {
+ test(`fixed system ${systemFlow} signature flow hides request toggles for groupadmin`, async ({ page }) => {
+ const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin'
+ const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin'
+
+ const adminRequest = await createAuthenticatedRequestContext(adminUser, adminPassword)
+ const groupAdminRequest = await createAuthenticatedRequestContext(GROUP_ADMIN_USER, GROUP_ADMIN_PASSWORD)
+
+ await ensureUserExists(adminRequest, GROUP_ADMIN_USER, GROUP_ADMIN_PASSWORD)
+ await ensureGroupExists(adminRequest, GROUP_ADMIN_GROUP)
+ await ensureUserInGroup(adminRequest, GROUP_ADMIN_USER, GROUP_ADMIN_GROUP)
+ await ensureSubadminOfGroup(adminRequest, GROUP_ADMIN_USER, GROUP_ADMIN_GROUP)
+
+ await configureOpenSsl(adminRequest, 'LibreSign Test', {
+ C: 'BR',
+ OU: ['Organization Unit'],
+ ST: 'Rio de Janeiro',
+ O: 'LibreSign',
+ L: 'Rio de Janeiro',
+ })
+
+ await setAppConfig(
+ adminRequest,
+ 'libresign',
+ 'identify_methods',
+ JSON.stringify([
+ { name: 'account', enabled: false, mandatory: false },
+ { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false },
+ ]),
+ )
+
+ await setAppConfig(
+ adminRequest,
+ 'libresign',
+ 'groups_request_sign',
+ JSON.stringify(['admin', GROUP_ADMIN_GROUP]),
+ )
+
+ try {
+ await setSystemSignatureFlowPolicy(adminRequest, systemFlow, false)
+ await clearOwnPreference(groupAdminRequest)
+
+ await login(page.request, GROUP_ADMIN_USER, GROUP_ADMIN_PASSWORD)
+ await page.goto('./apps/libresign/f/request')
+ await expect(page.getByRole('heading', { name: 'Request Signatures' })).toBeVisible()
+ 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 addEmailSigner(page, 'signer11@libresign.coop', 'Signer 11')
+ await addEmailSigner(page, 'signer12@libresign.coop', 'Signer 12')
+
+ await expect(page.getByLabel('Sign in order')).toBeHidden()
+ await expect(page.getByLabel('Use this as my default signing order')).toBeHidden()
+
+ const sendRequestResponsePromise = page.waitForResponse((response) => {
+ const request = response.request()
+ const body = request.postData() ?? ''
+ return response.url().includes('/apps/libresign/api/v1/request-signature')
+ && ['POST', 'PATCH'].includes(request.method())
+ && body.includes('"status":1')
+ })
+
+ await page.getByRole('button', { name: 'Request signatures' }).click()
+ await page.getByRole('button', { name: 'Send' }).click()
+
+ const sendRequestResponse = await sendRequestResponsePromise
+ expect(sendRequestResponse.status()).toBe(200)
+ const sendRequestPayload = JSON.parse(sendRequestResponse.request().postData() ?? '{}') as {
+ signatureFlow?: string
+ }
+ expect(sendRequestPayload.signatureFlow).toBeUndefined()
+
+ const sendRequestBody = await sendRequestResponse.json() as {
+ ocs?: {
+ data?: {
+ signatureFlow?: string
+ signers?: Array<{ signingOrder?: number }>
+ }
+ }
+ }
+ expect(sendRequestBody.ocs?.data?.signatureFlow).toBe(systemFlow)
+
+ if (systemFlow === 'ordered_numeric') {
+ expect(sendRequestBody.ocs?.data?.signers?.map((signer) => signer.signingOrder)).toEqual([1, 2])
+ }
+ } finally {
+ await clearOwnPreference(groupAdminRequest)
+ await setSystemSignatureFlowPolicy(adminRequest, 'none', true)
+ await setAppConfig(adminRequest, 'libresign', 'groups_request_sign', JSON.stringify(['admin']))
+ await Promise.all([
+ adminRequest.dispose(),
+ groupAdminRequest.dispose(),
+ ])
+ }
+ })
+}
diff --git a/playwright/support/nc-login.ts b/playwright/support/nc-login.ts
index f97f681ac6..775de89217 100644
--- a/playwright/support/nc-login.ts
+++ b/playwright/support/nc-login.ts
@@ -25,6 +25,12 @@ export async function login(
user: string,
password: string,
): Promise {
+ // Ensure a previous authenticated session does not leak across persona switches.
+ await request.get('./logout', {
+ failOnStatusCode: false,
+ maxRedirects: 0,
+ }).catch(() => {})
+
const tokenResponse = await request.get('./csrftoken', {
failOnStatusCode: true,
})
diff --git a/playwright/support/nc-provisioning.ts b/playwright/support/nc-provisioning.ts
index 39e5665c37..08c7b82f29 100644
--- a/playwright/support/nc-provisioning.ts
+++ b/playwright/support/nc-provisioning.ts
@@ -27,6 +27,21 @@ type SignatureElementResponse = {
}>
}
+function toStringList(data: unknown): string[] {
+ if (Array.isArray(data)) {
+ return data.filter((item): item is string => typeof item === 'string')
+ }
+
+ if (data && typeof data === 'object') {
+ const nested = data as { groups?: unknown[] }
+ if (Array.isArray(nested.groups)) {
+ return nested.groups.filter((item): item is string => typeof item === 'string')
+ }
+ }
+
+ return []
+}
+
async function ocsRequest(
request: APIRequestContext,
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
@@ -124,6 +139,95 @@ export async function deleteUser(
await ocsRequest(request, 'DELETE', `/cloud/users/${userId}`)
}
+// ---------------------------------------------------------------------------
+// Groups and delegated administration
+// ---------------------------------------------------------------------------
+
+/**
+ * Creates a group if it does not exist.
+ */
+export async function ensureGroupExists(
+ request: APIRequestContext,
+ groupId: string,
+): Promise {
+ const check = await ocsRequest(request, 'GET', `/cloud/groups?search=${encodeURIComponent(groupId)}`)
+ const groups = toStringList(check.ocs.data)
+ if (groups.includes(groupId)) {
+ return
+ }
+
+ const create = await ocsRequest(request, 'POST', '/cloud/groups', undefined, undefined, {
+ groupid: groupId,
+ })
+ if (create.ocs.meta.statuscode !== 200 && create.ocs.meta.statuscode !== 102) {
+ throw new Error(`Failed to create group "${groupId}": ${create.ocs.meta.message}`)
+ }
+}
+
+/**
+ * Adds a user to a group.
+ */
+export async function ensureUserInGroup(
+ request: APIRequestContext,
+ userId: string,
+ groupId: string,
+): Promise {
+ const groupsResponse = await ocsRequest(request, 'GET', `/cloud/users/${encodeURIComponent(userId)}/groups`)
+ const groups = toStringList(groupsResponse.ocs.data)
+ if (groups.includes(groupId)) {
+ return
+ }
+
+ const add = await ocsRequest(
+ request,
+ 'POST',
+ `/cloud/users/${encodeURIComponent(userId)}/groups`,
+ undefined,
+ undefined,
+ { groupid: groupId },
+ )
+ if (add.ocs.meta.statuscode !== 200) {
+ throw new Error(`Failed to add user "${userId}" to group "${groupId}": ${add.ocs.meta.message}`)
+ }
+
+ const verify = await ocsRequest(request, 'GET', `/cloud/users/${encodeURIComponent(userId)}/groups`)
+ if (!toStringList(verify.ocs.data).includes(groupId)) {
+ throw new Error(`User "${userId}" is not in group "${groupId}" after assignment.`)
+ }
+}
+
+/**
+ * Grants subadmin rights for a specific group.
+ */
+export async function ensureSubadminOfGroup(
+ request: APIRequestContext,
+ userId: string,
+ groupId: string,
+): Promise {
+ const subadmins = await ocsRequest(request, 'GET', `/cloud/users/${encodeURIComponent(userId)}/subadmins`)
+ const groups = toStringList(subadmins.ocs.data)
+ if (groups.includes(groupId)) {
+ return
+ }
+
+ const grant = await ocsRequest(
+ request,
+ 'POST',
+ `/cloud/users/${encodeURIComponent(userId)}/subadmins`,
+ undefined,
+ undefined,
+ { groupid: groupId },
+ )
+ if (grant.ocs.meta.statuscode !== 200) {
+ throw new Error(`Failed to grant subadmin for user "${userId}" in group "${groupId}": ${grant.ocs.meta.message}`)
+ }
+
+ const verify = await ocsRequest(request, 'GET', `/cloud/users/${encodeURIComponent(userId)}/subadmins`)
+ if (!toStringList(verify.ocs.data).includes(groupId)) {
+ throw new Error(`User "${userId}" was not granted subadmin rights for group "${groupId}".`)
+ }
+}
+
// ---------------------------------------------------------------------------
// App config (equivalent to `occ config:app:set`)
// ---------------------------------------------------------------------------
diff --git a/src/components/RightSidebar/RequestSignatureTab.vue b/src/components/RightSidebar/RequestSignatureTab.vue
index b17c599146..b9202c7ea6 100644
--- a/src/components/RightSidebar/RequestSignatureTab.vue
+++ b/src/components/RightSidebar/RequestSignatureTab.vue
@@ -16,6 +16,9 @@
{{ t('libresign', 'Loading signer details...') }}
+
+ {{ t('libresign', 'A previous signing order preference was removed because it is no longer compatible with higher-level policy.') }}
+
@@ -30,6 +33,12 @@
@update:modelValue="onPreserveOrderChange">
{{ t('libresign', 'Sign in order') }}
+
+ {{ t('libresign', 'Use this as my default signing order') }}
+
@@ -318,6 +327,7 @@ import { getSigningRouteUuid, getValidationRouteUuid } from '../../utils/signReq
import { openDocument } from '../../utils/viewer.js'
import router from '../../router/router'
import { useFilesStore } from '../../store/files.js'
+import { usePoliciesStore } from '../../store/policies'
import { useSidebarStore } from '../../store/sidebar.js'
import { useSignStore } from '../../store/sign.js'
import { useUserConfigStore } from '../../store/userconfig.js'
@@ -328,7 +338,6 @@ import type {
IdentifyMethodRecord,
IdentifyMethodSetting as IdentifyMethodConfig,
LibresignCapabilities as RequestSignatureTabCapabilities,
- SignatureFlowMode,
SignatureFlowValue,
} from '../../types/index'
@@ -345,6 +354,7 @@ type IdentifySignerToEdit = {
description?: string
identifyMethods?: IdentifySignerMethod[]
}
+type ResolvedSignatureFlowMode = 'none' | 'parallel' | 'ordered_numeric'
type SigningOrderDiagramSigner = {
displayName?: string
signed?: boolean
@@ -375,6 +385,7 @@ const props = withDefaults(defineProps<{
})
const filesStore = useFilesStore()
+const policiesStore = usePoliciesStore()
const signStore = useSignStore()
const sidebarStore = useSidebarStore()
const userConfigStore = useUserConfigStore() as ReturnType & {
@@ -396,33 +407,48 @@ const showConfirmRequestSigner = ref(false)
const selectedSigner = ref(null)
const activeTab = ref('')
const preserveOrder = ref(false)
+const rememberSignatureFlow = ref(false)
const showOrderDiagram = ref(false)
const showEnvelopeFilesDialog = ref(false)
-const adminSignatureFlow = ref(loadState('libresign', 'signature_flow', 'none'))
const signingProgress = ref(null)
const signingProgressStatus = ref(null)
const signingProgressStatusText = ref('')
const stopPollingFunction = ref void)>(null)
+const signatureFlowPolicy = computed(() => policiesStore.getPolicy('signature_flow'))
+const canChooseSigningOrderAtRequestLevel = computed(() => policiesStore.canUseRequestOverride('signature_flow'))
+const isAdminFlowForced = computed(() => !canChooseSigningOrderAtRequestLevel.value)
+
const signatureFlow = computed(() => {
const file = filesStore.getFile()
- let flow = file?.signatureFlow
+ const resolvedPolicy = toSignatureFlowMode(signatureFlowPolicy.value?.effectiveValue)
+ const fileFlow = file?.signatureFlow
+ const resolvedFileFlow = toSignatureFlowMode(fileFlow)
- if (typeof flow === 'number') {
- const flowMap: Record = { 0: 'none', 1: 'parallel', 2: 'ordered_numeric' }
- return flowMap[flow]
+ if (!canChooseSigningOrderAtRequestLevel.value && resolvedPolicy && resolvedPolicy !== 'none') {
+ return resolvedPolicy
}
- if (flow && flow !== 'none') {
- return flow
+ if (typeof fileFlow === 'number' && fileFlow !== 0 && resolvedFileFlow) {
+ return resolvedFileFlow
}
- if (adminSignatureFlow.value && adminSignatureFlow.value !== 'none') {
- return adminSignatureFlow.value
+
+ if (resolvedFileFlow && resolvedFileFlow !== 'none') {
+ return resolvedFileFlow
+ }
+
+ if (resolvedPolicy && resolvedPolicy !== 'none') {
+ return resolvedPolicy
+ }
+
+ if (fileFlow === 0) {
+ return 'none'
}
+
return 'parallel'
})
-const isAdminFlowForced = computed(() => adminSignatureFlow.value && adminSignatureFlow.value !== 'none')
+const canSaveSignatureFlowPreference = computed(() => signatureFlowPolicy.value?.canSaveAsUserDefault ?? false)
const isOrderedNumeric = computed(() => signatureFlow.value === 'ordered_numeric')
const hasSigners = computed(() => filesStore.hasSigners(filesStore.getFile()))
const totalSigners = computed(() => Number(filesStore.getFile()?.signersCount || filesStore.getFile()?.signers?.length || 0))
@@ -430,10 +456,12 @@ const isOriginalFileDeleted = computed(() => filesStore.isOriginalFileDeleted())
const currentFile = computed(() => (filesStore.getFile() as EditableRequestFile | null) ?? null)
const isCurrentFileDetailed = computed(() => currentFile.value?.detailsLoaded === true)
const shouldLoadDetail = computed(() => totalSigners.value > 0)
-const showSigningOrderOptions = computed(() => !isOriginalFileDeleted.value && isCurrentFileDetailed.value && hasSigners.value && filesStore.canSave() && !isAdminFlowForced.value)
-const showPreserveOrder = computed(() => !isOriginalFileDeleted.value && isCurrentFileDetailed.value && totalSigners.value > 1 && filesStore.canSave() && !isAdminFlowForced.value)
+const showSigningOrderOptions = computed(() => !isOriginalFileDeleted.value && isCurrentFileDetailed.value && hasSigners.value && filesStore.canSave() && canChooseSigningOrderAtRequestLevel.value)
+const showPreserveOrder = computed(() => !isOriginalFileDeleted.value && isCurrentFileDetailed.value && totalSigners.value > 1 && filesStore.canSave() && canChooseSigningOrderAtRequestLevel.value)
+const showRememberSignatureFlow = computed(() => showPreserveOrder.value && canSaveSignatureFlowPreference.value)
const showViewOrderButton = computed(() => !isOriginalFileDeleted.value && isCurrentFileDetailed.value && isOrderedNumeric.value && totalSigners.value > 1 && hasSigners.value)
const shouldShowOrderedOptions = computed(() => isOrderedNumeric.value && totalSigners.value > 1)
+const showSignatureFlowPreferenceClearedNotice = computed(() => signatureFlowPolicy.value?.preferenceWasCleared ?? false)
const currentUserDisplayName = computed(() => OC.getCurrentUser()?.displayName || '')
const showDocMdpWarning = computed(() => filesStore.isDocMdpNoChangesAllowed() && !filesStore.canAddSigner())
const fileName = computed(() => filesStore.getSelectedFileView()?.name ?? '')
@@ -452,12 +480,59 @@ const signingOrderDiagramSigners = computed(() => {
})
function normalizeSignatureFlow(flow: unknown): SignatureFlowValue | null {
+ if (flow && typeof flow === 'object' && 'flow' in (flow as Record)) {
+ const nestedFlow = (flow as { flow?: unknown }).flow
+ return normalizeSignatureFlow(nestedFlow)
+ }
+
if (flow === 'none' || flow === 'parallel' || flow === 'ordered_numeric' || flow === 0 || flow === 1 || flow === 2) {
return flow
}
return null
}
+function toSignatureFlowMode(flow: unknown): ResolvedSignatureFlowMode | null {
+ const normalizedFlow = normalizeSignatureFlow(flow)
+ if (normalizedFlow === 0) {
+ return 'none'
+ }
+
+ if (normalizedFlow === 1) {
+ return 'parallel'
+ }
+
+ if (normalizedFlow === 2) {
+ return 'ordered_numeric'
+ }
+
+ if (normalizedFlow === 'none' || normalizedFlow === 'parallel' || normalizedFlow === 'ordered_numeric') {
+ return normalizedFlow
+ }
+
+ return null
+}
+
+function getResolvedSignatureFlowForSave(): SignatureFlowValue {
+ const flow = signatureFlow.value
+ if (flow === 'ordered_numeric') {
+ return 'ordered_numeric'
+ }
+
+ if (flow === 'parallel') {
+ return 'parallel'
+ }
+
+ return 'parallel'
+}
+
+function getSignatureFlowPayloadForSave(): SignatureFlowValue | null {
+ if (!canChooseSigningOrderAtRequestLevel.value) {
+ return null
+ }
+
+ return getResolvedSignatureFlowForSave()
+}
+
function getSignerMethod(signer: { identifyMethods?: Array> }): string | undefined {
return signer.identifyMethods?.[0]?.method
}
@@ -747,7 +822,7 @@ const debouncedSave = debounce(async () => {
try {
const file = filesStore.getFile()
const signers = isOrderedNumeric.value ? file?.signers : null
- const signatureFlow = normalizeSignatureFlow(file?.signatureFlow)
+ const signatureFlow = getSignatureFlowPayloadForSave()
await filesStore.saveOrUpdateSignatureRequest({
signers,
signatureFlow,
@@ -764,6 +839,7 @@ const debouncedTabChange = debounce((tabId: string) => {
function onPreserveOrderChange(value: boolean) {
preserveOrder.value = value
const file = filesStore.getEditableFile()
+ const nextFlow = value ? 'ordered_numeric' : 'parallel'
if (value) {
if (file?.signers) {
@@ -776,7 +852,7 @@ function onPreserveOrderChange(value: boolean) {
})
}
if (file) {
- file.signatureFlow = 'ordered_numeric'
+ file.signatureFlow = nextFlow
}
} else if (!isAdminFlowForced.value) {
if (file?.signers) {
@@ -787,23 +863,84 @@ function onPreserveOrderChange(value: boolean) {
})
}
if (file) {
- file.signatureFlow = 'parallel'
+ file.signatureFlow = nextFlow
}
}
+ if (rememberSignatureFlow.value && canSaveSignatureFlowPreference.value) {
+ void saveSignatureFlowPreference(nextFlow)
+ }
+
debouncedSave()
}
+async function saveSignatureFlowPreference(flow: 'parallel' | 'ordered_numeric'): Promise {
+ try {
+ await policiesStore.saveUserPreference('signature_flow', flow)
+ syncRememberSignatureFlowWithPolicy()
+ } catch (error: unknown) {
+ showRequestError(error, t('libresign', 'Failed to save signing order preference'))
+ rememberSignatureFlow.value = false
+ }
+}
+
+async function onRememberSignatureFlowChange(value: boolean): Promise {
+ const previousValue = rememberSignatureFlow.value
+ rememberSignatureFlow.value = value
+ if (!canSaveSignatureFlowPreference.value) {
+ return
+ }
+
+ try {
+ if (value) {
+ await saveSignatureFlowPreference(isOrderedNumeric.value ? 'ordered_numeric' : 'parallel')
+ return
+ }
+
+ await policiesStore.clearUserPreference('signature_flow')
+ syncRememberSignatureFlowWithPolicy()
+ } catch (error: unknown) {
+ showRequestError(error, t('libresign', 'Failed to clear signing order preference'))
+ rememberSignatureFlow.value = previousValue
+ }
+}
+
function syncPreserveOrderWithFile() {
- const file = filesStore.getFile()
+ preserveOrder.value = signatureFlow.value === 'ordered_numeric' && canChooseSigningOrderAtRequestLevel.value
+}
+
+function syncFileSignatureFlowWithPolicy() {
+ const resolvedPolicy = toSignatureFlowMode(signatureFlowPolicy.value?.effectiveValue)
+ if (canChooseSigningOrderAtRequestLevel.value || !resolvedPolicy || resolvedPolicy === 'none') {
+ return
+ }
+
+ const file = currentFile.value
if (!file) {
- preserveOrder.value = false
return
}
- const flow = file.signatureFlow
- const normalizedFlow = normalizeSignatureFlow(flow)
- preserveOrder.value = (normalizedFlow === 'ordered_numeric' || normalizedFlow === 2) && !isAdminFlowForced.value
+ file.signatureFlow = resolvedPolicy
+
+ if (resolvedPolicy !== 'ordered_numeric' || !Array.isArray(file.signers)) {
+ return
+ }
+
+ const orders = file.signers.map((signer: EditableRequestSigner) => signer.signingOrder || 0)
+ const hasDuplicateOrders = orders.length !== new Set(orders).size
+ file.signers.forEach((signer: EditableRequestSigner, index: number) => {
+ if (!signer.signingOrder || hasDuplicateOrders) {
+ signer.signingOrder = index + 1
+ }
+ })
+
+ if (file.signers.every((signer: EditableRequestSigner) => typeof signer.signingOrder === 'number')) {
+ normalizeSigningOrders(file.signers as Array<{ signingOrder: number }>)
+ }
+}
+
+function syncRememberSignatureFlowWithPolicy() {
+ rememberSignatureFlow.value = signatureFlowPolicy.value?.sourceScope === 'user'
}
async function ensureCurrentFileDetail(force = false) {
@@ -815,6 +952,8 @@ async function ensureCurrentFileDetail(force = false) {
isLoadingFileDetail.value = true
try {
await filesStore.fetchFileDetail({ fileId: file.id, force })
+ syncFileSignatureFlowWithPolicy()
+ syncPreserveOrderWithFile()
} catch (error: unknown) {
showRequestError(error, t('libresign', 'Failed to load signer details'))
} finally {
@@ -1036,7 +1175,7 @@ async function confirmRequestSigner() {
}
return signer
})
- await filesStore.saveOrUpdateSignatureRequest({ signers: signers as never, status: 1 })
+ await filesStore.saveOrUpdateSignatureRequest({ signers: signers as never, status: 1, signatureFlow: getSignatureFlowPayloadForSave() })
showSuccess(t('libresign', 'Signature requested'))
showConfirmRequestSigner.value = false
selectedSigner.value = null
@@ -1073,7 +1212,7 @@ async function save() {
await ensureCurrentFileDetail()
hasLoading.value = true
try {
- await filesStore.saveOrUpdateSignatureRequest({})
+ await filesStore.saveOrUpdateSignatureRequest({ signatureFlow: getSignatureFlowPayloadForSave() })
emit('libresign:show-visible-elements', new CustomEvent('libresign:show-visible-elements'))
} catch (error: unknown) {
showRequestError(error, t('libresign', 'Failed to save signature request'))
@@ -1089,7 +1228,7 @@ async function confirmRequest() {
await ensureCurrentFileDetail()
hasLoading.value = true
try {
- const response = await filesStore.saveOrUpdateSignatureRequest({ status: 1 })
+ const response = await filesStore.saveOrUpdateSignatureRequest({ status: 1, signatureFlow: getSignatureFlowPayloadForSave() })
showSuccess(t('libresign', response.message || 'Signature requested'))
showConfirmRequest.value = false
} catch (error: unknown) {
@@ -1100,7 +1239,7 @@ async function confirmRequest() {
async function openManageFiles() {
hasLoading.value = true
- const response = await filesStore.saveOrUpdateSignatureRequest({})
+ const response = await filesStore.saveOrUpdateSignatureRequest({ signatureFlow: getSignatureFlowPayloadForSave() })
hasLoading.value = false
if (response && 'success' in response && response.success === false && response.message) {
showError(response.message)
@@ -1190,11 +1329,19 @@ function stopSigningProgressPolling() {
watch(() => filesStore.selectedFileId, (newFileId) => {
if (newFileId) {
+ syncFileSignatureFlowWithPolicy()
syncPreserveOrderWithFile()
+ syncRememberSignatureFlowWithPolicy()
void ensureCurrentFileDetail()
}
}, { immediate: true })
+watch(signatureFlowPolicy, () => {
+ syncFileSignatureFlowWithPolicy()
+ syncPreserveOrderWithFile()
+ syncRememberSignatureFlowWithPolicy()
+})
+
const handleEditSigner = ((event: NextcloudEvent) => {
editSigner((event as CustomEvent).detail)
}) as EventHandler
@@ -1211,7 +1358,10 @@ onMounted(() => {
subscribe('libresign:edit-signer', handleEditSigner)
filesStore.disableIdentifySigner()
activeTab.value = userConfigStore.files_list_signer_identify_tab || ''
+ void policiesStore.fetchEffectivePolicies()
+ syncFileSignatureFlowWithPolicy()
syncPreserveOrderWithFile()
+ syncRememberSignatureFlowWithPolicy()
void ensureCurrentFileDetail()
})
@@ -1233,9 +1383,10 @@ defineExpose({
selectedSigner,
activeTab,
preserveOrder,
+ rememberSignatureFlow,
showOrderDiagram,
showEnvelopeFilesDialog,
- adminSignatureFlow,
+ signatureFlowPolicy,
debouncedSave,
debouncedTabChange,
signingProgress,
@@ -1244,9 +1395,12 @@ defineExpose({
stopPollingFunction,
signatureFlow,
isAdminFlowForced,
+ getSignatureFlowPayloadForSave,
isOrderedNumeric,
showSigningOrderOptions,
showPreserveOrder,
+ showRememberSignatureFlow,
+ showSignatureFlowPreferenceClearedNotice,
showViewOrderButton,
shouldShowOrderedOptions,
currentUserDisplayName,
@@ -1274,6 +1428,8 @@ defineExpose({
showSigningProgress,
isSignerSigned,
onPreserveOrderChange,
+ onRememberSignatureFlowChange,
+ syncFileSignatureFlowWithPolicy,
syncPreserveOrderWithFile,
getSvgIcon,
canSignerActInOrder,
diff --git a/src/components/Settings/Settings.vue b/src/components/Settings/Settings.vue
index e5e9b34953..c5e766cb7d 100644
--- a/src/components/Settings/Settings.vue
+++ b/src/components/Settings/Settings.vue
@@ -11,11 +11,24 @@
-
+
+
+
+
+
+
+
+
+
+
-
+
import { t } from '@nextcloud/l10n'
import { getCurrentUser } from '@nextcloud/auth'
+import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
@@ -37,8 +51,10 @@ import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import {
mdiAccount,
+ mdiCogOutline,
mdiStar,
- mdiTune,
+ mdiShieldCheckOutline,
+ mdiTuneVariant,
} from '@mdi/js'
defineOptions({
@@ -46,6 +62,8 @@ defineOptions({
})
const isAdmin = getCurrentUser()?.isAdmin ?? false
+const config = loadState<{ can_manage_group_policies?: boolean }>('libresign', 'config', {})
+const canManagePolicies = isAdmin || Boolean(config.can_manage_group_policies)
function getAdminRoute() {
return generateUrl('settings/admin/libresign')
@@ -53,5 +71,6 @@ function getAdminRoute() {
defineExpose({
getAdminRoute,
+ canManagePolicies,
})
diff --git a/src/router/router.ts b/src/router/router.ts
index 26344bfa7a..25f455e86a 100644
--- a/src/router/router.ts
+++ b/src/router/router.ts
@@ -180,6 +180,16 @@ const routes: RouteRecordRaw[] = [
name: 'Account',
component: () => import('../views/Account/Account.vue'),
},
+ {
+ path: '/f/preferences',
+ name: 'Preferences',
+ component: () => import('../views/Preferences/Preferences.vue'),
+ },
+ {
+ path: '/f/policies',
+ name: 'Policies',
+ component: () => import('../views/Policies/Policies.vue'),
+ },
{
path: '/f/docs/id-docs/validation',
name: 'DocsIdDocsValidation',
diff --git a/src/store/files.js b/src/store/files.js
index aee1a10214..e5c56971b3 100644
--- a/src/store/files.js
+++ b/src/store/files.js
@@ -17,6 +17,7 @@ import { generateOcsUrl } from '@nextcloud/router'
import { useFilesSortingStore } from './filesSorting.js'
import { useFiltersStore } from './filters.js'
import { useIdentificationDocumentStore } from './identificationDocument.js'
+import { usePoliciesStore } from './policies'
import { useSidebarStore } from './sidebar.js'
import { FILE_STATUS } from '../constants.js'
import { getSigningRouteUuid } from '../utils/signRequestUuid.ts'
@@ -1199,15 +1200,20 @@ const _filesStore = defineStore('files', () => {
*/
async function saveOrUpdateSignatureRequest({ visibleElements = [], signers = null, uuid = null, status = 0, signatureFlow = null } = {}) {
const store = getStore()
+ const policiesStore = usePoliciesStore()
const currentFileKey = selectedFileId.value
const selectedFile = getFile()
const requestSigners = serializeRequestSigners(signers || selectedFile?.signers || [])
const requestVisibleElements = serializeVisibleElements(visibleElements)
-
- let flowValue = signatureFlow || selectedFile.signatureFlow
- if (typeof flowValue === 'number') {
- const flowMap = { 0: 'none', 1: 'parallel', 2: 'ordered_numeric' }
- flowValue = flowMap[flowValue] || 'parallel'
+ const canUseSignatureFlowOverride = policiesStore.canUseRequestOverride('signature_flow')
+
+ let flowValue = signatureFlow
+ if (canUseSignatureFlowOverride) {
+ flowValue = signatureFlow ?? selectedFile.signatureFlow
+ if (typeof flowValue === 'number') {
+ const flowMap = { 0: 'none', 1: 'parallel', 2: 'ordered_numeric' }
+ flowValue = flowMap[flowValue] || 'parallel'
+ }
}
const config = {
@@ -1218,7 +1224,7 @@ const _filesStore = defineStore('files', () => {
signers: requestSigners,
visibleElements: requestVisibleElements,
status,
- signatureFlow: flowValue,
+ ...(flowValue !== null && flowValue !== undefined ? { signatureFlow: flowValue } : {}),
},
}
diff --git a/src/store/policies.ts b/src/store/policies.ts
new file mode 100644
index 0000000000..a8243f6aed
--- /dev/null
+++ b/src/store/policies.ts
@@ -0,0 +1,325 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+
+import axios from '@nextcloud/axios'
+import { loadState } from '@nextcloud/initial-state'
+import { generateOcsUrl } from '@nextcloud/router'
+
+import type {
+ EffectivePolicyState,
+ EffectivePolicyValue,
+ EffectivePoliciesResponse,
+ EffectivePoliciesState,
+ GroupPolicyResponse,
+ GroupPolicyState,
+ GroupPolicyWritePayload,
+ GroupPolicyWriteResponse,
+ SystemPolicyWritePayload,
+ SystemPolicyResponse,
+ SystemPolicyState,
+ SystemPolicyWriteResponse,
+ UserPolicyResponse,
+ UserPolicyState,
+} from '../types/index'
+
+function isEffectivePolicyState(value: unknown): value is EffectivePolicyState {
+ if (typeof value !== 'object' || value === null) {
+ return false
+ }
+
+ const candidate = value as Partial
+ return typeof candidate.policyKey === 'string'
+ && Array.isArray(candidate.allowedValues)
+ && typeof candidate.sourceScope === 'string'
+ && typeof candidate.visible === 'boolean'
+ && typeof candidate.editableByCurrentActor === 'boolean'
+ && typeof candidate.canSaveAsUserDefault === 'boolean'
+ && typeof candidate.canUseAsRequestOverride === 'boolean'
+ && typeof candidate.preferenceWasCleared === 'boolean'
+ && (candidate.blockedBy === null || typeof candidate.blockedBy === 'string')
+}
+
+function isGroupPolicyState(value: unknown): value is GroupPolicyState {
+ if (typeof value !== 'object' || value === null) {
+ return false
+ }
+
+ const candidate = value as Partial
+ return typeof candidate.policyKey === 'string'
+ && candidate.scope === 'group'
+ && typeof candidate.targetId === 'string'
+ && typeof candidate.allowChildOverride === 'boolean'
+ && typeof candidate.visibleToChild === 'boolean'
+ && Array.isArray(candidate.allowedValues)
+}
+
+function isSystemPolicyState(value: unknown): value is SystemPolicyState {
+ if (typeof value !== 'object' || value === null) {
+ return false
+ }
+
+ const candidate = value as Partial
+ return typeof candidate.policyKey === 'string'
+ && (candidate.scope === 'system' || candidate.scope === 'global')
+ && typeof candidate.allowChildOverride === 'boolean'
+ && typeof candidate.visibleToChild === 'boolean'
+ && Array.isArray(candidate.allowedValues)
+}
+
+function isUserPolicyState(value: unknown): value is UserPolicyState {
+ if (typeof value !== 'object' || value === null) {
+ return false
+ }
+
+ const candidate = value as Partial
+ return typeof candidate.policyKey === 'string'
+ && candidate.scope === 'user'
+ && typeof candidate.targetId === 'string'
+}
+
+function sanitizePolicies(rawPolicies: Record): EffectivePoliciesState {
+ const nextPolicies: EffectivePoliciesState = {}
+
+ for (const [policyKey, candidate] of Object.entries(rawPolicies)) {
+ if (isEffectivePolicyState(candidate)) {
+ nextPolicies[policyKey] = candidate
+ }
+ }
+
+ return nextPolicies
+}
+
+const _policiesStore = defineStore('policies', () => {
+ const initialPolicies = loadState('libresign', 'effective_policies', { policies: {} })
+ const policies = ref(sanitizePolicies(initialPolicies.policies ?? {}))
+
+ const setPolicies = (nextPolicies: Record): void => {
+ policies.value = sanitizePolicies(nextPolicies)
+ }
+
+ const fetchEffectivePolicies = async (): Promise => {
+ try {
+ const response = await axios.get<{ ocs?: { data?: EffectivePoliciesResponse } }>(generateOcsUrl('/apps/libresign/api/v1/policies/effective'))
+ setPolicies(response.data?.ocs?.data?.policies ?? {})
+ } catch (error: unknown) {
+ console.error('Failed to load effective policies', error)
+ }
+ }
+
+ const saveSystemPolicy = async (
+ policyKey: string,
+ value: EffectivePolicyValue,
+ allowChildOverride?: boolean,
+ ): Promise => {
+ const payload: SystemPolicyWritePayload & { allowChildOverride?: boolean } = { value }
+ if (typeof allowChildOverride === 'boolean') {
+ payload.allowChildOverride = allowChildOverride
+ }
+ const response = await axios.post<{ ocs?: { data?: SystemPolicyWriteResponse } }>(
+ generateOcsUrl(`/apps/libresign/api/v1/policies/system/${policyKey}`),
+ payload,
+ )
+
+ const savedPolicy = response.data?.ocs?.data?.policy
+ if (!isEffectivePolicyState(savedPolicy)) {
+ return null
+ }
+
+ policies.value = {
+ ...policies.value,
+ [policyKey]: savedPolicy,
+ }
+
+ return savedPolicy
+ }
+
+ const fetchGroupPolicy = async (groupId: string, policyKey: string): Promise => {
+ const response = await axios.get<{ ocs?: { data?: GroupPolicyResponse } }>(
+ generateOcsUrl(`/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`),
+ )
+
+ const policy = response.data?.ocs?.data?.policy
+ if (!isGroupPolicyState(policy)) {
+ return null
+ }
+
+ return policy
+ }
+
+ const fetchSystemPolicy = async (policyKey: string): Promise => {
+ const response = await axios.get<{ ocs?: { data?: SystemPolicyResponse } }>(
+ generateOcsUrl(`/apps/libresign/api/v1/policies/system/${policyKey}`),
+ )
+
+ const policy = response.data?.ocs?.data?.policy
+ if (!isSystemPolicyState(policy)) {
+ return null
+ }
+
+ return policy
+ }
+
+ const fetchUserPolicyForUser = async (userId: string, policyKey: string): Promise => {
+ const response = await axios.get<{ ocs?: { data?: UserPolicyResponse } }>(
+ generateOcsUrl(`/apps/libresign/api/v1/policies/user/${userId}/${policyKey}`),
+ )
+
+ const policy = response.data?.ocs?.data?.policy
+ if (!isUserPolicyState(policy)) {
+ return null
+ }
+
+ return policy
+ }
+
+ const saveGroupPolicy = async (
+ groupId: string,
+ policyKey: string,
+ value: EffectivePolicyValue,
+ allowChildOverride: boolean,
+ ): Promise => {
+ const payload: GroupPolicyWritePayload = { value, allowChildOverride }
+ const response = await axios.put<{ ocs?: { data?: GroupPolicyWriteResponse } }>(
+ generateOcsUrl(`/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`),
+ payload,
+ )
+
+ const policy = response.data?.ocs?.data?.policy
+ if (!isGroupPolicyState(policy)) {
+ return null
+ }
+
+ return policy
+ }
+
+ const clearGroupPolicy = async (groupId: string, policyKey: string): Promise => {
+ const response = await axios.delete<{ ocs?: { data?: GroupPolicyWriteResponse } }>(
+ generateOcsUrl(`/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`),
+ )
+
+ const policy = response.data?.ocs?.data?.policy
+ if (!isGroupPolicyState(policy)) {
+ return null
+ }
+
+ return policy
+ }
+
+ const saveUserPreference = async (policyKey: string, value: EffectivePolicyValue): Promise => {
+ const payload: SystemPolicyWritePayload = { value }
+ const response = await axios.put<{ ocs?: { data?: SystemPolicyWriteResponse } }>(
+ generateOcsUrl(`/apps/libresign/api/v1/policies/user/${policyKey}`),
+ payload,
+ )
+
+ const savedPolicy = response.data?.ocs?.data?.policy
+ if (!isEffectivePolicyState(savedPolicy)) {
+ return null
+ }
+
+ policies.value = {
+ ...policies.value,
+ [policyKey]: savedPolicy,
+ }
+
+ return savedPolicy
+ }
+
+ const clearUserPreference = async (policyKey: string): Promise => {
+ const response = await axios.delete<{ ocs?: { data?: SystemPolicyWriteResponse } }>(
+ generateOcsUrl(`/apps/libresign/api/v1/policies/user/${policyKey}`),
+ )
+
+ const savedPolicy = response.data?.ocs?.data?.policy
+ if (!isEffectivePolicyState(savedPolicy)) {
+ return null
+ }
+
+ policies.value = {
+ ...policies.value,
+ [policyKey]: savedPolicy,
+ }
+
+ return savedPolicy
+ }
+
+ const saveUserPolicyForUser = async (userId: string, policyKey: string, value: EffectivePolicyValue): Promise => {
+ const payload: SystemPolicyWritePayload = { value }
+ const response = await axios.put<{ ocs?: { data?: UserPolicyResponse } }>(
+ generateOcsUrl(`/apps/libresign/api/v1/policies/user/${userId}/${policyKey}`),
+ payload,
+ )
+
+ const savedPolicy = response.data?.ocs?.data?.policy
+ if (!isUserPolicyState(savedPolicy)) {
+ return null
+ }
+
+ return savedPolicy
+ }
+
+ const clearUserPolicyForUser = async (userId: string, policyKey: string): Promise => {
+ const response = await axios.delete<{ ocs?: { data?: UserPolicyResponse } }>(
+ generateOcsUrl(`/apps/libresign/api/v1/policies/user/${userId}/${policyKey}`),
+ )
+
+ const savedPolicy = response.data?.ocs?.data?.policy
+ if (!isUserPolicyState(savedPolicy)) {
+ return null
+ }
+
+ return savedPolicy
+ }
+
+ const getPolicy = (policyKey: string): EffectivePolicyState | null => {
+ const policy = policies.value[policyKey]
+ if (!policy) {
+ return null
+ }
+
+ return policy
+ }
+
+ const getEffectiveValue = (policyKey: string): EffectivePolicyState['effectiveValue'] | null => {
+ return getPolicy(policyKey)?.effectiveValue ?? null
+ }
+
+ const canUseRequestOverride = (policyKey: string): boolean => {
+ return getPolicy(policyKey)?.canUseAsRequestOverride ?? true
+ }
+
+ return {
+ policies: computed(() => policies.value),
+ setPolicies,
+ fetchEffectivePolicies,
+ fetchGroupPolicy,
+ fetchSystemPolicy,
+ fetchUserPolicyForUser,
+ saveSystemPolicy,
+ saveGroupPolicy,
+ clearGroupPolicy,
+ saveUserPreference,
+ clearUserPreference,
+ saveUserPolicyForUser,
+ clearUserPolicyForUser,
+ getPolicy,
+ getEffectiveValue,
+ canUseRequestOverride,
+ }
+})
+
+export const usePoliciesStore = function(...args: Parameters) {
+ return _policiesStore(...args)
+}
+
+export {
+ isEffectivePolicyState,
+ isGroupPolicyState,
+ isSystemPolicyState,
+ isUserPolicyState,
+}
diff --git a/src/store/userconfig.js b/src/store/userconfig.js
index 5ebd054119..c577188c33 100644
--- a/src/store/userconfig.js
+++ b/src/store/userconfig.js
@@ -14,6 +14,7 @@ import { generateOcsUrl } from '@nextcloud/router'
* @typedef {Record & {
* locale?: string
* files_list_grid_view?: boolean
+ * policy_workbench_catalog_compact_view?: boolean
* files_list_signer_identify_tab?: string
* crl_filters?: { serialNumber?: string, status?: string | null, owner?: string }
* crl_sort?: { sortBy?: string | null, sortOrder?: 'ASC' | 'DESC' | null }
diff --git a/src/tests/App.spec.ts b/src/tests/App.spec.ts
index 218e3425c3..04165f4bcf 100644
--- a/src/tests/App.spec.ts
+++ b/src/tests/App.spec.ts
@@ -71,6 +71,28 @@ describe('App', () => {
expect(wrapper.find('.left-sidebar').exists()).toBe(true)
})
+ it('shows left sidebar on request route', () => {
+ routeState.path = '/f/request'
+ routeState.name = 'requestFiles'
+ routeState.matched = []
+
+ const wrapper = mount(App, {
+ global: {
+ stubs: {
+ NcContent: { template: '
' },
+ NcAppContent: { template: ' ' },
+ NcEmptyContent: { template: '
' },
+ LeftSidebar: { name: 'LeftSidebar', template: '' },
+ RightSidebar: true,
+ DefaultPageError: true,
+ RouterView: { template: '
' },
+ },
+ },
+ })
+
+ expect(wrapper.find('.left-sidebar').exists()).toBe(true)
+ })
+
it('hides left sidebar on incomplete setup routes', () => {
routeState.path = '/f/incomplete'
routeState.name = 'Incomplete'
diff --git a/src/tests/components/FooterTemplateEditor.spec.ts b/src/tests/components/FooterTemplateEditor.spec.ts
index 1f49ca0baf..3243fc726a 100644
--- a/src/tests/components/FooterTemplateEditor.spec.ts
+++ b/src/tests/components/FooterTemplateEditor.spec.ts
@@ -181,9 +181,9 @@ describe('FooterTemplateEditor.vue', () => {
const wrapper = createWrapper()
await flushPromises()
- wrapper.vm.previewWidth = 700
+ wrapper.vm.previewWidth = 800
wrapper.vm.previewHeight = 150
- wrapper.vm.resetDimensions()
+ await wrapper.vm.resetDimensions()
expect(wrapper.vm.previewWidth).toBe(wrapper.vm.DEFAULT_PREVIEW_WIDTH)
expect(wrapper.vm.previewHeight).toBe(wrapper.vm.DEFAULT_PREVIEW_HEIGHT)
diff --git a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts
index 6ecbb9feee..f9e84ca961 100644
--- a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts
+++ b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts
@@ -4,12 +4,13 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { shallowMount } from '@vue/test-utils'
+import { flushPromises, shallowMount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import axios from '@nextcloud/axios'
import { loadState } from '@nextcloud/initial-state'
import type { useFilesStore as useFilesStoreType } from '../../../store/files.js'
+import { usePoliciesStore } from '../../../store/policies'
import RequestSignatureTab from '../../../components/RightSidebar/RequestSignatureTab.vue'
import { useFilesStore } from '../../../store/files.js'
import { FILE_STATUS } from '../../../constants.js'
@@ -38,6 +39,24 @@ vi.mock('@nextcloud/initial-state', () => ({
}
}
if (key === 'can_request_sign') return true
+ if (key === 'effective_policies') {
+ return {
+ policies: {
+ signature_flow: {
+ policyKey: 'signature_flow',
+ effectiveValue: 'none',
+ sourceScope: 'system',
+ visible: true,
+ editableByCurrentActor: true,
+ allowedValues: ['none', 'parallel', 'ordered_numeric'],
+ canSaveAsUserDefault: true,
+ canUseAsRequestOverride: true,
+ preferenceWasCleared: false,
+ blockedBy: null,
+ },
+ },
+ }
+ }
return defaultValue
}),
}))
@@ -63,6 +82,7 @@ vi.mock('@nextcloud/axios', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
+ put: vi.fn(),
delete: vi.fn(),
patch: vi.fn(),
},
@@ -74,13 +94,41 @@ vi.mock('@nextcloud/router', () => ({
}))
vi.mock('@libresign/pdf-elements', () => ({
- ensureWorkerReady: vi.fn(),
+ setWorkerPath: vi.fn(),
}))
describe('RequestSignatureTab - Critical Business Rules', () => {
let wrapper: VueWrapper
let filesStore: ReturnType
+ const createEffectivePoliciesResponse = (policyOverrides: Record = {}) => ({
+ data: {
+ ocs: {
+ data: {
+ policies: {
+ signature_flow: {
+ policyKey: 'signature_flow',
+ effectiveValue: 'none',
+ sourceScope: 'system',
+ visible: true,
+ editableByCurrentActor: true,
+ allowedValues: ['none', 'parallel', 'ordered_numeric'],
+ canSaveAsUserDefault: true,
+ canUseAsRequestOverride: true,
+ preferenceWasCleared: false,
+ blockedBy: null,
+ ...policyOverrides,
+ },
+ },
+ },
+ },
+ },
+ })
+
+ const createSignatureFlowPolicy = (policyOverrides: Record = {}) => {
+ return createEffectivePoliciesResponse(policyOverrides).data.ocs.data.policies.signature_flow
+ }
+
const updateFile = async (patch: Record) => {
const current = filesStore.files[1] || { id: 1 }
const hasSigners = Object.prototype.hasOwnProperty.call(patch, 'signers')
@@ -101,21 +149,24 @@ describe('RequestSignatureTab - Critical Business Rules', () => {
const updateMethods = async (methods: unknown[]) => {
await setVmState({ methods })
}
+ const updatePolicies = async (policyOverrides: Record) => {
+ const policiesStore = usePoliciesStore()
+ policiesStore.setPolicies({
+ signature_flow: createSignatureFlowPolicy(policyOverrides),
+ })
+ await wrapper.vm.$nextTick()
+ }
beforeEach(async () => {
setActivePinia(createPinia())
generateUrlMock.mockClear()
- vi.mocked(loadState).mockImplementation((app, key, defaultValue) => {
- if (key === 'config') {
- return {
- 'sign-elements': { 'is-available': true },
- 'identification_documents': { enabled: false },
- }
+ vi.mocked(axios.get).mockImplementation(async (url: string) => {
+ if (url.includes('/apps/libresign/api/v1/policies/effective')) {
+ return createEffectivePoliciesResponse() as Awaited>
}
- if (key === 'can_request_sign') return true
- return defaultValue
+
+ return { data: { ocs: { data: null } } } as Awaited>
})
- vi.mocked(axios.get).mockResolvedValue({ data: { ocs: { data: null } } } as Awaited>)
filesStore = useFilesStore()
await filesStore.addFile({
@@ -158,6 +209,7 @@ describe('RequestSignatureTab - Critical Business Rules', () => {
},
},
}) as VueWrapper
+ await flushPromises()
})
describe('RULE: showDocMdpWarning when DocMDP level prevents changes', () => {
@@ -278,6 +330,84 @@ describe('RequestSignatureTab - Critical Business Rules', () => {
})
})
+ describe('RULE: showRememberSignatureFlow only when signing order is meaningful', () => {
+ it('shows when document has multiple signers and user can save preference', async () => {
+ await updateFile({
+ status: FILE_STATUS.DRAFT,
+ signers: [
+ { email: 'test1@example.com', signed: [] },
+ { email: 'test2@example.com', signed: [] },
+ ],
+ })
+
+ expect(wrapper.vm.showRememberSignatureFlow).toBe(true)
+ })
+
+ it('hides when document has only one signer', async () => {
+ await updateFile({
+ status: FILE_STATUS.DRAFT,
+ signers: [{ email: 'test@example.com', signed: [] }],
+ })
+
+ expect(wrapper.vm.showRememberSignatureFlow).toBe(false)
+ })
+
+ it('hides when user cannot save preference even with multiple signers', async () => {
+ await updateFile({
+ status: FILE_STATUS.DRAFT,
+ signers: [
+ { email: 'test1@example.com', signed: [] },
+ { email: 'test2@example.com', signed: [] },
+ ],
+ })
+ await updatePolicies({ canSaveAsUserDefault: false })
+
+ expect(wrapper.vm.showRememberSignatureFlow).toBe(false)
+ })
+
+ it('hides when effective signature flow policy is fixed to parallel', async () => {
+ await updateFile({
+ status: FILE_STATUS.DRAFT,
+ signers: [
+ { email: 'test1@example.com', signed: [] },
+ { email: 'test2@example.com', signed: [] },
+ ],
+ })
+ await updatePolicies({ effectiveValue: 'parallel', canUseAsRequestOverride: false })
+
+ expect(wrapper.vm.showPreserveOrder).toBe(false)
+ expect(wrapper.vm.showRememberSignatureFlow).toBe(false)
+ })
+
+ it('hides when effective signature flow policy is fixed to ordered_numeric', async () => {
+ await updateFile({
+ status: FILE_STATUS.DRAFT,
+ signers: [
+ { email: 'test1@example.com', signed: [] },
+ { email: 'test2@example.com', signed: [] },
+ ],
+ })
+ await updatePolicies({ effectiveValue: 'ordered_numeric', canUseAsRequestOverride: false })
+
+ expect(wrapper.vm.showPreserveOrder).toBe(false)
+ expect(wrapper.vm.showRememberSignatureFlow).toBe(false)
+ })
+
+ it('keeps toggles available when ordered_numeric is only the default and request overrides are still allowed', async () => {
+ await updateFile({
+ status: FILE_STATUS.DRAFT,
+ signers: [
+ { email: 'test1@example.com', signed: [] },
+ { email: 'test2@example.com', signed: [] },
+ ],
+ })
+ await updatePolicies({ effectiveValue: 'ordered_numeric', canUseAsRequestOverride: true })
+
+ expect(wrapper.vm.showPreserveOrder).toBe(true)
+ expect(wrapper.vm.showRememberSignatureFlow).toBe(true)
+ })
+ })
+
describe('RULE: showViewOrderButton for ordered signatures', () => {
it('shows when signature flow is ordered_numeric', async () => {
await updateFile({
@@ -448,7 +578,7 @@ describe('RequestSignatureTab - Critical Business Rules', () => {
})
it('does not use stale sign_request_uuid from initial state when file has no signing UUIDs', async () => {
- vi.mocked(loadState).mockImplementation((app, key, defaultValue) => {
+ vi.mocked(loadState).mockImplementation((_app: string, key: string, defaultValue: unknown) => {
if (key === 'sign_request_uuid') {
return 'stale-sign-request-uuid'
}
@@ -691,7 +821,59 @@ describe('RequestSignatureTab - Critical Business Rules', () => {
})
})
- describe('RULE: signatureFlow calculation with admin override', () => {
+ describe('RULE: signatureFlow calculation with effective policy bootstrap', () => {
+ it('refreshes policy state from effective policies endpoint on mount', async () => {
+ wrapper.unmount()
+ vi.mocked(axios.get).mockImplementation(async (url: string) => {
+ if (url.includes('/apps/libresign/api/v1/policies/effective')) {
+ return createEffectivePoliciesResponse({
+ effectiveValue: 'ordered_numeric',
+ sourceScope: 'group',
+ canUseAsRequestOverride: false,
+ blockedBy: 'group',
+ }) as Awaited>
+ }
+
+ return { data: { ocs: { data: null } } } as Awaited>
+ })
+
+ wrapper = shallowMount(RequestSignatureTab, {
+ mocks: {
+ t: (_app: string, text: string) => text,
+ },
+ global: {
+ stubs: {
+ EnvelopeFilesList: { name: 'EnvelopeFilesList', template: '
' },
+ NcButton: true,
+ NcCheckboxRadioSwitch: true,
+ NcNoteCard: true,
+ NcActionInput: true,
+ NcActionButton: true,
+ NcFormBox: true,
+ NcLoadingIcon: true,
+ Signers: true,
+ SigningProgress: true,
+ AccountPlus: true,
+ ChartGantt: true,
+ FileMultiple: true,
+ Send: true,
+ Delete: true,
+ Bell: true,
+ Draw: true,
+ Pencil: true,
+ MessageText: true,
+ OrderNumericAscending: true,
+ },
+ },
+ }) as VueWrapper
+
+ await flushPromises()
+
+ expect(wrapper.vm.signatureFlowPolicy.effectiveValue).toBe('ordered_numeric')
+ expect(wrapper.vm.signatureFlowPolicy.sourceScope).toBe('group')
+ expect(wrapper.vm.signatureFlowPolicy.canUseAsRequestOverride).toBe(false)
+ })
+
it('returns ordered_numeric when file flow is 2', async () => {
await updateFile({ signatureFlow: 2 })
expect(wrapper.vm.signatureFlow).toBe('ordered_numeric')
@@ -707,37 +889,67 @@ describe('RequestSignatureTab - Critical Business Rules', () => {
expect(wrapper.vm.signatureFlow).toBe('none')
})
- it('uses admin flow when file flow is none', async () => {
- await setVmState({ adminSignatureFlow: 'ordered_numeric' })
+ it('uses effective policy when file flow is none', async () => {
+ await updatePolicies({ effectiveValue: 'ordered_numeric' })
await updateFile({ signatureFlow: 'none' })
expect(wrapper.vm.signatureFlow).toBe('ordered_numeric')
})
- it('defaults to parallel when both file and admin are none', async () => {
- await setVmState({ adminSignatureFlow: 'none' })
+ it('uses fixed effective policy even when file flow was parallel', async () => {
+ await updatePolicies({ effectiveValue: 'ordered_numeric', canUseAsRequestOverride: false })
+ await updateFile({ signatureFlow: 'parallel' })
+ expect(wrapper.vm.signatureFlow).toBe('ordered_numeric')
+ })
+
+ it('uses fixed effective policy when value comes as object with flow', async () => {
+ await updatePolicies({ effectiveValue: { flow: 'ordered_numeric' }, canUseAsRequestOverride: false })
+ await updateFile({ signatureFlow: 'parallel' })
+ expect(wrapper.vm.signatureFlow).toBe('ordered_numeric')
+ })
+
+ it('keeps request-level file flow when policy defaults to ordered_numeric but still allows overrides', async () => {
+ await updatePolicies({ effectiveValue: 'ordered_numeric', canUseAsRequestOverride: true })
+ await updateFile({ signatureFlow: 'parallel' })
+ expect(wrapper.vm.signatureFlow).toBe('parallel')
+ })
+
+ it('defaults to parallel when both file and policy are none', async () => {
+ await updatePolicies({ effectiveValue: 'none' })
await updateFile({ signatureFlow: 'none' })
expect(wrapper.vm.signatureFlow).toBe('parallel')
})
})
describe('RULE: isAdminFlowForced detection', () => {
- it('returns true when admin flow set to ordered_numeric', async () => {
- await setVmState({ adminSignatureFlow: 'ordered_numeric' })
+ it('returns true when policy blocks request overrides', async () => {
+ await updatePolicies({ canUseAsRequestOverride: false })
expect(wrapper.vm.isAdminFlowForced).toBe(true)
})
- it('returns true when admin flow set to parallel', async () => {
- await setVmState({ adminSignatureFlow: 'parallel' })
- expect(wrapper.vm.isAdminFlowForced).toBe(true)
+ it('returns false when policy allows request overrides', async () => {
+ await updatePolicies({ canUseAsRequestOverride: true })
+ expect(wrapper.vm.isAdminFlowForced).toBe(false)
})
- it('returns false when admin flow is none', async () => {
- await setVmState({ adminSignatureFlow: 'none' })
- expect(wrapper.vm.isAdminFlowForced).toBe(false)
+ it('hides preserve order switch when policy forces flow', async () => {
+ await updatePolicies({
+ canUseAsRequestOverride: false,
+ effectiveValue: 'ordered_numeric',
+ })
+ await updateFile({
+ signers: [
+ { email: 'test1@example.com' },
+ { email: 'test2@example.com' },
+ ],
+ })
+ expect(wrapper.vm.showPreserveOrder).toBe(false)
})
- it('hides preserve order switch when admin forces flow', async () => {
- await setVmState({ adminSignatureFlow: 'ordered_numeric' })
+ it('hides preserve order switch when fixed policy comes as object with flow', async () => {
+ await updatePolicies({
+ canUseAsRequestOverride: false,
+ effectiveValue: { flow: 'ordered_numeric' },
+ })
await updateFile({
signers: [
{ email: 'test1@example.com' },
@@ -904,6 +1116,43 @@ describe('RequestSignatureTab - Critical Business Rules', () => {
expect(filesStore.files[1].signatureFlow).toBe('ordered_numeric')
})
+ it('persists user preference when remember choice is enabled', async () => {
+ vi.mocked(axios.put).mockResolvedValue({
+ data: {
+ ocs: {
+ data: {
+ policy: createSignatureFlowPolicy({
+ effectiveValue: 'ordered_numeric',
+ sourceScope: 'user',
+ canSaveAsUserDefault: true,
+ canUseAsRequestOverride: true,
+ }),
+ },
+ },
+ },
+ })
+ await updatePolicies({
+ canSaveAsUserDefault: true,
+ canUseAsRequestOverride: true,
+ })
+ await updateFile({
+ signatureFlow: 'parallel',
+ signers: [
+ { email: 'signer1@example.com', signed: [] },
+ { email: 'signer2@example.com', signed: [] },
+ ],
+ })
+
+ wrapper.vm.rememberSignatureFlow = true
+ wrapper.vm.onPreserveOrderChange(true)
+ await flushPromises()
+
+ expect(axios.put).toHaveBeenCalledWith(
+ '/ocs/apps/libresign/api/v1/policies/user/signature_flow',
+ { value: 'ordered_numeric' },
+ )
+ })
+
it('assigns sequential orders when enabling', async () => {
await updateFile({
signatureFlow: 'parallel',
@@ -936,7 +1185,10 @@ describe('RequestSignatureTab - Critical Business Rules', () => {
})
it('reverts to parallel when disabling', async () => {
- await setVmState({ adminSignatureFlow: 'none' })
+ await updatePolicies({
+ effectiveValue: 'none',
+ canUseAsRequestOverride: true,
+ })
await updateFile({
signatureFlow: 'ordered_numeric',
signers: [
@@ -949,7 +1201,10 @@ describe('RequestSignatureTab - Critical Business Rules', () => {
})
it('preserves admin flow when disabling user preference', async () => {
- await setVmState({ adminSignatureFlow: 'ordered_numeric' })
+ await updatePolicies({
+ effectiveValue: 'ordered_numeric',
+ canUseAsRequestOverride: false,
+ })
await updateFile({
signatureFlow: 'ordered_numeric',
signers: [
@@ -982,11 +1237,35 @@ describe('RequestSignatureTab - Critical Business Rules', () => {
})
it('disables preserve order when admin forces flow', async () => {
- await setVmState({ adminSignatureFlow: 'ordered_numeric' })
+ await updatePolicies({
+ effectiveValue: 'ordered_numeric',
+ canUseAsRequestOverride: false,
+ })
await updateFile({ signatureFlow: 'ordered_numeric' })
wrapper.vm.syncPreserveOrderWithFile()
expect(wrapper.vm.preserveOrder).toBe(false)
})
+
+ it('synchronizes stale draft flow and signing orders when policy locks ordered_numeric', async () => {
+ await updatePolicies({
+ effectiveValue: 'ordered_numeric',
+ canUseAsRequestOverride: false,
+ })
+ await updateFile({
+ signatureFlow: 'parallel',
+ signers: [
+ { email: 'signer1@example.com', signed: [], signingOrder: 1 },
+ { email: 'signer2@example.com', signed: [], signingOrder: 1 },
+ ],
+ })
+
+ wrapper.vm.syncFileSignatureFlowWithPolicy()
+
+ const syncedFile = filesStore.getEditableFile()
+ expect(syncedFile.signatureFlow).toBe('ordered_numeric')
+ expect(syncedFile.signers?.[0]?.signingOrder).toBe(1)
+ expect(syncedFile.signers?.[1]?.signingOrder).toBe(2)
+ })
})
describe('RULE: updateSigningOrder and sort signers', () => {
diff --git a/src/tests/components/Settings/Settings.spec.ts b/src/tests/components/Settings/Settings.spec.ts
index d139d13a59..d11f919e6d 100644
--- a/src/tests/components/Settings/Settings.spec.ts
+++ b/src/tests/components/Settings/Settings.spec.ts
@@ -11,12 +11,15 @@ import type { TranslationFunction } from '../../test-types'
type SettingsComponent = typeof import('../../../components/Settings/Settings.vue').default
type AuthModule = typeof import('@nextcloud/auth')
+type InitialStateModule = typeof import('@nextcloud/initial-state')
const t: TranslationFunction = (_app, text) => text
let Settings: SettingsComponent
let auth: AuthModule
+let initialState: InitialStateModule
let getCurrentUserMock: MockedFunction
+let loadStateMock: MockedFunction
type SettingsVm = {
getAdminRoute: () => string
@@ -33,13 +36,18 @@ vi.mock('@nextcloud/auth', () => ({
})),
}))
vi.mock('@nextcloud/l10n', () => globalThis.mockNextcloudL10n())
+vi.mock('@nextcloud/initial-state', () => ({
+ loadState: vi.fn((app, key, defaults) => defaults),
+}))
vi.mock('@nextcloud/router', () => ({
generateUrl: vi.fn((url) => `/admin/${url}`),
}))
beforeAll(async () => {
auth = await import('@nextcloud/auth')
+ initialState = await import('@nextcloud/initial-state')
getCurrentUserMock = auth.getCurrentUser as MockedFunction
+ loadStateMock = initialState.loadState as MockedFunction
;({ default: Settings } = await import('../../../components/Settings/Settings.vue'))
})
@@ -82,9 +90,18 @@ describe('Settings', () => {
return item
}
- const createWrapper = (isAdmin = false): SettingsWrapper => {
+ const createWrapper = (isAdmin = false, canManagePolicies = false): SettingsWrapper => {
const user = { isAdmin } as ReturnType
getCurrentUserMock.mockReturnValue(user)
+ loadStateMock.mockImplementation((app, key, defaults) => {
+ if (key === 'config') {
+ return {
+ ...(defaults as Record),
+ can_manage_group_policies: canManagePolicies,
+ }
+ }
+ return defaults
+ })
return mount(Settings, {
global: {
@@ -111,6 +128,7 @@ describe('Settings', () => {
wrapper = null
}
vi.clearAllMocks()
+ loadStateMock.mockImplementation((app, key, defaults) => defaults)
})
describe('RULE: Account navigation item always displays', () => {
@@ -301,8 +319,8 @@ describe('Settings', () => {
wrapper = createUnauthenticatedWrapper()
const items = getItems()
- // Account + Rate = 2
- expect(items.length).toBe(2)
+ // Account + Preferences + Rate = 3
+ expect(items.length).toBe(3)
})
})
@@ -311,16 +329,71 @@ describe('Settings', () => {
wrapper = createWrapper(false)
const items = getItems()
- // Account + Rate = 2
- expect(items.length).toBe(2)
+ // Account + Preferences + Rate = 3
+ expect(items.length).toBe(3)
})
it('shows 3 items for admin', () => {
wrapper = createWrapper(true)
const items = getItems()
- // Account + Administration + Rate = 3
- expect(items.length).toBe(3)
+ // Account + Preferences + Policies + Administration + Rate = 5
+ expect(items.length).toBe(5)
+ })
+ })
+
+ describe('RULE: Preferences item is always available inside the app', () => {
+ it('shows Preferences for non-admin users', () => {
+ wrapper = createWrapper(false)
+ const items = getItems()
+ const preferencesItem = expectItem(findItemByName(items, 'Preferences'))
+
+ expect(preferencesItem.props('to')).toEqual({ name: 'Preferences' })
+ })
+
+ it('shows Preferences for admin users', () => {
+ wrapper = createWrapper(true)
+ const items = getItems()
+ const preferencesItem = expectItem(findItemByName(items, 'Preferences'))
+
+ expect(preferencesItem).toBeTruthy()
+ })
+
+ it('renders the preferences icon', () => {
+ wrapper = createWrapper(false)
+
+ expect(getWrapper().find('.preferences-icon').exists()).toBe(true)
+ })
+ })
+
+ describe('RULE: Policies item follows policy-management capability in the app menu', () => {
+ it('hides Policies for non-admin users', () => {
+ wrapper = createWrapper(false)
+ const items = getItems()
+
+ expect(findItemByName(items, 'Policies')).toBeUndefined()
+ })
+
+ it('shows Policies for admin users', () => {
+ wrapper = createWrapper(true)
+ const items = getItems()
+ const policiesItem = expectItem(findItemByName(items, 'Policies'))
+
+ expect(policiesItem.props('to')).toEqual({ name: 'Policies' })
+ })
+
+ it('shows Policies for non-admin users with group policy capability', () => {
+ wrapper = createWrapper(false, true)
+ const items = getItems()
+ const policiesItem = expectItem(findItemByName(items, 'Policies'))
+
+ expect(policiesItem.props('to')).toEqual({ name: 'Policies' })
+ })
+
+ it('renders the policies icon for admin users', () => {
+ wrapper = createWrapper(true)
+
+ expect(getWrapper().find('.policies-icon').exists()).toBe(true)
})
})
@@ -350,6 +423,18 @@ describe('Settings', () => {
expect(adminItem.props('name')).toBeTruthy()
expect(adminItem.props('href')).toBeTruthy()
})
+
+ it('does not use fallback icon prop when custom icon slot is present', () => {
+ wrapper = createWrapper(true)
+ const items = getItems()
+ const preferencesItem = expectItem(findItemByName(items, 'Preferences'))
+ const policiesItem = expectItem(findItemByName(items, 'Policies'))
+ const adminItem = expectItem(findItemByName(items, 'Administration'))
+
+ expect(preferencesItem.props('icon')).toBeUndefined()
+ expect(policiesItem.props('icon')).toBeUndefined()
+ expect(adminItem.props('icon')).toBeUndefined()
+ })
})
describe('RULE: icons render correctly', () => {
@@ -397,7 +482,17 @@ describe('Settings', () => {
const items = getItems()
const secondItem = expectItemAt(items, 1)
- expect(secondItem.props('name')).toContain('Administration')
+ expect(secondItem.props('name')).toContain('Preferences')
+ })
+
+ it('renders Policies before Administration for admin users', () => {
+ wrapper = createWrapper(true)
+ const items = getItems()
+ const thirdItem = expectItemAt(items, 2)
+ const fourthItem = expectItemAt(items, 3)
+
+ expect(thirdItem.props('name')).toContain('Policies')
+ expect(fourthItem.props('name')).toContain('Administration')
})
it('renders Rate last', async () => {
@@ -430,13 +525,15 @@ describe('Settings', () => {
wrapper = createWrapper(false)
const items = getItems()
- expect(items).toHaveLength(2)
+ expect(items).toHaveLength(3)
const hasAccount = items.some(i => i.props('name')?.includes('Account'))
+ const hasPreferences = items.some(i => i.props('name')?.includes('Preferences'))
const hasRate = items.some(i => i.props('name')?.includes('Rate'))
const hasAdmin = items.some(i => i.props('name')?.includes('Administration'))
expect(hasAccount).toBe(true)
+ expect(hasPreferences).toBe(true)
expect(hasRate).toBe(true)
expect(hasAdmin).toBe(false)
})
@@ -445,16 +542,39 @@ describe('Settings', () => {
wrapper = createWrapper(true)
const items = getItems()
- expect(items).toHaveLength(3)
+ expect(items).toHaveLength(5)
const hasAccount = items.some(i => i.props('name')?.includes('Account'))
+ const hasPreferences = items.some(i => i.props('name')?.includes('Preferences'))
+ const hasPolicies = items.some(i => i.props('name')?.includes('Policies'))
const hasRate = items.some(i => i.props('name')?.includes('Rate'))
const hasAdmin = items.some(i => i.props('name')?.includes('Administration'))
expect(hasAccount).toBe(true)
+ expect(hasPreferences).toBe(true)
+ expect(hasPolicies).toBe(true)
expect(hasRate).toBe(true)
expect(hasAdmin).toBe(true)
})
+
+ it('provides policies entry for group manager without global admin link', () => {
+ wrapper = createWrapper(false, true)
+ const items = getItems()
+
+ expect(items).toHaveLength(4)
+
+ const hasAccount = items.some(i => i.props('name')?.includes('Account'))
+ const hasPreferences = items.some(i => i.props('name')?.includes('Preferences'))
+ const hasPolicies = items.some(i => i.props('name')?.includes('Policies'))
+ const hasRate = items.some(i => i.props('name')?.includes('Rate'))
+ const hasAdmin = items.some(i => i.props('name')?.includes('Administration'))
+
+ expect(hasAccount).toBe(true)
+ expect(hasPreferences).toBe(true)
+ expect(hasPolicies).toBe(true)
+ expect(hasRate).toBe(true)
+ expect(hasAdmin).toBe(false)
+ })
})
describe('RULE: Account item icon configuration', () => {
diff --git a/src/tests/setup.js b/src/tests/setup.js
index 70b3c40f0c..7abdba9656 100644
--- a/src/tests/setup.js
+++ b/src/tests/setup.js
@@ -57,6 +57,13 @@ vi.mock('@nextcloud/vue/components/NcSelect', () => ({
},
}))
+vi.mock('@nextcloud/vue/components/NcSelectUsers', () => ({
+ default: {
+ name: 'NcSelectUsers',
+ template: '
',
+ },
+}))
+
vi.mock('@nextcloud/vue/components/NcRichText', () => ({
default: {
diff --git a/src/tests/store/files.spec.ts b/src/tests/store/files.spec.ts
index c7a1ec9416..52c5929bdf 100644
--- a/src/tests/store/files.spec.ts
+++ b/src/tests/store/files.spec.ts
@@ -10,6 +10,7 @@ import { createPinia, setActivePinia } from 'pinia'
import axios from '@nextcloud/axios'
import { emit } from '@nextcloud/event-bus'
import { generateOCSResponse } from '../test-helpers'
+import { usePoliciesStore } from '../../store/policies'
type AxiosMock = Mock & {
get: Mock
@@ -1227,6 +1228,41 @@ describe('files store - critical business rules', () => {
expect(config.data.file.settings).toEqual({ path: '/files/contract.pdf' })
})
+ it('omits signatureFlow when policy blocks request overrides', async () => {
+ const store = useFilesStore()
+ const policiesStore = usePoliciesStore()
+ policiesStore.setPolicies({
+ signature_flow: {
+ policyKey: 'signature_flow',
+ effectiveValue: 'ordered_numeric',
+ sourceScope: 'global',
+ visible: true,
+ editableByCurrentActor: false,
+ allowedValues: ['ordered_numeric'],
+ canSaveAsUserDefault: false,
+ canUseAsRequestOverride: false,
+ preferenceWasCleared: false,
+ blockedBy: 'global',
+ },
+ })
+ store.selectedFileId = 1
+ store.files[1] = {
+ id: 1,
+ nodeId: 99,
+ name: 'contract.pdf',
+ signatureFlow: 'ordered_numeric',
+ signers: [],
+ }
+ axiosMock.mockResolvedValue({
+ data: { ocs: { data: { id: 1, nodeId: 99, signatureFlow: 'ordered_numeric', signers: [] } } },
+ })
+
+ await store.saveOrUpdateSignatureRequest({ status: 1 })
+
+ const config = axiosMock.mock.calls[0][0]
+ expect(config.data.signatureFlow).toBeUndefined()
+ })
+
it('sorts ordered_numeric signers by signingOrder', async () => {
const store = useFilesStore()
store.selectedFileId = 1
diff --git a/src/tests/store/policies.spec.ts b/src/tests/store/policies.spec.ts
new file mode 100644
index 0000000000..c1027013d3
--- /dev/null
+++ b/src/tests/store/policies.spec.ts
@@ -0,0 +1,358 @@
+/*
+ * SPDX-FileCopyrightText: 2026 LibreSign contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createPinia, setActivePinia } from 'pinia'
+import axios from '@nextcloud/axios'
+
+vi.mock('@nextcloud/axios', () => ({
+ default: {
+ get: vi.fn(),
+ post: vi.fn(),
+ put: vi.fn(),
+ delete: vi.fn(),
+ },
+}))
+
+vi.mock('@nextcloud/router', () => ({
+ generateOcsUrl: vi.fn((path: string) => `/ocs/v2.php${path}`),
+}))
+
+vi.mock('@nextcloud/initial-state', () => ({
+ loadState: vi.fn((_app, _key, defaultValue) => defaultValue),
+}))
+
+describe('policies store', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.clearAllMocks()
+ })
+
+ it('stores backend policy payload as provided', async () => {
+ vi.mocked(axios.get).mockResolvedValue({
+ data: {
+ ocs: {
+ data: {
+ policies: {
+ signature_flow: {
+ policyKey: 'signature_flow',
+ effectiveValue: 'banana',
+ allowedValues: ['parallel'],
+ sourceScope: 'system',
+ visible: true,
+ editableByCurrentActor: true,
+ canSaveAsUserDefault: true,
+ canUseAsRequestOverride: true,
+ preferenceWasCleared: false,
+ blockedBy: null,
+ },
+ },
+ },
+ },
+ },
+ })
+
+ const { usePoliciesStore } = await import('../../store/policies')
+ const store = usePoliciesStore()
+ await store.fetchEffectivePolicies()
+
+ expect(store.getPolicy('signature_flow')?.effectiveValue).toBe('banana')
+ })
+
+ it('replaces a policy with the latest backend payload', async () => {
+ vi.mocked(axios.get).mockResolvedValue({
+ data: {
+ ocs: {
+ data: {
+ policies: {
+ signature_flow: {
+ policyKey: 'signature_flow',
+ effectiveValue: 'ordered_numeric',
+ allowedValues: ['ordered_numeric'],
+ sourceScope: 'group',
+ visible: true,
+ editableByCurrentActor: false,
+ canSaveAsUserDefault: false,
+ canUseAsRequestOverride: false,
+ preferenceWasCleared: false,
+ blockedBy: 'group',
+ },
+ },
+ },
+ },
+ },
+ })
+
+ const { usePoliciesStore } = await import('../../store/policies')
+ const store = usePoliciesStore()
+ await store.fetchEffectivePolicies()
+
+ expect(store.getPolicy('signature_flow')?.effectiveValue).toBe('ordered_numeric')
+ expect(store.getPolicy('signature_flow')?.canUseAsRequestOverride).toBe(false)
+ })
+
+ it('saves a system policy through the generic endpoint', async () => {
+ vi.mocked(axios.post).mockResolvedValue({
+ data: {
+ ocs: {
+ data: {
+ policy: {
+ policyKey: 'signature_flow',
+ effectiveValue: 'ordered_numeric',
+ allowedValues: ['none', 'parallel', 'ordered_numeric'],
+ sourceScope: 'system',
+ visible: true,
+ editableByCurrentActor: true,
+ canSaveAsUserDefault: true,
+ canUseAsRequestOverride: false,
+ preferenceWasCleared: false,
+ blockedBy: null,
+ },
+ },
+ },
+ },
+ })
+
+ const { usePoliciesStore } = await import('../../store/policies')
+ const store = usePoliciesStore()
+ const policy = await store.saveSystemPolicy('signature_flow', 'ordered_numeric')
+
+ expect(axios.post).toHaveBeenCalledWith(
+ '/ocs/v2.php/apps/libresign/api/v1/policies/system/signature_flow',
+ { value: 'ordered_numeric' },
+ )
+ expect(policy?.effectiveValue).toBe('ordered_numeric')
+ expect(store.getPolicy('signature_flow')?.sourceScope).toBe('system')
+ })
+
+ it('saves system allowChildOverride when provided', async () => {
+ vi.mocked(axios.post).mockResolvedValue({
+ data: {
+ ocs: {
+ data: {
+ policy: {
+ policyKey: 'signature_flow',
+ effectiveValue: 'ordered_numeric',
+ allowedValues: [],
+ sourceScope: 'system',
+ visible: true,
+ editableByCurrentActor: true,
+ canSaveAsUserDefault: true,
+ canUseAsRequestOverride: true,
+ preferenceWasCleared: false,
+ blockedBy: null,
+ },
+ },
+ },
+ },
+ })
+
+ const { usePoliciesStore } = await import('../../store/policies')
+ const store = usePoliciesStore()
+ await store.saveSystemPolicy('signature_flow', 'ordered_numeric', true)
+
+ expect(axios.post).toHaveBeenCalledWith(
+ '/ocs/v2.php/apps/libresign/api/v1/policies/system/signature_flow',
+ { value: 'ordered_numeric', allowChildOverride: true },
+ )
+ })
+
+ it('saves a user preference through the generic endpoint', async () => {
+ vi.mocked(axios.put).mockResolvedValue({
+ data: {
+ ocs: {
+ data: {
+ policy: {
+ policyKey: 'signature_flow',
+ effectiveValue: 'parallel',
+ allowedValues: ['parallel', 'ordered_numeric'],
+ sourceScope: 'user',
+ visible: true,
+ editableByCurrentActor: true,
+ canSaveAsUserDefault: true,
+ canUseAsRequestOverride: true,
+ preferenceWasCleared: false,
+ blockedBy: null,
+ },
+ },
+ },
+ },
+ })
+
+ const { usePoliciesStore } = await import('../../store/policies')
+ const store = usePoliciesStore()
+ const policy = await store.saveUserPreference('signature_flow', 'parallel')
+
+ expect(axios.put).toHaveBeenCalledWith(
+ '/ocs/v2.php/apps/libresign/api/v1/policies/user/signature_flow',
+ { value: 'parallel' },
+ )
+ expect(policy?.sourceScope).toBe('user')
+ })
+
+ it('loads a group policy through the generic endpoint', async () => {
+ vi.mocked(axios.get).mockResolvedValueOnce({
+ data: {
+ ocs: {
+ data: {
+ policy: {
+ policyKey: 'signature_flow',
+ scope: 'group',
+ targetId: 'finance',
+ value: 'parallel',
+ allowChildOverride: true,
+ visibleToChild: true,
+ allowedValues: [],
+ },
+ },
+ },
+ },
+ })
+
+ const { usePoliciesStore } = await import('../../store/policies')
+ const store = usePoliciesStore()
+ const policy = await store.fetchGroupPolicy('finance', 'signature_flow')
+
+ expect(axios.get).toHaveBeenCalledWith('/ocs/v2.php/apps/libresign/api/v1/policies/group/finance/signature_flow')
+ expect(policy?.targetId).toBe('finance')
+ expect(policy?.value).toBe('parallel')
+ })
+
+ it('loads an explicit system policy through the generic endpoint', async () => {
+ vi.mocked(axios.get).mockResolvedValueOnce({
+ data: {
+ ocs: {
+ data: {
+ policy: {
+ policyKey: 'signature_flow',
+ scope: 'global',
+ value: 'ordered_numeric',
+ allowChildOverride: false,
+ visibleToChild: true,
+ allowedValues: ['ordered_numeric'],
+ },
+ },
+ },
+ },
+ })
+
+ const { usePoliciesStore } = await import('../../store/policies')
+ const store = usePoliciesStore()
+ const policy = await store.fetchSystemPolicy('signature_flow')
+
+ expect(axios.get).toHaveBeenCalledWith('/ocs/v2.php/apps/libresign/api/v1/policies/system/signature_flow')
+ expect(policy?.scope).toBe('global')
+ expect(policy?.value).toBe('ordered_numeric')
+ })
+
+ it('loads a target user policy through the admin endpoint', async () => {
+ vi.mocked(axios.get).mockResolvedValueOnce({
+ data: {
+ ocs: {
+ data: {
+ policy: {
+ policyKey: 'signature_flow',
+ scope: 'user',
+ targetId: 'user1',
+ value: 'parallel',
+ },
+ },
+ },
+ },
+ })
+
+ const { usePoliciesStore } = await import('../../store/policies')
+ const store = usePoliciesStore()
+ const policy = await store.fetchUserPolicyForUser('user1', 'signature_flow')
+
+ expect(axios.get).toHaveBeenCalledWith('/ocs/v2.php/apps/libresign/api/v1/policies/user/user1/signature_flow')
+ expect(policy?.targetId).toBe('user1')
+ expect(policy?.value).toBe('parallel')
+ })
+
+ it('saves a group policy through the generic endpoint', async () => {
+ vi.mocked(axios.put).mockResolvedValueOnce({
+ data: {
+ ocs: {
+ data: {
+ policy: {
+ policyKey: 'signature_flow',
+ scope: 'group',
+ targetId: 'finance',
+ value: 'ordered_numeric',
+ allowChildOverride: false,
+ visibleToChild: true,
+ allowedValues: ['ordered_numeric'],
+ },
+ },
+ },
+ },
+ })
+
+ const { usePoliciesStore } = await import('../../store/policies')
+ const store = usePoliciesStore()
+ const policy = await store.saveGroupPolicy('finance', 'signature_flow', 'ordered_numeric', false)
+
+ expect(axios.put).toHaveBeenCalledWith(
+ '/ocs/v2.php/apps/libresign/api/v1/policies/group/finance/signature_flow',
+ { value: 'ordered_numeric', allowChildOverride: false },
+ )
+ expect(policy?.value).toBe('ordered_numeric')
+ expect(policy?.allowChildOverride).toBe(false)
+ })
+
+ it('saves a user policy for a target user through the admin endpoint', async () => {
+ vi.mocked(axios.put).mockResolvedValueOnce({
+ data: {
+ ocs: {
+ data: {
+ policy: {
+ policyKey: 'signature_flow',
+ scope: 'user',
+ targetId: 'user1',
+ value: 'ordered_numeric',
+ },
+ },
+ },
+ },
+ })
+
+ const { usePoliciesStore } = await import('../../store/policies')
+ const store = usePoliciesStore()
+ const policy = await store.saveUserPolicyForUser('user1', 'signature_flow', 'ordered_numeric')
+
+ expect(axios.put).toHaveBeenCalledWith(
+ '/ocs/v2.php/apps/libresign/api/v1/policies/user/user1/signature_flow',
+ { value: 'ordered_numeric' },
+ )
+ expect(policy?.scope).toBe('user')
+ expect(policy?.value).toBe('ordered_numeric')
+ })
+
+ it('clears a user policy for a target user through the admin endpoint', async () => {
+ vi.mocked(axios.delete).mockResolvedValueOnce({
+ data: {
+ ocs: {
+ data: {
+ policy: {
+ policyKey: 'signature_flow',
+ scope: 'user',
+ targetId: 'user1',
+ value: null,
+ },
+ },
+ },
+ },
+ })
+
+ const { usePoliciesStore } = await import('../../store/policies')
+ const store = usePoliciesStore()
+ const policy = await store.clearUserPolicyForUser('user1', 'signature_flow')
+
+ expect(axios.delete).toHaveBeenCalledWith('/ocs/v2.php/apps/libresign/api/v1/policies/user/user1/signature_flow')
+ expect(policy?.scope).toBe('user')
+ expect(policy?.value).toBeNull()
+ })
+})
diff --git a/src/tests/views/Preferences/Preferences.spec.ts b/src/tests/views/Preferences/Preferences.spec.ts
new file mode 100644
index 0000000000..e6790414c8
--- /dev/null
+++ b/src/tests/views/Preferences/Preferences.spec.ts
@@ -0,0 +1,148 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { defineComponent, nextTick } from 'vue'
+
+import type { EffectivePolicyState, SignatureFlowMode } from '../../../types/index'
+import { createL10nMock } from '../../testHelpers/l10n.js'
+
+const fetchEffectivePoliciesMock = vi.fn()
+const saveUserPreferenceMock = vi.fn()
+const clearUserPreferenceMock = vi.fn()
+const getPolicyMock = vi.fn<() => EffectivePolicyState | null>()
+
+vi.mock('@nextcloud/l10n', () => createL10nMock())
+
+vi.mock('../../../store/policies', () => ({
+ usePoliciesStore: () => ({
+ fetchEffectivePolicies: fetchEffectivePoliciesMock,
+ saveUserPreference: saveUserPreferenceMock,
+ clearUserPreference: clearUserPreferenceMock,
+ getPolicy: getPolicyMock,
+ }),
+}))
+
+const NcSettingsSection = defineComponent({
+ name: 'NcSettingsSection',
+ props: ['name', 'description'],
+ template: '{{ name }} {{ description }}
',
+})
+
+const NcNoteCard = defineComponent({
+ name: 'NcNoteCard',
+ props: ['type'],
+ template: '
',
+})
+
+const NcCheckboxRadioSwitch = defineComponent({
+ name: 'NcCheckboxRadioSwitch',
+ props: ['value', 'modelValue', 'type', 'disabled', 'name'],
+ emits: ['update:modelValue'],
+ template: ' ',
+})
+
+const NcButton = defineComponent({
+ name: 'NcButton',
+ props: ['type', 'disabled'],
+ emits: ['click'],
+ template: ' ',
+})
+
+describe('Preferences view', () => {
+ beforeEach(() => {
+ fetchEffectivePoliciesMock.mockReset().mockResolvedValue(undefined)
+ saveUserPreferenceMock.mockReset().mockResolvedValue(undefined)
+ clearUserPreferenceMock.mockReset().mockResolvedValue(undefined)
+ getPolicyMock.mockReset().mockReturnValue({
+ policyKey: 'signature_flow',
+ effectiveValue: 'parallel',
+ sourceScope: 'system',
+ visible: true,
+ editableByCurrentActor: true,
+ allowedValues: ['parallel', 'ordered_numeric'],
+ blockedBy: null,
+ canSaveAsUserDefault: true,
+ canUseAsRequestOverride: true,
+ preferenceWasCleared: false,
+ })
+ })
+
+ async function createWrapper() {
+ const { default: Preferences } = await import('../../../views/Preferences/Preferences.vue')
+ return mount(Preferences, {
+ global: {
+ stubs: {
+ NcSettingsSection,
+ NcNoteCard,
+ NcCheckboxRadioSwitch,
+ NcButton,
+ },
+ },
+ })
+ }
+
+ it('loads effective policies on mount', async () => {
+ await createWrapper()
+
+ expect(fetchEffectivePoliciesMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('shows the effective signing order summary', async () => {
+ const wrapper = await createWrapper()
+
+ expect(wrapper.text()).toContain('Effective signing order')
+ expect(wrapper.text()).toContain('Simultaneous (Parallel)')
+ expect(wrapper.text()).toContain('Global default')
+ })
+
+ it('saves a user preference when requested', async () => {
+ const wrapper = await createWrapper()
+
+ await wrapper.vm.savePreference('ordered_numeric' as SignatureFlowMode)
+
+ expect(saveUserPreferenceMock).toHaveBeenCalledWith('signature_flow', 'ordered_numeric')
+ })
+
+ it('clears a saved user preference', async () => {
+ getPolicyMock.mockReturnValue({
+ policyKey: 'signature_flow',
+ effectiveValue: 'ordered_numeric',
+ sourceScope: 'user',
+ visible: true,
+ editableByCurrentActor: true,
+ allowedValues: ['parallel', 'ordered_numeric'],
+ blockedBy: null,
+ canSaveAsUserDefault: true,
+ canUseAsRequestOverride: true,
+ preferenceWasCleared: false,
+ })
+ const wrapper = await createWrapper()
+
+ await wrapper.vm.clearPreference()
+
+ expect(clearUserPreferenceMock).toHaveBeenCalledWith('signature_flow')
+ })
+
+ it('shows an informational note when saving is blocked', async () => {
+ getPolicyMock.mockReturnValue({
+ policyKey: 'signature_flow',
+ effectiveValue: 'parallel',
+ sourceScope: 'group',
+ visible: true,
+ editableByCurrentActor: false,
+ allowedValues: ['parallel'],
+ blockedBy: 'group',
+ canSaveAsUserDefault: false,
+ canUseAsRequestOverride: false,
+ preferenceWasCleared: false,
+ })
+ const wrapper = await createWrapper()
+ await nextTick()
+
+ expect(wrapper.text()).toContain('does not allow saving a personal default')
+ })
+})
diff --git a/src/tests/views/Settings/DocMDP.spec.ts b/src/tests/views/Settings/DocMDP.spec.ts
deleted file mode 100644
index 6cb5055f53..0000000000
--- a/src/tests/views/Settings/DocMDP.spec.ts
+++ /dev/null
@@ -1,206 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 LibreSign contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
-import { flushPromises, mount } from '@vue/test-utils'
-
-type DocMDPLevel = {
- value: number
- label: string
- description: string
-}
-
-type DocMDPVm = {
- enabled: boolean
- selectedLevel?: DocMDPLevel
- onEnabledChange: () => void
-}
-
-const loadStateMock = vi.fn()
-const generateOcsUrlMock = vi.fn((path: string) => path)
-const axiosPostMock = vi.fn((..._args: unknown[]) => Promise.resolve({ data: { ocs: { data: {} } } }))
-
-vi.mock('@nextcloud/initial-state', () => ({
- loadState: (...args: unknown[]) => loadStateMock(...args),
-}))
-
-vi.mock('@nextcloud/router', () => ({
- generateOcsUrl: (...args: unknown[]) => generateOcsUrlMock(...(args as [string])),
-}))
-
-vi.mock('@nextcloud/axios', () => ({
- default: {
- post: axiosPostMock,
- },
-}))
-
-vi.mock('@nextcloud/l10n', () => globalThis.mockNextcloudL10n())
-
-let DocMDP: unknown
-
-const NcCheckboxRadioSwitchStub = {
- name: 'NcCheckboxRadioSwitch',
- props: ['modelValue', 'value', 'type'],
- emits: ['update:modelValue'],
- template: ' ',
-}
-
-beforeAll(async () => {
- ;({ default: DocMDP } = await import('../../../views/Settings/DocMDP.vue'))
-})
-
-describe('DocMDP', () => {
- beforeEach(() => {
- loadStateMock.mockReset()
- generateOcsUrlMock.mockClear()
- axiosPostMock.mockClear()
- })
-
- it('uses typed backend config on load', async () => {
- loadStateMock.mockImplementation((_app: string, key: string, fallback: unknown) => {
- if (key === 'docmdp_config') {
- return {
- enabled: false,
- defaultLevel: 2,
- availableLevels: [
- { value: 1, label: 'L1', description: 'D1' },
- { value: 2, label: 'L2', description: 'D2' },
- ],
- }
- }
- return fallback
- })
-
- const wrapper = mount(DocMDP as never, {
- global: {
- stubs: {
- NcSettingsSection: { template: '
' },
- NcCheckboxRadioSwitch: NcCheckboxRadioSwitchStub,
- NcLoadingIcon: true,
- NcNoteCard: true,
- NcSavingIndicatorIcon: true,
- },
- },
- })
- const vm = wrapper.vm as unknown as DocMDPVm
- await flushPromises()
-
- expect(vm.enabled).toBe(false)
- expect(vm.selectedLevel?.value).toBe(2)
- })
-
- it('respects backend default config when storage is empty', async () => {
- loadStateMock.mockImplementation((_app: string, key: string, fallback: unknown) => {
- if (key === 'docmdp_config') {
- return {
- enabled: true,
- defaultLevel: 2,
- availableLevels: [
- { value: 0, label: 'L0', description: 'D0' },
- { value: 1, label: 'L1', description: 'D1' },
- { value: 2, label: 'L2', description: 'D2' },
- ],
- }
- }
- return fallback
- })
-
- const wrapper = mount(DocMDP as never, {
- global: {
- stubs: {
- NcSettingsSection: { template: '
' },
- NcCheckboxRadioSwitch: NcCheckboxRadioSwitchStub,
- NcLoadingIcon: true,
- NcNoteCard: true,
- NcSavingIndicatorIcon: true,
- },
- },
- })
- const vm = wrapper.vm as unknown as DocMDPVm
- await flushPromises()
-
- expect(vm.enabled).toBe(true)
- expect(vm.selectedLevel?.value).toBe(2)
- })
-
- it('changes selected level and persists selected radio value', async () => {
- loadStateMock.mockImplementation((_app: string, key: string, fallback: unknown) => {
- if (key === 'docmdp_config') {
- return {
- enabled: true,
- defaultLevel: 1,
- availableLevels: [
- { value: 1, label: 'L1', description: 'D1' },
- { value: 2, label: 'L2', description: 'D2' },
- { value: 3, label: 'L3', description: 'D3' },
- ],
- }
- }
- return fallback
- })
-
- const wrapper = mount(DocMDP as never, {
- global: {
- stubs: {
- NcSettingsSection: { template: '
' },
- NcCheckboxRadioSwitch: NcCheckboxRadioSwitchStub,
- NcLoadingIcon: true,
- NcNoteCard: true,
- NcSavingIndicatorIcon: true,
- },
- },
- })
- const vm = wrapper.vm as unknown as DocMDPVm
- await flushPromises()
-
- const radioAndSwitchButtons = wrapper.findAll('.checkbox-radio-switch-stub')
- await radioAndSwitchButtons[3].trigger('click')
- await flushPromises()
-
- expect(vm.selectedLevel?.value).toBe(3)
- expect(axiosPostMock).toHaveBeenCalled()
- const lastCall = axiosPostMock.mock.calls[axiosPostMock.mock.calls.length - 1] as [string, { enabled: boolean, defaultLevel: number }]
- expect(lastCall[1].defaultLevel).toBe(3)
- })
-
- it('uses preferred level 2 when enabling without explicit selected level', async () => {
- loadStateMock.mockImplementation((_app: string, key: string, fallback: unknown) => {
- if (key === 'docmdp_config') {
- return {
- enabled: false,
- defaultLevel: 2,
- availableLevels: [
- { value: 0, label: 'L0', description: 'D0' },
- { value: 1, label: 'L1', description: 'D1' },
- { value: 2, label: 'L2', description: 'D2' },
- ],
- }
- }
- return fallback
- })
-
- const wrapper = mount(DocMDP as never, {
- global: {
- stubs: {
- NcSettingsSection: { template: '
' },
- NcCheckboxRadioSwitch: NcCheckboxRadioSwitchStub,
- NcLoadingIcon: true,
- NcNoteCard: true,
- NcSavingIndicatorIcon: true,
- },
- },
- })
- const vm = wrapper.vm as unknown as DocMDPVm
- await flushPromises()
-
- expect(vm.selectedLevel?.value).toBe(2)
- vm.enabled = true
- vm.onEnabledChange()
- await flushPromises()
-
- const lastCall = axiosPostMock.mock.calls[axiosPostMock.mock.calls.length - 1] as [string, { enabled: boolean, defaultLevel: number }]
- expect(lastCall[1]).toMatchObject({ enabled: true, defaultLevel: 2 })
- })
-})
diff --git a/src/tests/views/Settings/PolicyWorkbench/SignatureFlowScalarRuleEditor.spec.ts b/src/tests/views/Settings/PolicyWorkbench/SignatureFlowScalarRuleEditor.spec.ts
new file mode 100644
index 0000000000..da62408cd5
--- /dev/null
+++ b/src/tests/views/Settings/PolicyWorkbench/SignatureFlowScalarRuleEditor.spec.ts
@@ -0,0 +1,112 @@
+/*
+ * SPDX-FileCopyrightText: 2026 LibreSign contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+
+import { createL10nMock } from '../../../testHelpers/l10n.js'
+
+vi.mock('@nextcloud/l10n', () => createL10nMock())
+
+import SignatureFlowScalarRuleEditor from '../../../../views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowScalarRuleEditor.vue'
+
+describe('SignatureFlowScalarRuleEditor.vue', () => {
+ it('shows three explicit options and emits the selected scalar value', async () => {
+ const wrapper = mount(SignatureFlowScalarRuleEditor, {
+ props: {
+ modelValue: 'parallel',
+ },
+ global: {
+ stubs: {
+ NcCheckboxRadioSwitch: {
+ template: '
',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.text()).toContain('Simultaneous (Parallel)')
+ expect(wrapper.text()).toContain('Sequential')
+ expect(wrapper.text()).toContain('User choice')
+ expect(wrapper.text()).toContain('Users can choose between simultaneous or sequential signing.')
+
+ const switches = wrapper.findAll('.switch')
+ expect(switches).toHaveLength(3)
+ await switches[2].trigger('click')
+
+ const emissions = wrapper.emitted('update:modelValue')
+ expect(emissions).toBeTruthy()
+ expect(emissions?.[0]?.[0]).toBe('none')
+ })
+
+ it('emits selected value even when radio update payload is omitted', async () => {
+ const wrapper = mount(SignatureFlowScalarRuleEditor, {
+ props: {
+ modelValue: 'parallel',
+ },
+ global: {
+ stubs: {
+ NcCheckboxRadioSwitch: {
+ template: '
',
+ },
+ },
+ },
+ })
+
+ const switches = wrapper.findAll('.switch-no-payload')
+ expect(switches).toHaveLength(3)
+ await switches[1].trigger('click')
+
+ const emissions = wrapper.emitted('update:modelValue')
+ expect(emissions).toBeTruthy()
+ expect(emissions?.[0]?.[0]).toBe('ordered_numeric')
+ })
+
+ it('starts with no option selected when draft value is empty', () => {
+ const wrapper = mount(SignatureFlowScalarRuleEditor, {
+ props: {
+ modelValue: '' as never,
+ },
+ global: {
+ stubs: {
+ NcCheckboxRadioSwitch: {
+ props: ['modelValue'],
+ template: "
",
+ },
+ },
+ },
+ })
+
+ const selectedStates = wrapper.findAll('.switch-state').map((node) => node.attributes('data-selected'))
+ expect(selectedStates).toEqual(['false', 'false', 'false'])
+ })
+
+ it('disables default option and shows helper copy for instance rule creation', async () => {
+ const wrapper = mount(SignatureFlowScalarRuleEditor, {
+ props: {
+ modelValue: 'none',
+ editorScope: 'system',
+ editorMode: 'create',
+ },
+ global: {
+ stubs: {
+ NcCheckboxRadioSwitch: {
+ props: ['disabled'],
+ template: '
',
+ },
+ },
+ },
+ })
+
+ expect(wrapper.text()).toContain('already matches the default')
+
+ const switches = wrapper.findAll('.switch-disabled')
+ expect(switches).toHaveLength(3)
+ expect(switches[2]?.attributes('data-disabled')).toBe('true')
+
+ await switches[2]?.trigger('click')
+ expect(wrapper.emitted('update:modelValue')).toBeFalsy()
+ })
+})
diff --git a/src/tests/views/Settings/PolicyWorkbench/signatureFooterModel.spec.ts b/src/tests/views/Settings/PolicyWorkbench/signatureFooterModel.spec.ts
new file mode 100644
index 0000000000..8bd415eb8d
--- /dev/null
+++ b/src/tests/views/Settings/PolicyWorkbench/signatureFooterModel.spec.ts
@@ -0,0 +1,55 @@
+/*
+ * SPDX-FileCopyrightText: 2026 LibreSign contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { describe, expect, it } from 'vitest'
+
+import {
+ getDefaultSignatureFooterPolicyConfig,
+ normalizeSignatureFooterPolicyConfig,
+ serializeSignatureFooterPolicyConfig,
+} from '../../../../views/Settings/PolicyWorkbench/settings/signature-footer/model'
+
+describe('signature-footer model', () => {
+ it('normalizes legacy boolean values', () => {
+ expect(normalizeSignatureFooterPolicyConfig(true)).toEqual({
+ enabled: true,
+ writeQrcodeOnFooter: true,
+ validationSite: '',
+ customizeFooterTemplate: false,
+ })
+
+ expect(normalizeSignatureFooterPolicyConfig('0')).toEqual({
+ enabled: false,
+ writeQrcodeOnFooter: true,
+ validationSite: '',
+ customizeFooterTemplate: false,
+ })
+ })
+
+ it('normalizes structured JSON payload', () => {
+ const payload = '{"enabled":true,"writeQrcodeOnFooter":false,"validationSite":"https://validation.example","customizeFooterTemplate":true}'
+ expect(normalizeSignatureFooterPolicyConfig(payload)).toEqual({
+ enabled: true,
+ writeQrcodeOnFooter: false,
+ validationSite: 'https://validation.example',
+ customizeFooterTemplate: true,
+ })
+ })
+
+ it('serializes canonical payload from config object', () => {
+ const serialized = serializeSignatureFooterPolicyConfig({
+ enabled: true,
+ writeQrcodeOnFooter: true,
+ validationSite: '',
+ customizeFooterTemplate: false,
+ })
+
+ expect(serialized).toBe('{"enabled":true,"writeQrcodeOnFooter":true,"validationSite":"","customizeFooterTemplate":false}')
+ })
+
+ it('returns defaults when payload is empty', () => {
+ expect(normalizeSignatureFooterPolicyConfig('')).toEqual(getDefaultSignatureFooterPolicyConfig())
+ })
+})
diff --git a/src/tests/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.spec.ts b/src/tests/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.spec.ts
new file mode 100644
index 0000000000..c953d47442
--- /dev/null
+++ b/src/tests/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.spec.ts
@@ -0,0 +1,1025 @@
+/*
+ * SPDX-FileCopyrightText: 2026 LibreSign contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createL10nMock } from '../../../testHelpers/l10n.js'
+
+vi.mock('@nextcloud/l10n', () => createL10nMock())
+
+const { currentUserState } = vi.hoisted(() => ({
+ currentUserState: {
+ isAdmin: true,
+ },
+}))
+
+vi.mock('@nextcloud/auth', () => ({
+ getCurrentUser: vi.fn(() => currentUserState),
+}))
+
+vi.mock('@nextcloud/initial-state', () => ({
+ loadState: vi.fn((_app, key: string, defaultValue: unknown) => {
+ if (key === 'config') {
+ return { can_manage_group_policies: true }
+ }
+
+ return defaultValue
+ }),
+}))
+
+const { axiosGet } = vi.hoisted(() => ({
+ axiosGet: vi.fn(),
+}))
+
+vi.mock('@nextcloud/axios', () => ({
+ default: {
+ get: axiosGet,
+ },
+}))
+
+vi.mock('@nextcloud/router', () => ({
+ generateOcsUrl: vi.fn((path: string) => path),
+}))
+
+const saveSystemPolicy = vi.fn()
+const saveGroupPolicy = vi.fn()
+const fetchGroupPolicy = vi.fn()
+const fetchSystemPolicy = vi.fn()
+const fetchUserPolicyForUser = vi.fn()
+const saveUserPolicyForUser = vi.fn()
+const clearUserPreference = vi.fn()
+const clearGroupPolicy = vi.fn()
+const clearUserPolicyForUser = vi.fn()
+const getPolicy = vi.fn()
+const fetchEffectivePolicies = vi.fn()
+
+vi.mock('../../../../store/policies', () => ({
+ usePoliciesStore: () => ({
+ saveSystemPolicy,
+ saveGroupPolicy,
+ fetchGroupPolicy,
+ fetchSystemPolicy,
+ fetchUserPolicyForUser,
+ saveUserPolicyForUser,
+ clearUserPreference,
+ clearGroupPolicy,
+ clearUserPolicyForUser,
+ getPolicy,
+ fetchEffectivePolicies,
+ }),
+}))
+
+import { createRealPolicyWorkbenchState } from '../../../../views/Settings/PolicyWorkbench/useRealPolicyWorkbench'
+
+describe('useRealPolicyWorkbench', () => {
+ beforeEach(() => {
+ currentUserState.isAdmin = true
+ axiosGet.mockReset()
+ saveSystemPolicy.mockReset()
+ saveGroupPolicy.mockReset()
+ fetchGroupPolicy.mockReset()
+ fetchSystemPolicy.mockReset()
+ fetchUserPolicyForUser.mockReset()
+ saveUserPolicyForUser.mockReset()
+ clearUserPreference.mockReset()
+ clearGroupPolicy.mockReset()
+ clearUserPolicyForUser.mockReset()
+ getPolicy.mockReset()
+ fetchEffectivePolicies.mockReset()
+ getPolicy.mockReturnValue({ effectiveValue: 'parallel' })
+ fetchSystemPolicy.mockResolvedValue(null)
+ fetchGroupPolicy.mockResolvedValue(null)
+ fetchUserPolicyForUser.mockResolvedValue(null)
+ clearUserPreference.mockResolvedValue(null)
+ fetchEffectivePolicies.mockResolvedValue(undefined)
+ axiosGet.mockImplementation((url: string) => {
+ if (url === 'cloud/groups/details') {
+ return Promise.resolve({
+ data: {
+ ocs: {
+ data: {
+ groups: [
+ { id: 'finance', displayname: 'Finance', usercount: 3 },
+ { id: 'legal', displayname: 'Legal', usercount: 2 },
+ ],
+ },
+ },
+ },
+ })
+ }
+
+ if (url === 'cloud/users/details') {
+ return Promise.resolve({
+ data: {
+ ocs: {
+ data: {
+ users: {
+ user1: { id: 'user1', displayname: 'User One', email: 'user1@example.com' },
+ user3: { id: 'user3', 'display-name': 'User Three', email: 'user3@example.com' },
+ fakeGroupLike: { id: 'finance', displayname: 'Finance', usercount: 3, isNoUser: true },
+ },
+ },
+ },
+ },
+ })
+ }
+
+ return Promise.resolve({ data: { ocs: { data: {} } } })
+ })
+ })
+
+ it('hydrates persisted group rules when opening a setting', async () => {
+ fetchGroupPolicy.mockImplementation(async (groupId: string) => {
+ if (groupId !== 'finance') {
+ return null
+ }
+
+ return {
+ policyKey: 'signature_flow',
+ scope: 'group',
+ targetId: 'finance',
+ value: 'ordered_numeric',
+ allowChildOverride: false,
+ visibleToChild: true,
+ allowedValues: ['ordered_numeric'],
+ }
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ await vi.waitFor(() => {
+ expect(fetchGroupPolicy).toHaveBeenCalledWith('finance', 'signature_flow')
+ expect(state.visibleGroupRules).toHaveLength(1)
+ })
+
+ expect(fetchGroupPolicy).toHaveBeenCalledWith('finance', 'signature_flow')
+ expect(state.visibleGroupRules).toHaveLength(1)
+ expect(state.visibleGroupRules[0]).toMatchObject({
+ targetId: 'finance',
+ value: 'ordered_numeric',
+ allowChildOverride: false,
+ })
+ })
+
+ it('shows docmdp as available setting in policy workbench summaries', async () => {
+ const state = createRealPolicyWorkbenchState()
+ const keys = state.visibleSettingSummaries.map((summary) => summary.key)
+
+ expect(keys).toContain('signature_flow')
+ expect(keys).toContain('docmdp')
+ })
+
+ it('keeps override counts isolated per setting after opening and closing dialogs', async () => {
+ fetchGroupPolicy.mockImplementation(async (groupId: string, policyKey: string) => {
+ if (policyKey !== 'signature_flow' || groupId !== 'finance') {
+ return null
+ }
+
+ return {
+ policyKey: 'signature_flow',
+ scope: 'group',
+ targetId: 'finance',
+ value: 'ordered_numeric',
+ allowChildOverride: false,
+ visibleToChild: true,
+ allowedValues: ['ordered_numeric'],
+ }
+ })
+
+ fetchUserPolicyForUser.mockImplementation(async (userId: string, policyKey: string) => {
+ if (policyKey !== 'signature_flow' || userId !== 'user1') {
+ return null
+ }
+
+ return {
+ policyKey: 'signature_flow',
+ scope: 'user',
+ targetId: 'user1',
+ value: 'parallel',
+ }
+ })
+
+ const state = createRealPolicyWorkbenchState()
+
+ state.openSetting('signature_flow')
+ await vi.waitFor(() => {
+ expect(state.visibleGroupRules).toHaveLength(1)
+ expect(state.visibleUserRules).toHaveLength(1)
+ })
+ state.closeSetting()
+
+ state.openSetting('docmdp')
+ await vi.waitFor(() => {
+ expect(state.visibleGroupRules).toHaveLength(0)
+ expect(state.visibleUserRules).toHaveLength(0)
+ })
+ state.closeSetting()
+
+ const summariesByKey = Object.fromEntries(state.visibleSettingSummaries.map((summary) => [summary.key, summary]))
+
+ expect(summariesByKey.signature_flow?.groupCount).toBe(1)
+ expect(summariesByKey.signature_flow?.userCount).toBe(1)
+ expect(summariesByKey.docmdp?.groupCount).toBe(0)
+ expect(summariesByKey.docmdp?.userCount).toBe(0)
+ })
+
+ it('saves system docmdp rule through generic policy endpoint flow', async () => {
+ getPolicy.mockImplementation((key: string) => {
+ if (key === 'docmdp') {
+ return {
+ policyKey: 'docmdp',
+ effectiveValue: 2,
+ allowedValues: [0, 1, 2, 3],
+ sourceScope: 'system',
+ visible: true,
+ editableByCurrentActor: true,
+ canSaveAsUserDefault: false,
+ canUseAsRequestOverride: false,
+ preferenceWasCleared: false,
+ blockedBy: null,
+ }
+ }
+
+ return { effectiveValue: 'parallel' }
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('docmdp')
+ await Promise.resolve()
+ await Promise.resolve()
+
+ state.startEditor({ scope: 'system' })
+ state.updateDraftValue(3)
+ await state.saveDraft()
+
+ expect(saveSystemPolicy).toHaveBeenCalledWith('docmdp', 3, true)
+ expect(fetchEffectivePolicies).toHaveBeenCalled()
+ })
+
+ it('hydrates explicit system rule and persisted user rules when opening a setting', async () => {
+ getPolicy.mockReturnValue({
+ effectiveValue: 'parallel',
+ sourceScope: 'user',
+ })
+
+ fetchSystemPolicy.mockResolvedValue({
+ policyKey: 'signature_flow',
+ scope: 'global',
+ value: 'ordered_numeric',
+ allowChildOverride: true,
+ visibleToChild: true,
+ allowedValues: [],
+ })
+ fetchUserPolicyForUser.mockImplementation(async (userId: string) => {
+ if (userId !== 'user1') {
+ return null
+ }
+
+ return {
+ policyKey: 'signature_flow',
+ scope: 'user',
+ targetId: 'user1',
+ value: 'parallel',
+ }
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ await vi.waitFor(() => {
+ expect(fetchSystemPolicy).toHaveBeenCalledWith('signature_flow')
+ expect(fetchUserPolicyForUser).toHaveBeenCalledWith('user1', 'signature_flow')
+ expect(state.inheritedSystemRule?.value).toBe('ordered_numeric')
+ expect(state.visibleUserRules).toHaveLength(1)
+ })
+
+ expect(state.visibleUserRules[0]).toMatchObject({
+ targetId: 'user1',
+ value: 'parallel',
+ })
+ })
+
+ it('hydrates persisted user rules beyond the first users page', async () => {
+ axiosGet.mockImplementation((url: string, config?: { params?: { offset?: number } }) => {
+ if (url === 'cloud/groups/details') {
+ return Promise.resolve({
+ data: {
+ ocs: {
+ data: {
+ groups: [],
+ },
+ },
+ },
+ })
+ }
+
+ if (url === 'cloud/users/details') {
+ const offset = config?.params?.offset ?? 0
+ if (offset === 0) {
+ const firstPageUsers = Object.fromEntries(
+ Array.from({ length: 20 }, (_, index) => {
+ const id = `user${index + 1}`
+ return [id, { id, displayname: `User ${index + 1}` }]
+ }),
+ )
+
+ return Promise.resolve({
+ data: {
+ ocs: {
+ data: {
+ users: firstPageUsers,
+ },
+ },
+ },
+ })
+ }
+
+ if (offset === 20) {
+ return Promise.resolve({
+ data: {
+ ocs: {
+ data: {
+ users: {
+ user21: { id: 'user21', displayname: 'User 21' },
+ },
+ },
+ },
+ },
+ })
+ }
+
+ return Promise.resolve({
+ data: {
+ ocs: {
+ data: {
+ users: {},
+ },
+ },
+ },
+ })
+ }
+
+ return Promise.resolve({ data: { ocs: { data: {} } } })
+ })
+
+ fetchUserPolicyForUser.mockImplementation(async (userId: string) => {
+ if (userId !== 'user21') {
+ return null
+ }
+
+ return {
+ policyKey: 'signature_flow',
+ scope: 'user',
+ targetId: 'user21',
+ value: 'ordered_numeric',
+ }
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ await vi.waitFor(() => {
+ expect(fetchUserPolicyForUser).toHaveBeenCalledWith('user21', 'signature_flow')
+ expect(state.visibleUserRules).toHaveLength(1)
+ })
+
+ expect(state.visibleUserRules[0]).toMatchObject({
+ targetId: 'user21',
+ value: 'ordered_numeric',
+ })
+ })
+
+ it('loads real group targets from OCS when opening the group editor', async () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'group' })
+
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(axiosGet).toHaveBeenCalledWith('cloud/groups/details', {
+ params: {
+ search: '',
+ limit: 20,
+ offset: 0,
+ },
+ })
+ expect(state.availableTargets).toEqual([
+ { id: 'finance', displayName: 'Finance', subname: '3 members', isNoUser: true },
+ { id: 'legal', displayName: 'Legal', subname: '2 members', isNoUser: true },
+ ])
+ })
+
+ it('loads real user targets from OCS when searching the user editor', async () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'user' })
+
+ await Promise.resolve()
+ state.searchAvailableTargets('user')
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(axiosGet).toHaveBeenLastCalledWith('cloud/users/details', {
+ params: {
+ search: 'user',
+ limit: 20,
+ offset: 0,
+ },
+ })
+ expect(state.availableTargets).toEqual([
+ { id: 'user1', displayName: 'User One', subname: 'user1@example.com', user: 'user1' },
+ { id: 'user3', displayName: 'User Three', subname: 'user3@example.com', user: 'user3' },
+ ])
+ expect(state.availableTargets.some((target) => target.id === 'finance')).toBe(false)
+ })
+
+ it('saves system signature_flow rule', async () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'system' })
+ state.updateDraftValue('ordered_numeric' as never)
+
+ await state.saveDraft()
+
+ expect(saveSystemPolicy).toHaveBeenCalledWith('signature_flow', 'ordered_numeric', false)
+ })
+
+ it('saves fixed parallel system signature_flow rule without child override', async () => {
+ getPolicy.mockReturnValue({
+ effectiveValue: 'none',
+ sourceScope: 'system',
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'system' })
+ state.updateDraftValue('parallel' as never)
+
+ await state.saveDraft()
+
+ expect(saveSystemPolicy).toHaveBeenCalledWith('signature_flow', 'parallel', false)
+ })
+
+ it('forces hidden signature_flow override state to remain locked in system and group editors', async () => {
+ fetchSystemPolicy.mockResolvedValue({
+ policyKey: 'signature_flow',
+ scope: 'global',
+ value: 'ordered_numeric',
+ allowChildOverride: true,
+ visibleToChild: true,
+ allowedValues: [],
+ })
+ getPolicy.mockReturnValue({
+ effectiveValue: 'ordered_numeric',
+ sourceScope: 'global',
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ await vi.waitFor(() => {
+ expect(fetchSystemPolicy).toHaveBeenCalledWith('signature_flow')
+ expect(state.inheritedSystemRule?.allowChildOverride).toBe(true)
+ })
+
+ state.startEditor({ scope: 'system', ruleId: 'system-default' })
+ expect(state.editorDraft?.allowChildOverride).toBe(false)
+
+ state.cancelEditor()
+ state.startEditor({ scope: 'group' })
+ expect(state.editorDraft?.allowChildOverride).toBe(false)
+ })
+
+ it('locks signature_flow system create draft even when inherited rule allows overrides', async () => {
+ fetchSystemPolicy.mockResolvedValue({
+ policyKey: 'signature_flow',
+ scope: 'global',
+ value: 'parallel',
+ allowChildOverride: true,
+ visibleToChild: true,
+ allowedValues: [],
+ })
+ getPolicy.mockReturnValue({
+ effectiveValue: 'parallel',
+ sourceScope: 'global',
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ await vi.waitFor(() => {
+ expect(fetchSystemPolicy).toHaveBeenCalledWith('signature_flow')
+ expect(state.inheritedSystemRule?.allowChildOverride).toBe(true)
+ })
+
+ state.startEditor({ scope: 'system' })
+ expect(state.editorMode).toBe('create')
+ expect(state.editorDraft?.allowChildOverride).toBe(false)
+ })
+
+ it('transitions from fallback none to persisted global default after saving system rule', async () => {
+ let currentPolicy: any = {
+ effectiveValue: 'none',
+ allowedValues: ['parallel', 'ordered_numeric'],
+ sourceScope: 'system',
+ }
+
+ getPolicy.mockImplementation((key: string) => {
+ if (key === 'signature_flow') {
+ return currentPolicy
+ }
+
+ return null
+ })
+
+ saveSystemPolicy.mockImplementation(async (_policyKey: string, value: unknown, allowChildOverride?: boolean) => {
+ currentPolicy = {
+ effectiveValue: value,
+ allowedValues: allowChildOverride === false ? [value] : [],
+ sourceScope: 'global',
+ }
+ return currentPolicy
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ expect(state.inheritedSystemRule).not.toBeNull()
+ expect(state.summary?.baseSource).toBe('System default')
+
+ state.startEditor({ scope: 'system' })
+ state.updateDraftValue('ordered_numeric' as never)
+ await state.saveDraft()
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(saveSystemPolicy).toHaveBeenCalledWith('signature_flow', 'ordered_numeric', false)
+ expect(fetchEffectivePolicies).toHaveBeenCalled()
+ expect(getPolicy('signature_flow')?.effectiveValue).toBe('ordered_numeric')
+
+ const refreshedState = createRealPolicyWorkbenchState()
+ refreshedState.openSetting('signature_flow')
+ expect(refreshedState.inheritedSystemRule).not.toBeNull()
+ expect(refreshedState.summary?.baseSource).toBe('Global default')
+ expect(refreshedState.summary?.currentBaseValue).toBe('Sequential')
+ })
+
+ it('supports multi-target group save for signature_flow', async () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'group' })
+ state.updateDraftTargets(['finance', 'legal'])
+ state.updateDraftValue('ordered_numeric' as never)
+ state.updateDraftAllowOverride(false)
+
+ await state.saveDraft()
+
+ expect(saveGroupPolicy).toHaveBeenCalledTimes(2)
+ expect(saveGroupPolicy).toHaveBeenNthCalledWith(1, 'finance', 'signature_flow', 'ordered_numeric', false)
+ expect(saveGroupPolicy).toHaveBeenNthCalledWith(2, 'legal', 'signature_flow', 'ordered_numeric', false)
+ })
+
+ it('keeps explicit instance rule visible after saving when effective source remains group', async () => {
+ let currentPolicy: any = {
+ effectiveValue: 'parallel',
+ allowedValues: ['parallel', 'ordered_numeric'],
+ sourceScope: 'group',
+ }
+
+ getPolicy.mockImplementation((key: string) => {
+ if (key === 'signature_flow') {
+ return currentPolicy
+ }
+
+ return null
+ })
+
+ saveSystemPolicy.mockImplementation(async (_policyKey: string, value: unknown) => {
+ currentPolicy = {
+ effectiveValue: currentPolicy.effectiveValue,
+ allowedValues: currentPolicy.allowedValues,
+ sourceScope: 'group',
+ }
+
+ return {
+ effectiveValue: value,
+ sourceScope: 'group',
+ allowedValues: ['parallel', 'ordered_numeric'],
+ }
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ expect(state.inheritedSystemRule).toBeNull()
+
+ state.startEditor({ scope: 'system' })
+ state.updateDraftValue('ordered_numeric' as never)
+ await state.saveDraft()
+
+ expect(saveSystemPolicy).toHaveBeenCalledWith('signature_flow', 'ordered_numeric', false)
+ expect(state.inheritedSystemRule).not.toBeNull()
+ expect(state.inheritedSystemRule?.value).toBe('ordered_numeric')
+ expect(state.hasGlobalDefault).toBe(true)
+ })
+
+ it('supports multi-target user save for signature_flow', async () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'user' })
+ state.updateDraftTargets(['user1', 'user3'])
+ state.updateDraftValue('parallel' as never)
+
+ await state.saveDraft()
+
+ expect(saveUserPolicyForUser).toHaveBeenCalledTimes(2)
+ expect(saveUserPolicyForUser).toHaveBeenNthCalledWith(1, 'user1', 'signature_flow', 'parallel')
+ expect(saveUserPolicyForUser).toHaveBeenNthCalledWith(2, 'user3', 'signature_flow', 'parallel')
+ })
+
+ it('hides groups that already have a rule when creating a new group rule', async () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ state.startEditor({ scope: 'group' })
+ state.updateDraftTargets(['finance'])
+ state.updateDraftValue('parallel' as never)
+ await state.saveDraft()
+
+ state.startEditor({ scope: 'group' })
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(state.availableTargets).toEqual([
+ { id: 'legal', displayName: 'Legal', subname: '2 members', isNoUser: true },
+ ])
+ })
+
+ it('hides users that already have a rule when creating a new user rule', async () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ state.startEditor({ scope: 'user' })
+ state.updateDraftTargets(['user1'])
+ state.updateDraftValue('parallel' as never)
+ await state.saveDraft()
+
+ state.startEditor({ scope: 'user' })
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(state.availableTargets).toEqual([
+ { id: 'user3', displayName: 'User Three', subname: 'user3@example.com', user: 'user3' },
+ ])
+ })
+
+ it('removes persisted group and user rules', async () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ state.startEditor({ scope: 'group' })
+ state.updateDraftTargets(['finance'])
+ state.updateDraftValue('parallel' as never)
+ await state.saveDraft()
+
+ state.startEditor({ scope: 'user' })
+ state.updateDraftTargets(['user1'])
+ state.updateDraftValue('parallel' as never)
+ await state.saveDraft()
+
+ const groupRuleId = state.visibleGroupRules[0]?.id
+ const userRuleId = state.visibleUserRules[0]?.id
+ expect(groupRuleId).toBeTruthy()
+ expect(userRuleId).toBeTruthy()
+
+ if (!groupRuleId || !userRuleId) {
+ throw new Error('Expected created group and user rules')
+ }
+
+ await state.removeRule(groupRuleId)
+ await state.removeRule(userRuleId)
+
+ expect(clearGroupPolicy).toHaveBeenCalledTimes(1)
+ expect(clearGroupPolicy).toHaveBeenCalledWith('finance', 'signature_flow')
+ expect(clearUserPolicyForUser).toHaveBeenCalledTimes(1)
+ expect(clearUserPolicyForUser).toHaveBeenCalledWith('user1', 'signature_flow')
+ expect(state.visibleGroupRules).toHaveLength(0)
+ expect(state.visibleUserRules).toHaveLength(0)
+ })
+
+ it('resets system default rule through backend request', async () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ await state.removeRule('system-default')
+
+ expect(saveSystemPolicy).toHaveBeenCalledTimes(1)
+ expect(saveSystemPolicy).toHaveBeenCalledWith('signature_flow', null, false)
+ })
+
+ it('closes editor when the edited system rule is reset', async () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'system', ruleId: 'system-default' })
+
+ expect(state.editorDraft).not.toBeNull()
+ expect(state.editorMode).toBe('edit')
+
+ await state.removeRule('system-default')
+
+ expect(state.editorDraft).toBeNull()
+ expect(state.editorMode).toBeNull()
+ })
+
+ it('closes editor when the edited group rule is removed', async () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ state.startEditor({ scope: 'group' })
+ state.updateDraftTargets(['finance'])
+ state.updateDraftValue('parallel' as never)
+ await state.saveDraft()
+
+ const groupRuleId = state.visibleGroupRules[0]?.id
+ expect(groupRuleId).toBeTruthy()
+
+ if (!groupRuleId) {
+ throw new Error('Expected a created group rule')
+ }
+
+ state.startEditor({ scope: 'group', ruleId: groupRuleId })
+ expect(state.editorMode).toBe('edit')
+
+ await state.removeRule(groupRuleId)
+
+ expect(state.editorDraft).toBeNull()
+ expect(state.editorMode).toBeNull()
+ })
+
+ it('keeps a visible instance row for system-sourced baseline values', () => {
+ getPolicy.mockReturnValue({ effectiveValue: 'none', sourceScope: 'system' })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ expect(state.inheritedSystemRule).not.toBeNull()
+ expect(state.inheritedSystemRule?.value).toBe('none')
+ expect(state.hasGlobalDefault).toBe(false)
+ })
+
+ it('treats none with empty allowedValues as explicit global "let users choose" rule', () => {
+ getPolicy.mockReturnValue({
+ effectiveValue: 'none',
+ sourceScope: 'global',
+ allowedValues: ['none', 'parallel', 'ordered_numeric'],
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ expect(state.inheritedSystemRule).not.toBeNull()
+ expect(state.inheritedSystemRule?.value).toBe('none')
+ expect(state.summary?.currentBaseValue).toBe('User choice')
+ })
+
+ it('keeps persisted numeric system default visible after reload', () => {
+ getPolicy.mockReturnValue({
+ effectiveValue: 0,
+ sourceScope: 'system',
+ allowedValues: [0, 1, 2],
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ expect(state.inheritedSystemRule).not.toBeNull()
+ expect(state.inheritedSystemRule?.value).toBe(0)
+ expect(state.summary?.currentBaseValue).toBe('User choice')
+ })
+
+ it('does not treat group-sourced effective value as explicit instance rule', () => {
+ getPolicy.mockReturnValue({
+ effectiveValue: 'parallel',
+ sourceScope: 'group',
+ allowedValues: ['none', 'parallel', 'ordered_numeric'],
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ expect(state.inheritedSystemRule).toBeNull()
+ expect(state.hasGlobalDefault).toBe(false)
+ })
+
+ it('prefills system rule creation with the current baseline value', () => {
+ getPolicy.mockReturnValue({
+ effectiveValue: 'ordered_numeric',
+ sourceScope: 'global',
+ allowedValues: ['none', 'parallel', 'ordered_numeric'],
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'system' })
+
+ expect(state.editorMode).toBe('create')
+ expect(state.editorDraft?.value).toBe('ordered_numeric')
+ })
+
+ it('normalizes numeric system value when opening editor in edit mode', () => {
+ getPolicy.mockReturnValue({
+ effectiveValue: 2,
+ allowedValues: ['parallel', 'ordered_numeric'],
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'system', ruleId: 'system-default' })
+
+ expect(state.editorMode).toBe('edit')
+ expect(state.editorDraft?.value).toBe('ordered_numeric')
+ })
+
+ it('hydrates system rule override toggle from backend allowed values', () => {
+ getPolicy.mockReturnValue({
+ effectiveValue: 'parallel',
+ allowedValues: ['parallel'],
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ expect(state.inheritedSystemRule?.allowChildOverride).toBe(false)
+
+ state.startEditor({ scope: 'system', ruleId: 'system-default' })
+ expect(state.editorDraft?.allowChildOverride).toBe(false)
+ })
+
+ it('builds sticky summary metadata with precedence mode and fallback', () => {
+ getPolicy.mockReturnValue({
+ effectiveValue: 'ordered_numeric',
+ sourceScope: 'global',
+ allowedValues: ['parallel', 'ordered_numeric'],
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ expect(state.policyResolutionMode).toBe('precedence')
+ expect(state.summary).not.toBeNull()
+ expect(state.summary?.currentBaseValue).toBe('Sequential')
+ expect(state.summary?.platformFallback).toBe('User choice')
+ expect(state.summary?.baseSource).toBe('Global default')
+ })
+
+ it('allows system-admin to create user exceptions even when a group blocks inheritance', async () => {
+ getPolicy.mockReturnValue({ effectiveValue: 'parallel', sourceScope: 'global' })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'group' })
+ state.updateDraftTargets(['finance'])
+ state.updateDraftValue('parallel' as never)
+ state.updateDraftAllowOverride(false)
+ await state.saveDraft()
+
+ expect(state.viewMode).toBe('system-admin')
+ expect(state.createUserOverrideDisabledReason).toBeNull()
+
+ state.startEditor({ scope: 'user' })
+ expect(state.editorDraft?.scope).toBe('user')
+ })
+
+ it('blocks user exceptions for group-admin when a group rule disables inheritance', async () => {
+ getPolicy.mockReturnValue({ effectiveValue: 'parallel', sourceScope: 'global' })
+
+ const state = createRealPolicyWorkbenchState()
+ state.setViewMode('group-admin')
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'group' })
+ state.updateDraftTargets(['finance'])
+ state.updateDraftValue('parallel' as never)
+ state.updateDraftAllowOverride(false)
+ await state.saveDraft()
+
+ expect(state.createUserOverrideDisabledReason).toContain('Blocked by the Finance group rule')
+
+ state.startEditor({ scope: 'user' })
+ expect(state.editorDraft).toBeNull()
+ })
+
+ it('allows creating group rule when no system rule is set', () => {
+ getPolicy.mockReturnValue({ effectiveValue: null })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ expect(state.inheritedSystemRule).toBeNull()
+ expect(state.createGroupOverrideDisabledReason).toBeNull()
+ })
+
+ it('allows instance admin to create group rule even when system rule disallows child override', () => {
+ // Single allowedValues → backend signals allowChildOverride = false
+ getPolicy.mockReturnValue({
+ effectiveValue: 'parallel',
+ allowedValues: ['parallel'],
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ expect(state.inheritedSystemRule?.allowChildOverride).toBe(false)
+ expect(state.createGroupOverrideDisabledReason).toBeNull()
+ })
+
+ it('blocks group-admin from creating group rule when system rule disallows child override', () => {
+ currentUserState.isAdmin = false
+ getPolicy.mockReturnValue({
+ effectiveValue: 'parallel',
+ allowedValues: ['parallel'],
+ })
+
+ const state = createRealPolicyWorkbenchState()
+ state.setViewMode('group-admin')
+ state.openSetting('signature_flow')
+
+ expect(state.inheritedSystemRule?.allowChildOverride).toBe(false)
+ expect(state.createGroupOverrideDisabledReason).not.toBeNull()
+ })
+
+ it('allows creating user rule when no system rule is set', () => {
+ getPolicy.mockReturnValue({ effectiveValue: null })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+
+ expect(state.inheritedSystemRule).toBeNull()
+ expect(state.createUserOverrideDisabledReason).toBeNull()
+ })
+
+ it('clears current user preference when system-admin saves system rule', async () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'system' })
+ state.updateDraftValue('ordered_numeric' as never)
+
+ await state.saveDraft()
+
+ expect(saveSystemPolicy).toHaveBeenCalledWith('signature_flow', 'ordered_numeric', false)
+ expect(clearUserPreference).toHaveBeenCalledWith('signature_flow')
+ })
+
+ it('requires dirty draft changes before save is enabled in edit mode', async () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'group' })
+ state.updateDraftTargets(['finance'])
+ state.updateDraftValue('parallel' as never)
+ await state.saveDraft()
+
+ const groupRuleId = state.visibleGroupRules[0]?.id
+ expect(groupRuleId).toBeTruthy()
+ if (!groupRuleId) {
+ throw new Error('Expected created group rule')
+ }
+
+ state.startEditor({ scope: 'group', ruleId: groupRuleId })
+ expect(state.isDraftDirty).toBe(false)
+ expect(state.canSaveDraft).toBe(false)
+
+ state.updateDraftValue('ordered_numeric' as never)
+ expect(state.isDraftDirty).toBe(true)
+ expect(state.canSaveDraft).toBe(true)
+ })
+
+ it('requires explicit value selection before enabling group create save', () => {
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'group' })
+
+ state.updateDraftTargets(['finance'])
+ expect(state.canSaveDraft).toBe(false)
+
+ state.updateDraftValue('parallel' as never)
+ expect(state.canSaveDraft).toBe(true)
+ })
+
+ it('requires changing the value before enabling system create save', () => {
+ getPolicy.mockReturnValue({ effectiveValue: 'parallel', sourceScope: 'system' })
+
+ const state = createRealPolicyWorkbenchState()
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'system' })
+
+ expect(state.canSaveDraft).toBe(false)
+
+ state.updateDraftValue('ordered_numeric' as never)
+ expect(state.canSaveDraft).toBe(true)
+
+ state.updateDraftValue('parallel' as never)
+ expect(state.canSaveDraft).toBe(false)
+ })
+})
diff --git a/src/tests/views/Settings/Settings.spec.ts b/src/tests/views/Settings/Settings.spec.ts
index 54e19825f1..f2cac21a9d 100644
--- a/src/tests/views/Settings/Settings.spec.ts
+++ b/src/tests/views/Settings/Settings.spec.ts
@@ -3,8 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
+import { createL10nMock } from '../../testHelpers/l10n.js'
+
+vi.mock('@nextcloud/l10n', () => createL10nMock())
import Settings from '../../../views/Settings/Settings.vue'
@@ -16,6 +19,7 @@ describe('Settings.vue', () => {
SupportProject: { template: '
' },
CertificateEngine: true,
SignatureEngine: true,
+ SettingsPolicyWorkbench: true,
DownloadBinaries: true,
ConfigureCheck: true,
RootCertificateCfssl: true,
@@ -24,8 +28,6 @@ describe('Settings.vue', () => {
ExpirationRules: true,
Validation: true,
CrlValidation: true,
- DocMDP: true,
- SignatureFlow: true,
SigningMode: true,
AllowedGroups: true,
LegalInformation: true,
@@ -37,7 +39,6 @@ describe('Settings.vue', () => {
Envelope: true,
Reminders: true,
TSA: true,
- Confetti: true,
},
},
})
@@ -54,6 +55,7 @@ describe('Settings.vue', () => {
SupportProject: true,
CertificateEngine: true,
SignatureEngine: true,
+ SettingsPolicyWorkbench: true,
DownloadBinaries: true,
ConfigureCheck: true,
RootCertificateCfssl: true,
@@ -62,8 +64,6 @@ describe('Settings.vue', () => {
ExpirationRules: true,
Validation: true,
CrlValidation: true,
- DocMDP: true,
- SignatureFlow: true,
SigningMode: { name: 'SigningMode', template: '
' },
AllowedGroups: true,
LegalInformation: true,
@@ -75,7 +75,6 @@ describe('Settings.vue', () => {
Envelope: true,
Reminders: true,
TSA: true,
- Confetti: true,
},
},
})
diff --git a/src/tests/views/Settings/SettingsPolicyWorkbench.spec.ts b/src/tests/views/Settings/SettingsPolicyWorkbench.spec.ts
new file mode 100644
index 0000000000..350ce6f467
--- /dev/null
+++ b/src/tests/views/Settings/SettingsPolicyWorkbench.spec.ts
@@ -0,0 +1,343 @@
+/*
+ * SPDX-FileCopyrightText: 2026 LibreSign contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { createL10nMock } from '../../testHelpers/l10n.js'
+
+vi.mock('@nextcloud/l10n', () => createL10nMock())
+
+const getPolicy = vi.fn((key: string) => {
+ if (key === 'signature_flow') {
+ return { effectiveValue: 'ordered_numeric' }
+ }
+
+ return null
+})
+const fetchSystemPolicy = vi.fn().mockResolvedValue(null)
+const fetchGroupPolicy = vi.fn().mockResolvedValue(null)
+const fetchUserPolicyForUser = vi.fn().mockResolvedValue(null)
+
+vi.mock('../../../store/policies', () => ({
+ usePoliciesStore: () => ({
+ getPolicy,
+ fetchEffectivePolicies: vi.fn().mockResolvedValue(undefined),
+ fetchSystemPolicy,
+ fetchGroupPolicy,
+ fetchUserPolicyForUser,
+ saveSystemPolicy: vi.fn().mockResolvedValue(undefined),
+ saveGroupPolicy: vi.fn().mockResolvedValue(undefined),
+ saveUserPreference: vi.fn().mockResolvedValue(undefined),
+ }),
+}))
+
+import RealPolicyWorkbench from '../../../views/Settings/PolicyWorkbench/RealPolicyWorkbench.vue'
+
+function mountWorkbench() {
+ return mount(RealPolicyWorkbench, {
+ global: {
+ stubs: {
+ NcSettingsSection: { template: '
' },
+ NcTextField: { template: 'Find setting
' },
+ NcAppNavigationSearch: { template: '' },
+ NcButton: { template: ' ' },
+ NcIconSvgWrapper: { template: ' ' },
+ NcNoteCard: { template: '
' },
+ NcDialog: {
+ props: ['name', 'buttons'],
+ template: '
{{ name }}
',
+ },
+ NcChip: { template: '{{ text }} ', props: ['text'] },
+ NcCheckboxRadioSwitch: {
+ props: ['modelValue', 'type', 'name', 'value'],
+ template: " ",
+ },
+ NcSelectUsers: {
+ props: ['placeholder', 'ariaLabel'],
+ template: '{{ ariaLabel }} {{ placeholder }} Select target
',
+ },
+ NcActions: {
+ props: ['open', 'ariaLabel'],
+ emits: ['update:open'],
+ template: '
',
+ },
+ NcActionButton: { template: ' ' },
+ },
+ },
+ })
+}
+
+function findButtonByText(wrapper: ReturnType, text: string) {
+ return wrapper.findAll('button').find((button) => button.text() === text)
+}
+
+function findButtonContainingText(wrapper: ReturnType, text: string) {
+ return wrapper.findAll('button').find((button) => button.text().includes(text))
+}
+
+function findConfigureButtonForSetting(wrapper: ReturnType, settingTitle: string) {
+ const settingCard = wrapper.findAll('article').find((article) => article.text().includes(settingTitle))
+ if (!settingCard) {
+ return undefined
+ }
+
+ return settingCard.findAll('button').find((button) => button.text().includes('Configure'))
+}
+
+describe('RealPolicyWorkbench.vue', () => {
+ beforeEach(() => {
+ getPolicy.mockReset()
+ fetchSystemPolicy.mockReset().mockResolvedValue(null)
+ fetchGroupPolicy.mockReset().mockResolvedValue(null)
+ fetchUserPolicyForUser.mockReset().mockResolvedValue(null)
+ getPolicy.mockImplementation((key: string) => {
+ if (key === 'signature_flow') {
+ return { effectiveValue: 'ordered_numeric' }
+ }
+
+ return null
+ })
+ })
+
+ it('keeps rule creation inside a modal multi-step flow', async () => {
+ const wrapper = mountWorkbench()
+
+ const openPolicyButton = findConfigureButtonForSetting(wrapper, 'Signing order')
+ expect(openPolicyButton).toBeTruthy()
+ await openPolicyButton?.trigger('click')
+ await findButtonByText(wrapper, 'Create rule')?.trigger('click')
+
+ expect(wrapper.findAll('.dialog-title').some((title) => title.text() === 'What do you want to create?')).toBe(true)
+
+ const createScopeDialog = wrapper.find('.policy-workbench__create-scope-dialog')
+ expect(createScopeDialog.exists()).toBe(true)
+
+ const text = createScopeDialog.text()
+ expect(text).toContain('User')
+ expect(text).toContain('Group')
+ expect(text).not.toContain('Instance')
+ expect(text).not.toContain('Where do you want to apply this rule?')
+
+ await findButtonContainingText(wrapper, 'User')?.trigger('click')
+
+ const editorModal = wrapper.find('.policy-workbench__editor-modal-body')
+ expect(editorModal.exists()).toBe(true)
+ const editorText = editorModal.text()
+ expect(editorText).toContain('Priority: User > Group > Default')
+ expect(editorText).not.toContain('This rule overrides group and default settings for selected users.')
+ expect(editorText).toContain('Target users')
+ expect(editorText).toContain('Search users')
+ expect(editorText).toContain('Simultaneous (Parallel)')
+ expect(editorText).toContain('Sequential')
+ expect(editorText).toContain('User choice')
+ expect(wrapper.text()).toContain('← Back')
+ expect(wrapper.text()).toContain('Cancel')
+ expect(editorText).not.toContain('Instance default rule')
+ expect(wrapper.find('.policy-workbench__editor-aside').exists()).toBe(false)
+
+ await findButtonContainingText(wrapper, 'Back')?.trigger('click')
+ expect(wrapper.find('.policy-workbench__create-scope-dialog').exists()).toBe(true)
+ })
+
+ it('opens the editor directly in edit mode without the type selection step', async () => {
+ const wrapper = mountWorkbench()
+
+ const openPolicyButton = findConfigureButtonForSetting(wrapper, 'Signing order')
+ expect(openPolicyButton).toBeTruthy()
+ await openPolicyButton?.trigger('click')
+
+ const actionsTrigger = wrapper.find('button[aria-label="Rule actions"]')
+ expect(actionsTrigger.exists()).toBe(true)
+ await actionsTrigger.trigger('click')
+
+ const editButton = wrapper.findAll('.nc-actions-stub__menu button').find((button) => button.text() === 'Edit')
+ expect(editButton).toBeTruthy()
+ await editButton?.trigger('click')
+
+ expect(wrapper.find('.policy-workbench__create-scope-dialog').exists()).toBe(false)
+ expect(wrapper.find('.policy-workbench__editor-aside').exists()).toBe(false)
+
+ const editorText = wrapper.find('.policy-workbench__editor-modal-body').text()
+ expect(editorText).toContain('Priority: User > Group > Default')
+ expect(editorText).not.toContain('This sets the default signing order for everyone.')
+ expect(wrapper.text()).toContain('Save changes')
+ expect(wrapper.text()).toContain('Cancel')
+ expect(wrapper.text()).not.toContain('← Back')
+ })
+
+ it('shows remove action for instance default rules', async () => {
+ getPolicy.mockImplementation((key: string) => {
+ if (key === 'signature_flow') {
+ return { effectiveValue: 'ordered_numeric', sourceScope: 'global' }
+ }
+
+ return null
+ })
+
+ const wrapper = mountWorkbench()
+
+ const openPolicyButton = findConfigureButtonForSetting(wrapper, 'Signing order')
+ expect(openPolicyButton).toBeTruthy()
+ await openPolicyButton?.trigger('click')
+
+ const actionsTrigger = wrapper.find('button[aria-label="Rule actions"]')
+ expect(actionsTrigger.exists()).toBe(true)
+ await actionsTrigger.trigger('click')
+
+ const removeButton = wrapper.findAll('.nc-actions-stub__menu button').find((button) => button.text() === 'Remove')
+ expect(removeButton).toBeTruthy()
+ })
+
+ it('allows reopening create flow after canceling a draft', async () => {
+ const wrapper = mountWorkbench()
+
+ const openPolicyButton = findConfigureButtonForSetting(wrapper, 'Signing order')
+ expect(openPolicyButton).toBeTruthy()
+ await openPolicyButton?.trigger('click')
+
+ const toolbarCreateRuleButton = wrapper.find('button.policy-workbench__crud-create-cta')
+ expect(toolbarCreateRuleButton.exists()).toBe(true)
+ await toolbarCreateRuleButton.trigger('click')
+ expect(wrapper.find('.policy-workbench__create-scope-dialog').exists()).toBe(true)
+
+ await findButtonContainingText(wrapper, 'User')?.trigger('click')
+ expect(wrapper.find('.policy-workbench__editor-modal-body').exists()).toBe(true)
+
+ const dialogCancelButton = wrapper.findAll('.dialog-footer button').find((button) => button.text() === 'Cancel')
+ expect(dialogCancelButton).toBeTruthy()
+ await dialogCancelButton?.trigger('click')
+ await Promise.resolve()
+
+ expect(wrapper.find('.policy-workbench__create-scope-dialog').exists()).toBe(false)
+
+ const toolbarCreateRuleButtonAfterSave = wrapper.find('button.policy-workbench__crud-create-cta')
+ expect(toolbarCreateRuleButtonAfterSave.exists()).toBe(true)
+ expect(toolbarCreateRuleButtonAfterSave.attributes('disabled')).toBeUndefined()
+ await toolbarCreateRuleButtonAfterSave.trigger('click')
+ expect(wrapper.find('.policy-workbench__create-scope-dialog').exists()).toBe(true)
+ })
+
+ it('shows instance option in create rule when only system default is active', async () => {
+ getPolicy.mockImplementation((key: string) => {
+ if (key === 'signature_flow') {
+ return { effectiveValue: 'ordered_numeric', sourceScope: 'system' }
+ }
+
+ return null
+ })
+
+ const wrapper = mountWorkbench()
+
+ const openPolicyButton = findConfigureButtonForSetting(wrapper, 'Signing order')
+ expect(openPolicyButton).toBeTruthy()
+ await openPolicyButton?.trigger('click')
+
+ await wrapper.find('button.policy-workbench__crud-create-cta').trigger('click')
+
+ const createScopeDialog = wrapper.find('.policy-workbench__create-scope-dialog')
+ expect(createScopeDialog.exists()).toBe(true)
+ expect(createScopeDialog.text()).toContain('Everyone')
+ })
+
+ it('shows unified default summary in system default mode', async () => {
+ getPolicy.mockImplementation((key: string) => {
+ if (key === 'signature_flow') {
+ return { effectiveValue: 'none', sourceScope: 'system' }
+ }
+
+ return null
+ })
+
+ const wrapper = mountWorkbench()
+ const openPolicyButton = findConfigureButtonForSetting(wrapper, 'Signing order')
+ expect(openPolicyButton).toBeTruthy()
+ await openPolicyButton?.trigger('click')
+
+ const text = wrapper.text()
+ expect(text).toContain('Choose whether documents are signed in order or all at once.')
+ expect(text).toContain('Default:')
+ expect(text).toContain('User choice')
+ expect(text).toContain('(default)')
+ expect(text).toContain('Change')
+ expect(text).not.toContain('Effective result:')
+ expect(text).not.toContain('No instance default is configured. This setting currently uses the system default.')
+ })
+
+ it('shows signing order with sophisticated visual interface: filter, toggle, counts, and scopes', async () => {
+ const wrapper = mountWorkbench()
+ const openPolicyButton = findConfigureButtonForSetting(wrapper, 'Signing order')
+ expect(openPolicyButton).toBeTruthy()
+ await openPolicyButton?.trigger('click')
+
+ const text = wrapper.text()
+
+ // Validate scope filter action is available in search actions area
+ expect(wrapper.find('button[aria-label="Filter rules by scope"]').exists()).toBe(true)
+
+ // Validate search/filter UI exists
+ expect(wrapper.find('input[type="text"]').exists()).toBe(true)
+ expect(text).toContain('Find setting')
+
+ // Validate settings count display is hidden
+ expect(text).not.toContain('Showing 2 settings')
+
+ // Validate toggle button exists for card/list view
+ expect(wrapper.find('.policy-workbench__catalog-view-button').exists()).toBe(true)
+
+ // Validate signing order is displayed with compact header copy
+ expect(text).toContain('Signing order')
+ expect(text).toContain('Choose whether documents are signed in order or all at once.')
+
+ // Validate default summary block content for custom default mode
+ expect(text).toContain('Default:')
+ expect(text).toContain('Sequential')
+ expect(text).toContain('(custom)')
+ expect(text).toContain('Change')
+ expect(text).toContain('Priority: User > Group > Default')
+ expect(text).not.toContain('Effective result:')
+
+ const tableHeaders = wrapper.findAll('th').map((header) => header.text())
+ expect(tableHeaders).toContain('Type')
+ expect(tableHeaders).toContain('Target')
+ expect(tableHeaders).toContain('Value')
+ expect(tableHeaders).toContain('Actions')
+ expect(tableHeaders).not.toContain('Behavior')
+
+ // Validate noisy inheritance warning is not shown by default
+ expect(text).not.toContain('Some users may not allow user overrides because their group rule requires inheritance.')
+
+ // Validate counts shown
+ expect(text).toContain('Custom rules:none')
+ expect(text).not.toContain('Custom rules active')
+
+ // Validate POC settings are NOT present
+ expect(text).not.toContain('Confetti')
+ expect(text).not.toContain('Identification factors')
+ })
+
+ it('closes the rule actions menu after clicking edit', async () => {
+ const wrapper = mountWorkbench()
+
+ const openPolicyButton = findConfigureButtonForSetting(wrapper, 'Signing order')
+ expect(openPolicyButton).toBeTruthy()
+ await openPolicyButton?.trigger('click')
+
+ const actionsTrigger = wrapper.find('button[aria-label="Rule actions"]')
+ expect(actionsTrigger.exists()).toBe(true)
+ await actionsTrigger.trigger('click')
+
+ expect(wrapper.find('.nc-actions-stub__menu').exists()).toBe(true)
+
+ const editButton = wrapper.findAll('.nc-actions-stub__menu button').find((button) => button.text() === 'Edit')
+ expect(editButton).toBeTruthy()
+ await editButton?.trigger('click')
+
+ expect(wrapper.find('.nc-actions-stub__menu').exists()).toBe(false)
+ expect(wrapper.text()).toContain('Edit rule')
+ })
+
+})
+
diff --git a/src/tests/views/Settings/SignatureFlow.spec.ts b/src/tests/views/Settings/SignatureFlow.spec.ts
deleted file mode 100644
index afaec07028..0000000000
--- a/src/tests/views/Settings/SignatureFlow.spec.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2026 LibreSign contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
-
-import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
-import { flushPromises, mount } from '@vue/test-utils'
-
-type SignatureFlowOption = {
- value: string
-}
-
-type SignatureFlowVm = {
- enabled: boolean
- selectedFlow?: SignatureFlowOption
- onToggleChange: () => void
-}
-
-const loadStateMock = vi.fn()
-const generateOcsUrlMock = vi.fn((path: string) => path)
-const axiosPostMock = vi.fn((..._args: unknown[]) => Promise.resolve({ data: { ocs: { data: {} } } }))
-
-vi.mock('@nextcloud/initial-state', () => ({
- loadState: (...args: unknown[]) => loadStateMock(...args),
-}))
-
-vi.mock('@nextcloud/router', () => ({
- generateOcsUrl: (...args: unknown[]) => generateOcsUrlMock(...(args as [string])),
-}))
-
-vi.mock('@nextcloud/axios', () => ({
- default: {
- post: axiosPostMock,
- },
-}))
-
-vi.mock('@nextcloud/l10n', () => globalThis.mockNextcloudL10n())
-
-let SignatureFlow: unknown
-
-const NcCheckboxRadioSwitchStub = {
- name: 'NcCheckboxRadioSwitch',
- props: ['modelValue', 'value', 'type'],
- emits: ['update:modelValue'],
- template: ' ',
-}
-
-beforeAll(async () => {
- ;({ default: SignatureFlow } = await import('../../../views/Settings/SignatureFlow.vue'))
-})
-
-describe('SignatureFlow', () => {
- beforeEach(() => {
- loadStateMock.mockReset()
- generateOcsUrlMock.mockClear()
- axiosPostMock.mockClear()
- })
-
- it('uses boolean switch payload before saving', async () => {
- loadStateMock.mockImplementation((_app: string, key: string, fallback: unknown) => {
- if (key === 'signature_flow') return 'parallel'
- return fallback
- })
-
- const wrapper = mount(SignatureFlow as never, {
- global: {
- stubs: {
- NcSettingsSection: { template: '
' },
- NcCheckboxRadioSwitch: NcCheckboxRadioSwitchStub,
- NcLoadingIcon: true,
- NcNoteCard: true,
- NcSavingIndicatorIcon: true,
- },
- },
- })
- const vm = wrapper.vm as unknown as SignatureFlowVm
-
- vm.enabled = false
- vm.onToggleChange()
- await flushPromises()
-
- expect(axiosPostMock).toHaveBeenCalled()
- const lastCall = axiosPostMock.mock.calls[axiosPostMock.mock.calls.length - 1] as [string, { enabled: boolean }]
- expect(lastCall[1].enabled).toBe(false)
- })
-
- it('loads backend mode and persists selected radio mode', async () => {
- loadStateMock.mockImplementation((_app: string, key: string, fallback: unknown) => {
- if (key === 'signature_flow') return 'ordered_numeric'
- return fallback
- })
-
- const wrapper = mount(SignatureFlow as never, {
- global: {
- stubs: {
- NcSettingsSection: { template: '
' },
- NcCheckboxRadioSwitch: NcCheckboxRadioSwitchStub,
- NcLoadingIcon: true,
- NcNoteCard: true,
- NcSavingIndicatorIcon: true,
- },
- },
- })
- const vm = wrapper.vm as unknown as SignatureFlowVm
- await flushPromises()
-
- expect(vm.selectedFlow?.value).toBe('ordered_numeric')
-
- const radioAndSwitchButtons = wrapper.findAll('.checkbox-radio-switch-stub')
- await radioAndSwitchButtons[1].trigger('click')
- await flushPromises()
-
- expect(vm.selectedFlow?.value).toBe('parallel')
- const lastCall = axiosPostMock.mock.calls[axiosPostMock.mock.calls.length - 1] as [string, { mode: string }]
- expect(lastCall[1].mode).toBe('parallel')
- })
-})
diff --git a/src/tests/views/Settings/Validation.spec.ts b/src/tests/views/Settings/Validation.spec.ts
index 4dda74ddd9..5411698fa3 100644
--- a/src/tests/views/Settings/Validation.spec.ts
+++ b/src/tests/views/Settings/Validation.spec.ts
@@ -114,7 +114,8 @@ describe('Settings/Validation.vue', () => {
})
it('resets the footer template when customization is disabled', async () => {
- axiosGetMock.mockResolvedValue({ data: { ocs: { data: { data: '1' } } } })
+ axiosGetMock
+ .mockResolvedValue({ data: { ocs: { data: { data: '1' } } } })
const wrapper = createWrapper()
await flushPromises()
diff --git a/src/tests/views/Settings/usePolicyWorkbench.spec.ts b/src/tests/views/Settings/usePolicyWorkbench.spec.ts
new file mode 100644
index 0000000000..9a586299ca
--- /dev/null
+++ b/src/tests/views/Settings/usePolicyWorkbench.spec.ts
@@ -0,0 +1,110 @@
+/*
+ * SPDX-FileCopyrightText: 2026 LibreSign contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+
+import { createL10nMock } from '../../testHelpers/l10n.js'
+
+vi.mock('@nextcloud/l10n', () => createL10nMock())
+
+import { createPolicyWorkbenchState } from '../../../views/Settings/PolicyWorkbench/usePolicyWorkbench'
+
+describe('usePolicyWorkbench', () => {
+ it('creates, edits and removes rules across settings without duplicating shell logic', () => {
+ const state = createPolicyWorkbenchState()
+
+ state.openSetting('confetti')
+ state.startEditor({ scope: 'user' })
+ expect(state.editorDraft.value?.targetId).toBe('maria')
+ state.saveDraft()
+ expect(state.settingsState.confetti.some((rule) => rule.scope === 'user' && rule.targetId === 'maria')).toBe(true)
+
+ state.openSetting('signature_flow')
+ state.startEditor({ scope: 'group', ruleId: 'signature-group-finance' })
+ state.updateDraftAllowOverride(false)
+ state.saveDraft()
+ expect(state.settingsState.signature_flow.find((rule) => rule.id === 'signature-group-finance')?.allowChildOverride).toBe(false)
+
+ state.removeRule('signature-user-maria')
+ expect(state.settingsState.signature_flow.some((rule) => rule.id === 'signature-user-maria')).toBe(false)
+
+ state.openSetting('signature_stamp')
+ state.startEditor({ scope: 'group', ruleId: 'stamp-group-legal' })
+ state.updateDraftValue({
+ enabled: true,
+ renderMode: 'GRAPHIC_ONLY',
+ template: '{{ signer_name }}',
+ templateFontSize: 12,
+ signatureFontSize: 18,
+ signatureWidth: 240,
+ signatureHeight: 90,
+ backgroundMode: 'custom',
+ showSigningDate: true,
+ })
+ state.saveDraft()
+ expect(state.settingsState.signature_stamp.find((rule) => rule.id === 'stamp-group-legal')?.value.renderMode).toBe('GRAPHIC_ONLY')
+
+ state.openSetting('identify_factors')
+ state.startEditor({ scope: 'user', ruleId: 'identify-user-maria' })
+ state.updateDraftValue({
+ enabled: true,
+ requireAnyTwo: true,
+ factors: [
+ {
+ key: 'email',
+ label: 'Email',
+ enabled: true,
+ required: true,
+ allowCreateAccount: true,
+ signatureMethod: 'email_token',
+ },
+ {
+ key: 'sms',
+ label: 'SMS',
+ enabled: true,
+ required: true,
+ allowCreateAccount: false,
+ signatureMethod: 'sms_token',
+ },
+ {
+ key: 'whatsapp',
+ label: 'WhatsApp',
+ enabled: false,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'whatsapp_token',
+ },
+ {
+ key: 'document',
+ label: 'Document data',
+ enabled: true,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'document_validation',
+ },
+ ],
+ })
+ state.saveDraft()
+ expect(state.settingsState.identify_factors.find((rule) => rule.id === 'identify-user-maria')?.value.requireAnyTwo).toBe(true)
+ })
+
+ it('filters the workspace for group admins to the current group and its users', () => {
+ const state = createPolicyWorkbenchState()
+
+ state.setViewMode('group-admin')
+ state.openSetting('signature_flow')
+
+ expect(state.visibleGroupRules.value).toHaveLength(1)
+ expect(state.visibleGroupRules.value[0]?.targetId).toBe('finance')
+ expect(state.visibleUserRules.value.every((rule) => ['maria', 'joao'].includes(rule.targetId ?? ''))).toBe(true)
+
+ state.startEditor({ scope: 'user' })
+ expect(state.editorDraft.value?.targetId).toBe('joao')
+
+ const summaryKeys = state.visibleSettingSummaries.value.map((summary) => summary.key)
+ expect(summaryKeys).toContain('signature_stamp')
+ expect(summaryKeys).toContain('identify_factors')
+ })
+})
diff --git a/src/types/index.ts b/src/types/index.ts
index a12fa7f3d6..bdc490b191 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -6,6 +6,7 @@
import type { components as ApiComponents } from './openapi/openapi'
import type { operations as ApiOperations } from './openapi/openapi'
import type { components as AdminComponents } from './openapi/openapi-administration'
+import type { operations as AdminOperations } from './openapi/openapi-administration'
type ApiJsonBody = TRequestBody extends {
content: {
@@ -44,8 +45,43 @@ type ApiRequestJsonBody = ApiJsonBody>
= ApiOcsJsonData[TStatusCode]>
+type ApiRecordValue = TRecord extends Record
+ ? TValue
+ : never
+
export type SignatureFlowMode = ApiComponents['schemas']['DetailedFileResponse']['signatureFlow']
export type SignatureFlowValue = SignatureFlowMode | 0 | 1 | 2
+export type EffectivePoliciesResponse = ApiOcsResponseData
+export type EffectivePoliciesState = EffectivePoliciesResponse['policies']
+export type EffectivePolicyState = ApiRecordValue
+export type EffectivePolicyValue = Exclude['value'], undefined>
+export type GroupPolicyResponse = ApiOcsResponseData
+export type GroupPolicyState = GroupPolicyResponse['policy']
+export type SystemPolicyState = {
+ policyKey: string
+ scope: 'system' | 'global'
+ value: EffectivePolicyValue | null
+ allowChildOverride: boolean
+ visibleToChild: boolean
+ allowedValues: EffectivePolicyValue[]
+}
+export type SystemPolicyResponse = {
+ policy: SystemPolicyState
+}
+export type UserPolicyState = {
+ policyKey: string
+ scope: 'user'
+ targetId: string
+ value: EffectivePolicyValue | null
+}
+export type UserPolicyResponse = {
+ policy: UserPolicyState
+}
+export type GroupPolicyWritePayload = ApiRequestJsonBody
+export type GroupPolicyWriteResponse = ApiOcsResponseData
+export type SystemPolicyWritePayload = ApiRequestJsonBody
+export type SystemPolicyWriteResponse = ApiOcsResponseData
+export type SystemPolicyWriteErrorResponse = ApiOcsResponseData
export type NewFilePayload = ApiComponents['schemas']['NewFile']
export type IdentifyMethodRecord = ApiComponents['schemas']['IdentifyMethod']
export type IdentifyAccountRecord = ApiComponents['schemas']['IdentifyAccount']
diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts
index 1038d0e772..0299a403e7 100644
--- a/src/types/openapi/openapi-administration.ts
+++ b/src/types/openapi/openapi-administration.ts
@@ -365,26 +365,6 @@ export type paths = {
patch?: never;
trace?: never;
};
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-flow/config": {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- get?: never;
- put?: never;
- /**
- * Set signature flow configuration
- * @description This endpoint requires admin access
- */
- post: operations["admin-set-signature-flow-config"];
- delete?: never;
- options?: never;
- head?: never;
- patch?: never;
- trace?: never;
- };
"/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": {
parameters: {
query?: never;
@@ -465,6 +445,58 @@ export type paths = {
patch?: never;
trace?: never;
};
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Read explicit system policy configuration
+ * @description This endpoint requires admin access
+ */
+ get: operations["policy-get-system"];
+ put?: never;
+ /**
+ * Save a system-level policy value
+ * @description This endpoint requires admin access
+ */
+ post: operations["policy-set-system"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{userId}/{policyKey}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Read a user-level policy preference for a target user (admin scope)
+ * @description This endpoint requires admin access
+ */
+ get: operations["policy-get-user-policy-for-user"];
+ /**
+ * Save a user policy preference for a target user (admin scope)
+ * @description This endpoint requires admin access
+ */
+ put: operations["policy-set-user-policy-for-user"];
+ post?: never;
+ /**
+ * Clear a user policy preference for a target user (admin scope)
+ * @description This endpoint requires admin access
+ */
+ delete: operations["policy-clear-user-policy-for-user"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/ocs/v2.php/apps/libresign/api/{apiVersion}/setting/has-root-cert": {
parameters: {
query?: never;
@@ -583,6 +615,22 @@ export type components = {
success: boolean;
message: string;
};
+ EffectivePolicyResponse: {
+ policy: components["schemas"]["EffectivePolicyState"];
+ };
+ EffectivePolicyState: {
+ policyKey: string;
+ effectiveValue: components["schemas"]["EffectivePolicyValue"];
+ sourceScope: string;
+ visible: boolean;
+ editableByCurrentActor: boolean;
+ allowedValues: components["schemas"]["EffectivePolicyValue"][];
+ canSaveAsUserDefault: boolean;
+ canUseAsRequestOverride: boolean;
+ preferenceWasCleared: boolean;
+ blockedBy: string | null;
+ };
+ EffectivePolicyValue: (boolean | number | string) | null;
EngineHandler: {
configPath: string;
cfsslUri?: string;
@@ -705,6 +753,30 @@ export type components = {
/** @enum {string} */
status: "success";
};
+ SystemPolicyResponse: {
+ policy: components["schemas"]["SystemPolicyState"];
+ };
+ SystemPolicyState: {
+ policyKey: string;
+ /** @enum {string} */
+ scope: "system" | "global";
+ value: components["schemas"]["EffectivePolicyValue"];
+ allowChildOverride: boolean;
+ visibleToChild: boolean;
+ allowedValues: components["schemas"]["EffectivePolicyValue"][];
+ };
+ SystemPolicyWriteResponse: components["schemas"]["MessageResponse"] & components["schemas"]["EffectivePolicyResponse"];
+ UserPolicyResponse: {
+ policy: components["schemas"]["UserPolicyState"];
+ };
+ UserPolicyState: {
+ policyKey: string;
+ /** @enum {string} */
+ scope: "user";
+ targetId: string;
+ value: components["schemas"]["EffectivePolicyValue"];
+ };
+ UserPolicyWriteResponse: components["schemas"]["MessageResponse"] & components["schemas"]["UserPolicyResponse"];
};
responses: never;
parameters: never;
@@ -1781,73 +1853,6 @@ export interface operations {
};
};
};
- "admin-set-signature-flow-config": {
- parameters: {
- query?: never;
- header: {
- /** @description Required to be true for the API request to pass */
- "OCS-APIRequest": boolean;
- };
- path: {
- apiVersion: "v1";
- };
- cookie?: never;
- };
- requestBody: {
- content: {
- "application/json": {
- /** @description Whether to force a signature flow for all documents */
- enabled: boolean;
- /** @description Signature flow mode: 'parallel' or 'ordered_numeric' (only used when enabled is true) */
- mode?: string | null;
- };
- };
- };
- responses: {
- /** @description Configuration saved successfully */
- 200: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": {
- ocs: {
- meta: components["schemas"]["OCSMeta"];
- data: components["schemas"]["MessageResponse"];
- };
- };
- };
- };
- /** @description Invalid signature flow mode provided */
- 400: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": {
- ocs: {
- meta: components["schemas"]["OCSMeta"];
- data: components["schemas"]["ErrorResponse"];
- };
- };
- };
- };
- /** @description Internal server error */
- 500: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": {
- ocs: {
- meta: components["schemas"]["OCSMeta"];
- data: components["schemas"]["ErrorResponse"];
- };
- };
- };
- };
- };
- };
"admin-set-doc-mdp-config": {
parameters: {
query?: never;
@@ -2087,6 +2092,219 @@ export interface operations {
};
};
};
+ "policy-get-system": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Policy identifier to read from the system layer. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["SystemPolicyResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-set-system": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Policy identifier to persist at the system layer. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": {
+ /** @description Policy value to persist. Null resets the policy to its default system value. */
+ value?: (boolean | number | string) | null;
+ /**
+ * @description Whether lower layers may override this system default.
+ * @default false
+ */
+ allowChildOverride?: boolean;
+ };
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["SystemPolicyWriteResponse"];
+ };
+ };
+ };
+ };
+ /** @description Invalid policy value */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["ErrorResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-get-user-policy-for-user": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Target user identifier that receives the policy preference. */
+ userId: string;
+ /** @description Policy identifier to read for the selected user. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["UserPolicyResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-set-user-policy-for-user": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Target user identifier that receives the policy preference. */
+ userId: string;
+ /** @description Policy identifier to persist for the target user. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": {
+ /** @description Policy value to persist as target user preference. */
+ value?: (boolean | number | string) | null;
+ };
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["UserPolicyWriteResponse"];
+ };
+ };
+ };
+ };
+ /** @description Invalid policy value */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["ErrorResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-clear-user-policy-for-user": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Target user identifier that receives the policy preference removal. */
+ userId: string;
+ /** @description Policy identifier to clear for the target user. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["UserPolicyWriteResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
"setting-has-root-cert": {
parameters: {
query?: never;
diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts
index 772db24646..e84bb52867 100644
--- a/src/types/openapi/openapi-full.ts
+++ b/src/types/openapi/openapi-full.ts
@@ -852,6 +852,60 @@ export type paths = {
patch?: never;
trace?: never;
};
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/effective": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Effective policies bootstrap */
+ get: operations["policy-effective"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/group/{groupId}/{policyKey}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read a group-level policy value */
+ get: operations["policy-get-group"];
+ /** Save a group-level policy value */
+ put: operations["policy-set-group"];
+ post?: never;
+ /** Clear a group-level policy value */
+ delete: operations["policy-clear-group"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{policyKey}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ /** Save a user policy preference */
+ put: operations["policy-set-user-preference"];
+ post?: never;
+ /** Clear a user policy preference */
+ delete: operations["policy-clear-user-preference"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": {
parameters: {
query?: never;
@@ -1400,26 +1454,6 @@ export type paths = {
patch?: never;
trace?: never;
};
- "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-flow/config": {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- get?: never;
- put?: never;
- /**
- * Set signature flow configuration
- * @description This endpoint requires admin access
- */
- post: operations["admin-set-signature-flow-config"];
- delete?: never;
- options?: never;
- head?: never;
- patch?: never;
- trace?: never;
- };
"/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": {
parameters: {
query?: never;
@@ -1500,6 +1534,58 @@ export type paths = {
patch?: never;
trace?: never;
};
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Read explicit system policy configuration
+ * @description This endpoint requires admin access
+ */
+ get: operations["policy-get-system"];
+ put?: never;
+ /**
+ * Save a system-level policy value
+ * @description This endpoint requires admin access
+ */
+ post: operations["policy-set-system"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{userId}/{policyKey}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Read a user-level policy preference for a target user (admin scope)
+ * @description This endpoint requires admin access
+ */
+ get: operations["policy-get-user-policy-for-user"];
+ /**
+ * Save a user policy preference for a target user (admin scope)
+ * @description This endpoint requires admin access
+ */
+ put: operations["policy-set-user-policy-for-user"];
+ post?: never;
+ /**
+ * Clear a user policy preference for a target user (admin scope)
+ * @description This endpoint requires admin access
+ */
+ delete: operations["policy-clear-user-policy-for-user"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/ocs/v2.php/apps/libresign/api/{apiVersion}/setting/has-root-cert": {
parameters: {
query?: never;
@@ -1782,6 +1868,27 @@ export type components = {
label: string;
description: string;
};
+ EffectivePoliciesResponse: {
+ policies: {
+ [key: string]: components["schemas"]["EffectivePolicyState"];
+ };
+ };
+ EffectivePolicyResponse: {
+ policy: components["schemas"]["EffectivePolicyState"];
+ };
+ EffectivePolicyState: {
+ policyKey: string;
+ effectiveValue: components["schemas"]["EffectivePolicyValue"];
+ sourceScope: string;
+ visible: boolean;
+ editableByCurrentActor: boolean;
+ allowedValues: components["schemas"]["EffectivePolicyValue"][];
+ canSaveAsUserDefault: boolean;
+ canUseAsRequestOverride: boolean;
+ preferenceWasCleared: boolean;
+ blockedBy: string | null;
+ };
+ EffectivePolicyValue: (boolean | number | string) | null;
EngineHandler: {
configPath: string;
cfsslUri?: string;
@@ -1922,6 +2029,24 @@ export type components = {
/** Format: int64 */
preview_height: number;
};
+ GroupPolicyResponse: {
+ policy: components["schemas"]["GroupPolicyState"];
+ };
+ GroupPolicyState: {
+ policyKey: string;
+ /** @enum {string} */
+ scope: "group";
+ targetId: string;
+ value: components["schemas"]["EffectivePolicyValue"];
+ allowChildOverride: boolean;
+ visibleToChild: boolean;
+ allowedValues: components["schemas"]["EffectivePolicyValue"][];
+ };
+ GroupPolicyWriteRequest: {
+ value: components["schemas"]["EffectivePolicyValue"];
+ allowChildOverride: boolean;
+ };
+ GroupPolicyWriteResponse: components["schemas"]["MessageResponse"] & components["schemas"]["GroupPolicyResponse"];
HasRootCertResponse: {
hasRootCert: boolean;
};
@@ -2034,6 +2159,15 @@ export type components = {
OID: string;
CPS: string;
};
+ PolicySnapshotEntry: {
+ effectiveValue: string;
+ sourceScope: string;
+ };
+ PolicySnapshotNumericEntry: {
+ /** Format: int64 */
+ effectiveValue: number;
+ sourceScope: string;
+ };
ProgressError: {
message: string;
/** Format: int64 */
@@ -2229,6 +2363,22 @@ export type components = {
/** @enum {string} */
status: "success";
};
+ SystemPolicyResponse: {
+ policy: components["schemas"]["SystemPolicyState"];
+ };
+ SystemPolicyState: {
+ policyKey: string;
+ /** @enum {string} */
+ scope: "system" | "global";
+ value: components["schemas"]["EffectivePolicyValue"];
+ allowChildOverride: boolean;
+ visibleToChild: boolean;
+ allowedValues: components["schemas"]["EffectivePolicyValue"][];
+ };
+ SystemPolicyWriteRequest: {
+ value: components["schemas"]["EffectivePolicyValue"];
+ };
+ SystemPolicyWriteResponse: components["schemas"]["MessageResponse"] & components["schemas"]["EffectivePolicyResponse"];
UserElement: {
/** Format: int64 */
id: number;
@@ -2253,6 +2403,17 @@ export type components = {
UserElementsResponse: {
elements: components["schemas"]["UserElement"][];
};
+ UserPolicyResponse: {
+ policy: components["schemas"]["UserPolicyState"];
+ };
+ UserPolicyState: {
+ policyKey: string;
+ /** @enum {string} */
+ scope: "user";
+ targetId: string;
+ value: components["schemas"]["EffectivePolicyValue"];
+ };
+ UserPolicyWriteResponse: components["schemas"]["MessageResponse"] & components["schemas"]["UserPolicyResponse"];
ValidateMetadata: {
extension: string;
/** Format: int64 */
@@ -2264,9 +2425,14 @@ export type components = {
h: number;
}[];
original_file_deleted?: boolean;
+ policy_snapshot?: components["schemas"]["ValidatePolicySnapshot"];
pdfVersion?: string;
status_changed_at?: string;
};
+ ValidatePolicySnapshot: {
+ docmdp?: components["schemas"]["PolicySnapshotNumericEntry"];
+ signature_flow?: components["schemas"]["PolicySnapshotEntry"];
+ };
ValidatedChildFile: {
/** Format: int64 */
id: number;
@@ -4577,7 +4743,7 @@ export interface operations {
};
};
};
- "request_signature-request": {
+ "policy-effective": {
parameters: {
query?: never;
header: {
@@ -4589,47 +4755,7 @@ export interface operations {
};
cookie?: never;
};
- requestBody?: {
- content: {
- "application/json": {
- /**
- * @description Collection of signers who must sign the document. Use identifyMethods as the canonical format. Other supported fields: displayName, description, notify, signingOrder, status
- * @default []
- */
- signers?: components["schemas"]["NewSigner"][];
- /**
- * @description The name of file to sign
- * @default
- */
- name?: string;
- /**
- * @description Settings to define how and where the file should be stored
- * @default []
- */
- settings?: components["schemas"]["FolderSettings"];
- /**
- * @description File object. Supports nodeId, url, base64 or path.
- * @default []
- */
- file?: components["schemas"]["NewFile"];
- /**
- * @description Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.
- * @default []
- */
- files?: components["schemas"]["NewFile"][];
- /** @description URL that will receive a POST after the document is signed */
- callback?: string | null;
- /**
- * Format: int64
- * @description Numeric code of status * 0 - no signers * 1 - signed * 2 - pending
- * @default 1
- */
- status?: number | null;
- /** @description Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration */
- signatureFlow?: string | null;
- };
- };
- };
+ requestBody?: never;
responses: {
/** @description OK */
200: {
@@ -4640,28 +4766,14 @@ export interface operations {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
- data: components["schemas"]["DetailedFileResponse"];
- };
- };
- };
- };
- /** @description Unauthorized */
- 422: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": {
- ocs: {
- meta: components["schemas"]["OCSMeta"];
- data: components["schemas"]["MessageResponse"] | components["schemas"]["ActionErrorResponse"];
+ data: components["schemas"]["EffectivePoliciesResponse"];
};
};
};
};
};
};
- "request_signature-update-sign": {
+ "policy-get-group": {
parameters: {
query?: never;
header: {
@@ -4670,48 +4782,14 @@ export interface operations {
};
path: {
apiVersion: "v1";
+ /** @description Group identifier that receives the policy binding. */
+ groupId: string;
+ /** @description Policy identifier to read for the selected group. */
+ policyKey: string;
};
cookie?: never;
};
- requestBody?: {
- content: {
- "application/json": {
- /**
- * @description Collection of signers who must sign the document. Use identifyMethods as the canonical format.
- * @default []
- */
- signers?: components["schemas"]["NewSigner"][] | null;
- /** @description UUID of sign request. The signer UUID is what the person receives via email when asked to sign. This is not the file UUID. */
- uuid?: string | null;
- /** @description Visible elements on document */
- visibleElements?: components["schemas"]["VisibleElement"][] | null;
- /**
- * @description File object. Supports nodeId, url, base64 or path when creating a new request.
- * @default []
- */
- file?: (components["schemas"]["NewFile"] | unknown[]) | null;
- /**
- * Format: int64
- * @description Numeric code of status * 0 - no signers * 1 - signed * 2 - pending
- */
- status?: number | null;
- /** @description Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration */
- signatureFlow?: string | null;
- /** @description The name of file to sign */
- name?: string | null;
- /**
- * @description Settings to define how and where the file should be stored
- * @default []
- */
- settings?: components["schemas"]["FolderSettings"];
- /**
- * @description Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.
- * @default []
- */
- files?: components["schemas"]["NewFile"][];
- };
- };
- };
+ requestBody?: never;
responses: {
/** @description OK */
200: {
@@ -4722,13 +4800,13 @@ export interface operations {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
- data: components["schemas"]["DetailedFileResponse"];
+ data: components["schemas"]["GroupPolicyResponse"];
};
};
};
};
- /** @description Unauthorized */
- 422: {
+ /** @description Forbidden */
+ 403: {
headers: {
[name: string]: unknown;
};
@@ -4736,14 +4814,14 @@ export interface operations {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
- data: components["schemas"]["MessageResponse"] | components["schemas"]["ActionErrorResponse"];
+ data: components["schemas"]["ErrorResponse"];
};
};
};
};
};
};
- "request_signature-delete-one-request-signature-using-file-id": {
+ "policy-set-group": {
parameters: {
query?: never;
header: {
@@ -4752,14 +4830,26 @@ export interface operations {
};
path: {
apiVersion: "v1";
- /** @description LibreSign file ID */
- fileId: number;
- /** @description The sign request id */
- signRequestId: number;
+ /** @description Group identifier that receives the policy binding. */
+ groupId: string;
+ /** @description Policy identifier to persist at the group layer. */
+ policyKey: string;
};
cookie?: never;
};
- requestBody?: never;
+ requestBody?: {
+ content: {
+ "application/json": {
+ /** @description Policy value to persist for the group. */
+ value?: (boolean | number | string) | null;
+ /**
+ * @description Whether users and requests below this group may override the group default.
+ * @default false
+ */
+ allowChildOverride?: boolean;
+ };
+ };
+ };
responses: {
/** @description OK */
200: {
@@ -4770,13 +4860,13 @@ export interface operations {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
- data: components["schemas"]["MessageResponse"];
+ data: components["schemas"]["GroupPolicyWriteResponse"];
};
};
};
};
- /** @description Failed */
- 401: {
+ /** @description Invalid policy value */
+ 400: {
headers: {
[name: string]: unknown;
};
@@ -4784,13 +4874,13 @@ export interface operations {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
- data: components["schemas"]["MessageResponse"];
+ data: components["schemas"]["ErrorResponse"];
};
};
};
};
- /** @description Failed */
- 422: {
+ /** @description Forbidden */
+ 403: {
headers: {
[name: string]: unknown;
};
@@ -4798,14 +4888,14 @@ export interface operations {
"application/json": {
ocs: {
meta: components["schemas"]["OCSMeta"];
- data: components["schemas"]["ActionErrorResponse"];
+ data: components["schemas"]["ErrorResponse"];
};
};
};
};
};
};
- "sign_file-sign-using-file-id": {
+ "policy-clear-group": {
parameters: {
query?: never;
header: {
@@ -4814,20 +4904,381 @@ export interface operations {
};
path: {
apiVersion: "v1";
- /** @description Id of LibreSign file */
- fileId: number;
+ /** @description Group identifier that receives the policy binding. */
+ groupId: string;
+ /** @description Policy identifier to clear for the selected group. */
+ policyKey: string;
};
cookie?: never;
};
- requestBody: {
- content: {
- "application/json": {
- /** @description Signature method */
- method: string;
- /**
- * @description List of visible elements
- * @default {}
- */
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["GroupPolicyWriteResponse"];
+ };
+ };
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["ErrorResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-set-user-preference": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Policy identifier to persist for the current user. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": {
+ /** @description Policy value to persist as the current user's default. */
+ value?: (boolean | number | string) | null;
+ };
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["SystemPolicyWriteResponse"];
+ };
+ };
+ };
+ };
+ /** @description Invalid policy value */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["ErrorResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-clear-user-preference": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Policy identifier to clear for the current user. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["SystemPolicyWriteResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "request_signature-request": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": {
+ /**
+ * @description Collection of signers who must sign the document. Use identifyMethods as the canonical format. Other supported fields: displayName, description, notify, signingOrder, status
+ * @default []
+ */
+ signers?: components["schemas"]["NewSigner"][];
+ /**
+ * @description The name of file to sign
+ * @default
+ */
+ name?: string;
+ /**
+ * @description Settings to define how and where the file should be stored
+ * @default []
+ */
+ settings?: components["schemas"]["FolderSettings"];
+ /**
+ * @description File object. Supports nodeId, url, base64 or path.
+ * @default []
+ */
+ file?: components["schemas"]["NewFile"];
+ /**
+ * @description Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.
+ * @default []
+ */
+ files?: components["schemas"]["NewFile"][];
+ /** @description URL that will receive a POST after the document is signed */
+ callback?: string | null;
+ /**
+ * Format: int64
+ * @description Numeric code of status * 0 - no signers * 1 - signed * 2 - pending
+ * @default 1
+ */
+ status?: number | null;
+ /** @description Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution. */
+ signatureFlow?: string | null;
+ };
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["DetailedFileResponse"];
+ };
+ };
+ };
+ };
+ /** @description Unauthorized */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["MessageResponse"] | components["schemas"]["ActionErrorResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "request_signature-update-sign": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": {
+ /**
+ * @description Collection of signers who must sign the document. Use identifyMethods as the canonical format.
+ * @default []
+ */
+ signers?: components["schemas"]["NewSigner"][] | null;
+ /** @description UUID of sign request. The signer UUID is what the person receives via email when asked to sign. This is not the file UUID. */
+ uuid?: string | null;
+ /** @description Visible elements on document */
+ visibleElements?: components["schemas"]["VisibleElement"][] | null;
+ /**
+ * @description File object. Supports nodeId, url, base64 or path when creating a new request.
+ * @default []
+ */
+ file?: (components["schemas"]["NewFile"] | unknown[]) | null;
+ /**
+ * Format: int64
+ * @description Numeric code of status * 0 - no signers * 1 - signed * 2 - pending
+ */
+ status?: number | null;
+ /** @description Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution. */
+ signatureFlow?: string | null;
+ /** @description The name of file to sign */
+ name?: string | null;
+ /**
+ * @description Settings to define how and where the file should be stored
+ * @default []
+ */
+ settings?: components["schemas"]["FolderSettings"];
+ /**
+ * @description Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.
+ * @default []
+ */
+ files?: components["schemas"]["NewFile"][];
+ };
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["DetailedFileResponse"];
+ };
+ };
+ };
+ };
+ /** @description Unauthorized */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["MessageResponse"] | components["schemas"]["ActionErrorResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "request_signature-delete-one-request-signature-using-file-id": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description LibreSign file ID */
+ fileId: number;
+ /** @description The sign request id */
+ signRequestId: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["MessageResponse"];
+ };
+ };
+ };
+ };
+ /** @description Failed */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["MessageResponse"];
+ };
+ };
+ };
+ };
+ /** @description Failed */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["ActionErrorResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "sign_file-sign-using-file-id": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Id of LibreSign file */
+ fileId: number;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @description Signature method */
+ method: string;
+ /**
+ * @description List of visible elements
+ * @default {}
+ */
elements?: {
[key: string]: Record;
};
@@ -6528,73 +6979,6 @@ export interface operations {
};
};
};
- "admin-set-signature-flow-config": {
- parameters: {
- query?: never;
- header: {
- /** @description Required to be true for the API request to pass */
- "OCS-APIRequest": boolean;
- };
- path: {
- apiVersion: "v1";
- };
- cookie?: never;
- };
- requestBody: {
- content: {
- "application/json": {
- /** @description Whether to force a signature flow for all documents */
- enabled: boolean;
- /** @description Signature flow mode: 'parallel' or 'ordered_numeric' (only used when enabled is true) */
- mode?: string | null;
- };
- };
- };
- responses: {
- /** @description Configuration saved successfully */
- 200: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": {
- ocs: {
- meta: components["schemas"]["OCSMeta"];
- data: components["schemas"]["MessageResponse"];
- };
- };
- };
- };
- /** @description Invalid signature flow mode provided */
- 400: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": {
- ocs: {
- meta: components["schemas"]["OCSMeta"];
- data: components["schemas"]["ErrorResponse"];
- };
- };
- };
- };
- /** @description Internal server error */
- 500: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": {
- ocs: {
- meta: components["schemas"]["OCSMeta"];
- data: components["schemas"]["ErrorResponse"];
- };
- };
- };
- };
- };
- };
"admin-set-doc-mdp-config": {
parameters: {
query?: never;
@@ -6834,6 +7218,219 @@ export interface operations {
};
};
};
+ "policy-get-system": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Policy identifier to read from the system layer. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["SystemPolicyResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-set-system": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Policy identifier to persist at the system layer. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": {
+ /** @description Policy value to persist. Null resets the policy to its default system value. */
+ value?: (boolean | number | string) | null;
+ /**
+ * @description Whether lower layers may override this system default.
+ * @default false
+ */
+ allowChildOverride?: boolean;
+ };
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["SystemPolicyWriteResponse"];
+ };
+ };
+ };
+ };
+ /** @description Invalid policy value */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["ErrorResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-get-user-policy-for-user": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Target user identifier that receives the policy preference. */
+ userId: string;
+ /** @description Policy identifier to read for the selected user. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["UserPolicyResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-set-user-policy-for-user": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Target user identifier that receives the policy preference. */
+ userId: string;
+ /** @description Policy identifier to persist for the target user. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": {
+ /** @description Policy value to persist as target user preference. */
+ value?: (boolean | number | string) | null;
+ };
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["UserPolicyWriteResponse"];
+ };
+ };
+ };
+ };
+ /** @description Invalid policy value */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["ErrorResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-clear-user-policy-for-user": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Target user identifier that receives the policy preference removal. */
+ userId: string;
+ /** @description Policy identifier to clear for the target user. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["UserPolicyWriteResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
"setting-has-root-cert": {
parameters: {
query?: never;
diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts
index 83f883b799..deb1b0900a 100644
--- a/src/types/openapi/openapi.ts
+++ b/src/types/openapi/openapi.ts
@@ -852,6 +852,60 @@ export type paths = {
patch?: never;
trace?: never;
};
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/effective": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Effective policies bootstrap */
+ get: operations["policy-effective"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/group/{groupId}/{policyKey}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Read a group-level policy value */
+ get: operations["policy-get-group"];
+ /** Save a group-level policy value */
+ put: operations["policy-set-group"];
+ post?: never;
+ /** Clear a group-level policy value */
+ delete: operations["policy-clear-group"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{policyKey}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ /** Save a user policy preference */
+ put: operations["policy-set-user-preference"];
+ post?: never;
+ /** Clear a user policy preference */
+ delete: operations["policy-clear-user-preference"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": {
parameters: {
query?: never;
@@ -1215,6 +1269,27 @@ export type components = {
/** @enum {string} */
signatureFlow: "none" | "parallel" | "ordered_numeric";
};
+ EffectivePoliciesResponse: {
+ policies: {
+ [key: string]: components["schemas"]["EffectivePolicyState"];
+ };
+ };
+ EffectivePolicyResponse: {
+ policy: components["schemas"]["EffectivePolicyState"];
+ };
+ EffectivePolicyState: {
+ policyKey: string;
+ effectiveValue: components["schemas"]["EffectivePolicyValue"];
+ sourceScope: string;
+ visible: boolean;
+ editableByCurrentActor: boolean;
+ allowedValues: components["schemas"]["EffectivePolicyValue"][];
+ canSaveAsUserDefault: boolean;
+ canUseAsRequestOverride: boolean;
+ preferenceWasCleared: boolean;
+ blockedBy: string | null;
+ };
+ EffectivePolicyValue: (boolean | number | string) | null;
ErrorItem: {
message: string;
title?: string;
@@ -1328,6 +1403,20 @@ export type components = {
/** Format: int64 */
envelopeFolderId?: number;
};
+ GroupPolicyResponse: {
+ policy: components["schemas"]["GroupPolicyState"];
+ };
+ GroupPolicyState: {
+ policyKey: string;
+ /** @enum {string} */
+ scope: "group";
+ targetId: string;
+ value: components["schemas"]["EffectivePolicyValue"];
+ allowChildOverride: boolean;
+ visibleToChild: boolean;
+ allowedValues: components["schemas"]["EffectivePolicyValue"][];
+ };
+ GroupPolicyWriteResponse: components["schemas"]["MessageResponse"] & components["schemas"]["GroupPolicyResponse"];
IdDocs: {
file: components["schemas"]["NewFile"];
name?: string;
@@ -1426,6 +1515,15 @@ export type components = {
last: string | null;
first: string | null;
};
+ PolicySnapshotEntry: {
+ effectiveValue: string;
+ sourceScope: string;
+ };
+ PolicySnapshotNumericEntry: {
+ /** Format: int64 */
+ effectiveValue: number;
+ sourceScope: string;
+ };
ProgressError: {
message: string;
/** Format: int64 */
@@ -1580,6 +1678,7 @@ export type components = {
message: string;
status: string;
};
+ SystemPolicyWriteResponse: components["schemas"]["MessageResponse"] & components["schemas"]["EffectivePolicyResponse"];
UserElement: {
/** Format: int64 */
id: number;
@@ -1615,9 +1714,14 @@ export type components = {
h: number;
}[];
original_file_deleted?: boolean;
+ policy_snapshot?: components["schemas"]["ValidatePolicySnapshot"];
pdfVersion?: string;
status_changed_at?: string;
};
+ ValidatePolicySnapshot: {
+ docmdp?: components["schemas"]["PolicySnapshotNumericEntry"];
+ signature_flow?: components["schemas"]["PolicySnapshotEntry"];
+ };
ValidatedChildFile: {
/** Format: int64 */
id: number;
@@ -3928,6 +4032,291 @@ export interface operations {
};
};
};
+ "policy-effective": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["EffectivePoliciesResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-get-group": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Group identifier that receives the policy binding. */
+ groupId: string;
+ /** @description Policy identifier to read for the selected group. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["GroupPolicyResponse"];
+ };
+ };
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["ErrorResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-set-group": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Group identifier that receives the policy binding. */
+ groupId: string;
+ /** @description Policy identifier to persist at the group layer. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": {
+ /** @description Policy value to persist for the group. */
+ value?: (boolean | number | string) | null;
+ /**
+ * @description Whether users and requests below this group may override the group default.
+ * @default false
+ */
+ allowChildOverride?: boolean;
+ };
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["GroupPolicyWriteResponse"];
+ };
+ };
+ };
+ };
+ /** @description Invalid policy value */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["ErrorResponse"];
+ };
+ };
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["ErrorResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-clear-group": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Group identifier that receives the policy binding. */
+ groupId: string;
+ /** @description Policy identifier to clear for the selected group. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["GroupPolicyWriteResponse"];
+ };
+ };
+ };
+ };
+ /** @description Forbidden */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["ErrorResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-set-user-preference": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Policy identifier to persist for the current user. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": {
+ /** @description Policy value to persist as the current user's default. */
+ value?: (boolean | number | string) | null;
+ };
+ };
+ };
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["SystemPolicyWriteResponse"];
+ };
+ };
+ };
+ };
+ /** @description Invalid policy value */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["ErrorResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
+ "policy-clear-user-preference": {
+ parameters: {
+ query?: never;
+ header: {
+ /** @description Required to be true for the API request to pass */
+ "OCS-APIRequest": boolean;
+ };
+ path: {
+ apiVersion: "v1";
+ /** @description Policy identifier to clear for the current user. */
+ policyKey: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description OK */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ ocs: {
+ meta: components["schemas"]["OCSMeta"];
+ data: components["schemas"]["SystemPolicyWriteResponse"];
+ };
+ };
+ };
+ };
+ };
+ };
"request_signature-request": {
parameters: {
query?: never;
@@ -3976,7 +4365,7 @@ export interface operations {
* @default 1
*/
status?: number | null;
- /** @description Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration */
+ /** @description Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution. */
signatureFlow?: string | null;
};
};
@@ -4046,7 +4435,7 @@ export interface operations {
* @description Numeric code of status * 0 - no signers * 1 - signed * 2 - pending
*/
status?: number | null;
- /** @description Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration */
+ /** @description Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution. */
signatureFlow?: string | null;
/** @description The name of file to sign */
name?: string | null;
diff --git a/src/views/FilesList/VirtualList.vue b/src/views/FilesList/VirtualList.vue
index 68d94fb6ac..9d9be1eb9d 100644
--- a/src/views/FilesList/VirtualList.vue
+++ b/src/views/FilesList/VirtualList.vue
@@ -4,7 +4,7 @@
-->
@@ -55,9 +55,8 @@
-
diff --git a/src/views/Preferences/Preferences.vue b/src/views/Preferences/Preferences.vue
new file mode 100644
index 0000000000..9718c5c7cf
--- /dev/null
+++ b/src/views/Preferences/Preferences.vue
@@ -0,0 +1,234 @@
+
+
+
+
+
+
+ {{ t('libresign', 'A previously saved signing order preference was cleared because it is no longer compatible with a higher-level policy.') }}
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
{{ t('libresign', 'Effective signing order') }}
+
{{ effectiveLabel }}
+
+
+
{{ t('libresign', 'Source') }}
+
{{ sourceLabel }}
+
+
+
+
+ {{ t('libresign', 'Your current context does not allow saving a personal default for signing order. The effective value above is still applied when you create requests.') }}
+
+
+
+
+
+
{{ flow.label }}
+
{{ flow.description }}
+
+
+
+
+
+ {{ hasSavedPreference ? t('libresign', 'Update saved preference') : t('libresign', 'Save as my default') }}
+
+
+ {{ t('libresign', 'Clear saved preference') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/Settings/DocMDP.vue b/src/views/Settings/DocMDP.vue
deleted file mode 100644
index 1f6416cdd7..0000000000
--- a/src/views/Settings/DocMDP.vue
+++ /dev/null
@@ -1,237 +0,0 @@
-
-
-
-
-
-
- {{ t('libresign', 'Enable DocMDP') }}
-
-
-
- {{ errorMessage }}
-
-
-
-
- {{ t('libresign', 'Default certification level for new signatures:') }}
-
-
-
-
-
-
{{ level.label }}
-
- {{ level.description }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/views/Settings/PolicyWorkbench/PolicyRuleCard.vue b/src/views/Settings/PolicyWorkbench/PolicyRuleCard.vue
new file mode 100644
index 0000000000..4ec5fac2c4
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/PolicyRuleCard.vue
@@ -0,0 +1,252 @@
+
+
+
+
+
+
+
+ {{ description }}
+
+
+
+
+
+
+ {{ editText || editLabel }}
+
+
+ {{ removeText || removeLabel }}
+
+
+
+
+
+
+
+
diff --git a/src/views/Settings/PolicyWorkbench/PolicyRuleEditorPanel.vue b/src/views/Settings/PolicyWorkbench/PolicyRuleEditorPanel.vue
new file mode 100644
index 0000000000..028b9f7e0a
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/PolicyRuleEditorPanel.vue
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+
+
+ {{ editorDraft.scope === 'group' ? t('libresign', 'Target groups') : t('libresign', 'Target users') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('libresign', 'Allow lower-level overrides') }}
+
+ {{ editorDraft.allowChildOverride ? t('libresign', 'Groups and users can define a more specific value.') : t('libresign', 'Groups and users must inherit this value.') }}
+
+
+
+
+
+ {{ duplicateMessage }}
+
+
+
+
+ {{ t('libresign', '← Back') }}
+
+
+ {{ editorMode === 'edit' ? t('libresign', 'Save changes') : t('libresign', 'Create rule') }}
+
+
+ {{ t('libresign', 'Cancel') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/Settings/PolicyWorkbench/RealPolicyWorkbench.vue b/src/views/Settings/PolicyWorkbench/RealPolicyWorkbench.vue
new file mode 100644
index 0000000000..47472c5810
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/RealPolicyWorkbench.vue
@@ -0,0 +1,3101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('libresign', 'Custom rules active') }}
+
+
+
+
+ {{ t('libresign', 'Default') }}:
+
+
+
+ {{ t('libresign', 'Custom rules') }}:
+ {{ formatOverrideSummary(summary.groupCount, summary.userCount) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ( )
+
+
+
+ {{ t('libresign', 'Custom rules active') }}
+
+
+
+
+
+ {{ t('libresign', 'Default') }}:
+
+
+ {{ t('libresign', 'Custom rules') }}: {{ formatOverrideSummary(summary.groupCount, summary.userCount) }}
+
+
+
+ {{ t('libresign', 'Configure') }}
+
+
+
+
+
+
+
{{ t('libresign', 'No settings match this search. Try fewer keywords or clear the filter.') }}
+
+
+ {{ t('libresign', 'Clear filter') }}
+
+
+ {{ t('libresign', 'Show all settings') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ removalFeedback }}
+
+
+
+ {{ t('libresign', 'Default:') }}
+ {{ state.summary.currentBaseValue }}
+ ({{ defaultSourceLabel }})
+ ·
+
+ {{ t('libresign', 'Change') }}
+
+
+
+
+
+
+ {{ createRuleDisabledReason }}
+
+
+
+ {{ t('libresign', 'Some users may not allow personal rules because their group rule requires inheritance.') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('libresign', 'Choose the rule type to continue.') }}
+
+
+
+
+
+ {{ option.label }}
+ {{ option.description }}
+
+
+
+ {{ t('libresign', 'Group') }}: {{ scopeCreateDisabledReason('group') }}
+ {{ t('libresign', 'User') }}: {{ scopeCreateDisabledReason('user') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/Settings/PolicyWorkbench/SettingsPolicyWorkbench.vue b/src/views/Settings/PolicyWorkbench/SettingsPolicyWorkbench.vue
new file mode 100644
index 0000000000..e7e4ad3b05
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/SettingsPolicyWorkbench.vue
@@ -0,0 +1,1517 @@
+
+
+
+
+
+ {{ t('libresign', 'This POC keeps state only in the current browser session. It is meant to validate UX, composition and reuse before wiring the real persistence layer.') }}
+
+
+
+
+
+
+
+
+
+ {{ resolveSettingOrigin(summary.groupCount, summary.userCount) }}
+
+
+
+
+ {{ t('libresign', 'Default') }}:
+
+
+
+ {{ t('libresign', 'Group rules') }}:
+ {{ summary.groupCount }}
+
+
+ {{ t('libresign', 'User rules') }}:
+ {{ summary.userCount }}
+
+
+
+
+
+
+
+
+
+
+ ( )
+
+
+
+ {{ resolveSettingOrigin(summary.groupCount, summary.userCount) }}
+
+
+
+
+
+ {{ t('libresign', 'Default') }}:
+
+
+ {{ t('libresign', 'Group') }}: {{ summary.groupCount }}
+ {{ t('libresign', 'User') }}: {{ summary.userCount }}
+
+
+
+ {{ t('libresign', 'Manage') }}
+
+
+
+
+
+
+
{{ t('libresign', 'No settings matched this search. Try fewer words or clear the filter.') }}
+
+
+ {{ t('libresign', 'Clear filter') }}
+
+
+ {{ t('libresign', 'Show all settings') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('libresign', 'No custom default exists yet for this setting.') }}
+
+
+
+
+
+
+
+
+
+ {{ state.viewMode === 'system-admin'
+ ? t('libresign', 'No group rules exist yet for this setting.')
+ : t('libresign', 'The current group still inherits the instance default.') }}
+
+
+
+
+
+
+
+
+
+ {{ t('libresign', 'No user rules exist yet for this setting.') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ state.editorDraft.scope === 'group' ? t('libresign', 'Target group') : t('libresign', 'Target user') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('libresign', 'Require this signing order') }}
+
+ {{ state.editorDraft.allowChildOverride ? t('libresign', 'All users must follow this signing order.') : t('libresign', 'Users can choose their preferred signing order.') }}
+
+
+
+
+
+ {{ state.duplicateMessage }}
+
+
+
+
+ {{ state.editorMode === 'edit' ? t('libresign', 'Save changes') : t('libresign', 'Create rule') }}
+
+
+ {{ t('libresign', 'Cancel') }}
+
+
+
+ {{ saveStatus === 'saving' ? t('libresign', 'Saving...') : t('libresign', 'Saved') }}
+
+
+
+
+
+
{{ t('libresign', 'Saving...') }}
+
+
+
+
+ {{ t('libresign', 'Editing surface') }}
+ {{ t('libresign', 'Editor opened in modal') }}
+
+ {{ t('libresign', 'On small screens, editing opens in a focused modal to avoid a cramped form and preserve context.') }}
+
+
+ {{ t('libresign', 'Cancel editing') }}
+
+
+
+
+ {{ t('libresign', 'Editing surface') }}
+ {{ t('libresign', 'Choose an action to start editing') }}
+
+ {{ t('libresign', 'Use the buttons above to create a default rule, add a rule for a group or user, or edit one of the cards from the left column.') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ state.editorDraft.scope === 'group' ? t('libresign', 'Target group') : t('libresign', 'Target user') }}
+
+
+
+
+
+
+
+
+
+
+
{{ t('libresign', 'Require this signing order') }}
+
+ {{ state.editorDraft.allowChildOverride ? t('libresign', 'All users must follow this signing order.') : t('libresign', 'Users can choose their preferred signing order.') }}
+
+
+
+
+
+ {{ state.duplicateMessage }}
+
+
+
+
+ {{ state.editorMode === 'edit' ? t('libresign', 'Save changes') : t('libresign', 'Create rule') }}
+
+
+ {{ t('libresign', 'Cancel') }}
+
+
+
+ {{ saveStatus === 'saving' ? t('libresign', 'Saving...') : t('libresign', 'Saved') }}
+
+
+
+
+
+
{{ t('libresign', 'Saving...') }}
+
+
+
+
+
+
+
+
{{ t('libresign', 'You are about to remove this rule:') }}
+
{{ pendingRemoval.targetLabel }}
+
{{ pendingRemoval.help }}
+
+
+ {{ t('libresign', 'Cancel') }}
+
+
+ {{ t('libresign', 'Remove rule') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/Settings/PolicyWorkbench/settings/confetti/ConfettiRuleEditor.vue b/src/views/Settings/PolicyWorkbench/settings/confetti/ConfettiRuleEditor.vue
new file mode 100644
index 0000000000..99ccd40eb4
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/confetti/ConfettiRuleEditor.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+ {{ t('libresign', 'Show confetti animation after signing') }}
+
+
+
+ {{ t('libresign', 'This is intentionally simple so we can validate the policy shell with a lightweight setting too.') }}
+
+
+
+
+
+
+
diff --git a/src/views/Settings/PolicyWorkbench/settings/confetti/index.ts b/src/views/Settings/PolicyWorkbench/settings/confetti/index.ts
new file mode 100644
index 0000000000..3fa124d084
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/confetti/index.ts
@@ -0,0 +1,26 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { t } from '@nextcloud/l10n'
+
+import ConfettiRuleEditor from './ConfettiRuleEditor.vue'
+import type { PolicySettingDefinition } from '../../types'
+
+export const confettiDefinition: PolicySettingDefinition<'confetti'> = {
+ key: 'confetti',
+ title: t('libresign', 'Confetti animation'),
+ context: t('libresign', 'UI effect'),
+ description: t('libresign', 'Control whether a celebratory animation is shown when someone signs a document.'),
+ editor: ConfettiRuleEditor,
+ createEmptyValue: () => ({
+ enabled: true,
+ }),
+ summarizeValue: (value) => value.enabled
+ ? t('libresign', 'Enabled')
+ : t('libresign', 'Disabled'),
+ formatAllowOverride: (allowChildOverride) => allowChildOverride
+ ? t('libresign', 'Lower layers may override this rule')
+ : t('libresign', 'Lower layers must inherit this value'),
+}
diff --git a/src/views/Settings/PolicyWorkbench/settings/docmdp/DocMdpScalarRuleEditor.vue b/src/views/Settings/PolicyWorkbench/settings/docmdp/DocMdpScalarRuleEditor.vue
new file mode 100644
index 0000000000..6e7ae56b72
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/docmdp/DocMdpScalarRuleEditor.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
{{ level.label }}
+
{{ level.description }}
+
+
+
+
+
+
+
+
+
diff --git a/src/views/Settings/PolicyWorkbench/settings/docmdp/realDefinition.ts b/src/views/Settings/PolicyWorkbench/settings/docmdp/realDefinition.ts
new file mode 100644
index 0000000000..0ca74bee5d
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/docmdp/realDefinition.ts
@@ -0,0 +1,64 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { t } from '@nextcloud/l10n'
+
+import DocMdpScalarRuleEditor from './DocMdpScalarRuleEditor.vue'
+import type { EffectivePolicyValue } from '../../../../../types/index'
+import type { RealPolicySettingDefinition } from '../realTypes'
+
+export function resolveDocMdpLevel(value: EffectivePolicyValue): number | null {
+ if (typeof value === 'number' && value >= 0 && value <= 3) {
+ return value
+ }
+
+ if (typeof value === 'string' && /^[0-3]$/.test(value)) {
+ return Number(value)
+ }
+
+ return null
+}
+
+export const docMdpRealDefinition: RealPolicySettingDefinition = {
+ key: 'docmdp',
+ title: t('libresign', 'PDF certification'),
+ context: t('libresign', 'DocMDP'),
+ description: t('libresign', 'Control what changes are allowed after a document is signed.'),
+ editor: DocMdpScalarRuleEditor,
+ resolutionMode: 'precedence',
+ createEmptyValue: () => 0,
+ normalizeDraftValue: (value: EffectivePolicyValue) => {
+ const level = resolveDocMdpLevel(value)
+ return level ?? 0
+ },
+ hasSelectableDraftValue: (value: EffectivePolicyValue) => resolveDocMdpLevel(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 0
+ },
+ summarizeValue: (value: EffectivePolicyValue) => {
+ const level = resolveDocMdpLevel(value)
+ switch (level) {
+ case 0:
+ return t('libresign', 'Disabled')
+ case 1:
+ return t('libresign', 'No changes allowed')
+ case 2:
+ return t('libresign', 'Form filling')
+ case 3:
+ return t('libresign', 'Form filling and annotations')
+ default:
+ return t('libresign', 'Not configured')
+ }
+ },
+ formatAllowOverride: (allowChildOverride: boolean) =>
+ allowChildOverride
+ ? t('libresign', 'Groups and users can set their own rule')
+ : t('libresign', 'Groups and users must follow this value'),
+}
diff --git a/src/views/Settings/PolicyWorkbench/settings/identify-factors/IdentifyFactorsRuleEditor.vue b/src/views/Settings/PolicyWorkbench/settings/identify-factors/IdentifyFactorsRuleEditor.vue
new file mode 100644
index 0000000000..ae1afac191
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/identify-factors/IdentifyFactorsRuleEditor.vue
@@ -0,0 +1,266 @@
+
+
+
+
+
+ {{ t('libresign', 'Enable identification factors rules') }}
+
+
+
+
+
+ {{ t('libresign', '{enabled} enabled, {required} required', {
+ enabled: String(enabledFactorsCount),
+ required: String(requiredFactorsCount),
+ }) }}
+
+
+ {{ modelValue.requireAnyTwo
+ ? t('libresign', 'Rule strategy: any two factors')
+ : t('libresign', 'Rule strategy: a single configured factor') }}
+
+
+
+
+ {{ t('libresign', 'Require at least two enabled factors') }}
+
+
+
+ {{ t('libresign', 'At least two factors must be enabled to satisfy this policy strategy.') }}
+
+
+
+
+
+
+ {{ t('libresign', 'Enabled for this target') }}
+
+
+
+
+ {{ t('libresign', 'Mandatory for signer') }}
+
+
+
+ {{ t('libresign', 'Allow account creation fallback') }}
+
+
+
+ {{ t('libresign', 'Signature method') }}
+
+ {{ methodOption.label }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/Settings/PolicyWorkbench/settings/identify-factors/index.ts b/src/views/Settings/PolicyWorkbench/settings/identify-factors/index.ts
new file mode 100644
index 0000000000..fb8765f8ab
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/identify-factors/index.ts
@@ -0,0 +1,69 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { t } from '@nextcloud/l10n'
+
+import IdentifyFactorsRuleEditor from './IdentifyFactorsRuleEditor.vue'
+import type { PolicySettingDefinition } from '../../types'
+
+export const identifyFactorsDefinition: PolicySettingDefinition<'identify_factors'> = {
+ key: 'identify_factors',
+ title: t('libresign', 'Identification factors'),
+ context: t('libresign', 'Identity matrix'),
+ description: t('libresign', 'Configure which factors identify signers and how each factor maps to signature methods.'),
+ editor: IdentifyFactorsRuleEditor,
+ createEmptyValue: () => ({
+ enabled: true,
+ requireAnyTwo: false,
+ factors: [
+ {
+ key: 'email',
+ label: t('libresign', 'Email'),
+ enabled: true,
+ required: true,
+ allowCreateAccount: true,
+ signatureMethod: 'email_token',
+ },
+ {
+ key: 'sms',
+ label: t('libresign', 'SMS'),
+ enabled: false,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'sms_token',
+ },
+ {
+ key: 'whatsapp',
+ label: t('libresign', 'WhatsApp'),
+ enabled: false,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'whatsapp_token',
+ },
+ {
+ key: 'document',
+ label: t('libresign', 'Document data'),
+ enabled: false,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'document_validation',
+ },
+ ],
+ }),
+ summarizeValue: (value) => {
+ if (!value.enabled) {
+ return t('libresign', 'Disabled')
+ }
+
+ const enabledCount = value.factors.filter((factor) => factor.enabled).length
+ const strategy = value.requireAnyTwo
+ ? t('libresign', 'any two')
+ : t('libresign', 'single factor')
+ return `${enabledCount} ${t('libresign', 'factors enabled')} - ${strategy}`
+ },
+ formatAllowOverride: (allowChildOverride) => allowChildOverride
+ ? t('libresign', 'Lower layers may override this rule')
+ : t('libresign', 'Lower layers must inherit this value'),
+}
diff --git a/src/views/Settings/PolicyWorkbench/settings/realDefinitions.ts b/src/views/Settings/PolicyWorkbench/settings/realDefinitions.ts
new file mode 100644
index 0000000000..ba65d6ccf9
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/realDefinitions.ts
@@ -0,0 +1,15 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { docMdpRealDefinition } from './docmdp/realDefinition'
+import { signatureFooterRealDefinition } from './signature-footer/realDefinition'
+import { signatureFlowRealDefinition } from './signature-flow/realDefinition'
+import type { RealPolicySettingDefinition } from './realTypes'
+
+export const realDefinitions = {
+ add_footer: signatureFooterRealDefinition,
+ signature_flow: signatureFlowRealDefinition,
+ docmdp: docMdpRealDefinition,
+} satisfies Record
diff --git a/src/views/Settings/PolicyWorkbench/settings/realTypes.ts b/src/views/Settings/PolicyWorkbench/settings/realTypes.ts
new file mode 100644
index 0000000000..8b56008c73
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/realTypes.ts
@@ -0,0 +1,27 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { EffectivePolicyValue } from '../../../../types/index'
+
+export type RealPolicyScope = 'system' | 'group' | 'user'
+export type RealPolicyResolutionMode = 'precedence' | 'merge' | 'conflict_requires_selection'
+export type RealPolicyEditorDialogLayout = 'default' | 'wide'
+
+export interface RealPolicySettingDefinition {
+ key: string
+ title: string
+ context?: string
+ description: string
+ editor: unknown
+ editorDialogLayout?: RealPolicyEditorDialogLayout
+ resolutionMode: RealPolicyResolutionMode
+ createEmptyValue: () => EffectivePolicyValue
+ normalizeDraftValue: (value: EffectivePolicyValue) => EffectivePolicyValue
+ hasSelectableDraftValue: (value: EffectivePolicyValue) => boolean
+ normalizeAllowChildOverride: (scope: RealPolicyScope, allowChildOverride: boolean) => boolean
+ getFallbackSystemDefault: (policyValue: EffectivePolicyValue | null | undefined, sourceScope?: string | null) => EffectivePolicyValue
+ summarizeValue: (value: EffectivePolicyValue) => string
+ formatAllowOverride: (allowChildOverride: boolean) => string
+}
diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowRuleEditor.vue b/src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowRuleEditor.vue
new file mode 100644
index 0000000000..be05cf9220
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowRuleEditor.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+ {{ t('libresign', 'Enable a signing order override for this target') }}
+
+
+
+
+
+
{{ flow.label }}
+
{{ flow.description }}
+
+
+
+
+
+
+
+
+
diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowScalarRuleEditor.vue b/src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowScalarRuleEditor.vue
new file mode 100644
index 0000000000..56a623baa1
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowScalarRuleEditor.vue
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+
{{ flow.label }}
+
{{ flow.description }}
+
+
+
+
+ {{ t('libresign', 'To create a rule for everyone, choose Simultaneous or Sequential. "User choice" already matches the default and is not saved as a custom rule.') }}
+
+
+
+
+
+
+
diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-flow/index.ts b/src/views/Settings/PolicyWorkbench/settings/signature-flow/index.ts
new file mode 100644
index 0000000000..d2e0af65f2
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/signature-flow/index.ts
@@ -0,0 +1,33 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { t } from '@nextcloud/l10n'
+
+import SignatureFlowRuleEditor from './SignatureFlowRuleEditor.vue'
+import type { PolicySettingDefinition } from '../../types'
+
+export const signatureFlowDefinition: PolicySettingDefinition<'signature_flow'> = {
+ key: 'signature_flow',
+ title: t('libresign', 'Signing order'),
+ context: t('libresign', 'SignatureFlow'),
+ description: t('libresign', 'Define how signers receive and process the signature request.'),
+ editor: SignatureFlowRuleEditor,
+ createEmptyValue: () => ({
+ enabled: true,
+ flow: 'parallel',
+ }),
+ summarizeValue: (value) => {
+ if (!value.enabled) {
+ return t('libresign', 'User choice')
+ }
+
+ return value.flow === 'parallel'
+ ? t('libresign', 'Parallel')
+ : t('libresign', 'Sequential')
+ },
+ formatAllowOverride: (allowChildOverride) => allowChildOverride
+ ? t('libresign', 'Groups and users can set their own rule')
+ : t('libresign', 'Groups and users must follow this value'),
+}
diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-flow/realDefinition.ts b/src/views/Settings/PolicyWorkbench/settings/signature-flow/realDefinition.ts
new file mode 100644
index 0000000000..2b62ddbb24
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/signature-flow/realDefinition.ts
@@ -0,0 +1,84 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { t } from '@nextcloud/l10n'
+
+import SignatureFlowScalarRuleEditor from './SignatureFlowScalarRuleEditor.vue'
+import type { EffectivePolicyValue } from '../../../../../types/index'
+import type { RealPolicySettingDefinition } from '../realTypes'
+
+export function resolveSignatureFlowMode(value: EffectivePolicyValue): string | null {
+ if (value === 0) {
+ return 'none'
+ }
+
+ if (value === 1) {
+ return 'parallel'
+ }
+
+ if (value === 2) {
+ return 'ordered_numeric'
+ }
+
+ if (typeof value === 'string') {
+ if (value === 'parallel' || value === 'ordered_numeric' || value === 'none') {
+ return value
+ }
+
+ return null
+ }
+
+ if (value && typeof value === 'object' && 'flow' in (value as Record)) {
+ const candidate = (value as { flow?: unknown }).flow
+ return typeof candidate === 'string' ? candidate : null
+ }
+
+ return null
+}
+
+export const signatureFlowRealDefinition: RealPolicySettingDefinition = {
+ key: 'signature_flow',
+ title: t('libresign', 'Signing order'),
+ description: t('libresign', 'Choose whether documents are signed in order or all at once.'),
+ editor: SignatureFlowScalarRuleEditor,
+ resolutionMode: 'precedence',
+ createEmptyValue: () => '' as unknown as EffectivePolicyValue,
+ normalizeDraftValue: (value: EffectivePolicyValue) => {
+ const mode = resolveSignatureFlowMode(value)
+ return mode ?? 'parallel'
+ },
+ hasSelectableDraftValue: (value: EffectivePolicyValue) => resolveSignatureFlowMode(value) !== null,
+ normalizeAllowChildOverride: (scope, allowChildOverride: boolean) => {
+ if (scope === 'system' || scope === 'group') {
+ return false
+ }
+
+ return allowChildOverride
+ },
+ getFallbackSystemDefault: (policyValue: EffectivePolicyValue | null | undefined, sourceScope?: string | null) => {
+ if (sourceScope === 'system' && policyValue !== null && policyValue !== undefined) {
+ return policyValue
+ }
+
+ return 'none'
+ },
+ summarizeValue: (value: EffectivePolicyValue) => {
+ const flowValue = resolveSignatureFlowMode(value)
+ switch (flowValue) {
+ case 'parallel':
+ return t('libresign', 'Simultaneous (Parallel)')
+ case 'ordered_numeric':
+ return t('libresign', 'Sequential')
+ case 'none':
+ return t('libresign', 'User choice')
+ default:
+ return t('libresign', 'Not configured')
+ }
+ },
+ formatAllowOverride: (allowChildOverride: boolean) =>
+ allowChildOverride
+ ? t('libresign', 'Groups and users can set their own rule')
+ : t('libresign', 'Groups and users must follow this value'),
+}
diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-footer/SignatureFooterRuleEditor.vue b/src/views/Settings/PolicyWorkbench/settings/signature-footer/SignatureFooterRuleEditor.vue
new file mode 100644
index 0000000000..888d5c20e9
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/signature-footer/SignatureFooterRuleEditor.vue
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-footer/model.ts b/src/views/Settings/PolicyWorkbench/settings/signature-footer/model.ts
new file mode 100644
index 0000000000..945399548e
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/signature-footer/model.ts
@@ -0,0 +1,111 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { EffectivePolicyValue } from '../../../../../types/index'
+
+export type SignatureFooterPolicyConfig = {
+ enabled: boolean
+ writeQrcodeOnFooter: boolean
+ validationSite: string
+ customizeFooterTemplate: boolean
+}
+
+export function getDefaultSignatureFooterPolicyConfig(): SignatureFooterPolicyConfig {
+ return {
+ enabled: true,
+ writeQrcodeOnFooter: true,
+ validationSite: '',
+ customizeFooterTemplate: false,
+ }
+}
+
+function toBoolean(value: unknown, fallback: boolean): boolean {
+ if (typeof value === 'boolean') {
+ return value
+ }
+
+ if (typeof value === 'number') {
+ return value === 1
+ }
+
+ if (typeof value === 'string') {
+ return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase())
+ }
+
+ if (value === null || value === undefined) {
+ return fallback
+ }
+
+ return Boolean(value)
+}
+
+function toStringValue(value: unknown): string {
+ if (typeof value === 'string') {
+ return value.trim()
+ }
+
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value)
+ }
+
+ return ''
+}
+
+export function normalizeSignatureFooterPolicyConfig(value: EffectivePolicyValue): SignatureFooterPolicyConfig {
+ const defaults = getDefaultSignatureFooterPolicyConfig()
+
+ if (typeof value === 'boolean' || typeof value === 'number') {
+ return {
+ ...defaults,
+ enabled: toBoolean(value, defaults.enabled),
+ }
+ }
+
+ if (typeof value === 'string') {
+ const trimmedValue = value.trim()
+ if (trimmedValue === '') {
+ return defaults
+ }
+
+ try {
+ const parsedValue = JSON.parse(trimmedValue) as Record | string | number | boolean | null
+ if (parsedValue && typeof parsedValue === 'object') {
+ return {
+ enabled: toBoolean(parsedValue.enabled ?? parsedValue.addFooter, defaults.enabled),
+ writeQrcodeOnFooter: toBoolean(parsedValue.writeQrcodeOnFooter ?? parsedValue.write_qrcode_on_footer, defaults.writeQrcodeOnFooter),
+ validationSite: toStringValue(parsedValue.validationSite ?? parsedValue.validation_site),
+ customizeFooterTemplate: toBoolean(parsedValue.customizeFooterTemplate ?? parsedValue.customize_footer_template, defaults.customizeFooterTemplate),
+ }
+ }
+
+ if (typeof parsedValue === 'boolean' || typeof parsedValue === 'number' || typeof parsedValue === 'string') {
+ return {
+ ...defaults,
+ enabled: toBoolean(parsedValue, defaults.enabled),
+ }
+ }
+ } catch {
+ return {
+ ...defaults,
+ enabled: toBoolean(trimmedValue, defaults.enabled),
+ }
+ }
+
+ return defaults
+ }
+
+ return defaults
+}
+
+export function serializeSignatureFooterPolicyConfig(value: SignatureFooterPolicyConfig): EffectivePolicyValue {
+ const normalizedValue: SignatureFooterPolicyConfig = {
+ enabled: toBoolean(value.enabled, true),
+ writeQrcodeOnFooter: toBoolean(value.writeQrcodeOnFooter, true),
+ validationSite: toStringValue(value.validationSite),
+ customizeFooterTemplate: toBoolean(value.customizeFooterTemplate, false),
+ }
+
+ return JSON.stringify(normalizedValue)
+}
diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-footer/realDefinition.ts b/src/views/Settings/PolicyWorkbench/settings/signature-footer/realDefinition.ts
new file mode 100644
index 0000000000..973617a850
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/signature-footer/realDefinition.ts
@@ -0,0 +1,58 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode 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 SignatureFooterRuleEditor from './SignatureFooterRuleEditor.vue'
+import {
+ getDefaultSignatureFooterPolicyConfig,
+ normalizeSignatureFooterPolicyConfig,
+ serializeSignatureFooterPolicyConfig,
+} from './model'
+
+export const signatureFooterRealDefinition: RealPolicySettingDefinition = {
+ key: 'add_footer',
+ title: t('libresign', 'Signature footer'),
+ description: t('libresign', 'Manage footer visibility, QR code behavior, validation URL, and footer template customization.'),
+ editor: SignatureFooterRuleEditor,
+ editorDialogLayout: 'wide',
+ resolutionMode: 'precedence',
+ createEmptyValue: () => serializeSignatureFooterPolicyConfig(getDefaultSignatureFooterPolicyConfig()),
+ normalizeDraftValue: (value: EffectivePolicyValue) => {
+ return serializeSignatureFooterPolicyConfig(normalizeSignatureFooterPolicyConfig(value))
+ },
+ hasSelectableDraftValue: () => true,
+ normalizeAllowChildOverride: (_scope, allowChildOverride: boolean) => allowChildOverride,
+ getFallbackSystemDefault: (policyValue: EffectivePolicyValue | null | undefined, sourceScope?: string | null) => {
+ if (sourceScope === 'system' && policyValue !== null && policyValue !== undefined) {
+ return policyValue
+ }
+
+ return serializeSignatureFooterPolicyConfig(getDefaultSignatureFooterPolicyConfig())
+ },
+ summarizeValue: (value: EffectivePolicyValue) => {
+ const normalized = normalizeSignatureFooterPolicyConfig(value)
+ if (!normalized.enabled) {
+ return t('libresign', 'Disabled')
+ }
+
+ const summary: string[] = [t('libresign', 'Enabled')]
+ summary.push(normalized.writeQrcodeOnFooter ? t('libresign', 'QR code on') : t('libresign', 'QR code off'))
+ if (normalized.validationSite) {
+ summary.push(t('libresign', 'Custom URL'))
+ }
+ if (normalized.customizeFooterTemplate) {
+ summary.push(t('libresign', 'Custom template'))
+ }
+
+ return summary.join(' • ')
+ },
+ formatAllowOverride: (allowChildOverride: boolean) =>
+ allowChildOverride
+ ? t('libresign', 'Groups and users can set their own rule')
+ : t('libresign', 'Groups and users must follow this value'),
+}
diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-stamp/SignatureStampRuleEditor.vue b/src/views/Settings/PolicyWorkbench/settings/signature-stamp/SignatureStampRuleEditor.vue
new file mode 100644
index 0000000000..cd60c97e14
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/signature-stamp/SignatureStampRuleEditor.vue
@@ -0,0 +1,429 @@
+
+
+
+
+
+ {{ t('libresign', 'Enable visible signature stamp') }}
+
+
+
+
+
{{ t('libresign', 'Quick presets') }}
+
+
+ {{ preset.label }}
+
+
+
+
+
+ {{ t('libresign', 'Render mode') }}
+
+
+
{{ renderModeOption.label }}
+
{{ renderModeOption.description }}
+
+
+
+
+
+ {{ t('libresign', 'Background mode') }}
+
+ {{ backgroundModeOption.label }}
+
+
+
+
+
+ {{ t('libresign', 'Template text') }}
+
+
+
+ {{ t('libresign', 'Insert variable:') }}
+
+ {{ stampVariable }}
+
+
+
+ {{ t('libresign', 'Template length: {count} characters', {
+ count: String(templateLength),
+ }) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('libresign', 'Show signing date in the stamp') }}
+
+
+
+ {{ t('libresign', 'This template is long and can overflow small signature boxes. Consider fewer variables or a larger width.') }}
+
+
+
+ {{ t('libresign', 'Current width and height ratio is extreme. This may reduce readability on mobile and PDF preview.') }}
+
+
+
+
{{ t('libresign', 'Preview summary') }}
+
{{ previewSummary }}
+
+
+
+
+
+
+
+
diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-stamp/index.ts b/src/views/Settings/PolicyWorkbench/settings/signature-stamp/index.ts
new file mode 100644
index 0000000000..8959c0ae05
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/settings/signature-stamp/index.ts
@@ -0,0 +1,38 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { t } from '@nextcloud/l10n'
+
+import SignatureStampRuleEditor from './SignatureStampRuleEditor.vue'
+import type { PolicySettingDefinition } from '../../types'
+
+export const signatureStampDefinition: PolicySettingDefinition<'signature_stamp'> = {
+ key: 'signature_stamp',
+ title: t('libresign', 'Signature stamp'),
+ context: t('libresign', 'Template and rendering'),
+ description: t('libresign', 'Manage the visible signature block, template and dimensions with a richer policy model.'),
+ editor: SignatureStampRuleEditor,
+ createEmptyValue: () => ({
+ enabled: true,
+ renderMode: 'GRAPHIC_AND_DESCRIPTION',
+ template: '{{ signer_name }} - {{ signed_at }}',
+ templateFontSize: 10,
+ signatureFontSize: 20,
+ signatureWidth: 180,
+ signatureHeight: 70,
+ backgroundMode: 'default',
+ showSigningDate: true,
+ }),
+ summarizeValue: (value) => {
+ if (!value.enabled) {
+ return t('libresign', 'Disabled')
+ }
+
+ return `${value.renderMode} - ${value.signatureWidth}x${value.signatureHeight}`
+ },
+ formatAllowOverride: (allowChildOverride) => allowChildOverride
+ ? t('libresign', 'Lower layers may override this rule')
+ : t('libresign', 'Lower layers must inherit this value'),
+}
diff --git a/src/views/Settings/PolicyWorkbench/types.ts b/src/views/Settings/PolicyWorkbench/types.ts
new file mode 100644
index 0000000000..71fd23f56e
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/types.ts
@@ -0,0 +1,112 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Component } from 'vue'
+
+export type AdminViewMode = 'system-admin' | 'group-admin'
+export type PolicyScope = 'system' | 'group' | 'user'
+export type PolicySettingKey =
+ | 'signature_flow'
+ | 'confetti'
+ | 'signature_stamp'
+ | 'identify_factors'
+ | 'auto_reminders'
+ | 'request_notifications'
+ | 'document_download_after_sign'
+export type SignatureFlowMode = 'parallel' | 'ordered_numeric'
+export type SignatureStampRenderMode = 'DESCRIPTION_ONLY' | 'GRAPHIC_AND_DESCRIPTION' | 'SIGNAME_AND_DESCRIPTION' | 'GRAPHIC_ONLY'
+export type SignatureStampBackgroundMode = 'default' | 'custom' | 'none'
+export type IdentifyFactorKey = 'email' | 'sms' | 'whatsapp' | 'document'
+export type IdentifyFactorSignatureMethod = 'email_token' | 'sms_token' | 'whatsapp_token' | 'document_validation'
+
+export type SignatureFlowRuleValue = {
+ enabled: boolean
+ flow: SignatureFlowMode
+}
+
+export type ConfettiRuleValue = {
+ enabled: boolean
+}
+
+export type SignatureStampRuleValue = {
+ enabled: boolean
+ renderMode: SignatureStampRenderMode
+ template: string
+ templateFontSize: number
+ signatureFontSize: number
+ signatureWidth: number
+ signatureHeight: number
+ backgroundMode: SignatureStampBackgroundMode
+ showSigningDate: boolean
+}
+
+export type IdentifyFactorOption = {
+ key: IdentifyFactorKey
+ label: string
+ enabled: boolean
+ required: boolean
+ allowCreateAccount: boolean
+ signatureMethod: IdentifyFactorSignatureMethod
+}
+
+export type IdentifyFactorsRuleValue = {
+ enabled: boolean
+ requireAnyTwo: boolean
+ factors: IdentifyFactorOption[]
+}
+
+export type PolicySettingValueMap = {
+ signature_flow: SignatureFlowRuleValue
+ confetti: ConfettiRuleValue
+ signature_stamp: SignatureStampRuleValue
+ identify_factors: IdentifyFactorsRuleValue
+ auto_reminders: ConfettiRuleValue
+ request_notifications: ConfettiRuleValue
+ document_download_after_sign: ConfettiRuleValue
+}
+
+export type PolicyRuleRecord = {
+ id: string
+ scope: PolicyScope
+ targetId: string | null
+ allowChildOverride: boolean
+ value: PolicySettingValueMap[K]
+}
+
+export type PolicyEditorDraft = {
+ id: string | null
+ settingKey: PolicySettingKey
+ scope: PolicyScope
+ targetId: string | null
+ allowChildOverride: boolean
+ value: PolicySettingValueMap[PolicySettingKey]
+}
+
+export type PolicyTargetOption = {
+ id: string
+ label: string
+ groupId?: string
+}
+
+export type PolicySettingDefinition = {
+ key: K
+ title: string
+ context?: string
+ description: string
+ editor: Component
+ createEmptyValue: (scope: PolicyScope) => PolicySettingValueMap[K]
+ summarizeValue: (value: PolicySettingValueMap[K]) => string
+ formatAllowOverride: (allowChildOverride: boolean) => string | null
+}
+
+export type PolicySettingSummary = {
+ key: PolicySettingKey
+ title: string
+ context?: string
+ description: string
+ defaultSummary: string
+ groupCount: number
+ userCount: number
+}
diff --git a/src/views/Settings/PolicyWorkbench/usePolicyWorkbench.ts b/src/views/Settings/PolicyWorkbench/usePolicyWorkbench.ts
new file mode 100644
index 0000000000..b6c3e187e6
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/usePolicyWorkbench.ts
@@ -0,0 +1,879 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { computed, reactive, ref, toRaw } from 'vue'
+import { t } from '@nextcloud/l10n'
+
+import ConfettiRuleEditor from './settings/confetti/ConfettiRuleEditor.vue'
+import { confettiDefinition } from './settings/confetti'
+import { identifyFactorsDefinition } from './settings/identify-factors'
+import { signatureFlowDefinition } from './settings/signature-flow'
+import { signatureStampDefinition } from './settings/signature-stamp'
+import type {
+ AdminViewMode,
+ PolicyEditorDraft,
+ PolicyRuleRecord,
+ PolicyScope,
+ PolicySettingDefinition,
+ PolicySettingKey,
+ PolicySettingSummary,
+ PolicySettingValueMap,
+ PolicyTargetOption,
+} from './types'
+
+type SettingsState = {
+ [K in PolicySettingKey]: PolicyRuleRecord[]
+}
+
+type StartEditorOptions = {
+ scope: PolicyScope
+ ruleId?: string
+}
+
+const CURRENT_GROUP_ID = 'finance'
+
+const groups: PolicyTargetOption[] = [
+ { id: 'finance', label: 'Financeiro' },
+ { id: 'legal', label: 'Jurídico' },
+ { id: 'sales', label: 'Comercial' },
+]
+
+const users: PolicyTargetOption[] = [
+ { id: 'maria', label: 'Maria Silva', groupId: 'finance' },
+ { id: 'joao', label: 'João Pereira', groupId: 'finance' },
+ { id: 'ana', label: 'Ana Carvalho', groupId: 'legal' },
+ { id: 'bruno', label: 'Bruno Costa', groupId: 'sales' },
+]
+
+const definitions = {
+ signature_flow: signatureFlowDefinition,
+ confetti: confettiDefinition,
+ signature_stamp: signatureStampDefinition,
+ identify_factors: identifyFactorsDefinition,
+ auto_reminders: {
+ key: 'auto_reminders',
+ title: t('libresign', 'Automatic reminders'),
+ context: t('libresign', 'Notification cadence'),
+ description: t('libresign', 'Control whether reminder notifications are automatically sent for pending signers.'),
+ editor: ConfettiRuleEditor,
+ createEmptyValue: () => ({ enabled: true }),
+ summarizeValue: (value) => value.enabled
+ ? t('libresign', 'Enabled')
+ : t('libresign', 'Disabled'),
+ formatAllowOverride: (allowChildOverride) => allowChildOverride
+ ? t('libresign', 'Lower layers may override this rule')
+ : t('libresign', 'Lower layers must inherit this value'),
+ } satisfies PolicySettingDefinition<'auto_reminders'>,
+ request_notifications: {
+ key: 'request_notifications',
+ title: t('libresign', 'Request notifications'),
+ context: t('libresign', 'Delivery channel'),
+ description: t('libresign', 'Define whether users receive status notifications while a request is in progress.'),
+ editor: ConfettiRuleEditor,
+ createEmptyValue: () => ({ enabled: true }),
+ summarizeValue: (value) => value.enabled
+ ? t('libresign', 'Enabled')
+ : t('libresign', 'Disabled'),
+ formatAllowOverride: (allowChildOverride) => allowChildOverride
+ ? t('libresign', 'Lower layers may override this rule')
+ : t('libresign', 'Lower layers must inherit this value'),
+ } satisfies PolicySettingDefinition<'request_notifications'>,
+ document_download_after_sign: {
+ key: 'document_download_after_sign',
+ title: t('libresign', 'Download after signing'),
+ context: t('libresign', 'Post-sign action'),
+ description: t('libresign', 'Control whether the finalized document is automatically offered for download after signing.'),
+ editor: ConfettiRuleEditor,
+ createEmptyValue: () => ({ enabled: true }),
+ summarizeValue: (value) => value.enabled
+ ? t('libresign', 'Enabled')
+ : t('libresign', 'Disabled'),
+ formatAllowOverride: (allowChildOverride) => allowChildOverride
+ ? t('libresign', 'Lower layers may override this rule')
+ : t('libresign', 'Lower layers must inherit this value'),
+ } satisfies PolicySettingDefinition<'document_download_after_sign'>,
+} satisfies { [K in PolicySettingKey]: PolicySettingDefinition }
+
+function cloneValue(value: PolicySettingValueMap[K]): PolicySettingValueMap[K] {
+ return JSON.parse(JSON.stringify(toRaw(value))) as PolicySettingValueMap[K]
+}
+
+export function createPolicyWorkbenchState() {
+ const viewMode = ref('system-admin')
+ const activeSettingKey = ref(null)
+ const editorDraft = ref(null)
+ const editorMode = ref<'create' | 'edit' | null>(null)
+ const highlightedRuleId = ref(null)
+ const nextRuleNumber = ref(100)
+
+ const settingsState = reactive({
+ signature_flow: [
+ {
+ id: 'signature-system',
+ scope: 'system',
+ targetId: null,
+ allowChildOverride: true,
+ value: { enabled: true, flow: 'ordered_numeric' },
+ },
+ {
+ id: 'signature-group-finance',
+ scope: 'group',
+ targetId: 'finance',
+ allowChildOverride: true,
+ value: { enabled: true, flow: 'parallel' },
+ },
+ {
+ id: 'signature-group-legal',
+ scope: 'group',
+ targetId: 'legal',
+ allowChildOverride: false,
+ value: { enabled: true, flow: 'ordered_numeric' },
+ },
+ {
+ id: 'signature-user-maria',
+ scope: 'user',
+ targetId: 'maria',
+ allowChildOverride: false,
+ value: { enabled: true, flow: 'parallel' },
+ },
+ ],
+ auto_reminders: [
+ {
+ id: 'reminders-system',
+ scope: 'system',
+ targetId: null,
+ allowChildOverride: true,
+ value: { enabled: true },
+ },
+ {
+ id: 'reminders-group-sales',
+ scope: 'group',
+ targetId: 'sales',
+ allowChildOverride: false,
+ value: { enabled: false },
+ },
+ {
+ id: 'reminders-user-ana',
+ scope: 'user',
+ targetId: 'ana',
+ allowChildOverride: false,
+ value: { enabled: false },
+ },
+ ],
+ request_notifications: [
+ {
+ id: 'notifications-system',
+ scope: 'system',
+ targetId: null,
+ allowChildOverride: true,
+ value: { enabled: true },
+ },
+ {
+ id: 'notifications-group-legal',
+ scope: 'group',
+ targetId: 'legal',
+ allowChildOverride: true,
+ value: { enabled: true },
+ },
+ {
+ id: 'notifications-user-joao',
+ scope: 'user',
+ targetId: 'joao',
+ allowChildOverride: false,
+ value: { enabled: false },
+ },
+ ],
+ document_download_after_sign: [
+ {
+ id: 'download-system',
+ scope: 'system',
+ targetId: null,
+ allowChildOverride: true,
+ value: { enabled: true },
+ },
+ {
+ id: 'download-group-finance',
+ scope: 'group',
+ targetId: 'finance',
+ allowChildOverride: true,
+ value: { enabled: true },
+ },
+ {
+ id: 'download-user-maria',
+ scope: 'user',
+ targetId: 'maria',
+ allowChildOverride: false,
+ value: { enabled: false },
+ },
+ ],
+ confetti: [
+ {
+ id: 'confetti-system',
+ scope: 'system',
+ targetId: null,
+ allowChildOverride: true,
+ value: { enabled: true },
+ },
+ {
+ id: 'confetti-group-sales',
+ scope: 'group',
+ targetId: 'sales',
+ allowChildOverride: false,
+ value: { enabled: false },
+ },
+ {
+ id: 'confetti-group-legal',
+ scope: 'group',
+ targetId: 'legal',
+ allowChildOverride: true,
+ value: { enabled: true },
+ },
+ {
+ id: 'confetti-user-ana',
+ scope: 'user',
+ targetId: 'ana',
+ allowChildOverride: false,
+ value: { enabled: false },
+ },
+ {
+ id: 'confetti-user-bruno',
+ scope: 'user',
+ targetId: 'bruno',
+ allowChildOverride: false,
+ value: { enabled: true },
+ },
+ ],
+ signature_stamp: [
+ {
+ id: 'stamp-system',
+ scope: 'system',
+ targetId: null,
+ allowChildOverride: true,
+ value: {
+ enabled: true,
+ renderMode: 'GRAPHIC_AND_DESCRIPTION',
+ template: '{{ signer_name }} - {{ signed_at }}',
+ templateFontSize: 10,
+ signatureFontSize: 19,
+ signatureWidth: 180,
+ signatureHeight: 70,
+ backgroundMode: 'default',
+ showSigningDate: true,
+ },
+ },
+ {
+ id: 'stamp-group-legal',
+ scope: 'group',
+ targetId: 'legal',
+ allowChildOverride: false,
+ value: {
+ enabled: true,
+ renderMode: 'DESCRIPTION_ONLY',
+ template: '{{ signer_name }} - {{ request_uuid }}',
+ templateFontSize: 11,
+ signatureFontSize: 16,
+ signatureWidth: 220,
+ signatureHeight: 80,
+ backgroundMode: 'none',
+ showSigningDate: false,
+ },
+ },
+ {
+ id: 'stamp-group-sales',
+ scope: 'group',
+ targetId: 'sales',
+ allowChildOverride: true,
+ value: {
+ enabled: true,
+ renderMode: 'GRAPHIC_ONLY',
+ template: '{{ signer_name }} - {{ organization }}',
+ templateFontSize: 10,
+ signatureFontSize: 18,
+ signatureWidth: 190,
+ signatureHeight: 70,
+ backgroundMode: 'default',
+ showSigningDate: true,
+ },
+ },
+ {
+ id: 'stamp-user-joao',
+ scope: 'user',
+ targetId: 'joao',
+ allowChildOverride: false,
+ value: {
+ enabled: true,
+ renderMode: 'SIGNAME_AND_DESCRIPTION',
+ template: '{{ signer_name }}',
+ templateFontSize: 12,
+ signatureFontSize: 22,
+ signatureWidth: 210,
+ signatureHeight: 80,
+ backgroundMode: 'custom',
+ showSigningDate: true,
+ },
+ },
+ {
+ id: 'stamp-user-ana',
+ scope: 'user',
+ targetId: 'ana',
+ allowChildOverride: false,
+ value: {
+ enabled: true,
+ renderMode: 'DESCRIPTION_ONLY',
+ template: '{{ signer_name }} - {{ request_uuid }}',
+ templateFontSize: 11,
+ signatureFontSize: 17,
+ signatureWidth: 220,
+ signatureHeight: 82,
+ backgroundMode: 'none',
+ showSigningDate: false,
+ },
+ },
+ ],
+ identify_factors: [
+ {
+ id: 'identify-system',
+ scope: 'system',
+ targetId: null,
+ allowChildOverride: true,
+ value: {
+ enabled: true,
+ requireAnyTwo: false,
+ factors: [
+ {
+ key: 'email',
+ label: 'Email',
+ enabled: true,
+ required: true,
+ allowCreateAccount: true,
+ signatureMethod: 'email_token',
+ },
+ {
+ key: 'sms',
+ label: 'SMS',
+ enabled: true,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'sms_token',
+ },
+ {
+ key: 'whatsapp',
+ label: 'WhatsApp',
+ enabled: false,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'whatsapp_token',
+ },
+ {
+ key: 'document',
+ label: 'Document data',
+ enabled: false,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'document_validation',
+ },
+ ],
+ },
+ },
+ {
+ id: 'identify-group-finance',
+ scope: 'group',
+ targetId: 'finance',
+ allowChildOverride: true,
+ value: {
+ enabled: true,
+ requireAnyTwo: true,
+ factors: [
+ {
+ key: 'email',
+ label: 'Email',
+ enabled: true,
+ required: true,
+ allowCreateAccount: true,
+ signatureMethod: 'email_token',
+ },
+ {
+ key: 'sms',
+ label: 'SMS',
+ enabled: true,
+ required: true,
+ allowCreateAccount: false,
+ signatureMethod: 'sms_token',
+ },
+ {
+ key: 'whatsapp',
+ label: 'WhatsApp',
+ enabled: false,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'whatsapp_token',
+ },
+ {
+ key: 'document',
+ label: 'Document data',
+ enabled: true,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'document_validation',
+ },
+ ],
+ },
+ },
+ {
+ id: 'identify-group-legal',
+ scope: 'group',
+ targetId: 'legal',
+ allowChildOverride: false,
+ value: {
+ enabled: true,
+ requireAnyTwo: false,
+ factors: [
+ {
+ key: 'email',
+ label: 'Email',
+ enabled: true,
+ required: true,
+ allowCreateAccount: true,
+ signatureMethod: 'email_token',
+ },
+ {
+ key: 'sms',
+ label: 'SMS',
+ enabled: false,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'sms_token',
+ },
+ {
+ key: 'whatsapp',
+ label: 'WhatsApp',
+ enabled: true,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'whatsapp_token',
+ },
+ {
+ key: 'document',
+ label: 'Document data',
+ enabled: true,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'document_validation',
+ },
+ ],
+ },
+ },
+ {
+ id: 'identify-user-maria',
+ scope: 'user',
+ targetId: 'maria',
+ allowChildOverride: false,
+ value: {
+ enabled: true,
+ requireAnyTwo: false,
+ factors: [
+ {
+ key: 'email',
+ label: 'Email',
+ enabled: true,
+ required: true,
+ allowCreateAccount: true,
+ signatureMethod: 'email_token',
+ },
+ {
+ key: 'sms',
+ label: 'SMS',
+ enabled: false,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'sms_token',
+ },
+ {
+ key: 'whatsapp',
+ label: 'WhatsApp',
+ enabled: true,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'whatsapp_token',
+ },
+ {
+ key: 'document',
+ label: 'Document data',
+ enabled: false,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'document_validation',
+ },
+ ],
+ },
+ },
+ {
+ id: 'identify-user-joao',
+ scope: 'user',
+ targetId: 'joao',
+ allowChildOverride: false,
+ value: {
+ enabled: true,
+ requireAnyTwo: true,
+ factors: [
+ {
+ key: 'email',
+ label: 'Email',
+ enabled: true,
+ required: true,
+ allowCreateAccount: true,
+ signatureMethod: 'email_token',
+ },
+ {
+ key: 'sms',
+ label: 'SMS',
+ enabled: true,
+ required: true,
+ allowCreateAccount: false,
+ signatureMethod: 'sms_token',
+ },
+ {
+ key: 'whatsapp',
+ label: 'WhatsApp',
+ enabled: true,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'whatsapp_token',
+ },
+ {
+ key: 'document',
+ label: 'Document data',
+ enabled: false,
+ required: false,
+ allowCreateAccount: false,
+ signatureMethod: 'document_validation',
+ },
+ ],
+ },
+ },
+ ],
+ })
+
+ const activeDefinition = computed(() => activeSettingKey.value ? definitions[activeSettingKey.value] : null)
+
+ const activeRules = computed(() => activeSettingKey.value ? settingsState[activeSettingKey.value] : [])
+
+ const inheritedSystemRule = computed(() => activeRules.value.find(rule => rule.scope === 'system') ?? null)
+
+ const currentGroupRule = computed(() => activeRules.value.find(rule => rule.scope === 'group' && rule.targetId === CURRENT_GROUP_ID) ?? null)
+
+ const visibleGroupRules = computed(() => {
+ if (viewMode.value === 'group-admin') {
+ return currentGroupRule.value ? [currentGroupRule.value] : []
+ }
+
+ return activeRules.value.filter(rule => rule.scope === 'group')
+ })
+
+ const visibleUserRules = computed(() => {
+ const visibleUsers = viewMode.value === 'group-admin'
+ ? users.filter(user => user.groupId === CURRENT_GROUP_ID).map(user => user.id)
+ : users.map(user => user.id)
+
+ return activeRules.value.filter(rule => rule.scope === 'user' && rule.targetId !== null && visibleUsers.includes(rule.targetId))
+ })
+
+ const visibleSettingSummaries = computed(() => {
+ return Object.values(definitions).map((definition) => {
+ const rules = settingsState[definition.key]
+ const systemRule = rules.find(rule => rule.scope === 'system') ?? null
+ const groupRuleCount = viewMode.value === 'group-admin'
+ ? rules.filter(rule => rule.scope === 'group' && rule.targetId === CURRENT_GROUP_ID).length
+ : rules.filter(rule => rule.scope === 'group').length
+ const visibleUsersForSummary = viewMode.value === 'group-admin'
+ ? users.filter(user => user.groupId === CURRENT_GROUP_ID).map(user => user.id)
+ : users.map(user => user.id)
+ const userRuleCount = rules.filter(rule => rule.scope === 'user' && rule.targetId !== null && visibleUsersForSummary.includes(rule.targetId)).length
+
+ return {
+ key: definition.key,
+ title: definition.title,
+ context: definition.context,
+ description: definition.description,
+ defaultSummary: systemRule ? definition.summarizeValue(systemRule.value as never) : t('libresign', 'No global default rule'),
+ groupCount: groupRuleCount,
+ userCount: userRuleCount,
+ }
+ })
+ })
+
+ const availableTargets = computed(() => {
+ if (!editorDraft.value) {
+ return []
+ }
+
+ if (editorDraft.value.scope === 'group') {
+ const takenTargets = activeRules.value
+ .filter(rule => rule.scope === 'group' && rule.id !== editorDraft.value?.id)
+ .map(rule => rule.targetId)
+
+ return groups.filter(group => !takenTargets.includes(group.id))
+ }
+
+ if (editorDraft.value.scope === 'user') {
+ const baseUsers = viewMode.value === 'group-admin'
+ ? users.filter(user => user.groupId === CURRENT_GROUP_ID)
+ : users
+ const takenTargets = activeRules.value
+ .filter(rule => rule.scope === 'user' && rule.id !== editorDraft.value?.id)
+ .map(rule => rule.targetId)
+
+ return baseUsers.filter(user => !takenTargets.includes(user.id))
+ }
+
+ return []
+ })
+
+ const draftTargetLabel = computed(() => {
+ if (!editorDraft.value) {
+ return ''
+ }
+
+ return resolveTargetLabel(editorDraft.value.scope, editorDraft.value.targetId)
+ })
+
+ const duplicateMessage = computed(() => {
+ if (!editorDraft.value || !activeSettingKey.value) {
+ return ''
+ }
+
+ const hasDuplicate = settingsState[activeSettingKey.value].some((rule) => {
+ return rule.scope === editorDraft.value?.scope
+ && rule.targetId === editorDraft.value?.targetId
+ && rule.id !== editorDraft.value?.id
+ })
+
+ return hasDuplicate ? t('libresign', 'An override for this target already exists.') : ''
+ })
+
+ const canSaveDraft = computed(() => {
+ if (!editorDraft.value) {
+ return false
+ }
+
+ if (duplicateMessage.value) {
+ return false
+ }
+
+ if (editorDraft.value.scope === 'group' || editorDraft.value.scope === 'user') {
+ return editorDraft.value.targetId !== null
+ }
+
+ return true
+ })
+
+ function setViewMode(mode: AdminViewMode) {
+ viewMode.value = mode
+ resetEditor()
+ }
+
+ function openSetting(key: PolicySettingKey) {
+ activeSettingKey.value = key
+ resetEditor()
+ }
+
+ function closeSetting() {
+ activeSettingKey.value = null
+ resetEditor()
+ }
+
+ function resetEditor() {
+ editorDraft.value = null
+ editorMode.value = null
+ highlightedRuleId.value = null
+ }
+
+ function cancelEditor() {
+ resetEditor()
+ }
+
+ function resolveTargetLabel(scope: PolicyScope, targetId: string | null) {
+ if (scope === 'system') {
+ return t('libresign', 'Global default rule')
+ }
+
+ if (scope === 'group') {
+ return groups.find(group => group.id === targetId)?.label ?? t('libresign', 'Unknown group')
+ }
+
+ return users.find(user => user.id === targetId)?.label ?? t('libresign', 'Unknown user')
+ }
+
+ function getDefinition(key: PolicySettingKey) {
+ return definitions[key]
+ }
+
+ function createDraft(key: K, scope: PolicyScope, targetId: string | null): PolicyEditorDraft {
+ const definition = definitions[key]
+ return {
+ id: null,
+ settingKey: key,
+ scope,
+ targetId,
+ allowChildOverride: scope !== 'user',
+ value: cloneValue(definition.createEmptyValue(scope)) as PolicySettingValueMap[PolicySettingKey],
+ }
+ }
+
+ function getNextTarget(scope: PolicyScope) {
+ if (scope === 'group') {
+ return groups.find(group => !activeRules.value.some(rule => rule.scope === 'group' && rule.targetId === group.id))?.id ?? null
+ }
+
+ if (scope === 'user') {
+ const baseUsers = viewMode.value === 'group-admin'
+ ? users.filter(user => user.groupId === CURRENT_GROUP_ID)
+ : users
+ return baseUsers.find(user => !activeRules.value.some(rule => rule.scope === 'user' && rule.targetId === user.id))?.id ?? null
+ }
+
+ return null
+ }
+
+ function startEditor(options: StartEditorOptions) {
+ if (!activeSettingKey.value) {
+ return
+ }
+
+ if (options.ruleId) {
+ const existingRule = activeRules.value.find(rule => rule.id === options.ruleId)
+ if (!existingRule) {
+ return
+ }
+
+ editorMode.value = 'edit'
+ editorDraft.value = {
+ id: existingRule.id,
+ settingKey: activeSettingKey.value,
+ scope: existingRule.scope,
+ targetId: existingRule.targetId,
+ allowChildOverride: existingRule.allowChildOverride,
+ value: cloneValue(existingRule.value) as PolicySettingValueMap[PolicySettingKey],
+ }
+ highlightedRuleId.value = existingRule.id
+ return
+ }
+
+ editorMode.value = 'create'
+ const targetId = options.scope === 'system'
+ ? null
+ : options.scope === 'group' && viewMode.value === 'group-admin'
+ ? CURRENT_GROUP_ID
+ : getNextTarget(options.scope)
+
+ editorDraft.value = createDraft(activeSettingKey.value, options.scope, targetId)
+ highlightedRuleId.value = null
+ }
+
+ function updateDraftTarget(targetId: string | null) {
+ if (!editorDraft.value) {
+ return
+ }
+
+ editorDraft.value = {
+ ...editorDraft.value,
+ targetId,
+ }
+ }
+
+ function updateDraftAllowOverride(allowChildOverride: boolean) {
+ if (!editorDraft.value) {
+ return
+ }
+
+ editorDraft.value = {
+ ...editorDraft.value,
+ allowChildOverride,
+ }
+ }
+
+ function updateDraftValue(value: PolicySettingValueMap[PolicySettingKey]) {
+ if (!editorDraft.value) {
+ return
+ }
+
+ editorDraft.value = {
+ ...editorDraft.value,
+ value,
+ }
+ }
+
+ function saveDraft() {
+ if (!activeSettingKey.value || !editorDraft.value || !canSaveDraft.value) {
+ return
+ }
+
+ const rules = settingsState[activeSettingKey.value]
+ const nextRule: PolicyRuleRecord = {
+ id: editorDraft.value.id ?? `${activeSettingKey.value}-${nextRuleNumber.value++}`,
+ scope: editorDraft.value.scope,
+ targetId: editorDraft.value.scope === 'system' ? null : editorDraft.value.targetId,
+ allowChildOverride: editorDraft.value.scope === 'user' ? false : editorDraft.value.allowChildOverride,
+ value: cloneValue(editorDraft.value.value),
+ }
+
+ const existingIndex = rules.findIndex(rule => rule.id === nextRule.id)
+ if (existingIndex >= 0) {
+ rules.splice(existingIndex, 1, nextRule)
+ } else {
+ rules.push(nextRule as never)
+ }
+
+ resetEditor()
+ }
+
+ function removeRule(ruleId: string) {
+ if (!activeSettingKey.value) {
+ return
+ }
+
+ const rules = settingsState[activeSettingKey.value]
+ const index = rules.findIndex(rule => rule.id === ruleId)
+ if (index >= 0) {
+ rules.splice(index, 1)
+ }
+
+ if (highlightedRuleId.value === ruleId) {
+ resetEditor()
+ }
+ }
+
+ return {
+ viewMode,
+ activeSettingKey,
+ activeDefinition,
+ editorDraft,
+ editorMode,
+ highlightedRuleId,
+ visibleSettingSummaries,
+ visibleGroupRules,
+ visibleUserRules,
+ inheritedSystemRule,
+ currentGroupRule,
+ availableTargets,
+ draftTargetLabel,
+ duplicateMessage,
+ canSaveDraft,
+ currentGroupId: CURRENT_GROUP_ID,
+ settingsState,
+ setViewMode,
+ openSetting,
+ closeSetting,
+ cancelEditor,
+ startEditor,
+ updateDraftTarget,
+ updateDraftAllowOverride,
+ updateDraftValue,
+ saveDraft,
+ removeRule,
+ resolveTargetLabel,
+ getDefinition,
+ }
+}
diff --git a/src/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.ts b/src/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.ts
new file mode 100644
index 0000000000..7cebd09db0
--- /dev/null
+++ b/src/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.ts
@@ -0,0 +1,985 @@
+/**
+ * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import axios from '@nextcloud/axios'
+import { getCurrentUser } from '@nextcloud/auth'
+import { loadState } from '@nextcloud/initial-state'
+import { generateOcsUrl } from '@nextcloud/router'
+import { computed, reactive, ref } from 'vue'
+import { t } from '@nextcloud/l10n'
+
+import { realDefinitions } from './settings/realDefinitions'
+import type { RealPolicyResolutionMode } from './settings/realTypes'
+import { usePoliciesStore } from '../../../store/policies'
+import type { EffectivePolicyState, EffectivePolicyValue } from '../../../types/index'
+import logger from '../../../logger.js'
+
+type PolicyScope = 'system' | 'group' | 'user'
+type PolicyResolutionMode = RealPolicyResolutionMode
+
+interface PolicyImpactPreview {
+ groupCount?: number
+ userCount?: number
+ activeChildRules?: number
+ blockedChildRules?: number
+}
+
+interface PolicyStickySummary {
+ currentBaseValue: string
+ baseSource: string
+ configurableLayers: string
+ platformFallback: string
+ resolutionMode: PolicyResolutionMode
+ activeGroupExceptions: number
+ activeUserExceptions: number
+ activeBlockCount: number
+}
+
+interface PolicyRuleRecord {
+ id: string
+ scope: PolicyScope
+ targetId: string | null
+ allowChildOverride: boolean
+ value: EffectivePolicyValue
+}
+
+interface PolicyEditorDraft {
+ scope: PolicyScope
+ ruleId: string | null
+ targetIds: string[]
+ value: EffectivePolicyValue
+ allowChildOverride: boolean
+}
+
+interface PolicySettingSummary {
+ key: string
+ title: string
+ context?: string
+ description: string
+ defaultSummary: string
+ groupCount: number
+ userCount: number
+}
+
+interface PolicyTargetOption {
+ id: string
+ displayName: string
+ subname?: string
+ user?: string
+ isNoUser?: boolean
+}
+
+interface GroupDetailsResponse {
+ ocs?: {
+ data?: {
+ groups?: Array<{
+ id: string
+ displayname?: string
+ usercount?: number
+ }>
+ }
+ }
+}
+
+interface UserDetailsRecord {
+ id: string
+ displayname?: string
+ 'display-name'?: string
+ email?: string
+}
+
+interface UserDetailsResponse {
+ ocs?: {
+ data?: {
+ users?: Record | string>
+ }
+ }
+}
+
+function isUserDetailsRecord(candidate: unknown): candidate is UserDetailsRecord {
+ if (!candidate || typeof candidate !== 'object') {
+ return false
+ }
+
+ const record = candidate as Record
+ if (typeof record.id !== 'string' || record.id.length === 0) {
+ return false
+ }
+
+ // Defensive filtering: if backend response includes mixed entities,
+ // keep only user-like entries for the user rule target picker.
+ if ('usercount' in record || record.isNoUser === true) {
+ return false
+ }
+
+ return true
+}
+
+function inferSystemAllowOverride(policy: { allowedValues?: unknown[] } | null): boolean {
+ if (!policy || !Array.isArray(policy.allowedValues)) {
+ return true
+ }
+
+ // When lower layers are locked, backend narrows allowedValues to a single value.
+ return policy.allowedValues.length !== 1
+}
+
+function toDraftSnapshot(draft: PolicyEditorDraft | null): string {
+ if (!draft) {
+ return ''
+ }
+
+ return JSON.stringify({
+ scope: draft.scope,
+ ruleId: draft.ruleId,
+ targetIds: [...draft.targetIds].sort(),
+ value: draft.value,
+ allowChildOverride: draft.allowChildOverride,
+ })
+}
+
+export function createRealPolicyWorkbenchState() {
+ const policiesStore = usePoliciesStore()
+ const currentUser = getCurrentUser()
+ const isInstanceAdmin = currentUser?.isAdmin === true
+ const config = loadState<{ can_manage_group_policies?: boolean }>('libresign', 'config', {})
+ const initialViewMode: 'system-admin' | 'group-admin' = currentUser?.isAdmin
+ ? 'system-admin'
+ : config.can_manage_group_policies
+ ? 'group-admin'
+ : 'system-admin'
+ const viewMode = ref<'system-admin' | 'group-admin'>(initialViewMode)
+ const activeSettingKey = ref(null)
+ const editorDraft = ref(null)
+ const editorMode = ref<'create' | 'edit' | null>(null)
+ const highlightedRuleId = ref(null)
+ const duplicateMessage = ref(null)
+ const nextRuleNumber = ref(1)
+
+ const groupRules = ref([])
+ const userRules = ref([])
+ const explicitSystemRule = ref(null)
+ const settingRuleCounts = ref>({})
+
+ function setSettingRuleCounts(policyKey: string, groupCount: number, userCount: number) {
+ settingRuleCounts.value = {
+ ...settingRuleCounts.value,
+ [policyKey]: {
+ groupCount,
+ userCount,
+ },
+ }
+ }
+
+ function syncActiveSettingRuleCounts() {
+ if (!activeSettingKey.value) {
+ return
+ }
+
+ setSettingRuleCounts(activeSettingKey.value, groupRules.value.length, userRules.value.length)
+ }
+
+ const groups = ref([])
+ const users = ref([])
+ const loadingTargets = ref(false)
+ const hydratePersistedRulesRequestId = ref(0)
+ const editorInitialSnapshot = ref('')
+
+ const visibleSettingSummaries = computed(() => {
+ return Object.values(realDefinitions).map((definition) => {
+ const policy = policiesStore.getPolicy(definition.key)
+ const hasEffectiveValue = policy?.effectiveValue !== null && policy?.effectiveValue !== undefined
+ const persistedCounts = settingRuleCounts.value[definition.key]
+ const isActiveSetting = activeSettingKey.value === definition.key
+
+ return {
+ key: definition.key,
+ title: definition.title,
+ context: definition.context,
+ description: definition.description,
+ defaultSummary: hasEffectiveValue ? definition.summarizeValue(policy.effectiveValue) : t('libresign', 'Not configured'),
+ groupCount: isActiveSetting ? groupRules.value.length : (persistedCounts?.groupCount ?? 0),
+ userCount: isActiveSetting ? userRules.value.length : (persistedCounts?.userCount ?? 0),
+ }
+ })
+ })
+
+ const activeDefinition = computed(() => {
+ if (!activeSettingKey.value) {
+ return null
+ }
+
+ return realDefinitions[activeSettingKey.value as keyof typeof realDefinitions] || null
+ })
+
+ const activePolicyState = computed(() => {
+ if (!activeDefinition.value) {
+ return null
+ }
+
+ return policiesStore.getPolicy(activeDefinition.value.key)
+ })
+
+ const inheritedSystemRule = computed(() => {
+ if (!activeDefinition.value) {
+ return null
+ }
+
+ const policy = activePolicyState.value
+ if (!policy || policy.effectiveValue === null || policy.effectiveValue === undefined) {
+ return explicitSystemRule.value
+ }
+
+ const sourceScope = policy.sourceScope
+ if (sourceScope === 'group' || sourceScope === 'user') {
+ return explicitSystemRule.value
+ }
+
+ if (sourceScope === 'system') {
+ return {
+ id: 'system-inherited-default',
+ scope: 'system',
+ targetId: null,
+ allowChildOverride: inferSystemAllowOverride(policy),
+ value: policy.effectiveValue,
+ }
+ }
+
+ explicitSystemRule.value = {
+ id: 'system-default',
+ scope: 'system',
+ targetId: null,
+ allowChildOverride: inferSystemAllowOverride(policy),
+ value: policy.effectiveValue,
+ }
+
+ return explicitSystemRule.value
+ })
+
+ const policyResolutionMode = computed(() => {
+ if (!activeDefinition.value) {
+ return 'precedence'
+ }
+
+ return activeDefinition.value.resolutionMode
+ })
+
+ const systemDefaultRule = computed(() => {
+ if (!activeDefinition.value) {
+ return null
+ }
+
+ const policy = activePolicyState.value
+ const fallbackValue = activeDefinition.value.getFallbackSystemDefault(
+ policy?.effectiveValue,
+ policy?.sourceScope,
+ )
+
+ if (fallbackValue === null || fallbackValue === undefined) {
+ return null
+ }
+
+ return {
+ id: 'policy-system-default',
+ scope: 'system',
+ targetId: null,
+ allowChildOverride: true,
+ value: fallbackValue,
+ }
+ })
+
+ const hasGlobalDefault = computed(() => {
+ if (explicitSystemRule.value !== null) {
+ return true
+ }
+
+ const policy = activePolicyState.value
+ if (!policy) {
+ return false
+ }
+
+ if (!policy.sourceScope) {
+ return inheritedSystemRule.value !== null
+ }
+
+ return policy.sourceScope === 'global'
+ })
+
+ const effectiveSource = computed(() => {
+ const sourceScope = activePolicyState.value?.sourceScope
+ if (sourceScope === 'system') {
+ return 'system'
+ }
+
+ if (sourceScope === 'group' || sourceScope === 'user') {
+ return sourceScope
+ }
+
+ return hasGlobalDefault.value ? 'global' : 'system'
+ })
+
+ const summary = computed(() => {
+ if (!activeDefinition.value) {
+ return null
+ }
+
+ const fallbackLabel = systemDefaultRule.value
+ ? activeDefinition.value.summarizeValue(systemDefaultRule.value.value)
+ : t('libresign', 'Not configured')
+ const currentBaseValue = inheritedSystemRule.value
+ ? activeDefinition.value.summarizeValue(inheritedSystemRule.value.value)
+ : fallbackLabel
+
+ const activeGroupExceptions = groupRules.value.length
+ const activeUserExceptions = userRules.value.length
+ const activeBlockCount = [
+ inheritedSystemRule.value?.allowChildOverride === false ? 1 : 0,
+ ...groupRules.value.map((rule) => rule.allowChildOverride ? 0 : 1),
+ ].reduce((sum, count) => sum + count, 0)
+
+ const baseSource = hasGlobalDefault.value
+ ? t('libresign', 'Global default')
+ : t('libresign', 'System default')
+
+ return {
+ currentBaseValue,
+ baseSource,
+ configurableLayers: t('libresign', 'Default > Group > User'),
+ platformFallback: fallbackLabel,
+ resolutionMode: policyResolutionMode.value,
+ activeGroupExceptions,
+ activeUserExceptions,
+ activeBlockCount,
+ }
+ })
+
+ const createGroupOverrideDisabledReason = computed(() => {
+ if (isInstanceAdmin) {
+ return null
+ }
+
+ if (inheritedSystemRule.value?.allowChildOverride === false) {
+ return t('libresign', 'Blocked by the global default.')
+ }
+
+ return null
+ })
+
+ const createUserOverrideDisabledReason = computed(() => {
+ if (viewMode.value === 'system-admin') {
+ return null
+ }
+
+ if (inheritedSystemRule.value?.allowChildOverride === false) {
+ return t('libresign', 'Blocked by a locked default rule.')
+ }
+
+ const blockingGroup = groupRules.value.find((rule) => !rule.allowChildOverride)
+ if (blockingGroup?.targetId) {
+ return t('libresign', 'Blocked by the {group} group rule.', {
+ group: resolveTargetLabel('group', blockingGroup.targetId),
+ })
+ }
+
+ return null
+ })
+
+ const visibleGroupRules = computed(() => groupRules.value)
+ const visibleUserRules = computed(() => userRules.value)
+
+ function filterTargetsForCreate(scope: 'group' | 'user', targets: PolicyTargetOption[]): PolicyTargetOption[] {
+ if (!editorDraft.value || editorDraft.value.scope !== scope || editorMode.value !== 'create') {
+ return targets
+ }
+
+ const selectedTargetIds = new Set(editorDraft.value.targetIds)
+ const assignedTargetIds = new Set(
+ (scope === 'group' ? groupRules.value : userRules.value)
+ .map((rule) => rule.targetId)
+ .filter((targetId): targetId is string => !!targetId),
+ )
+
+ return targets.filter((target) => {
+ return selectedTargetIds.has(target.id) || !assignedTargetIds.has(target.id)
+ })
+ }
+
+ const availableTargets = computed(() => {
+ if (!editorDraft.value) {
+ return []
+ }
+
+ if (editorDraft.value.scope === 'group') {
+ return filterTargetsForCreate('group', groups.value)
+ }
+
+ if (editorDraft.value.scope === 'user') {
+ return filterTargetsForCreate('user', users.value)
+ }
+
+ return []
+ })
+
+ const draftTargetLabel = computed(() => {
+ if (!editorDraft.value || editorDraft.value.targetIds.length === 0) {
+ return null
+ }
+
+ const labels = editorDraft.value.targetIds
+ .map((targetId) => availableTargets.value.find((target) => target.id === targetId)?.displayName ?? targetId)
+
+ if (labels.length === 1) {
+ return labels[0]
+ }
+
+ return t('libresign', '{count} targets selected', { count: String(labels.length) })
+ })
+
+ const isDraftDirty = computed(() => {
+ if (!editorDraft.value) {
+ return false
+ }
+
+ return toDraftSnapshot(editorDraft.value) !== editorInitialSnapshot.value
+ })
+
+ function hasSelectableDraftValue(draft: PolicyEditorDraft) {
+ if (!activeDefinition.value) {
+ return false
+ }
+
+ return activeDefinition.value.hasSelectableDraftValue(draft.value)
+ }
+
+ const canSaveDraft = computed(() => {
+ if (!editorDraft.value) {
+ return false
+ }
+
+ if (!hasSelectableDraftValue(editorDraft.value)) {
+ return false
+ }
+
+ if (editorDraft.value.scope !== 'system' && editorDraft.value.targetIds.length === 0) {
+ return false
+ }
+
+ return isDraftDirty.value
+ })
+
+ async function hydratePersistedRules(policyKey: string) {
+ const currentRequestId = hydratePersistedRulesRequestId.value + 1
+ hydratePersistedRulesRequestId.value = currentRequestId
+
+ await Promise.all([
+ loadTargets('group', '', true),
+ loadTargets('user', '', true),
+ ])
+
+ const [persistedSystemPolicy, persistedGroupPolicies, persistedUserPolicies] = await Promise.all([
+ policiesStore.fetchSystemPolicy(policyKey).catch((error) => {
+ logger.debug('Could not load explicit system policy for workbench', {
+ error,
+ policyKey,
+ })
+ return null
+ }),
+ Promise.all(groups.value.map(async (group) => {
+ try {
+ const persistedPolicy = await policiesStore.fetchGroupPolicy(group.id, policyKey)
+ if (!persistedPolicy || persistedPolicy.value === null || persistedPolicy.value === undefined) {
+ return null
+ }
+
+ return {
+ id: `group-${group.id}-persisted`,
+ scope: 'group' as const,
+ targetId: group.id,
+ allowChildOverride: persistedPolicy.allowChildOverride,
+ value: persistedPolicy.value,
+ }
+ } catch (error) {
+ logger.debug('Could not load persisted group policy for target', {
+ error,
+ policyKey,
+ groupId: group.id,
+ })
+ return null
+ }
+ })),
+ Promise.all(users.value.map(async (user) => {
+ try {
+ const persistedPolicy = await policiesStore.fetchUserPolicyForUser(user.id, policyKey)
+ if (!persistedPolicy || persistedPolicy.value === null || persistedPolicy.value === undefined) {
+ return null
+ }
+
+ return {
+ id: `user-${user.id}-persisted`,
+ scope: 'user' as const,
+ targetId: user.id,
+ allowChildOverride: true,
+ value: persistedPolicy.value,
+ }
+ } catch (error) {
+ logger.debug('Could not load persisted user policy for target', {
+ error,
+ policyKey,
+ userId: user.id,
+ })
+ return null
+ }
+ })),
+ ])
+
+ if (currentRequestId !== hydratePersistedRulesRequestId.value || activeSettingKey.value !== policyKey) {
+ return
+ }
+
+ const hasPersistedRule = (rule: TRule | null): rule is TRule => rule !== null
+
+ explicitSystemRule.value = persistedSystemPolicy?.scope === 'global' && persistedSystemPolicy.value !== null && persistedSystemPolicy.value !== undefined
+ ? {
+ id: 'system-default',
+ scope: 'system',
+ targetId: null,
+ allowChildOverride: persistedSystemPolicy.allowChildOverride,
+ value: persistedSystemPolicy.value,
+ }
+ : null
+
+ groupRules.value = persistedGroupPolicies.filter(hasPersistedRule)
+ userRules.value = persistedUserPolicies.filter(hasPersistedRule)
+ nextRuleNumber.value = groupRules.value.length + userRules.value.length + 1
+ setSettingRuleCounts(policyKey, groupRules.value.length, userRules.value.length)
+ }
+
+ function openSetting(key: string) {
+ activeSettingKey.value = key
+ explicitSystemRule.value = null
+ groupRules.value = []
+ userRules.value = []
+ void hydratePersistedRules(key)
+ }
+
+ function mergeSelectedTargets(scope: 'group' | 'user', fetchedTargets: PolicyTargetOption[]) {
+ const existingTargets = scope === 'group' ? groups.value : users.value
+ const selectedIds = editorDraft.value?.scope === scope ? editorDraft.value.targetIds : []
+ const selectedTargets = existingTargets.filter((target) => {
+ return selectedIds.includes(target.id) && !fetchedTargets.some((option) => option.id === target.id)
+ })
+
+ return [...selectedTargets, ...fetchedTargets]
+ }
+
+ async function fetchGroups(query = '', limit = 20, offset = 0): Promise {
+ const { data } = await axios.get(generateOcsUrl('cloud/groups/details'), {
+ params: {
+ search: query,
+ limit,
+ offset,
+ },
+ })
+
+ return (data.ocs?.data?.groups ?? [])
+ .map((group) => ({
+ id: group.id,
+ displayName: group.displayname || group.id,
+ subname: typeof group.usercount === 'number'
+ ? t('libresign', '{count} members', { count: String(group.usercount) })
+ : undefined,
+ isNoUser: true,
+ }))
+ .sort((left, right) => left.displayName.localeCompare(right.displayName))
+ }
+
+ async function fetchUsers(query = '', limit = 20, offset = 0): Promise {
+ const { data } = await axios.get(generateOcsUrl('cloud/users/details'), {
+ params: {
+ search: query,
+ limit,
+ offset,
+ },
+ })
+
+ return Object.values(data.ocs?.data?.users ?? {})
+ .filter(isUserDetailsRecord)
+ .map((user) => ({
+ id: user.id,
+ displayName: user['display-name'] || user.displayname || user.id,
+ subname: user.email,
+ user: user.id,
+ }))
+ .sort((left, right) => left.displayName.localeCompare(right.displayName))
+ }
+
+ async function fetchAllTargets(scope: 'group' | 'user'): Promise {
+ const pageSize = 20
+ let offset = 0
+ const aggregatedTargets: PolicyTargetOption[] = []
+ const seenTargetIds = new Set()
+
+ for (let page = 0; page < 100; page += 1) {
+ const pageTargets = scope === 'group'
+ ? await fetchGroups('', pageSize, offset)
+ : await fetchUsers('', pageSize, offset)
+
+ for (const target of pageTargets) {
+ if (seenTargetIds.has(target.id)) {
+ continue
+ }
+
+ seenTargetIds.add(target.id)
+ aggregatedTargets.push(target)
+ }
+
+ if (pageTargets.length < pageSize) {
+ break
+ }
+
+ offset += pageSize
+ }
+
+ return aggregatedTargets.sort((left, right) => left.displayName.localeCompare(right.displayName))
+ }
+
+ async function loadTargets(scope: 'group' | 'user', query = '', includeAll = false) {
+ loadingTargets.value = true
+ try {
+ if (scope === 'group') {
+ const fetchedGroups = includeAll && query === ''
+ ? await fetchAllTargets('group')
+ : await fetchGroups(query)
+ groups.value = mergeSelectedTargets('group', fetchedGroups)
+ return
+ }
+
+ const fetchedUsers = includeAll && query === ''
+ ? await fetchAllTargets('user')
+ : await fetchUsers(query)
+ users.value = mergeSelectedTargets('user', fetchedUsers)
+ } catch (error) {
+ logger.debug('Could not load policy workbench targets', {
+ error,
+ scope,
+ query,
+ })
+ } finally {
+ loadingTargets.value = false
+ }
+ }
+
+ function searchAvailableTargets(query: string) {
+ const scope = editorDraft.value?.scope
+ if (scope !== 'group' && scope !== 'user') {
+ return
+ }
+
+ void loadTargets(scope, query)
+ }
+
+ function setViewMode(mode: 'system-admin' | 'group-admin') {
+ viewMode.value = mode
+ }
+
+ function closeSetting() {
+ activeSettingKey.value = null
+ groupRules.value = []
+ userRules.value = []
+ explicitSystemRule.value = null
+ editorDraft.value = null
+ editorMode.value = null
+ duplicateMessage.value = null
+ }
+
+ function resolveTargetLabel(scope: 'group' | 'user', targetId: string): string {
+ const targets = scope === 'group' ? groups.value : users.value
+ return targets.find((target) => target.id === targetId)?.displayName || targetId
+ }
+
+ function findRuleById(scope: PolicyScope, ruleId: string): PolicyRuleRecord | null {
+ if (scope === 'group') {
+ return groupRules.value.find((rule) => rule.id === ruleId) ?? null
+ }
+
+ if (scope === 'user') {
+ return userRules.value.find((rule) => rule.id === ruleId) ?? null
+ }
+
+ return inheritedSystemRule.value?.id === ruleId ? inheritedSystemRule.value : null
+ }
+
+ function startEditor({ scope, ruleId }: { scope: PolicyScope, ruleId?: string }) {
+ if (!activeDefinition.value) {
+ return
+ }
+
+ if (!ruleId && scope === 'group' && createGroupOverrideDisabledReason.value) {
+ duplicateMessage.value = createGroupOverrideDisabledReason.value
+ return
+ }
+
+ if (!ruleId && scope === 'user' && createUserOverrideDisabledReason.value) {
+ duplicateMessage.value = createUserOverrideDisabledReason.value
+ return
+ }
+
+ // Cancel any in-flight hydration result to avoid stale overwrite while editing.
+ hydratePersistedRulesRequestId.value += 1
+
+ const isEdit = !!ruleId
+ editorMode.value = isEdit ? 'edit' : 'create'
+ duplicateMessage.value = null
+
+ let value: EffectivePolicyValue = activeDefinition.value.createEmptyValue()
+ let targetIds: string[] = []
+ let allowChildOverride = activeDefinition.value.normalizeAllowChildOverride(scope, true)
+
+ if (isEdit && ruleId) {
+ const rule = findRuleById(scope, ruleId)
+ if (rule) {
+ value = activeDefinition.value.normalizeDraftValue(rule.value)
+ allowChildOverride = activeDefinition.value.normalizeAllowChildOverride(scope, rule.allowChildOverride)
+ targetIds = rule.targetId ? [rule.targetId] : []
+ }
+ } else if (scope === 'system') {
+ const baselineRuleValue = inheritedSystemRule.value?.value ?? systemDefaultRule.value?.value
+ if (baselineRuleValue !== null && baselineRuleValue !== undefined) {
+ value = activeDefinition.value.normalizeDraftValue(baselineRuleValue)
+ }
+ } else if (scope === 'group') {
+ targetIds = []
+ } else if (scope === 'user') {
+ targetIds = []
+ }
+
+ editorDraft.value = {
+ scope,
+ ruleId: ruleId || null,
+ targetIds,
+ value,
+ allowChildOverride,
+ }
+ editorInitialSnapshot.value = toDraftSnapshot(editorDraft.value)
+
+ if (scope === 'group' || scope === 'user') {
+ void loadTargets(scope)
+ }
+ }
+
+ function cancelEditor() {
+ editorDraft.value = null
+ editorMode.value = null
+ duplicateMessage.value = null
+ editorInitialSnapshot.value = ''
+ }
+
+ function updateDraftValue(value: EffectivePolicyValue) {
+ if (editorDraft.value) {
+ editorDraft.value.value = value
+ }
+ }
+
+ function updateDraftTargets(targetIds: string[]) {
+ if (!editorDraft.value) {
+ return
+ }
+
+ editorDraft.value.targetIds = Array.from(new Set(targetIds.filter(Boolean)))
+ }
+
+ function updateDraftTarget(targetId: string | null) {
+ updateDraftTargets(targetId ? [targetId] : [])
+ }
+
+ function updateDraftAllowOverride(allowChildOverride: boolean) {
+ if (editorDraft.value) {
+ editorDraft.value.allowChildOverride = activeDefinition.value
+ ? activeDefinition.value.normalizeAllowChildOverride(editorDraft.value.scope, allowChildOverride)
+ : allowChildOverride
+ }
+ }
+
+ function upsertRule(ruleList: PolicyRuleRecord[], scope: 'group' | 'user', targetId: string, value: EffectivePolicyValue, allowChildOverride: boolean) {
+ const existingRule = ruleList.find((rule) => rule.targetId === targetId)
+ if (existingRule) {
+ existingRule.value = value
+ existingRule.allowChildOverride = allowChildOverride
+ highlightedRuleId.value = existingRule.id
+ return
+ }
+
+ const id = `${scope}-${targetId}-${String(nextRuleNumber.value)}`
+ nextRuleNumber.value += 1
+
+ ruleList.push({
+ id,
+ scope,
+ targetId,
+ allowChildOverride,
+ value,
+ })
+
+ highlightedRuleId.value = id
+ }
+
+ async function saveDraft() {
+ if (!editorDraft.value || !activeDefinition.value || !canSaveDraft.value) {
+ return
+ }
+
+ const { scope, value, targetIds } = editorDraft.value
+ const policyKey = activeDefinition.value.key
+ const allowChildOverride = activeDefinition.value.normalizeAllowChildOverride(scope, editorDraft.value.allowChildOverride)
+
+ try {
+ if (scope === 'system') {
+ await policiesStore.saveSystemPolicy(policyKey, value, allowChildOverride)
+ if (viewMode.value === 'system-admin') {
+ await policiesStore.clearUserPreference(policyKey)
+ }
+ explicitSystemRule.value = {
+ id: 'system-default',
+ scope: 'system',
+ targetId: null,
+ allowChildOverride,
+ value,
+ }
+ await policiesStore.fetchEffectivePolicies()
+ syncActiveSettingRuleCounts()
+ cancelEditor()
+ return
+ }
+
+ if (scope === 'group') {
+ await Promise.all(targetIds.map((targetId) => {
+ return policiesStore.saveGroupPolicy(targetId, policyKey, value, allowChildOverride)
+ }))
+
+ for (const targetId of targetIds) {
+ upsertRule(groupRules.value, 'group', targetId, value, allowChildOverride)
+ }
+
+ await policiesStore.fetchEffectivePolicies()
+ syncActiveSettingRuleCounts()
+ cancelEditor()
+ return
+ }
+
+ await Promise.all(targetIds.map((targetId) => {
+ return policiesStore.saveUserPolicyForUser(targetId, policyKey, value)
+ }))
+
+ for (const targetId of targetIds) {
+ upsertRule(userRules.value, 'user', targetId, value, true)
+ }
+
+ await policiesStore.fetchEffectivePolicies()
+ syncActiveSettingRuleCounts()
+ cancelEditor()
+ } catch (error) {
+ console.error('Failed to save policy:', error)
+ }
+ }
+
+ async function removeRule(ruleId: string) {
+ if (!activeDefinition.value) {
+ return
+ }
+
+ const policyKey = activeDefinition.value.key
+ const inheritedSystemRuleId = inheritedSystemRule.value?.id
+ const isEditingMode = editorMode.value === 'edit'
+
+ const shouldCloseSystemEditor = isEditingMode && editorDraft.value?.scope === 'system'
+ const shouldCloseGroupEditor = isEditingMode
+ && editorDraft.value?.scope === 'group'
+ && editorDraft.value.ruleId === ruleId
+ const shouldCloseUserEditor = isEditingMode
+ && editorDraft.value?.scope === 'user'
+ && editorDraft.value.ruleId === ruleId
+
+ if (ruleId === 'system-default' || (inheritedSystemRuleId !== null && ruleId === inheritedSystemRuleId)) {
+ await policiesStore.saveSystemPolicy(policyKey, null as unknown as EffectivePolicyValue, false)
+ explicitSystemRule.value = null
+ highlightedRuleId.value = null
+ await policiesStore.fetchEffectivePolicies()
+ syncActiveSettingRuleCounts()
+ if (shouldCloseSystemEditor) {
+ cancelEditor()
+ }
+ return
+ }
+
+ const groupIndex = groupRules.value.findIndex((rule) => rule.id === ruleId)
+ if (groupIndex >= 0) {
+ const targetId = groupRules.value[groupIndex]?.targetId
+ if (targetId) {
+ await policiesStore.clearGroupPolicy(targetId, policyKey)
+ }
+ groupRules.value.splice(groupIndex, 1)
+ highlightedRuleId.value = null
+ await policiesStore.fetchEffectivePolicies()
+ syncActiveSettingRuleCounts()
+ if (shouldCloseGroupEditor) {
+ cancelEditor()
+ }
+ return
+ }
+
+ const userIndex = userRules.value.findIndex((rule) => rule.id === ruleId)
+ if (userIndex >= 0) {
+ const targetId = userRules.value[userIndex]?.targetId
+ if (targetId) {
+ await policiesStore.clearUserPolicyForUser(targetId, policyKey)
+ }
+ userRules.value.splice(userIndex, 1)
+ highlightedRuleId.value = null
+ await policiesStore.fetchEffectivePolicies()
+ syncActiveSettingRuleCounts()
+ if (shouldCloseUserEditor) {
+ cancelEditor()
+ }
+ }
+ }
+
+ return reactive({
+ activeDefinition,
+ editorDraft,
+ editorMode: editorMode as any,
+ inheritedSystemRule,
+ systemDefaultRule,
+ hasGlobalDefault,
+ effectiveSource,
+ policyResolutionMode,
+ summary,
+ visibleGroupRules,
+ visibleUserRules,
+ createGroupOverrideDisabledReason,
+ createUserOverrideDisabledReason,
+ visibleSettingSummaries,
+ highlightedRuleId: highlightedRuleId as any,
+ viewMode: viewMode as any,
+ availableTargets,
+ loadingTargets,
+ draftTargetLabel,
+ duplicateMessage: duplicateMessage as any,
+ canSaveDraft,
+ isDraftDirty,
+ openSetting,
+ closeSetting,
+ startEditor,
+ cancelEditor,
+ updateDraftValue,
+ updateDraftTarget,
+ updateDraftTargets,
+ updateDraftAllowOverride,
+ searchAvailableTargets,
+ setViewMode,
+ saveDraft,
+ removeRule,
+ resolveTargetLabel,
+ })
+}
diff --git a/src/views/Settings/Settings.vue b/src/views/Settings/Settings.vue
index 6bab780442..ae7f504d37 100644
--- a/src/views/Settings/Settings.vue
+++ b/src/views/Settings/Settings.vue
@@ -16,8 +16,7 @@
-
-
+
@@ -29,7 +28,6 @@
-
@@ -38,10 +36,8 @@ import AllowedGroups from './AllowedGroups.vue'
import CertificateEngine from './CertificateEngine.vue'
import ConfigureCheck from './ConfigureCheck.vue'
import CollectMetadata from './CollectMetadata.vue'
-import Confetti from './Confetti.vue'
import CrlValidation from './CrlValidation.vue'
import DefaultUserFolder from './DefaultUserFolder.vue'
-import DocMDP from './DocMDP.vue'
import DownloadBinaries from './DownloadBinaries.vue'
import Envelope from './Envelope.vue'
import ExpirationRules from './ExpirationRules.vue'
@@ -52,7 +48,7 @@ import Reminders from './Reminders.vue'
import RootCertificateCfssl from './RootCertificateCfssl.vue'
import RootCertificateOpenSsl from './RootCertificateOpenSsl.vue'
import SignatureEngine from './SignatureEngine.vue'
-import SignatureFlow from './SignatureFlow.vue'
+import SettingsPolicyWorkbench from './PolicyWorkbench/RealPolicyWorkbench.vue'
import SignatureHashAlgorithm from './SignatureHashAlgorithm.vue'
import SignatureStamp from './SignatureStamp.vue'
import SigningMode from './SigningMode.vue'
@@ -62,5 +58,30 @@ import Validation from './Validation.vue'
defineOptions({
name: 'Settings',
+ components: {
+ AllowedGroups,
+ CertificateEngine,
+ ConfigureCheck,
+ CollectMetadata,
+ CrlValidation,
+ DefaultUserFolder,
+ DownloadBinaries,
+ Envelope,
+ ExpirationRules,
+ IdentificationDocuments,
+ IdentificationFactors,
+ LegalInformation,
+ Reminders,
+ RootCertificateCfssl,
+ RootCertificateOpenSsl,
+ SettingsPolicyWorkbench,
+ SignatureEngine,
+ SignatureHashAlgorithm,
+ SignatureStamp,
+ SigningMode,
+ SupportProject,
+ TSA,
+ Validation,
+ },
})
diff --git a/src/views/Settings/SignatureFlow.vue b/src/views/Settings/SignatureFlow.vue
deleted file mode 100644
index 585dce1946..0000000000
--- a/src/views/Settings/SignatureFlow.vue
+++ /dev/null
@@ -1,258 +0,0 @@
-
-
-
-
- {{ errorMessage }}
-
-
-
-
- {{ t('libresign', 'Set default signing order') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ flow.label }}
-
- {{ flow.description }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/views/Settings/Validation.vue b/src/views/Settings/Validation.vue
index 52f481db8b..66330c3fc0 100644
--- a/src/views/Settings/Validation.vue
+++ b/src/views/Settings/Validation.vue
@@ -19,34 +19,36 @@
{{ t('libresign', 'Add visible footer with signature details') }}
-
-
- {{ t('libresign', 'Write QR code on footer with validation URL') }}
-
-
-
- {{ t('libresign', 'To validate the signature of the documents. Only change this value if you want to replace the default validation URL with a different one.') }}
-
-
-
-
- {{ t('libresign', 'Customize footer template') }}
-
-
-
+