diff --git a/composer.json b/composer.json index 085c2a5985..94228cd5e8 100644 --- a/composer.json +++ b/composer.json @@ -2,6 +2,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8", "nextcloud/ocp": "dev-master", + "psr/http-client": "^1.0", "roave/security-advisories": "dev-latest" }, "config": { diff --git a/composer.lock b/composer.lock index d4a85967d6..2aa3188090 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "683fc6c9ae20a480af7b531acfea05bb", + "content-hash": "bff2849d977248d68622e13c32586bd9", "packages": [ { "name": "cweagans/composer-configurable-plugin", diff --git a/eslint.config.mjs b/eslint.config.mjs index db96bf43ed..362ccce0aa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,11 +3,23 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import js from '@eslint/js' +import { FlatCompat } from '@eslint/eslintrc' import nextcloudConfig from '@nextcloud/eslint-config' -import globals from 'globals' +import { dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const compat = new FlatCompat({ + baseDirectory: dirname(fileURLToPath(import.meta.url)), + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}) + +const compatConfigs = (Array.isArray(nextcloudConfig) ? nextcloudConfig : [nextcloudConfig]) + .flatMap((config) => compat.config(config)) export default [ - ...(Array.isArray(nextcloudConfig) ? nextcloudConfig : [nextcloudConfig]), + ...compatConfigs, { name: 'libresign/ignores', diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 43238b34fd..6cfef858ca 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -60,7 +60,6 @@ public function register(IRegistrationContext $context): void { $context->registerNotifierService(Notifier::class); $context->registerSearchProvider(FileSearchProvider::class); - $context->registerEventListener(LoadSidebar::class, TemplateLoader::class); $context->registerEventListener(BeforeNodeDeletedEvent::class, BeforeNodeDeletedListener::class); $context->registerEventListener(CacheEntryRemovedEvent::class, BeforeNodeDeletedListener::class); diff --git a/lib/Capabilities.php b/lib/Capabilities.php index b024f13ee7..c3c6abcd6c 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -8,13 +8,13 @@ namespace OCA\Libresign; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Service\Envelope\EnvelopeService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Confetti\ConfettiPolicy; use OCA\Libresign\Service\SignatureTextService; use OCA\Libresign\Service\SignerElementsService; use OCP\App\IAppManager; use OCP\Capabilities\IPublicCapability; -use OCP\IAppConfig; /** * @psalm-import-type LibresignCapabilities from ResponseDefinitions @@ -29,7 +29,7 @@ public function __construct( protected SignatureTextService $signatureTextService, protected IAppManager $appManager, protected EnvelopeService $envelopeService, - protected IAppConfig $appConfig, + protected PolicyService $policyService, ) { } @@ -43,7 +43,7 @@ public function getCapabilities(): array { $capabilities = [ 'features' => self::FEATURES, 'config' => [ - 'show-confetti' => $this->appConfig->getValueBool(Application::APP_ID, 'show_confetti_after_signing', true), + 'show-confetti' => $this->policyService->resolve(ConfettiPolicy::KEY)->getEffectiveValueAsBool(true), 'sign-elements' => [ 'is-available' => $this->signerElementsService->isSignElementsAvailable(), 'can-create-signature' => $this->signerElementsService->canCreateSignature(), 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 0d24f7ed52..e924828562 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -16,7 +16,6 @@ use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Handler\CertificateEngine\IEngineHandler; -use OCA\Libresign\Helper\ConfigureCheckHelper; use OCA\Libresign\Service\Certificate\ValidateService; use OCA\Libresign\Service\CertificatePolicyService; use OCA\Libresign\Service\DocMdp\ConfigService as DocMdpConfigService; @@ -24,12 +23,16 @@ 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\Policy\Provider\Tsa\TsaPolicy; +use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicyValue; use OCA\Libresign\Service\ReminderService; use OCA\Libresign\Service\SignatureBackgroundService; use OCA\Libresign\Service\SignatureTextService; use OCA\Libresign\Settings\Admin; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\DataDownloadResponse; @@ -83,6 +86,7 @@ public function __construct( private ReminderService $reminderService, private FooterService $footerService, private DocMdpConfigService $docMdpConfigService, + private PolicyService $policyService, private IdentifyMethodService $identifyMethodService, private FileMapper $fileMapper, ) { @@ -233,14 +237,8 @@ private function generateCertificate( #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/certificate', requirements: ['apiVersion' => '(v1)'])] public function loadCertificate(): DataResponse { $engine = $this->certificateEngineFactory->getEngine(); - /** @var LibresignEngineHandler */ + /** @var LibresignCertificateDataGenerated */ $certificate = $engine->toArray(); - $configureResult = $engine->configureCheck(); - $success = array_filter( - $configureResult, - fn (ConfigureCheckHelper $config) => $config->getStatus() === 'success' - ); - $certificate['generated'] = count($success) === count($configureResult); return new DataResponse($certificate); } @@ -764,10 +762,9 @@ public function setTsaConfig( ], Http::STATUS_BAD_REQUEST); } - $this->appConfig->setValueString(Application::APP_ID, 'tsa_url', $trimmedUrl); - + $trimmedOid = ''; if (empty($tsa_policy_oid)) { - $this->appConfig->deleteKey(Application::APP_ID, 'tsa_policy_oid'); + $trimmedOid = ''; } else { $trimmedOid = trim($tsa_policy_oid); if (!preg_match('/^[0-9]+(\.[0-9]+)*$/', $trimmedOid)) { @@ -776,11 +773,10 @@ public function setTsaConfig( 'message' => 'Invalid OID format' ], Http::STATUS_BAD_REQUEST); } - $this->appConfig->setValueString(Application::APP_ID, 'tsa_policy_oid', $trimmedOid); } $authType = $tsa_auth_type ?? 'none'; - $this->appConfig->setValueString(Application::APP_ID, 'tsa_auth_type', $authType); + $username = ''; if ($authType === 'basic') { $hasUsername = !empty($tsa_username); @@ -803,7 +799,7 @@ public function setTsaConfig( ], Http::STATUS_BAD_REQUEST); } - $this->appConfig->setValueString(Application::APP_ID, 'tsa_username', trim($tsa_username)); + $username = trim($tsa_username); $this->appConfig->setValueString( Application::APP_ID, key: 'tsa_password', @@ -811,10 +807,21 @@ public function setTsaConfig( sensitive: true, ); } else { - $this->appConfig->deleteKey(Application::APP_ID, 'tsa_username'); + $username = ''; $this->appConfig->deleteKey(Application::APP_ID, 'tsa_password'); } + $this->policyService->saveSystem( + TsaPolicy::KEY, + [ + 'url' => $trimmedUrl, + 'policy_oid' => $trimmedOid, + 'auth_type' => $authType, + 'username' => $username, + ], + false, + ); + return new DataResponse(['status' => 'success']); } @@ -830,11 +837,7 @@ public function setTsaConfig( #[NoCSRFRequired] #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/admin/tsa', requirements: ['apiVersion' => '(v1)'])] public function deleteTsaConfig(): DataResponse { - $fields = ['tsa_url', 'tsa_policy_oid', 'tsa_auth_type', 'tsa_username', 'tsa_password']; - - foreach ($fields as $field) { - $this->appConfig->deleteKey(Application::APP_ID, $field); - } + $this->policyService->saveSystem(TsaPolicy::KEY, TsaPolicyValue::defaults(), false); return new DataResponse(['status' => 'success']); } @@ -850,11 +853,14 @@ public function deleteTsaConfig(): DataResponse { */ #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/footer-template', requirements: ['apiVersion' => '(v1)'])] public function getFooterTemplate(): DataResponse { + $previewSettings = $this->footerService->getPreviewSettings(); + return new DataResponse([ 'template' => $this->footerService->getTemplate(), 'isDefault' => $this->footerService->isDefaultTemplate(), - 'preview_width' => $this->appConfig->getValueInt(Application::APP_ID, 'footer_preview_width', 595), - 'preview_height' => $this->appConfig->getValueInt(Application::APP_ID, 'footer_preview_height', 100), + 'preview_width' => $previewSettings['preview_width'], + 'preview_height' => $previewSettings['preview_height'], + 'preview_zoom' => $previewSettings['preview_zoom'], ]); } @@ -874,7 +880,7 @@ public function getFooterTemplate(): DataResponse { #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/footer-template', requirements: ['apiVersion' => '(v1)'])] public function saveFooterTemplate(string $template = '', int $width = 595, int $height = 50) { try { - $this->footerService->saveTemplate($template); + $this->footerService->saveTemplate($template, $width, $height); $pdf = $this->footerService->renderPreviewPdf('', $width, $height); return new DataDownloadResponse($pdf, 'footer-preview.pdf', 'application/pdf'); @@ -894,15 +900,18 @@ public function saveFooterTemplate(string $template = '', int $width = 595, int * @param string $template Template to preview * @param int $width Width of preview in points (default: 595 - A4 width) * @param int $height Height of preview in points (default: 50) + * @param ?bool $writeQrcodeOnFooter Whether to force QR code rendering in footer preview (null uses policy) * @return DataDownloadResponse|DataResponse * * 200: OK * 400: Bad request */ + #[NoAdminRequired] + #[NoCSRFRequired] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/footer-template/preview-pdf', requirements: ['apiVersion' => '(v1)'])] - public function footerTemplatePreviewPdf(string $template = '', int $width = 595, int $height = 50) { + public function footerTemplatePreviewPdf(string $template = '', int $width = 595, int $height = 50, ?bool $writeQrcodeOnFooter = null) { try { - $pdf = $this->footerService->renderPreviewPdf($template ?: null, $width, $height); + $pdf = $this->footerService->renderPreviewPdf($template ?: null, $width, $height, $writeQrcodeOnFooter); return new DataDownloadResponse($pdf, 'footer-preview.pdf', 'application/pdf'); } catch (\Exception $e) { return new DataResponse([ @@ -960,57 +969,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/FileController.php b/lib/Controller/FileController.php index 9da50bab63..ba49a69748 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -183,7 +183,7 @@ public function validateBinary(): DataResponse { ->toArray(); $statusCode = Http::STATUS_OK; } catch (InvalidArgumentException $e) { - $message = $this->l10n->t($e->getMessage()); + $message = $e->getMessage(); $return = [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => [['message' => $message]] @@ -255,15 +255,15 @@ private function validate( ->toArray(); $statusCode = Http::STATUS_OK; } catch (LibresignException $e) { - $message = $this->l10n->t($e->getMessage()); + $message = $e->getMessage(); $return = [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => [['message' => $message]] ]; $statusCode = Http::STATUS_NOT_FOUND; } catch (\Throwable $th) { - $message = $this->l10n->t($th->getMessage()); - $this->logger->error($message); + $this->logger->error($th->getMessage(), ['exception' => $th]); + $message = $this->l10n->t('Internal error. Contact admin.'); $return = [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => [['message' => $message]] diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 46f41b8a98..13a52daa20 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -12,17 +12,18 @@ use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Handler\FooterHandler; use OCA\Libresign\Helper\JSActions; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Middleware\Attribute\PrivateValidation; use OCA\Libresign\Middleware\Attribute\RequireSetupOk; use OCA\Libresign\Middleware\Attribute\RequireSignRequestUuid; use OCA\Libresign\Service\AccountService; -use OCA\Libresign\Service\DocMdp\ConfigService; use OCA\Libresign\Service\File\FileListService; 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; @@ -60,6 +61,8 @@ public function __construct( private AccountService $accountService, protected SignFileService $signFileService, protected RequestSignatureService $requestSignatureService, + private PolicyService $policyService, + private FooterHandler $footerHandler, private SignerElementsService $signerElementsService, protected IL10N $l10n, private IdentifyMethodService $identifyMethodService, @@ -72,7 +75,6 @@ public function __construct( private ValidateHelper $validateHelper, private IEventDispatcher $eventDispatcher, private IURLGenerator $urlGenerator, - private ConfigService $docMdpConfigService, ) { parent::__construct( request: $request, @@ -107,10 +109,14 @@ 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)); - $this->initialState->provideInitialState('docmdp_config', $this->docMdpConfigService->getConfig()); - $this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information')); + $resolvedPolicies = []; + foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { + $resolvedPolicies[$policyKey] = $resolvedPolicy->toArray(); + } + $this->initialState->provideInitialState('effective_policies', [ + 'policies' => $resolvedPolicies, + ]); + $this->initialState->provideInitialState('footer_template', $this->footerHandler->getTemplate()); Util::addScript(Application::APP_ID, 'libresign-main'); Util::addStyle(Application::APP_ID, 'libresign-main'); @@ -637,7 +643,13 @@ public function validationFilePublic(string $uuid): TemplateResponse { $this->fileService->setSignRequest($signRequest); } - $this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information')); + $resolvedPolicies = []; + foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { + $resolvedPolicies[$policyKey] = $resolvedPolicy->toArray(); + } + $this->initialState->provideInitialState('effective_policies', [ + 'policies' => $resolvedPolicies, + ]); $this->initialState->provideInitialState('file_info', $this->fileService diff --git a/lib/Controller/PolicyController.php b/lib/Controller/PolicyController.php new file mode 100644 index 0000000000..c726dd98d5 --- /dev/null +++ b/lib/Controller/PolicyController.php @@ -0,0 +1,580 @@ + + * + * 200: OK + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/effective', requirements: ['apiVersion' => '(v1)'])] + public function effective(): DataResponse { + $user = $this->userSession->getUser(); + $ruleCounts = $this->resolveRuleCountsForActor($user); + + /** @var array $policies */ + $policies = []; + foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { + /** @var LibresignEffectivePolicyState $policyState */ + $policyState = $resolvedPolicy->toArray(); + $policyState['groupCount'] = $ruleCounts[$policyKey]['groupCount'] ?? 0; + $policyState['userCount'] = $ruleCounts[$policyKey]['userCount'] ?? 0; + $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() ?? [], + ], + ]; + $rawValue = $data['policy']['value']; + if (is_string($rawValue)) { + $decoded = json_decode($rawValue, true); + if (is_array($decoded)) { + $data['policy']['value'] = $decoded; + } + } + + 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 an explicit user-level policy for a target user (admin scope) + * + * @param string $userId Target user identifier that receives the policy assignment. + * @param string $policyKey Policy identifier to read for the selected user. + * @return DataResponse|DataResponse + * + * 200: OK + * 403: Forbidden + */ + #[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 { + if (!$this->canManageUserPolicy($userId)) { + return $this->forbiddenUserPolicyResponse(); + } + + $policy = $this->policyService->getUserPolicyForUserId($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|array $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|array $value = null, bool $allowChildOverride = false): DataResponse { + $value = $this->readPolicyValueParam('value', $value); + $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride); + + try { + $value = $this->requestSignGroupsPolicyGuard->normalizeManagedValue($policyKey, $value, true); + $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' => $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|array $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|array $value = null, bool $allowChildOverride = false): DataResponse { + if (!$this->canManageGroupPolicy($groupId)) { + return $this->forbiddenGroupPolicyResponse(); + } + + $value = $this->readPolicyValueParam('value', $value); + $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride); + + try { + $value = $this->requestSignGroupsPolicyGuard->normalizeManagedValue($policyKey, $value); + $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 (\DomainException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_FORBIDDEN); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $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(); + } + + try { + $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); + } catch (\DomainException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_FORBIDDEN); + } + } + + /** + * Save a user policy preference + * + * @param string $policyKey Policy identifier to persist for the current user. + * @param null|bool|int|float|string|array $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|array $value = null): DataResponse { + $value = $this->readPolicyValueParam('value', $value); + + try { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + $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' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** + * Save an explicit user policy for a target user (admin scope) + * + * @param string $userId Target user identifier that receives the policy assignment. + * @param string $policyKey Policy identifier to persist for the target user. + * @param null|bool|int|float|string|array $value Policy value to persist as assigned target user policy. + * @param bool $allowChildOverride Whether the target user may still override the assigned value in personal preferences. + * @return DataResponse|DataResponse|DataResponse + * + * 200: OK + * 400: Invalid policy value + * 403: Forbidden + */ + #[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|array $value = null, bool $allowChildOverride = false): DataResponse { + if (!$this->canManageUserPolicy($userId)) { + return $this->forbiddenUserPolicyResponse(); + } + + $value = $this->readPolicyValueParam('value', $value); + $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride); + + try { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + $policy = $this->policyService->saveUserPolicyForUserId($policyKey, $userId, $value, $allowChildOverride); + /** @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' => $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|DataResponse + * + * 200: OK + * 400: User-scope not supported + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/user/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] + public function clearUserPreference(string $policyKey): DataResponse { + try { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + $policy = $this->policyService->clearUserPreference($policyKey); + /** @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' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** + * Clear an explicit user policy for a target user (admin scope) + * + * @param string $userId Target user identifier that receives the policy assignment removal. + * @param string $policyKey Policy identifier to clear for the target user. + * @return DataResponse|DataResponse|DataResponse + * + * 200: OK + * 400: User-scope not supported + * 403: Forbidden + */ + #[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 { + if (!$this->canManageUserPolicy($userId)) { + return $this->forbiddenUserPolicyResponse(); + } + + try { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + $policy = $this->policyService->clearUserPolicyForUserId($policyKey, $userId); + /** @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' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** @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_policy', + 'targetId' => $userId, + 'value' => $policy?->getValue(), + 'allowChildOverride' => $policy?->isAllowChildOverride() ?? true, + ]; + } + + 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 canManageUserPolicy(string $userId): bool { + $user = $this->userSession->getUser(); + if ($user === null) { + return false; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + return true; + } + + if (!$this->subAdmin->isSubAdmin($user)) { + return false; + } + + $targetUser = $this->userManager->get($userId); + if (!$targetUser instanceof IUser) { + return false; + } + + $managedGroupIds = array_values(array_map( + static fn ($group): string => $group->getGID(), + $this->subAdmin->getSubAdminsGroups($user), + )); + if ($managedGroupIds === []) { + return false; + } + + $targetGroupIds = $this->groupManager->getUserGroupIds($targetUser); + return array_intersect($managedGroupIds, $targetGroupIds) !== []; + } + + /** + * @return array + */ + private function resolveRuleCountsForActor(?IUser $user): array { + if ($user === null) { + return []; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + $groupIds = array_values(array_map( + static fn ($group): string => $group->getGID(), + $this->groupManager->search(''), + )); + $userIds = array_values(array_map( + static fn ($candidate): string => $candidate->getUID(), + $this->userManager->searchDisplayName(''), + )); + + return $this->policyService->getRuleCounts($groupIds, $userIds); + } + + if ($this->subAdmin->isSubAdmin($user)) { + $groupIds = array_map( + static fn ($group) => $group->getGID(), + $this->subAdmin->getSubAdminsGroups($user), + ); + return $this->policyService->getRuleCounts($groupIds, []); + } + + return []; + } + + private function readPolicyValueParam(string $key, null|bool|int|float|string|array $default): null|bool|int|float|string|array { + $value = $this->request->getParams()[$key] ?? $default; + if (!is_scalar($value) && !is_array($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); + } + + /** @return DataResponse */ + private function forbiddenUserPolicyResponse(): DataResponse { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $this->l10n->t('Not allowed to manage this user policy'), + ]; + + return new DataResponse($data, Http::STATUS_FORBIDDEN); + } +} diff --git a/lib/Controller/RequestSignatureController.php b/lib/Controller/RequestSignatureController.php index a3bd023cc7..c64de56605 100644 --- a/lib/Controller/RequestSignatureController.php +++ b/lib/Controller/RequestSignatureController.php @@ -68,7 +68,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 array|null $policy Structured policy payload with request-level overrides and active context. * @return DataResponse|DataResponse * * 200: OK @@ -87,10 +87,13 @@ public function requestSignature( array $files = [], ?string $callback = null, ?int $status = 1, - ?string $signatureFlow = null, + ?array $policy = null, ): DataResponse { try { $user = $this->userSession->getUser(); + $policyOverrides = $this->extractPolicyOverrides($policy); + $policyActiveContext = $this->extractPolicyActiveContext($policy); + return $this->createSignatureRequest( $user, $file, @@ -100,7 +103,8 @@ public function requestSignature( $signers, $status, $callback, - $signatureFlow + $policyOverrides, + $policyActiveContext, ); } catch (LibresignException $e) { $errorMessage = $e->getMessage(); @@ -135,7 +139,7 @@ public function requestSignature( * @param LibresignVisibleElement[]|null $visibleElements Visible elements on document * @param LibresignNewFile|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 array|null $policy Structured policy payload with request-level overrides and active context. * @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. @@ -155,7 +159,7 @@ public function updateSignatureRequest( ?array $visibleElements = null, ?array $file = null, ?int $status = null, - ?string $signatureFlow = null, + ?array $policy = null, ?string $name = null, array $settings = [], array $files = [], @@ -164,6 +168,8 @@ public function updateSignatureRequest( $user = $this->userSession->getUser(); $signers = is_array($signers) ? $signers : []; $file = is_array($file) ? $file : []; + $policyOverrides = $this->extractPolicyOverrides($policy); + $policyActiveContext = $this->extractPolicyActiveContext($policy); if (empty($uuid)) { return $this->createSignatureRequest( @@ -175,7 +181,8 @@ public function updateSignatureRequest( $signers, $status, null, - $signatureFlow, + $policyOverrides, + $policyActiveContext, $visibleElements ); } @@ -186,7 +193,8 @@ public function updateSignatureRequest( 'signers' => $signers, 'userManager' => $user, 'visibleElements' => $visibleElements, - 'signatureFlow' => $signatureFlow, + 'policyOverrides' => $policyOverrides, + 'policyActiveContext' => $policyActiveContext, 'name' => $name, 'settings' => $settings, ]; @@ -230,7 +238,8 @@ private function createSignatureRequest( array $signers, ?int $status, ?string $callback, - ?string $signatureFlow, + array $policyOverrides = [], + ?array $policyActiveContext = null, ?array $visibleElements = null, ): DataResponse { $isEnvelope = !empty($files); @@ -247,7 +256,8 @@ private function createSignatureRequest( 'signers' => $signers, 'callback' => $callback, 'userManager' => $user, - 'signatureFlow' => $signatureFlow, + 'policyOverrides' => $policyOverrides, + 'policyActiveContext' => $policyActiveContext, 'settings' => !empty($settings) ? $settings : ($file['settings'] ?? []), ]; @@ -370,4 +380,18 @@ private function loadChildFilesIfEnvelope($fileEntity): array { ? $this->fileMapper->getChildrenFiles($fileEntity->getId()) : []; } + + /** @return array */ + private function extractPolicyOverrides(?array $policy): array { + $overrides = $policy['overrides'] ?? null; + + return is_array($overrides) ? $overrides : []; + } + + /** @return array|null */ + private function extractPolicyActiveContext(?array $policy): ?array { + $activeContext = $policy['activeContext'] ?? null; + + return is_array($activeContext) ? $activeContext : null; + } } diff --git a/lib/Db/IdentifyMethod.php b/lib/Db/IdentifyMethod.php index 31da06d0c1..a2214647fe 100644 --- a/lib/Db/IdentifyMethod.php +++ b/lib/Db/IdentifyMethod.php @@ -8,6 +8,7 @@ namespace OCA\Libresign\Db; +use OCA\Libresign\Enum\IdentifyMethodRequirement; use OCP\AppFramework\Db\Entity; use OCP\DB\Types; @@ -93,4 +94,26 @@ public function setLastAttemptDate(null|string|\DateTime $lastAttemptDate): void public function getUniqueIdentifier(): string { return $this->getIdentifierKey() . ':' . $this->getIdentifierValue(); } + + public function getRequirement(): string { + $metadata = $this->getMetadata(); + if (is_array($metadata) && isset($metadata['requirement']) && is_string($metadata['requirement'])) { + $requirement = IdentifyMethodRequirement::tryFrom($metadata['requirement']); + if ($requirement !== null) { + return $requirement->value; + } + } + + return $this->mandatory === 1 + ? IdentifyMethodRequirement::REQUIRED->value + : IdentifyMethodRequirement::OPTIONAL->value; + } + + public function setRequirement(string $requirement): void { + $normalized = IdentifyMethodRequirement::tryFrom($requirement) ?? IdentifyMethodRequirement::OPTIONAL; + $metadata = $this->getMetadata() ?? []; + $metadata['requirement'] = $normalized->value; + $this->setMetadata($metadata); + $this->setMandatory($normalized === IdentifyMethodRequirement::REQUIRED ? 1 : 0); + } } 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..3952835c74 --- /dev/null +++ b/lib/Db/PermissionSetBindingMapper.php @@ -0,0 +1,101 @@ + + */ +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; + } + + /** + * @return list + */ + public function findByTargetType(string $targetType): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('target_type', $qb->createNamedParameter($targetType))); + + /** @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/Enum/IdentifyMethodRequirement.php b/lib/Enum/IdentifyMethodRequirement.php new file mode 100644 index 0000000000..ed4873c4bf --- /dev/null +++ b/lib/Enum/IdentifyMethodRequirement.php @@ -0,0 +1,15 @@ +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(), - 'docmdp_config' => $this->docMdpConfigService->getConfig(), + 'effective_policies' => [ + 'policies' => $resolvedPolicies, + ], '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/CertificateEngine/AEngineHandler.php b/lib/Handler/CertificateEngine/AEngineHandler.php index 7aca24face..be7c25113f 100644 --- a/lib/Handler/CertificateEngine/AEngineHandler.php +++ b/lib/Handler/CertificateEngine/AEngineHandler.php @@ -19,6 +19,9 @@ use OCA\Libresign\Service\CertificatePolicyService; use OCA\Libresign\Service\Crl\CrlDistributionPointsExtractor; use OCA\Libresign\Service\Crl\CrlRevocationChecker; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\ExpirationRules\ExpirationRulesPolicy; +use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicy; use OCP\Files\AppData\IAppDataFactory; use OCP\Files\IAppData; use OCP\Files\SimpleFS\ISimpleFolder; @@ -85,6 +88,7 @@ public function __construct( protected CertificatePolicyService $certificatePolicyService, protected IURLGenerator $urlGenerator, protected CaIdentifierService $caIdentifierService, + protected PolicyService $policyService, protected LoggerInterface $logger, private CrlRevocationChecker $crlRevocationChecker, ) { @@ -204,10 +208,10 @@ private function addCrlValidationInfo(array &$certData, string $certPem): void { } } - $externalValidationEnabled = $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true); - $certData['crl_validation'] = $externalValidationEnabled - ? CrlValidationStatus::MISSING - : CrlValidationStatus::DISABLED; + $emptyCrlValidation = $this->crlRevocationChecker->validate([], $certPem); + $certData['crl_validation'] = ($emptyCrlValidation['status'] ?? CrlValidationStatus::NO_URLS) === CrlValidationStatus::DISABLED + ? CrlValidationStatus::DISABLED + : CrlValidationStatus::MISSING; $certData['crl_urls'] = []; } @@ -276,9 +280,9 @@ public function translateToLong($name): string { #[\Override] public function setEngine(string $engine): void { + $this->configureIdentifyMethodsForEngine($engine); $this->appConfig->setValueString(Application::APP_ID, 'certificate_engine', $engine); $this->engine = $engine; - $this->configureIdentifyMethodsForEngine($engine); } /** @@ -305,7 +309,7 @@ private function configureIdentifyMethodsForEngine(string $engine): void { 'enabled' => true, 'mandatory' => true, ]]; - $this->appConfig->setValueArray(Application::APP_ID, 'identify_methods', $config); + $this->policyService->saveSystem(IdentifyMethodsPolicy::KEY, $config); } #[\Override] @@ -477,7 +481,7 @@ public function getLeafExpiryInDays(): int { if ($this->leafExpiryOverrideInDays !== null) { return $this->leafExpiryOverrideInDays; } - $exp = $this->appConfig->getValueInt(Application::APP_ID, 'expiry_in_days', 365); + $exp = (int)$this->policyService->resolve(ExpirationRulesPolicy::KEY_EXPIRY_IN_DAYS)->getEffectiveValue(); return $exp > 0 ? $exp : 365; } diff --git a/lib/Handler/CertificateEngine/CfsslHandler.php b/lib/Handler/CertificateEngine/CfsslHandler.php index d55adf4074..4e869b6f7e 100644 --- a/lib/Handler/CertificateEngine/CfsslHandler.php +++ b/lib/Handler/CertificateEngine/CfsslHandler.php @@ -22,6 +22,7 @@ use OCA\Libresign\Service\CertificatePolicyService; use OCA\Libresign\Service\Crl\CrlRevocationChecker; use OCA\Libresign\Service\Install\InstallService; +use OCA\Libresign\Service\Policy\PolicyService; use OCA\Libresign\Service\Process\ProcessManager; use OCA\Libresign\Vendor\Symfony\Component\Process\Process; use OCP\Files\AppData\IAppDataFactory; @@ -58,6 +59,7 @@ public function __construct( protected CertificatePolicyService $certificatePolicyService, protected IURLGenerator $urlGenerator, protected CaIdentifierService $caIdentifierService, + protected PolicyService $policyService, protected CrlMapper $crlMapper, protected LoggerInterface $logger, CrlRevocationChecker $crlRevocationChecker, @@ -72,6 +74,7 @@ public function __construct( $certificatePolicyService, $urlGenerator, $caIdentifierService, + $policyService, $logger, $crlRevocationChecker, ); diff --git a/lib/Handler/CertificateEngine/OpenSslHandler.php b/lib/Handler/CertificateEngine/OpenSslHandler.php index 7b8a521d60..6bc224e7d4 100644 --- a/lib/Handler/CertificateEngine/OpenSslHandler.php +++ b/lib/Handler/CertificateEngine/OpenSslHandler.php @@ -15,6 +15,7 @@ use OCA\Libresign\Service\CaIdentifierService; use OCA\Libresign\Service\CertificatePolicyService; use OCA\Libresign\Service\Crl\CrlRevocationChecker; +use OCA\Libresign\Service\Policy\PolicyService; use OCA\Libresign\Service\SerialNumberService; use OCA\Libresign\Service\SubjectAlternativeNameService; use OCP\Files\AppData\IAppDataFactory; @@ -46,6 +47,7 @@ public function __construct( protected IURLGenerator $urlGenerator, protected SerialNumberService $serialNumberService, protected CaIdentifierService $caIdentifierService, + protected PolicyService $policyService, protected LoggerInterface $logger, protected CrlMapper $crlMapper, protected SubjectAlternativeNameService $subjectAlternativeNameService, @@ -60,6 +62,7 @@ public function __construct( $certificatePolicyService, $urlGenerator, $caIdentifierService, + $policyService, $logger, $crlRevocationChecker, ); diff --git a/lib/Handler/DocMdpHandler.php b/lib/Handler/DocMdpHandler.php index ad0f1d2afd..f65d277a55 100644 --- a/lib/Handler/DocMdpHandler.php +++ b/lib/Handler/DocMdpHandler.php @@ -329,14 +329,14 @@ private function validateModifications(DocMdpLevel $docmdpLevel, array $modifica * * @param bool $valid Whether modification is valid * @param int $status Status constant from File class - * @param string $messageKey Translation key + * @param string $message Translated message * @return array Validation result */ - private function buildValidationResult(bool $valid, int $status, string $messageKey): array { + private function buildValidationResult(bool $valid, int $status, string $message): array { return [ 'valid' => $valid, 'status' => $status, - 'message' => $this->l10n->t($messageKey), + 'message' => $message, ]; } @@ -348,10 +348,10 @@ private function buildValidationResult(bool $valid, int $status, string $message */ private function getAllowedModificationMessage(DocMdpLevel $level): string { return match ($level) { - DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED => 'Invalid: Document was modified after signing (DocMDP violation - no changes allowed)', - DocMdpLevel::CERTIFIED_FORM_FILLING => 'Document form fields were modified (allowed by DocMDP P=2)', - DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => 'Document form fields or annotations were modified (allowed by DocMDP P=3)', - default => 'Document was modified after signing', + DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED => $this->l10n->t('Invalid: Document was modified after signing (DocMDP violation - no changes allowed)'), + DocMdpLevel::CERTIFIED_FORM_FILLING => $this->l10n->t('Document form fields were modified (allowed by DocMDP P=2)'), + DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $this->l10n->t('Document form fields or annotations were modified (allowed by DocMDP P=3)'), + default => $this->l10n->t('Document was modified after signing'), }; } diff --git a/lib/Handler/FooterHandler.php b/lib/Handler/FooterHandler.php index fd5ea2c882..fbb54281bc 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\FooterPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; use OCA\Libresign\Vendor\Endroid\QrCode\Color\Color; use OCA\Libresign\Vendor\Endroid\QrCode\Encoding\Encoding; use OCA\Libresign\Vendor\Endroid\QrCode\ErrorCorrectionLevel; @@ -31,6 +34,10 @@ class FooterHandler { private QrCode $qrCode; + /** @var array */ + private array $requestPolicyOverrides = []; + private ?string $templateOverride = null; + private ?bool $writeQrcodeOnFooterOverride = null; private const MIN_QRCODE_SIZE = 100; private const POINT_TO_MILIMETER = 0.3527777778; @@ -41,17 +48,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 +101,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 +119,36 @@ public function setTemplateVar(string $name, mixed $value): self { return $this; } - private function prepareTemplateVars(): array { + /** @param array $requestPolicyOverrides */ + public function setRequestPolicyOverrides(array $requestPolicyOverrides): self { + $this->requestPolicyOverrides = $requestPolicyOverrides; + return $this; + } + + public function setWriteQrcodeOnFooterOverride(?bool $value): self { + $this->writeQrcodeOnFooterOverride = $value; + return $this; + } + + public function setTemplateOverride(?string $template): self { + $this->templateOverride = $template; + return $this; + } + + public function getEffectiveFooterPolicyAsJson(): string { + return (string)$this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue(); + } + + /** @return array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool, footerTemplate: string, previewWidth: int, previewHeight: int, previewZoom: int} */ + private function resolveFooterPolicy(): array { + return FooterPolicyValue::normalize( + $this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue() + ); + } + + private function prepareTemplateVars(bool $forceEnabled = false): array { + $footerPolicy = $this->resolveFooterPolicy(); + 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 +168,7 @@ private function prepareTemplateVars(): array { } if (!$this->templateVars->getValidationSite() && $this->templateVars->getUuid()) { - $validationSite = $this->appConfig->getValueString(Application::APP_ID, 'validation_site'); + $validationSite = $footerPolicy['validationSite']; if ($validationSite) { $this->templateVars->setValidationSite( rtrim($validationSite, '/') . '/' . $this->templateVars->getUuid() @@ -155,7 +191,8 @@ private function prepareTemplateVars(): array { } } - if ($this->appConfig->getValueBool(Application::APP_ID, 'write_qrcode_on_footer', true) && $this->templateVars->getValidationSite()) { + $shouldWriteQrcode = $this->writeQrcodeOnFooterOverride ?? $footerPolicy['writeQrcodeOnFooter']; + if ($shouldWriteQrcode && $this->templateVars->getValidationSite()) { $this->templateVars->setQrcode($this->getQrCodeImageBase64($this->templateVars->getValidationSite())); } @@ -170,9 +207,17 @@ private function prepareTemplateVars(): array { } public function getTemplate(): string { - $footerTemplate = $this->appConfig->getValueString(Application::APP_ID, 'footer_template', ''); - if ($footerTemplate) { - return $footerTemplate; + if ($this->templateOverride !== null) { + return trim($this->templateOverride) !== '' ? $this->templateOverride : $this->getDefaultTemplate(); + } + + $footerPolicy = $this->resolveFooterPolicy(); + + if ($footerPolicy['customizeFooterTemplate']) { + $policyTemplate = trim((string)($footerPolicy['footerTemplate'] ?? '')); + if ($policyTemplate !== '') { + return $policyTemplate; + } } return $this->getDefaultTemplate(); } @@ -204,4 +249,10 @@ private function getQrCodeImageBase64(string $text): string { public function getTemplateVariablesMetadata(): array { return $this->templateVars->getVariablesMetadata(); } + + private function isFooterEnabled(): bool { + return FooterPolicyValue::isEnabled( + $this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue() + ); + } } diff --git a/lib/Handler/PdfTk/Pdf.php b/lib/Handler/PdfTk/Pdf.php index 2d7821cd72..c7aac6aee0 100644 --- a/lib/Handler/PdfTk/Pdf.php +++ b/lib/Handler/PdfTk/Pdf.php @@ -9,7 +9,7 @@ namespace OCA\Libresign\Handler\PdfTk; use OCA\Libresign\AppInfo\Application; -use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Exception\FooterStampUnavailableException; use OCA\Libresign\Helper\JavaHelper; use OCA\Libresign\Vendor\mikehaertl\pdftk\Command; use OCA\Libresign\Vendor\mikehaertl\pdftk\Pdf as BasePdf; @@ -45,17 +45,16 @@ public function applyStamp(string $input, string $stamp): string { protected function configureCommand(): void { $this->javaPath = $this->javaHelper->getJavaPath(); if ($this->javaPath === '') { - throw new RuntimeException('Java path not set.'); + throw new FooterStampUnavailableException('Java path not set.'); } $this->pdftkPath = $this->appConfig->getValueString(Application::APP_ID, 'pdftk_path'); if ($this->pdftkPath === '') { - throw new RuntimeException('PDFtk path not set.'); + throw new FooterStampUnavailableException('PDFtk path not set.'); } - if (!file_exists($this->javaPath) || !file_exists($this->pdftkPath)) { - throw new LibresignException($this->l10n->t('The admin hasn\'t set up LibreSign yet, please wait.')); + throw new FooterStampUnavailableException($this->l10n->t('The admin hasn\'t set up LibreSign yet, please wait.')); } $cmd = sprintf('%s -jar %s', escapeshellcmd($this->javaPath), escapeshellarg($this->pdftkPath)); diff --git a/lib/Handler/SignEngine/JSignPdfHandler.php b/lib/Handler/SignEngine/JSignPdfHandler.php index d699ce192c..c4831046ab 100644 --- a/lib/Handler/SignEngine/JSignPdfHandler.php +++ b/lib/Handler/SignEngine/JSignPdfHandler.php @@ -16,6 +16,10 @@ use OCA\Libresign\Helper\JavaHelper; use OCA\Libresign\Service\DocMdp\ConfigService as DocMdpConfigService; use OCA\Libresign\Service\Install\InstallService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\SignatureHashAlgorithm\SignatureHashAlgorithmPolicy; +use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicy; +use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicyValue; use OCA\Libresign\Service\SignatureBackgroundService; use OCA\Libresign\Service\SignatureTextService; use OCA\Libresign\Service\SignerElementsService; @@ -48,6 +52,7 @@ public function __construct( private SignatureTextService $signatureTextService, private ITempManager $tempManager, private SignatureBackgroundService $signatureBackgroundService, + private PolicyService $policyService, protected CertificateEngineFactory $certificateEngineFactory, protected JavaHelper $javaHelper, private DocMdpConfigService $docMdpConfigService, @@ -153,7 +158,7 @@ private function createEmptyFile(string $path): void { } private function getHashAlgorithm(string $pdfContent): string { - $configuredAlgorithm = $this->appConfig->getValueString(Application::APP_ID, 'signature_hash_algorithm', 'SHA256'); + $configuredAlgorithm = (string)$this->policyService->resolve(SignatureHashAlgorithmPolicy::KEY)->getEffectiveValue(); /** * Need to respect the follow code: * https://github.com/intoolswetrust/jsignpdf/blob/JSignPdf_2_2_2/jsignpdf/src/main/java/net/sf/jsignpdf/types/HashAlgorithm.java#L46-L47 @@ -226,7 +231,7 @@ private function requiresPdfVersionUpgradeForSha256(float $version): bool { if ($version >= self::MIN_PDF_VERSION_SHA256) { return false; } - $hashAlgorithm = $this->appConfig->getValueString(Application::APP_ID, 'signature_hash_algorithm', 'SHA256'); + $hashAlgorithm = (string)$this->policyService->resolve(SignatureHashAlgorithmPolicy::KEY)->getEffectiveValue(); return $hashAlgorithm === 'SHA256'; } @@ -641,23 +646,24 @@ private function listParamsToString(array $params): string { } private function getTsaParameters(): array { - $tsaUrl = $this->appConfig->getValueString(Application::APP_ID, 'tsa_url', ''); + $tsaSettings = $this->getTsaSettings(); + $tsaUrl = $tsaSettings['url']; if (empty($tsaUrl)) { return []; } $params = [ '--tsa-server-url' => $tsaUrl, - '--tsa-policy-oid' => $this->appConfig->getValueString(Application::APP_ID, 'tsa_policy_oid', ''), + '--tsa-policy-oid' => $tsaSettings['policy_oid'], ]; if (!$params['--tsa-policy-oid']) { unset($params['--tsa-policy-oid']); } - $tsaAuthType = $this->appConfig->getValueString(Application::APP_ID, 'tsa_auth_type', 'none'); + $tsaAuthType = $tsaSettings['auth_type']; if ($tsaAuthType === 'basic') { - $tsaUsername = $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', ''); + $tsaUsername = $tsaSettings['username']; $tsaPassword = $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', ''); if (!empty($tsaUsername) && !empty($tsaPassword)) { @@ -670,6 +676,15 @@ private function getTsaParameters(): array { return $params; } + /** + * @return array{url: string, policy_oid: string, auth_type: string, username: string} + */ + private function getTsaSettings(): array { + $resolved = $this->policyService->resolve(TsaPolicy::KEY)->getEffectiveValue(); + $settings = TsaPolicyValue::decode($resolved); + return $settings; + } + private function signWrapper(JSignPDF $jSignPDF): string { try { return $jSignPDF->sign(); diff --git a/lib/Handler/SignEngine/PhpNativeHandler.php b/lib/Handler/SignEngine/PhpNativeHandler.php index 488559131a..10e322fd59 100644 --- a/lib/Handler/SignEngine/PhpNativeHandler.php +++ b/lib/Handler/SignEngine/PhpNativeHandler.php @@ -11,6 +11,9 @@ use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Service\DocMdp\ConfigService as DocMdpConfigService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicy; +use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicyValue; use OCA\Libresign\Service\SignatureBackgroundService; use OCA\Libresign\Service\SignatureTextService; use OCA\Libresign\Service\SignerElementsService; @@ -36,6 +39,7 @@ public function __construct( private DocMdpConfigService $docMdpConfigService, private SignatureTextService $signatureTextService, private SignatureBackgroundService $signatureBackgroundService, + private PolicyService $policyService, protected CertificateEngineFactory $certificateEngineFactory, ) { } @@ -211,16 +215,17 @@ private function resolvePageHeight(array $pageDimensions, int $pageIndex): float } private function buildTimestampOptions(): ?TimestampOptionsDto { - $tsaUrl = $this->appConfig->getValueString(Application::APP_ID, 'tsa_url', ''); + $tsaSettings = $this->getTsaSettings(); + $tsaUrl = $tsaSettings['url']; if (empty($tsaUrl)) { return null; } $username = null; $password = null; - $authType = $this->appConfig->getValueString(Application::APP_ID, 'tsa_auth_type', 'none'); + $authType = $tsaSettings['auth_type']; if ($authType === 'basic') { - $username = $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', '') ?: null; + $username = $tsaSettings['username'] ?: null; $password = $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', '') ?: null; } @@ -231,6 +236,15 @@ private function buildTimestampOptions(): ?TimestampOptionsDto { ); } + /** + * @return array{url: string, policy_oid: string, auth_type: string, username: string} + */ + private function getTsaSettings(): array { + $resolved = $this->policyService->resolve(TsaPolicy::KEY)->getEffectiveValue(); + $settings = TsaPolicyValue::decode($resolved); + return $settings; + } + private function resolveCertificationLevel(bool $noVisibleElements): ?CertificationLevel { if (!$this->docMdpConfigService->isEnabled()) { return null; diff --git a/lib/Handler/SigningErrorHandler.php b/lib/Handler/SigningErrorHandler.php index f3cd539c52..c6a6383f35 100644 --- a/lib/Handler/SigningErrorHandler.php +++ b/lib/Handler/SigningErrorHandler.php @@ -60,7 +60,7 @@ private function handleGenericException(\Throwable $exception): array { return [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => $this->isKnownError($message) - ? [['message' => $this->l10n->t($message)]] + ? [['message' => $this->translateKnownError($message)]] : $this->formatUnknownError($message, $exception), ]; } @@ -73,6 +73,15 @@ private function isKnownError(string $message): bool { ], true); } + private function translateKnownError(string $message): string { + return match ($message) { + 'Host violates local access rules.' => $this->l10n->t('Host violates local access rules.'), + 'Certificate Password Invalid.' => $this->l10n->t('Certificate Password Invalid.'), + 'Certificate Password is Empty.' => $this->l10n->t('Certificate Password is Empty.'), + default => $message, + }; + } + /** * @return list */ diff --git a/lib/Helper/ValidateHelper.php b/lib/Helper/ValidateHelper.php index c58327cd1d..cf8ee27222 100644 --- a/lib/Helper/ValidateHelper.php +++ b/lib/Helper/ValidateHelper.php @@ -11,14 +11,12 @@ use InvalidArgumentException; use OC\AppFramework\Http; use OC\User\NoUserException; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\File; use OCA\Libresign\Db\FileElement; use OCA\Libresign\Db\FileElementMapper; use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Db\FileTypeMapper; use OCA\Libresign\Db\IdDocsMapper; -use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Db\SignRequest; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Db\UserElementMapper; @@ -26,8 +24,11 @@ use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\DocMdp\Validator as DocMdpValidator; use OCA\Libresign\Service\FileService; +use OCA\Libresign\Service\IdDocsPolicyService; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; +use OCA\Libresign\Service\IdentifyMethod\RuntimeRequirementValidator; use OCA\Libresign\Service\IdentifyMethodService; +use OCA\Libresign\Service\Policy\RequestSignAuthorizationService; use OCA\Libresign\Service\SequentialSigningService; use OCA\Libresign\Service\SignerElementsService; use OCP\AppFramework\Db\DoesNotExistException; @@ -35,12 +36,9 @@ use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; -use OCP\IAppConfig; -use OCP\IGroupManager; use OCP\IL10N; use OCP\IUser; use OCP\IUserManager; -use OCP\Security\IHasher; class ValidateHelper { /** @var \OCP\Files\File[] */ @@ -63,17 +61,16 @@ public function __construct( private FileElementMapper $fileElementMapper, private IdDocsMapper $idDocsMapper, private UserElementMapper $userElementMapper, - private IdentifyMethodMapper $identifyMethodMapper, private IdentifyMethodService $identifyMethodService, private SequentialSigningService $sequentialSigningService, private SignerElementsService $signerElementsService, private IMimeTypeDetector $mimeTypeDetector, - private IHasher $hasher, - private IAppConfig $appConfig, - private IGroupManager $groupManager, + private IdDocsPolicyService $idDocsPolicyService, private IUserManager $userManager, private IRootFolder $root, private DocMdpValidator $docMdpValidator, + private RequestSignAuthorizationService $requestSignAuthorizationService, + private RuntimeRequirementValidator $runtimeRequirementValidator, ) { } @@ -407,7 +404,6 @@ public function validateAuthenticatedUserIsOwnerOfPdfVisibleElement(int $documen throw new LibresignException($this->l10n->t('Field %s does not belong to user', (string)$documentElementId)); } } catch (\Throwable) { - ($signRequest->getFileId()); throw new LibresignException($this->l10n->t('Field %s does not belong to user', (string)$documentElementId)); } } @@ -505,21 +501,7 @@ private function getLibreSignFileByNodeId(int $nodeId): ?\OCP\Files\File { } public function canRequestSign(IUser $user): void { - $authorized = $this->appConfig->getValueArray(Application::APP_ID, 'groups_request_sign', ['admin']); - if (empty($authorized)) { - $authorized = ['admin']; - } - if (!is_array($authorized)) { - throw new LibresignException( - json_encode([ - 'action' => JSActions::ACTION_DO_NOTHING, - 'errors' => [['message' => $this->l10n->t('You are not allowed to request signing')]], - ]), - Http::STATUS_UNPROCESSABLE_ENTITY, - ); - } - $userGroups = $this->groupManager->getUserGroupIds($user); - if (!array_intersect($userGroups, $authorized)) { + if (!$this->requestSignAuthorizationService->canRequestSign($user)) { throw new LibresignException( json_encode([ 'action' => JSActions::ACTION_DO_NOTHING, @@ -879,6 +861,7 @@ public function validateCredentials(SignRequest $signRequest, string $identifyMe $identifyMethod = $this->resolveIdentifyMethod($signRequest, $identifyMethodName, $identifyValue); $identifyMethod->setCodeSentByUser($token); $identifyMethod->validateToSign(); + $this->runtimeRequirementValidator->validate($signRequest); } private function resolveIdentifyMethod(SignRequest $signRequest, string $methodName, ?string $identifyValue): IIdentifyMethod { @@ -950,7 +933,7 @@ private function getFirstAvailableMethod(array $methods): IIdentifyMethod { } public function validateIfIdentifyMethodExists(string $identifyMethod): void { - if (!in_array($identifyMethod, IdentifyMethodService::IDENTIFY_METHODS)) { + if (!$this->identifyMethodService->exists($identifyMethod)) { // TRANSLATORS When is requested to a person to sign a file, is // necessary identify what is the identification method. The // identification method is used to define how will be the sign @@ -967,22 +950,7 @@ public function validateFileTypeExists(string $type): void { } public function userCanApproveValidationDocuments(?IUser $user, bool $throw = true): bool { - if ($user == null) { - return false; - } - - $authorized = $this->appConfig->getValueArray(Application::APP_ID, 'approval_group', ['admin']); - if (!$authorized || !is_array($authorized) || empty($authorized)) { - $authorized = ['admin']; - } - $userGroups = $this->groupManager->getUserGroupIds($user); - if (!array_intersect($userGroups, $authorized)) { - if ($throw) { - throw new LibresignException($this->l10n->t('You are not allowed to approve user profile documents.')); - } - return false; - } - return true; + return $this->idDocsPolicyService->userCanApproveValidationDocuments($user, $throw); } private function validateDocMdpPdfRestrictions(array $data): void { diff --git a/lib/Middleware/InjectionMiddleware.php b/lib/Middleware/InjectionMiddleware.php index 1f0d9b58e9..5b3f342d8b 100644 --- a/lib/Middleware/InjectionMiddleware.php +++ b/lib/Middleware/InjectionMiddleware.php @@ -29,6 +29,8 @@ use OCA\Libresign\Middleware\Attribute\RequireSignerUuid; use OCA\Libresign\Middleware\Attribute\RequireSignRequestUuid; use OCA\Libresign\Service\FileAccessService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\ValidationAccess\ValidationAccessPolicy; use OCA\Libresign\Service\SignFileService; use OCA\Libresign\Service\UuidResolverService; use OCP\AppFramework\Controller; @@ -41,7 +43,6 @@ use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Middleware; use OCP\AppFramework\Services\IInitialState; -use OCP\IAppConfig; use OCP\IL10N; use OCP\IRequest; use OCP\ISession; @@ -63,8 +64,8 @@ public function __construct( private FileAccessService $fileAccessService, private SignFileService $signFileService, private UuidResolverService $uuidResolverService, + private PolicyService $policyService, private IL10N $l10n, - private IAppConfig $appConfig, private IURLGenerator $urlGenerator, protected ?string $userId, ) { @@ -114,7 +115,9 @@ private function privateValidation(\ReflectionMethod $reflectionMethod): void { if ($this->userSession->isLoggedIn()) { return; } - $isValidationUrlPrivate = (bool)$this->appConfig->getValueBool(Application::APP_ID, 'make_validation_url_private', false); + $isValidationUrlPrivate = $this->policyService + ->resolve(ValidationAccessPolicy::KEY) + ->getEffectiveValueAsBool(); if (!$isValidationUrlPrivate) { return; } 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..8557673dba --- /dev/null +++ b/lib/Migration/Version18001Date20260320000000.php @@ -0,0 +1,566 @@ +migrateLegacyFooterSettings(); + $this->migrateCollectMetadataType(); + $this->migrateIdentificationDocumentsType(); + $this->migrateEnvelopeType(); + $this->migrateCrlValidationType(); + $this->migrateConfettiType(); + $this->migrateDocMdpLevelType(); + $this->migrateGroupsRequestSignType(); + $this->migrateSignatureFlowSettings(); + $this->migrateSignatureTextSettingsType(); + $this->migrateReminderSettings(); + $this->migrateExpirationRulesType(); + $this->migrateIdentifyMethodsType(); + $this->migrateTsaSettings(); + } + + private function migrateTsaSettings(): void { + $existingConsolidated = $this->readLegacyString(TsaPolicy::SYSTEM_APP_CONFIG_KEY); + if ($existingConsolidated !== null && trim($existingConsolidated) !== '') { + $this->deleteLegacyTsaNonSensitiveKeys(); + return; + } + + $tsaUrl = $this->readLegacyString('tsa_url'); + $tsaPolicyOid = $this->readLegacyString('tsa_policy_oid'); + $tsaAuthType = $this->readLegacyString('tsa_auth_type'); + $tsaUsername = $this->readLegacyString('tsa_username'); + + if ($tsaUrl === null && $tsaPolicyOid === null && $tsaAuthType === null && $tsaUsername === null) { + return; + } + + $encoded = TsaPolicyValue::encode([ + 'url' => $tsaUrl ?? '', + 'policy_oid' => $tsaPolicyOid ?? '', + 'auth_type' => $tsaAuthType ?? 'none', + 'username' => $tsaUsername ?? '', + ]); + + $this->deleteLegacyTsaNonSensitiveKeys(); + $this->appConfig->setValueString(Application::APP_ID, TsaPolicy::SYSTEM_APP_CONFIG_KEY, $encoded); + } + + private function deleteLegacyTsaNonSensitiveKeys(): void { + $this->appConfig->deleteKey(Application::APP_ID, 'tsa_url'); + $this->appConfig->deleteKey(Application::APP_ID, 'tsa_policy_oid'); + $this->appConfig->deleteKey(Application::APP_ID, 'tsa_auth_type'); + $this->appConfig->deleteKey(Application::APP_ID, 'tsa_username'); + } + + private function migrateExpirationRulesType(): void { + $this->migrateIntType(ExpirationRulesPolicy::KEY_MAXIMUM_VALIDITY, ExpirationRulesPolicy::DEFAULT_MAXIMUM_VALIDITY, false); + $this->migrateIntType(ExpirationRulesPolicy::KEY_RENEWAL_INTERVAL, ExpirationRulesPolicy::DEFAULT_RENEWAL_INTERVAL, false); + $this->migrateIntType(ExpirationRulesPolicy::KEY_EXPIRY_IN_DAYS, ExpirationRulesPolicy::DEFAULT_EXPIRY_IN_DAYS, true); + } + + private function migrateIntType(string $key, int $default, bool $enforcePositive): void { + $legacyValue = $this->readLegacyString($key); + if ($legacyValue === null || trim($legacyValue) === '' || !is_numeric($legacyValue)) { + return; + } + + $parsed = (int)$legacyValue; + $normalized = $enforcePositive + ? ($parsed > 0 ? $parsed : $default) + : max(0, $parsed); + + $this->appConfig->deleteKey(Application::APP_ID, $key); + $this->appConfig->setValueInt(Application::APP_ID, $key, $normalized); + } + + private function migrateReminderSettings(): void { + $existingConsolidated = $this->readLegacyString(ReminderPolicy::SYSTEM_APP_CONFIG_KEY); + if ($existingConsolidated !== null && trim($existingConsolidated) !== '') { + $this->deleteLegacyReminderKeys(); + return; + } + + $daysBefore = $this->readLegacyString('reminder_days_before'); + $daysBetween = $this->readLegacyString('reminder_days_between'); + $max = $this->readLegacyString('reminder_max'); + $sendTimer = $this->readLegacyString('reminder_send_timer'); + + if ($daysBefore === null && $daysBetween === null && $max === null && $sendTimer === null) { + return; + } + + $encoded = ReminderPolicyValue::encode([ + 'days_before' => $daysBefore, + 'days_between' => $daysBetween, + 'max' => $max, + 'send_timer' => $sendTimer, + ]); + + $this->deleteLegacyReminderKeys(); + $this->appConfig->setValueString(Application::APP_ID, ReminderPolicy::SYSTEM_APP_CONFIG_KEY, $encoded); + } + + private function deleteLegacyReminderKeys(): void { + $this->appConfig->deleteKey(Application::APP_ID, 'reminder_days_before'); + $this->appConfig->deleteKey(Application::APP_ID, 'reminder_days_between'); + $this->appConfig->deleteKey(Application::APP_ID, 'reminder_max'); + $this->appConfig->deleteKey(Application::APP_ID, 'reminder_send_timer'); + } + + private function migrateCollectMetadataType(): void { + $this->migrateBoolType(CollectMetadataPolicy::SYSTEM_APP_CONFIG_KEY, false); + } + + private function migrateIdentificationDocumentsType(): void { + /** + * Consolidate legacy identification_documents (bool) and approval_group (array) + * into unified payload {enabled: bool, approvers: string[]} + */ + $existingConsolidated = $this->readLegacyString(IdentificationDocumentsPolicy::SYSTEM_APP_CONFIG_KEY); + + // Try to parse existing consolidated value + if ($existingConsolidated !== null && trim($existingConsolidated) !== '') { + $decoded = json_decode($existingConsolidated, true); + if (is_array($decoded) && isset($decoded['enabled'], $decoded['approvers'])) { + // Already consolidated, just clean up legacy approval_group + $this->appConfig->deleteKey(Application::APP_ID, 'approval_group'); + return; + } + } + + // Read legacy values + $legacyIdDocs = $this->readLegacyBool(IdentificationDocumentsPolicy::SYSTEM_APP_CONFIG_KEY, false); + $legacyApprovalGroup = $this->readLegacyApprovalGroup(); + + // Build unified payload + $consolidatedValue = [ + 'enabled' => $legacyIdDocs, + 'approvers' => !empty($legacyApprovalGroup) ? $legacyApprovalGroup : ['admin'], + ]; + + // Save unified payload + $this->appConfig->setValueArray( + Application::APP_ID, + IdentificationDocumentsPolicy::SYSTEM_APP_CONFIG_KEY, + $consolidatedValue + ); + + // Clean up legacy approval_group + $this->appConfig->deleteKey(Application::APP_ID, 'approval_group'); + } + + private function readLegacyApprovalGroup(): array { + try { + $rawValue = $this->appConfig->getValueString(Application::APP_ID, 'approval_group', ''); + if ($rawValue === '' || $rawValue === '[]') { + return []; + } + + $decoded = json_decode($rawValue, true); + if (is_array($decoded)) { + return array_filter( + array_map('strval', $decoded), + static fn (string $v): bool => $v !== '' + ) ?: []; + } + + return []; + } catch (AppConfigTypeConflictException) { + // Try as array directly + return $this->appConfig->getValueArray(Application::APP_ID, 'approval_group', []); + } + } + + private function migrateEnvelopeType(): void { + $this->migrateBoolType(EnvelopePolicy::SYSTEM_APP_CONFIG_KEY, true); + } + + private function migrateCrlValidationType(): void { + $this->migrateBoolType(CrlValidationPolicy::SYSTEM_APP_CONFIG_KEY, true); + } + + private function migrateConfettiType(): void { + $this->migrateBoolType(ConfettiPolicy::SYSTEM_APP_CONFIG_KEY, true); + } + + private function migrateBoolType(string $key, bool $default): void { + $legacyValue = $this->readLegacyString($key); + if ($legacyValue === null || trim($legacyValue) === '') { + return; + } + + $normalized = filter_var($legacyValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($normalized === null) { + return; + } + + $this->appConfig->deleteKey(Application::APP_ID, $key); + $this->appConfig->setValueBool(Application::APP_ID, $key, $normalized ?? $default); + } + + private function migrateSignatureFlowSettings(): void { + $currentSystemValue = $this->readLegacyString(SignatureFlowPolicy::SYSTEM_APP_CONFIG_KEY); + if ($currentSystemValue !== null && trim($currentSystemValue) !== '') { + $normalizedSystemValue = $this->normalizeSignatureFlowValue($currentSystemValue); + if ($normalizedSystemValue !== $currentSystemValue) { + $this->appConfig->deleteKey(Application::APP_ID, SignatureFlowPolicy::SYSTEM_APP_CONFIG_KEY); + $this->appConfig->setValueString(Application::APP_ID, SignatureFlowPolicy::SYSTEM_APP_CONFIG_KEY, $normalizedSystemValue); + } + + return; + } + + $legacyValue = $this->readLegacyString(SignatureFlowPolicy::KEY); + if ($legacyValue === null || trim($legacyValue) === '') { + return; + } + + $this->appConfig->setValueString( + Application::APP_ID, + SignatureFlowPolicy::SYSTEM_APP_CONFIG_KEY, + $this->normalizeSignatureFlowValue($legacyValue), + ); + $this->appConfig->deleteKey(Application::APP_ID, SignatureFlowPolicy::KEY); + } + + private function normalizeSignatureFlowValue(string $value): string { + $normalized = strtolower(trim($value)); + + return match ($normalized) { + SignatureFlow::NONE->value, + '0' => SignatureFlow::NONE->value, + SignatureFlow::PARALLEL->value, + '1' => SignatureFlow::PARALLEL->value, + SignatureFlow::ORDERED_NUMERIC->value, + '2' => SignatureFlow::ORDERED_NUMERIC->value, + default => SignatureFlow::NONE->value, + }; + } + + private function migrateSignatureTextSettingsType(): void { + // First, consolidate individual keys into a JSON payload + $consolidatedValue = [ + 'template' => $this->readLegacyString(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_TEMPLATE) ?? '', + 'template_font_size' => (float)($this->readLegacyString(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_TEMPLATE_FONT_SIZE) ?? 9.0), + 'signature_font_size' => (float)($this->readLegacyString(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_SIGNATURE_FONT_SIZE) ?? 9.0), + 'signature_width' => (float)($this->readLegacyString(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_SIGNATURE_WIDTH) ?? 90.0), + 'signature_height' => (float)($this->readLegacyString(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_SIGNATURE_HEIGHT) ?? 60.0), + 'render_mode' => $this->readLegacyString(SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_RENDER_MODE) ?? 'default', + ]; + + // Normalize and encode the consolidated value + $encodedValue = $this->encodeSignatureTextPolicyValue($consolidatedValue); + + // Check if there's an existing consolidated value + $existingValue = $this->appConfig->getValueString( + Application::APP_ID, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY, + '', + ); + + // Only update if we have legacy values or no existing consolidated value + if (!empty($existingValue) && $existingValue !== '') { + // Already consolidated, just clean up legacy keys + $this->deleteLegacySignatureTextKeys(); + return; + } + + // Delete all individual legacy keys + $this->deleteLegacySignatureTextKeys(); + + // Save the consolidated JSON value + $this->appConfig->setValueString( + Application::APP_ID, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY, + $encodedValue, + ); + } + + private function deleteLegacySignatureTextKeys(): void { + $legacyKeys = [ + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_TEMPLATE, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_TEMPLATE_FONT_SIZE, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_SIGNATURE_WIDTH, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_SIGNATURE_HEIGHT, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_SIGNATURE_FONT_SIZE, + SignatureTextPolicy::SYSTEM_APP_CONFIG_KEY_RENDER_MODE, + ]; + + foreach ($legacyKeys as $key) { + $this->appConfig->deleteKey(Application::APP_ID, $key); + } + } + + /** + * @param array $rawValue + */ + private function encodeSignatureTextPolicyValue(array $rawValue): string { + $renderMode = strtolower(trim((string)($rawValue['render_mode'] ?? 'default'))); + if (!in_array($renderMode, ['default', 'graphic', 'text'], true)) { + $renderMode = 'default'; + } + + $normalized = [ + 'template' => (string)($rawValue['template'] ?? ''), + 'template_font_size' => max(0.1, (float)($rawValue['template_font_size'] ?? 9.0)), + 'signature_font_size' => max(0.1, (float)($rawValue['signature_font_size'] ?? 9.0)), + 'signature_width' => max(0.1, (float)($rawValue['signature_width'] ?? 90.0)), + 'signature_height' => max(0.1, (float)($rawValue['signature_height'] ?? 60.0)), + 'render_mode' => $renderMode, + ]; + + return json_encode($normalized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + private function migrateGroupsRequestSignType(): void { + $legacyValue = $this->readLegacyString(RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY); + if ($legacyValue !== null) { + if ($legacyValue === '') { + return; + } + + $this->appConfig->deleteKey(Application::APP_ID, RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY); + $this->appConfig->setValueString( + Application::APP_ID, + RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY, + RequestSignGroupsPolicyValue::encode($legacyValue), + ); + return; + } + + $typedValue = $this->appConfig->getValueArray( + Application::APP_ID, + RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY, + RequestSignGroupsPolicyValue::DEFAULT_GROUPS, + ); + + $this->appConfig->deleteKey(Application::APP_ID, RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY); + $this->appConfig->setValueString( + Application::APP_ID, + RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY, + RequestSignGroupsPolicyValue::encode($typedValue), + ); + } + + private function migrateLegacyFooterSettings(): void { + $legacyAddFooter = $this->readLegacyValue(FooterPolicy::KEY); + $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 = FooterPolicyValue::encode( + FooterPolicyValue::normalize($rawFooterPolicyValue), + ); + + $this->appConfig->deleteKey(Application::APP_ID, FooterPolicy::KEY); + $this->appConfig->setValueString(Application::APP_ID, FooterPolicy::KEY, $encodedFooterPolicyValue); + } + + private function migrateDocMdpLevelType(): void { + $legacyValue = $this->readLegacyString(DocMdpPolicy::SYSTEM_APP_CONFIG_KEY); + if ($legacyValue === null || $legacyValue === '' || !is_numeric($legacyValue)) { + return; + } + + $this->appConfig->deleteKey(Application::APP_ID, DocMdpPolicy::SYSTEM_APP_CONFIG_KEY); + $this->appConfig->setValueInt(Application::APP_ID, DocMdpPolicy::SYSTEM_APP_CONFIG_KEY, (int)$legacyValue); + } + + private function migrateIdentifyMethodsType(): void { + $legacyValue = $this->readLegacyString('identify_methods'); + if ($legacyValue === null || $legacyValue === '') { + return; + } + + $normalized = $this->normalizeIdentifyMethodsLegacyPayload($legacyValue); + if ($normalized === null) { + return; + } + + $this->appConfig->deleteKey(Application::APP_ID, 'identify_methods'); + $this->appConfig->setValueArray(Application::APP_ID, 'identify_methods', $normalized); + } + + /** + * @return array|null + */ + private function normalizeIdentifyMethodsLegacyPayload(mixed $rawValue): ?array { + $decoded = $this->decodeIdentifyMethodsLegacyPayload($rawValue); + if ($decoded === null) { + return null; + } + + $prepared = $this->normalizeLegacyIdentifyMethodsSignatureMethodEnabled($decoded); + + return IdentifyMethodsPolicyValue::normalize($prepared); + } + + private function normalizeLegacyIdentifyMethodsSignatureMethodEnabled(mixed $payload): mixed { + if (!is_array($payload)) { + return $payload; + } + + if (array_is_list($payload)) { + $normalized = []; + foreach ($payload as $entry) { + $normalized[] = $this->normalizeLegacyIdentifyMethodsSignatureMethodEnabled($entry); + } + return $normalized; + } + + if (isset($payload['signatureMethodEnabled']) && is_array($payload['signatureMethodEnabled'])) { + $payload['signatureMethodEnabled'] = $this->normalizeLegacySignatureMethodEnabled($payload['signatureMethodEnabled']); + } + + if (isset($payload['factors']) && is_array($payload['factors'])) { + $payload['factors'] = $this->normalizeLegacyIdentifyMethodsSignatureMethodEnabled($payload['factors']); + } + + return $payload; + } + + /** + * @return array|null + */ + private function decodeIdentifyMethodsLegacyPayload(mixed $rawValue): ?array { + if (is_string($rawValue)) { + $decoded = json_decode($rawValue, true); + return is_array($decoded) ? $decoded : null; + } + + return is_array($rawValue) ? $rawValue : null; + } + + private function normalizeLegacySignatureMethodEnabled(array $value): ?string { + foreach ($value as $signatureMethodName) { + if (is_string($signatureMethodName) && trim($signatureMethodName) !== '') { + return $signatureMethodName; + } + } + + return null; + } + + private function readLegacyString(string $key): ?string { + try { + return $this->appConfig->getValueString(Application::APP_ID, $key, ''); + } catch (AppConfigTypeConflictException) { + // The key is already stored in the target typed format + return null; + } + } + + private function readLegacyValue(string $key): mixed { + try { + return $this->appConfig->getValueString(Application::APP_ID, $key, ''); + } catch (AppConfigTypeConflictException) { + return $this->appConfig->getValueBool(Application::APP_ID, $key, true); + } + } + + private function readLegacyBool(string $key, bool $default): bool { + try { + $rawValue = $this->appConfig->getValueString(Application::APP_ID, $key, ''); + if ($rawValue === '') { + return $default; + } + + return in_array(strtolower(trim($rawValue)), ['1', 'true', 'yes', 'on'], true); + } catch (AppConfigTypeConflictException) { + return $this->appConfig->getValueBool(Application::APP_ID, $key, $default); + } + } + + 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 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 91fb39436a..bf1dbffd0d 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -28,6 +28,12 @@ * needIdentificationDocuments: bool, * identificationDocumentsWaitingApproval: bool, * } + * @psalm-type LibresignAccountCapabilitySettings = array{ + * canRequestSign: bool, + * hasSignatureFile: bool, + * isApprover: bool, + * } + * @psalm-type LibresignIdentifyMethodRequirement = 'required'|'optional' * * Request input contracts * @@ -45,7 +51,7 @@ * identifyMethods: list, * displayName?: string, * description?: string, @@ -70,7 +76,7 @@ * @psalm-type LibresignIdentifyMethod = array{ * method: 'account'|'email'|'signal'|'sms'|'telegram'|'whatsapp'|'xmpp', * value: string, - * mandatory: non-negative-int, + * requirement: LibresignIdentifyMethodRequirement, * } * @psalm-type LibresignCoordinate = array{ * page?: int, @@ -117,7 +123,8 @@ * name: string, * friendly_name: string, * enabled: bool, - * mandatory: bool, + * requirement: LibresignIdentifyMethodRequirement, + * minimumTotalVerifiedFactors?: positive-int, * signatureMethods?: LibresignSignatureMethods, * } * @psalm-type LibresignIdentifyAccount = array{ @@ -141,8 +148,8 @@ * displayName: ?string, * } * @psalm-type LibresignDynamicMetadataScalar = string|int|float|bool|null - * @psalm-type LibresignDynamicMetadataRecord = array - * @psalm-type LibresignDynamicMetadataValue = LibresignDynamicMetadataScalar|list|LibresignDynamicMetadataRecord|list + * @psalm-type LibresignDynamicMetadataRecord = array + * @psalm-type LibresignDynamicMetadataValue = mixed * @psalm-type LibresignSignerCertificateInfo = array{ * serialNumber?: string, * serialNumberHex?: string, @@ -285,7 +292,7 @@ * } * @psalm-type LibresignRootCertificateName = array{ * id: string, - * value: string, + * value: string|list|null, * } * @psalm-type LibresignRootCertificate = array{ * commonName: string, @@ -356,6 +363,7 @@ * isDefault: bool, * preview_width: int, * preview_height: int, + * preview_zoom: int, * } * @psalm-type LibresignActiveSigningItem = array{ * id: int, @@ -371,11 +379,94 @@ * * Validation and progress contracts * + * @psalm-type LibresignEffectivePolicyValue = null|bool|int|float|string|array + * @psalm-type LibresignEffectivePolicyState = array{ + * policyKey: string, + * effectiveValue: LibresignEffectivePolicyValue, + * sourceScope: string, + * visible: bool, + * editableByCurrentActor: bool, + * allowedValues: list, + * canSaveAsUserDefault: bool, + * canUseAsRequestOverride: bool, + * preferenceWasCleared: bool, + * blockedBy: ?string, + * groupCount: non-negative-int, + * userCount: non-negative-int, + * } + * @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_policy', + * targetId: string, + * value: null|LibresignEffectivePolicyValue, + * allowChildOverride: bool, + * } + * @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 LibresignPolicySnapshotIdentifyMethodsEntry = array{ + * effectiveValue: list, + * sourceScope: string, + * } + * @psalm-type LibresignValidatePolicySnapshot = array{ + * docmdp?: LibresignPolicySnapshotNumericEntry, + * signature_flow?: LibresignPolicySnapshotEntry, + * add_footer?: LibresignPolicySnapshotEntry, + * identify_methods?: LibresignPolicySnapshotIdentifyMethodsEntry, + * } * @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 13f2974e55..579d4b6a6f 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -26,6 +26,8 @@ use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\Crl\CrlService; +use OCA\Libresign\Service\Policy\PolicyAuthorizationService; +use OCA\Libresign\Service\Policy\RequestSignAuthorizationService; use OCA\Settings\Mailer\NewUserMailHelper; use OCP\Accounts\IAccountManager; use OCP\AppFramework\Db\DoesNotExistException; @@ -73,6 +75,8 @@ public function __construct( private IURLGenerator $urlGenerator, private Pkcs12Handler $pkcs12Handler, private IGroupManager $groupManager, + private PolicyAuthorizationService $policyAuthorizationService, + private IdDocsPolicyService $idDocsPolicyService, private IdDocsService $idDocsService, private SignerElementsService $signerElementsService, private UserElementMapper $userElementMapper, @@ -81,6 +85,7 @@ public function __construct( private ITimeFactory $timeFactory, private FileUploadHelper $uploadHelper, private CrlService $crlService, + private RequestSignAuthorizationService $requestSignAuthorizationService, ) { } @@ -194,8 +199,7 @@ public function getCertificateEngineName(): string { * @return array */ public function getConfig(?IUser $user = null): array { - - $info['identificationDocumentsFlow'] = $this->appConfig->getValueBool(Application::APP_ID, 'identification_documents', false); + $info['identificationDocumentsFlow'] = $this->idDocsPolicyService->isIdentificationDocumentsEnabled($user); $info['hasSignatureFile'] = $this->hasSignatureFile($user); $info['phoneNumber'] = $this->getPhoneNumber($user); $info['isApprover'] = $this->validateHelper->userCanApproveValidationDocuments($user, false); @@ -207,8 +211,13 @@ 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['policy_workbench_catalog_collapsed'] = $this->getUserConfigByKey('policy_workbench_catalog_collapsed', $user) === '1'; + $info['policy_workbench_category_collapsed_state'] = $this->getUserConfigJsonByKey('policy_workbench_category_collapsed_state', $user); + $info['can_manage_group_policies'] = $this->policyAuthorizationService->canUserManageGroupPolicies($user); + $info['manageable_policy_group_ids'] = $this->policyAuthorizationService->getManageablePolicyGroupIds($user); - return array_filter($info); + return array_filter($info, static fn (mixed $value): bool => $value !== null && $value !== ''); } public function getConfigFilters(?IUser $user = null): array { @@ -268,6 +277,23 @@ private function getUserConfigByKey(string $key, ?IUser $user = null): string { return $this->userConfig->getValueString($user->getUID(), Application::APP_ID, $key); } + /** + * @return array|null + */ + private function getUserConfigJsonByKey(string $key, ?IUser $user = null): ?array { + if (!$user) { + return null; + } + + $value = $this->userConfig->getValueString($user->getUID(), Application::APP_ID, $key, ''); + if (empty($value)) { + return null; + } + + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : null; + } + private function getUserConfigIdDocsFilters(?IUser $user = null): array { if (!$user) { return []; @@ -358,18 +384,7 @@ public function getFileByNodeId(int $nodeId): File { } public function canRequestSign(?IUser $user = null): bool { - if (!$user) { - return false; - } - $authorized = $this->appConfig->getValueArray(Application::APP_ID, 'groups_request_sign', ['admin']); - if (empty($authorized)) { - return false; - } - $userGroups = $this->groupManager->getUserGroupIds($user); - if (!array_intersect($userGroups, $authorized)) { - return false; - } - return true; + return $this->requestSignAuthorizationService->canRequestSign($user); } public function getSettings(?IUser $user = null): array { diff --git a/lib/Service/Crl/CrlRevocationChecker.php b/lib/Service/Crl/CrlRevocationChecker.php index 81d5edd033..f583b29808 100644 --- a/lib/Service/Crl/CrlRevocationChecker.php +++ b/lib/Service/Crl/CrlRevocationChecker.php @@ -9,10 +9,10 @@ namespace OCA\Libresign\Service\Crl; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Enum\CrlValidationStatus; use OCA\Libresign\Service\Crl\Ldap\LdapCrlDownloader; -use OCP\IAppConfig; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\CrlValidation\CrlValidationPolicy; use OCP\ICache; use OCP\ICacheFactory; use OCP\IConfig; @@ -34,7 +34,7 @@ class CrlRevocationChecker { public function __construct( private IConfig $config, - private IAppConfig $appConfig, + private PolicyService $policyService, private IURLGenerator $urlGenerator, private ITempManager $tempManager, private LoggerInterface $logger, @@ -62,7 +62,7 @@ public function validate(array $crlUrls, string $certPem): array { * @return array{status: CrlValidationStatus, revoked_at?: string} */ private function validateFromUrlsWithDetails(array $crlUrls, string $certPem): array { - $externalValidationEnabled = $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true); + $externalValidationEnabled = $this->policyService->resolve(CrlValidationPolicy::KEY)->getEffectiveValueAsBool(true); if (empty($crlUrls)) { // When external validation is disabled, treat an empty distribution-point diff --git a/lib/Service/Crl/CrlUrlParserService.php b/lib/Service/Crl/CrlUrlParserService.php index 2da5e04edb..296e9aa853 100644 --- a/lib/Service/Crl/CrlUrlParserService.php +++ b/lib/Service/Crl/CrlUrlParserService.php @@ -18,28 +18,20 @@ public function __construct( } public function parseUrl(string $crlUrl): ?array { - $templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [ - 'instanceId' => 'INSTANCEID', - 'generation' => 999999, - 'engineType' => 'ENGINETYPE', - ]); - - $patternUrl = str_replace('INSTANCEID', '([a-z0-9]+)', $templateUrl); - $patternUrl = str_replace('999999', '(\d+)', $patternUrl); - $patternUrl = str_replace('ENGINETYPE', '([a-z])', $patternUrl); - $escapedPattern = str_replace([':', '/', '.'], ['\:', '\/', '\.'], $patternUrl); - $escapedPattern = str_replace('\/index\.php', '', $escapedPattern); - $escapedPattern = str_replace('\/apps\/', '(?:\/index\.php)?\/apps\/', $escapedPattern); - $pattern = '/^' . $escapedPattern . '$/i'; - - if (!preg_match($pattern, $crlUrl, $matches)) { + $path = parse_url($crlUrl, PHP_URL_PATH); + if (!is_string($path)) { + return null; + } + + $pattern = '#^/(?:index\.php/)?apps/libresign/crl/libresign_(?P[A-Za-z0-9]+)_(?P\d+)_(?P[a-z])\.crl$#'; + if (!preg_match($pattern, $path, $matches)) { return null; } return [ - 'instanceId' => $matches[1], - 'generation' => (int)$matches[2], - 'engineType' => $matches[3], + 'instanceId' => $matches['instanceId'], + 'generation' => (int)$matches['generation'], + 'engineType' => $matches['engineType'], ]; } diff --git a/lib/Service/DocMdp/ConfigService.php b/lib/Service/DocMdp/ConfigService.php index 39e0689d99..6865f990d8 100644 --- a/lib/Service/DocMdp/ConfigService.php +++ b/lib/Service/DocMdp/ConfigService.php @@ -9,9 +9,9 @@ namespace OCA\Libresign\Service\DocMdp; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Enum\DocMdpLevel; -use OCP\IAppConfig; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\DocMdp\DocMdpPolicy; use OCP\IL10N; /** @@ -19,10 +19,10 @@ * @psalm-import-type LibresignDocMdpLevelOption from \OCA\Libresign\ResponseDefinitions */ class ConfigService { - private const CONFIG_KEY_LEVEL = 'docmdp_level'; + private const DEFAULT_LEVEL = DocMdpLevel::NOT_CERTIFIED; public function __construct( - private IAppConfig $appConfig, + private PolicyService $policyService, private IL10N $l10n, ) { } @@ -43,12 +43,26 @@ 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; + $storedValue = $this->policyService->getSystemPolicy(DocMdpPolicy::KEY)?->getValue(); + + if ($storedValue instanceof DocMdpLevel) { + return $storedValue; + } + + if (is_string($storedValue) && preg_match('/^\d+$/', $storedValue) === 1) { + $storedValue = (int)$storedValue; + } + + if (is_int($storedValue)) { + return DocMdpLevel::tryFrom($storedValue) ?? self::DEFAULT_LEVEL; + } + + return self::DEFAULT_LEVEL; } public function setLevel(DocMdpLevel $level): void { - $this->appConfig->setValueInt(Application::APP_ID, self::CONFIG_KEY_LEVEL, $level->value); + $allowChildOverride = $this->policyService->getSystemPolicy(DocMdpPolicy::KEY)?->isAllowChildOverride() ?? false; + $this->policyService->saveSystem(DocMdpPolicy::KEY, $level->value, $allowChildOverride); } /** @return LibresignDocMdpConfig */ @@ -71,4 +85,5 @@ private function getAvailableLevels(): array { DocMdpLevel::cases() ); } + } diff --git a/lib/Service/Envelope/EnvelopeService.php b/lib/Service/Envelope/EnvelopeService.php index 72f24796ca..59c2a4830b 100644 --- a/lib/Service/Envelope/EnvelopeService.php +++ b/lib/Service/Envelope/EnvelopeService.php @@ -16,6 +16,8 @@ use OCA\Libresign\Enum\NodeType; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\FolderService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Envelope\EnvelopePolicy; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IAppConfig; use OCP\IL10N; @@ -25,13 +27,14 @@ class EnvelopeService { public function __construct( protected FileMapper $fileMapper, protected IL10N $l10n, + protected PolicyService $policyService, protected IAppConfig $appConfig, protected FolderService $folderService, ) { } public function isEnabled(): bool { - return $this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true); + return $this->policyService->resolve(EnvelopePolicy::KEY)->getEffectiveValueAsBool(true); } /** diff --git a/lib/Service/EnvelopeService.php b/lib/Service/EnvelopeService.php index 6248b24975..29e2f01ca9 100644 --- a/lib/Service/EnvelopeService.php +++ b/lib/Service/EnvelopeService.php @@ -15,6 +15,8 @@ use OCA\Libresign\Enum\FileStatus; use OCA\Libresign\Enum\NodeType; use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Envelope\EnvelopePolicy; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IAppConfig; use OCP\IL10N; @@ -24,13 +26,14 @@ class EnvelopeService { public function __construct( protected FileMapper $fileMapper, protected IL10N $l10n, + protected PolicyService $policyService, protected IAppConfig $appConfig, protected FolderService $folderService, ) { } public function isEnabled(): bool { - return $this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true); + return $this->policyService->resolve(EnvelopePolicy::KEY)->getEffectiveValueAsBool(true); } /** diff --git a/lib/Service/File/AccountSettingsProvider.php b/lib/Service/File/AccountSettingsProvider.php index b2397e615c..6bf8c318a2 100644 --- a/lib/Service/File/AccountSettingsProvider.php +++ b/lib/Service/File/AccountSettingsProvider.php @@ -9,28 +9,30 @@ namespace OCA\Libresign\Service\File; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; +use OCA\Libresign\Service\IdDocsPolicyService; use OCP\Accounts\IAccountManager; -use OCP\IAppConfig; -use OCP\IGroupManager; use OCP\IUser; +/** @psalm-import-type LibresignAccountCapabilitySettings from \OCA\Libresign\ResponseDefinitions */ class AccountSettingsProvider { public function __construct( private IAccountManager $accountManager, - private IAppConfig $appConfig, - private IGroupManager $groupManager, + private IdDocsPolicyService $idDocsPolicyService, private Pkcs12Handler $pkcs12Handler, ) { } + /** @psalm-return LibresignAccountCapabilitySettings */ public function getSettings(?IUser $user = null): array { - $return['canRequestSign'] = $this->canRequestSign($user); - $return['hasSignatureFile'] = $this->hasSignatureFile($user); - $return['isApprover'] = $this->isApprover($user); - return $return; + $canApproveIdDocs = $this->idDocsPolicyService->userCanApproveValidationDocuments($user, false); + + return [ + 'canRequestSign' => $canApproveIdDocs, + 'hasSignatureFile' => $this->hasSignatureFile($user), + 'isApprover' => $canApproveIdDocs, + ]; } public function getPhoneNumber(IUser $user): string { @@ -38,21 +40,6 @@ public function getPhoneNumber(IUser $user): string { return $userAccount->getProperty(IAccountManager::PROPERTY_PHONE)->getValue(); } - private function canRequestSign(?IUser $user = null): bool { - if (!$user) { - return false; - } - $authorized = $this->appConfig->getValueArray(Application::APP_ID, 'approval_group', ['admin']); - if (empty($authorized)) { - return false; - } - $userGroups = $this->groupManager->getUserGroupIds($user); - if (!array_intersect($userGroups, $authorized)) { - return false; - } - return true; - } - private function hasSignatureFile(?IUser $user = null): bool { if (!$user) { return false; @@ -64,16 +51,4 @@ private function hasSignatureFile(?IUser $user = null): bool { return false; } } - - private function isApprover(?IUser $user = null): bool { - if (!$user) { - return false; - } - $approvalGroups = $this->appConfig->getValueArray(Application::APP_ID, 'approval_group', ['admin']); - if (empty($approvalGroups)) { - return false; - } - $userGroups = $this->groupManager->getUserGroupIds($user); - return (bool)array_intersect($userGroups, $approvalGroups); - } } diff --git a/lib/Service/File/EnvelopeAssembler.php b/lib/Service/File/EnvelopeAssembler.php index cb6c304da3..ad9dcd6583 100644 --- a/lib/Service/File/EnvelopeAssembler.php +++ b/lib/Service/File/EnvelopeAssembler.php @@ -84,7 +84,7 @@ public function buildEnvelopeChildData(File $childFile, \OCA\Libresign\Service\F $identifyMethodsArray[] = [ 'method' => $entity->getIdentifierKey(), 'value' => $entity->getIdentifierValue(), - 'mandatory' => $entity->getMandatory(), + 'requirement' => $entity->getRequirement(), ]; $signerUid ??= $entity->getUniqueIdentifier(); } diff --git a/lib/Service/File/FileListService.php b/lib/Service/File/FileListService.php index 94977366db..58baf68927 100644 --- a/lib/Service/File/FileListService.php +++ b/lib/Service/File/FileListService.php @@ -16,6 +16,7 @@ use OCA\Libresign\Db\IdentifyMethod; use OCA\Libresign\Db\SignRequest; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Enum\IdentifyMethodRequirement; use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\ResponseDefinitions; use OCA\Libresign\Service\FileElementService; @@ -428,7 +429,7 @@ private function formatSignerData( 'identifyMethods' => array_map(fn (IdentifyMethod $identifyMethod): array => [ 'method' => $identifyMethod->getIdentifierKey(), 'value' => $identifyMethod->getIdentifierValue(), - 'mandatory' => $identifyMethod->getMandatory(), + 'requirement' => $identifyMethod->getRequirement(), ], array_values($identifyMethodsOfSigner)), ]; @@ -510,7 +511,7 @@ private function formatSignerDataBasic( 'identifyMethods' => array_map(fn (IdentifyMethod $identifyMethod): array => [ 'method' => $identifyMethod->getIdentifierKey(), 'value' => $identifyMethod->getIdentifierValue(), - 'mandatory' => $identifyMethod->getMandatory(), + 'requirement' => $identifyMethod->getRequirement(), ], array_values($identifyMethodsOfSigner)), ]; @@ -549,7 +550,7 @@ private function resolveSignerDisplayName(SignRequest $signer, array $identifyMe } foreach ($identifyMethodsOfSigner as $identifyMethod) { - if (!$identifyMethod->getMandatory()) { + if ($identifyMethod->getRequirement() !== IdentifyMethodRequirement::REQUIRED->value) { continue; } if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_ACCOUNT) { @@ -865,7 +866,7 @@ private function formatChildFilesResponse( return $carry; }, ''); $displayName = array_reduce($identifyMethodsOfSigner, function (string $carry, IdentifyMethod $identifyMethod): string { - if (!$carry && $identifyMethod->getMandatory()) { + if (!$carry && $identifyMethod->getRequirement() === IdentifyMethodRequirement::REQUIRED->value) { return $identifyMethod->getIdentifierValue(); } return $carry; @@ -879,7 +880,7 @@ private function formatChildFilesResponse( 'identifyMethods' => array_map(fn (IdentifyMethod $identifyMethod): array => [ 'method' => $identifyMethod->getIdentifierKey(), 'value' => $identifyMethod->getIdentifierValue(), - 'mandatory' => $identifyMethod->getMandatory(), + 'requirement' => $identifyMethod->getRequirement(), ], array_values($identifyMethodsOfSigner)), 'signed' => $signer->getSigned()?->format(\DateTimeInterface::ATOM), 'status' => $signer->getSigned() ? 1 : 0, @@ -910,7 +911,6 @@ private function getFileSize(File $file): int { if ($nodeId === null || $file->getUserId() === '') { return 0; } - try { $fileNode = $this->root->getUserFolder($file->getUserId())->getFirstNodeById($nodeId); if ($fileNode instanceof NodeFile && method_exists($fileNode, 'getSize')) { diff --git a/lib/Service/File/SettingsLoader.php b/lib/Service/File/SettingsLoader.php index 552bdb0cbc..bab43071fc 100644 --- a/lib/Service/File/SettingsLoader.php +++ b/lib/Service/File/SettingsLoader.php @@ -9,22 +9,15 @@ namespace OCA\Libresign\Service\File; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\File; use OCA\Libresign\Db\IdDocsMapper; use OCA\Libresign\Db\SignRequest; use OCA\Libresign\Enum\FileStatus; -use OCA\Libresign\ResponseDefinitions; use OCA\Libresign\Service\IdDocsPolicyService; use OCA\Libresign\Service\IdentifyMethodService; -use OCP\IAppConfig; -use OCP\IGroupManager; use OCP\IUser; -use stdClass; -/** - * @psalm-import-type LibresignSettings from ResponseDefinitions - */ +/** @psalm-import-type LibresignSettings from \OCA\Libresign\ResponseDefinitions */ class SettingsLoader { public const IDENTIFICATION_DOCUMENTS_DISABLED = 0; public const IDENTIFICATION_DOCUMENTS_NEED_SEND = 1; @@ -34,15 +27,13 @@ class SettingsLoader { public function __construct( private AccountSettingsProvider $accountSettingsProvider, private IdDocsPolicyService $idDocsPolicyService, - private IAppConfig $appConfig, - private IGroupManager $groupManager, private IdDocsMapper $idDocsMapper, private IdentifyMethodService $identifyMethodService, ) { } public function loadSettings( - stdClass $fileData, + \stdClass $fileData, FileResponseOptions $options, ): void { if (!$options->isShowSettings()) { @@ -69,7 +60,7 @@ public function loadSettings( } } - private function loadApproverSignatureMethods(stdClass $fileData): void { + private function loadApproverSignatureMethods(\stdClass $fileData): void { try { $idDocs = $this->idDocsMapper->getByFileId($fileData->id); $signRequestId = $idDocs->getSignRequestId(); @@ -84,16 +75,12 @@ private function loadApproverSignatureMethods(stdClass $fileData): void { } public function getIdentificationDocumentsStatus(?IUser $user = null, ?SignRequest $signRequest = null): int { - if (!$this->appConfig->getValueBool(Application::APP_ID, 'identification_documents', false)) { + if (!$this->idDocsPolicyService->isIdentificationDocumentsEnabled($user)) { return self::IDENTIFICATION_DOCUMENTS_DISABLED; } - $approvalGroups = $this->appConfig->getValueArray(Application::APP_ID, 'approval_group', ['admin']); - if ($user && !empty($approvalGroups) && is_array($approvalGroups)) { - $userGroups = $this->groupManager->getUserGroupIds($user); - if (array_intersect($userGroups, $approvalGroups)) { - return self::IDENTIFICATION_DOCUMENTS_APPROVED; - } + if ($user && $this->idDocsPolicyService->userCanApproveValidationDocuments($user, false)) { + return self::IDENTIFICATION_DOCUMENTS_APPROVED; } $files = $this->getIdDocFiles($user, $signRequest); @@ -101,6 +88,7 @@ public function getIdentificationDocumentsStatus(?IUser $user = null, ?SignReque return $this->calculateStatusFromFiles($files); } + /** @return array|null */ private function getIdDocFiles(?IUser $user, ?SignRequest $signRequest): ?array { if ($user) { return $this->idDocsMapper->getFilesOfAccount($user->getUID()); @@ -113,6 +101,7 @@ private function getIdDocFiles(?IUser $user, ?SignRequest $signRequest): ?array return null; } + /** @param array|null $files */ private function calculateStatusFromFiles(?array $files): int { if (empty($files)) { return self::IDENTIFICATION_DOCUMENTS_NEED_SEND; @@ -134,7 +123,8 @@ private function calculateStatusFromFiles(?array $files): int { /** * Get user identification documents settings * These are user-specific settings, not file-specific - * Always returns complete LibresignSettings with defaults + * Always returns complete settings payload with defaults. + * Canonical API shape is documented as LibresignSettings in ResponseDefinitions. * * @psalm-return LibresignSettings */ diff --git a/lib/Service/File/SignersLoader.php b/lib/Service/File/SignersLoader.php index caf935c374..8e36d2c214 100644 --- a/lib/Service/File/SignersLoader.php +++ b/lib/Service/File/SignersLoader.php @@ -98,7 +98,7 @@ public function loadLibreSignSigners( $fileData->signers[$index]->identifyMethods[] = [ 'method' => $entity->getIdentifierKey(), 'value' => $entity->getIdentifierValue(), - 'mandatory' => $entity->getMandatory(), + 'requirement' => $entity->getRequirement(), ]; switch ($type) { diff --git a/lib/Service/FolderService.php b/lib/Service/FolderService.php index 626193bdaf..a263fbe54d 100644 --- a/lib/Service/FolderService.php +++ b/lib/Service/FolderService.php @@ -69,14 +69,12 @@ public function getUserRootFolder(): Folder { public function getFolder(): Folder { $path = $this->getLibreSignDefaultPath(); $containerFolder = $this->getContainerFolder(); + try { - /** @var Folder $folder */ - $folder = $containerFolder->get($path); + return $this->ensureFolderPathExists($containerFolder, $path); } catch (NotFoundException) { - /** @var Folder $folder */ - $folder = $containerFolder->newFolder($path); + return $this->ensureFolderPathExists($this->getAppDataContainerFolder(), $path); } - return $folder; } /** @@ -108,17 +106,42 @@ public function getFileByNodeId(int $nodeId): File { protected function getContainerFolder(): Folder { if ($this->getUserId() && !$this->groupManager->isInGroup($this->getUserId(), 'guest_app')) { - $containerFolder = $this->root->getUserFolder($this->getUserId()); - if ($containerFolder->isUpdateable()) { - return $containerFolder; + try { + $containerFolder = $this->root->getUserFolder($this->getUserId()); + if ($containerFolder->isUpdateable()) { + return $containerFolder; + } + } catch (NotFoundException) { + // Users provisioned in tests may not have a home folder yet. } } + return $this->getAppDataContainerFolder(); + } + + private function getAppDataContainerFolder(): Folder { $containerFolder = $this->appData->getFolder('/'); $reflection = new \ReflectionClass($containerFolder); $reflectionProperty = $reflection->getProperty('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/FooterService.php b/lib/Service/FooterService.php index abbe7055d0..a7b9cab48f 100644 --- a/lib/Service/FooterService.php +++ b/lib/Service/FooterService.php @@ -8,46 +8,91 @@ namespace OCA\Libresign\Service; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Handler\FooterHandler; -use OCP\IAppConfig; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; class FooterService { public function __construct( - private IAppConfig $appConfig, + private PolicyService $policyService, private FooterHandler $footerHandler, ) { } public function isDefaultTemplate(): bool { - $customTemplate = $this->appConfig->getValueString(Application::APP_ID, 'footer_template', ''); - return empty($customTemplate); + $footerPolicy = $this->getEffectiveFooterPolicy(); + return !$footerPolicy['customizeFooterTemplate']; } public function getTemplate(): string { return $this->footerHandler->getTemplate(); } - public function saveTemplate(string $template = ''): void { + public function getDefaultTemplate(): string { + return $this->footerHandler->getDefaultTemplate(); + } + + /** @return array{preview_width: int, preview_height: int, preview_zoom: int} */ + public function getPreviewSettings(): array { + $footerPolicy = $this->getEffectiveFooterPolicy(); + + return [ + 'preview_width' => (int)$footerPolicy['previewWidth'], + 'preview_height' => (int)$footerPolicy['previewHeight'], + 'preview_zoom' => (int)$footerPolicy['previewZoom'], + ]; + } + + public function saveTemplate(string $template = '', ?int $previewWidth = null, ?int $previewHeight = null): void { + $defaultTemplate = $this->footerHandler->getDefaultTemplate(); + $currentPolicy = $this->getEffectiveFooterPolicy(); + $normalizedPolicy = FooterPolicyValue::normalize($currentPolicy); + + if ($previewWidth !== null) { + $normalizedPolicy['previewWidth'] = $previewWidth; + } + + if ($previewHeight !== null) { + $normalizedPolicy['previewHeight'] = $previewHeight; + } + if (empty($template)) { - $this->appConfig->deleteKey(Application::APP_ID, 'footer_template'); + $normalizedPolicy['customizeFooterTemplate'] = false; + $normalizedPolicy['footerTemplate'] = ''; + $this->saveSystemFooterPolicy($normalizedPolicy); return; } - if ($template === $this->footerHandler->getDefaultTemplate()) { - $this->appConfig->deleteKey(Application::APP_ID, 'footer_template'); + $isProvidedTemplateEqualsDefault = $template === $defaultTemplate; + + if ($isProvidedTemplateEqualsDefault) { + $normalizedPolicy['customizeFooterTemplate'] = false; + $normalizedPolicy['footerTemplate'] = ''; } else { - $this->appConfig->setValueString(Application::APP_ID, 'footer_template', $template); + $normalizedPolicy['customizeFooterTemplate'] = true; + $normalizedPolicy['footerTemplate'] = $template; } + + $this->saveSystemFooterPolicy($normalizedPolicy); } - public function renderPreviewPdf(string $template = '', int $width = 595, int $height = 50): string { - if (!empty($template)) { - $this->saveTemplate($template); - } + /** @param array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool, footerTemplate: string, previewWidth: int, previewHeight: int, previewZoom: int} $normalizedPolicy */ + private function saveSystemFooterPolicy(array $normalizedPolicy): void { + $allowChildOverride = $this->policyService->getSystemPolicy(FooterPolicy::KEY)?->isAllowChildOverride() ?? false; + $this->policyService->saveSystem( + FooterPolicy::KEY, + FooterPolicyValue::encode($normalizedPolicy), + $allowChildOverride, + ); + } - // Generate a realistic UUID format for preview (36 chars with hyphens, same as real UUIDs) - // This ensures QR code size matches the final document + private function getEffectiveFooterPolicy(): array { + $policyJson = $this->footerHandler->getEffectiveFooterPolicyAsJson(); + return FooterPolicyValue::normalize($policyJson, ''); + } + + public function renderPreviewPdf(string $template = '', int $width = 595, int $height = 50, ?bool $writeQrcodeOnFooter = null): string { $previewUuid = sprintf( 'preview-%04x-%04x-%04x-%012x', random_int(0, 0xffff), @@ -56,15 +101,21 @@ public function renderPreviewPdf(string $template = '', int $width = 595, int $h random_int(0, 0xffffffffffff) ); - return $this->footerHandler + $handler = $this->footerHandler + ->setTemplateOverride($template !== '' ? $template : null) ->setTemplateVar('uuid', $previewUuid) ->setTemplateVar('signers', [ [ 'displayName' => 'Preview Signer', 'signed' => date('c'), ], - ]) - ->getFooter([['w' => $width, 'h' => $height]]); + ]); + + if ($writeQrcodeOnFooter !== null) { + $handler->setWriteQrcodeOnFooterOverride($writeQrcodeOnFooter); + } + + return $handler->getFooter([['w' => $width, 'h' => $height]], true); } public function getTemplateVariablesMetadata(): array { diff --git a/lib/Service/IdDocsPolicyService.php b/lib/Service/IdDocsPolicyService.php index 694218247f..cee0cd0c01 100644 --- a/lib/Service/IdDocsPolicyService.php +++ b/lib/Service/IdDocsPolicyService.php @@ -8,27 +8,33 @@ namespace OCA\Libresign\Service; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\IdDocsMapper; use OCA\Libresign\Enum\FileStatus; -use OCA\Libresign\Helper\ValidateHelper; +use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\ApprovalGroups\ApprovalGroupsPolicy; +use OCA\Libresign\Service\Policy\Provider\ApprovalGroups\ApprovalGroupsPolicyValue; +use OCA\Libresign\Service\Policy\Provider\IdentificationDocuments\IdentificationDocumentsPolicy; +use OCA\Libresign\Service\Policy\Provider\IdentificationDocuments\IdentificationDocumentsPolicyValue; use OCP\AppFramework\Db\DoesNotExistException; -use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IL10N; use OCP\IUser; class IdDocsPolicyService { public function __construct( - private IAppConfig $appConfig, - private ValidateHelper $validateHelper, + private PolicyService $policyService, + private IGroupManager $groupManager, + private IL10N $l10n, private IdDocsMapper $idDocsMapper, ) { } public function canApproverSignIdDoc(IUser $user, int $fileId, int $status): bool { - if (!$this->appConfig->getValueBool(Application::APP_ID, 'identification_documents', false)) { + if (!$this->isIdentificationDocumentsEnabled($user)) { return false; } - if (!$this->validateHelper->userCanApproveValidationDocuments($user, false)) { + if (!$this->userCanApproveValidationDocuments($user, false)) { return false; } $readyStatuses = [FileStatus::ABLE_TO_SIGN->value, FileStatus::PARTIAL_SIGNED->value]; @@ -42,4 +48,55 @@ public function canApproverSignIdDoc(IUser $user, int $fileId, int $status): boo return false; } } + + public function isIdentificationDocumentsEnabled(?IUser $user = null): bool { + $resolved = $user + ? $this->policyService->resolveForUser(IdentificationDocumentsPolicy::KEY, $user) + : $this->policyService->resolve(IdentificationDocumentsPolicy::KEY); + $value = $resolved->getEffectiveValue(); + return IdentificationDocumentsPolicyValue::isEnabled($value, false); + } + + /** + * Get approver group IDs for identification documents flow. + * + * @return list + */ + public function getApproverGroups(?IUser $user = null): array { + $resolved = $user + ? $this->policyService->resolveForUser(IdentificationDocumentsPolicy::KEY, $user) + : $this->policyService->resolve(IdentificationDocumentsPolicy::KEY); + $value = $resolved->getEffectiveValue(); + return IdentificationDocumentsPolicyValue::getApprovers($value); + } + + public function userCanApproveValidationDocuments(?IUser $user, bool $throw = true): bool { + if ($user === null) { + return false; + } + + $authorized = $this->getApprovalGroups($user); + if (empty($authorized)) { + $authorized = ApprovalGroupsPolicyValue::DEFAULT_GROUPS; + } + + $userGroups = $this->groupManager->getUserGroupIds($user); + if (!array_intersect($userGroups, $authorized)) { + if ($throw) { + throw new LibresignException($this->l10n->t('You are not allowed to approve user profile documents.')); + } + return false; + } + + return true; + } + + /** @return list */ + public function getApprovalGroups(?IUser $user = null): array { + $resolved = $user + ? $this->policyService->resolveForUser(ApprovalGroupsPolicy::KEY, $user) + : $this->policyService->resolve(ApprovalGroupsPolicy::KEY); + + return ApprovalGroupsPolicyValue::decode($resolved->getEffectiveValue()); + } } diff --git a/lib/Service/IdDocsService.php b/lib/Service/IdDocsService.php index dd5be169c0..dbaff8a827 100644 --- a/lib/Service/IdDocsService.php +++ b/lib/Service/IdDocsService.php @@ -17,6 +17,7 @@ use OCA\Libresign\Db\SignRequest; use OCA\Libresign\Db\SignRequest as SignRequestEntity; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Enum\IdentifyMethodRequirement; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Helper\ValidateHelper; use OCP\AppFramework\Utility\ITimeFactory; @@ -101,7 +102,7 @@ public function addIdDocs(array $files, IUser $user): void { $identifyMethod->setSignRequestId($signRequest->getId()); $identifyMethod->setIdentifierKey(IdentifyMethodService::IDENTIFY_ACCOUNT); $identifyMethod->setIdentifierValue($user->getUID()); - $identifyMethod->setMandatory(1); + $identifyMethod->setRequirement(IdentifyMethodRequirement::REQUIRED->value); $this->identifyMethodMapper->insert($identifyMethod); $this->idDocsMapper->save($file->getId(), $signRequest->getId(), $user->getUID(), $fileData['type']); diff --git a/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php b/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php index 795e54b3b6..6b739ce69e 100644 --- a/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php +++ b/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php @@ -11,13 +11,14 @@ use DateTime; use InvalidArgumentException; use OC\AppFramework\Http as AppFrameworkHttp; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\IdentifyMethod; use OCA\Libresign\Enum\FileStatus; +use OCA\Libresign\Enum\IdentifyMethodRequirement; use OCA\Libresign\Events\SendSignNotificationEvent; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Helper\JSActions; use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\AbstractSignatureMethod; +use OCA\Libresign\Service\Policy\Provider\ExpirationRules\ExpirationRulesPolicy; use OCA\Libresign\Service\SessionService; use OCA\Libresign\Vendor\Wobeto\EmailBlur\Blur; use OCP\Files\NotFoundException; @@ -216,7 +217,7 @@ protected function throwIfFileNotFound(): void { } protected function throwIfMaximumValidityExpired(): void { - $maximumValidity = (int)$this->identifyService->getAppConfig()->getValueInt(Application::APP_ID, 'maximum_validity', SessionService::NO_MAXIMUM_VALIDITY); + $maximumValidity = $this->getRuntimeConfigInt(ExpirationRulesPolicy::KEY_MAXIMUM_VALIDITY, SessionService::NO_MAXIMUM_VALIDITY); if ($maximumValidity <= 0) { return; } @@ -247,11 +248,11 @@ protected function throwIfInvalidToken(): void { protected function renewSession(): void { $this->identifyService->getSessionService()->setIdentifyMethodId($this->getEntity()->getId()); - $renewalInterval = $this->getRuntimeConfigInt('renewal_interval', SessionService::NO_RENEWAL_INTERVAL); + $renewalInterval = $this->getRuntimeConfigInt(ExpirationRulesPolicy::KEY_RENEWAL_INTERVAL, SessionService::NO_RENEWAL_INTERVAL); if ($renewalInterval <= 0) { return; } - $this->identifyService->getSessionService()->resetDurationOfSignPage(); + $this->identifyService->getSessionService()->resetDurationOfSignPage($renewalInterval); } protected function updateIdentifiedAt(): void { @@ -265,7 +266,7 @@ protected function updateIdentifiedAt(): void { } protected function throwIfRenewalIntervalExpired(): void { - $renewalInterval = $this->getRuntimeConfigInt('renewal_interval', SessionService::NO_RENEWAL_INTERVAL); + $renewalInterval = $this->getRuntimeConfigInt(ExpirationRulesPolicy::KEY_RENEWAL_INTERVAL, SessionService::NO_RENEWAL_INTERVAL); if ($renewalInterval <= 0) { return; } @@ -318,9 +319,9 @@ private function getRenewAction(): int { } private function getRuntimeConfigInt(string $key, int $default): int { - $appConfig = $this->identifyService->getAppConfig(); - $appConfig->clearCache(true); - return (int)$appConfig->getValueInt(Application::APP_ID, $key, $default); + $resolved = $this->identifyService->getPolicyService()->resolve($key); + $value = $resolved->getEffectiveValue(); + return $value !== null ? (int)$value : $default; } protected function throwIfAlreadySigned(): void { @@ -350,7 +351,7 @@ protected function getSettingsFromDatabase(array $default = [], array $immutable 'name' => $this->name, 'friendly_name' => $this->getFriendlyName(), 'enabled' => true, - 'mandatory' => true, + 'requirement' => IdentifyMethodRequirement::REQUIRED->value, 'signatureMethods' => $this->signatureMethodsToArray(), ], $default @@ -358,6 +359,9 @@ protected function getSettingsFromDatabase(array $default = [], array $immutable $this->removeKeysThatDontExists($default); $this->overrideImmutable($immutable); $this->settings = $this->applyDefault($this->settings, $default); + if (!isset($this->settings['requirement'])) { + $this->settings['requirement'] = IdentifyMethodRequirement::REQUIRED->value; + } return $this->settings; } 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..a1832840e1 100644 --- a/lib/Service/IdentifyMethod/IdentifyService.php +++ b/lib/Service/IdentifyMethod/IdentifyService.php @@ -8,12 +8,15 @@ namespace OCA\Libresign\Service\IdentifyMethod; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Db\IdentifyMethod; use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Service\FolderService; +use OCA\Libresign\Service\IdentifyMethodService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicy; +use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicyValue; use OCA\Libresign\Service\SessionService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\EventDispatcher\IEventDispatcher; @@ -26,7 +29,7 @@ use Psr\Log\LoggerInterface; class IdentifyService { - private array $savedSettings = []; + private ?array $savedSettings = null; public function __construct( private IdentifyMethodMapper $identifyMethodMapper, private SessionService $sessionService, @@ -42,6 +45,7 @@ public function __construct( private IURLGenerator $urlGenerator, private LoggerInterface $logger, private FolderService $folderService, + private PolicyService $policyService, ) { } @@ -126,10 +130,26 @@ 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', []); + + $resolved = $this->getPolicyService()->resolve(IdentifyMethodsPolicy::KEY)->getEffectiveValue(); + $normalizedPayload = IdentifyMethodsPolicyValue::normalize($resolved); + $settings = IdentifyMethodsPolicyValue::extractFactors($normalizedPayload); + $globalCanCreateAccount = IdentifyMethodsPolicyValue::resolveGlobalCanCreateAccount($normalizedPayload); + + if ($globalCanCreateAccount !== null) { + foreach ($settings as &$setting) { + if (($setting['name'] ?? null) === IdentifyMethodService::IDENTIFY_EMAIL) { + $setting['can_create_account'] = $globalCanCreateAccount; + } + } + unset($setting); + } + + $this->savedSettings = $settings; + return $this->savedSettings; } public function getEventDispatcher(): IEventDispatcher { @@ -183,4 +203,8 @@ public function getLogger(): LoggerInterface { public function getFolderService(): FolderService { return $this->folderService; } + + public function getPolicyService(): PolicyService { + return $this->policyService; + } } diff --git a/lib/Service/IdentifyMethod/RuntimeRequirementValidator.php b/lib/Service/IdentifyMethod/RuntimeRequirementValidator.php new file mode 100644 index 0000000000..e84f955574 --- /dev/null +++ b/lib/Service/IdentifyMethod/RuntimeRequirementValidator.php @@ -0,0 +1,233 @@ +getMethodsByName($signRequest); + if (empty($methodsByName)) { + return; + } + + $summary = $this->summarizeVerificationState($methodsByName); + $this->validateRequiredFactorsCompleted($summary); + + $minimumTotalVerifiedFactors = $this->resolveMinimumTotalVerifiedFactors( + $signRequest, + $summary['methodNames'], + $summary['hasOptionalFactor'], + ); + if ($minimumTotalVerifiedFactors === null) { + return; + } + + $this->validateMinimumFactorsCompleted($summary, $minimumTotalVerifiedFactors); + } + + /** + * @return array> + */ + private function getMethodsByName(SignRequest $signRequest): array { + if (!$signRequest->getId()) { + return []; + } + + return $this->identifyMethodService->getIdentifyMethodsFromSignRequestId($signRequest->getId()); + } + + /** + * @param array> $methodsByName + * @return array{ + * requiredFactors: int, + * identifiedRequiredFactors: int, + * identifiedFactors: int, + * hasOptionalFactor: bool, + * methodNames: list + * } + */ + private function summarizeVerificationState(array $methodsByName): array { + + $requiredFactors = 0; + $identifiedRequiredFactors = 0; + $identifiedFactors = 0; + $hasOptionalFactor = false; + $methodNames = []; + + foreach ($methodsByName as $methods) { + foreach ($methods as $identifyMethod) { + $entity = $identifyMethod->getEntity(); + $methodName = $entity->getIdentifierKey(); + $methodNames[$methodName] = true; + + $isRequired = $entity->getRequirement() === IdentifyMethodRequirement::REQUIRED->value; + $isIdentified = $entity->getIdentifiedAtDate() !== null; + + if (!$isRequired) { + $hasOptionalFactor = true; + } + + if ($isRequired) { + $requiredFactors++; + if ($isIdentified) { + $identifiedRequiredFactors++; + } + } + + if ($isIdentified) { + $identifiedFactors++; + } + } + } + + return [ + 'requiredFactors' => $requiredFactors, + 'identifiedRequiredFactors' => $identifiedRequiredFactors, + 'identifiedFactors' => $identifiedFactors, + 'hasOptionalFactor' => $hasOptionalFactor, + 'methodNames' => array_keys($methodNames), + ]; + } + + /** + * Pure logic: validates that all required factors are identified. + * @param array{requiredFactors:int, identifiedRequiredFactors:int, identifiedFactors:int, hasOptionalFactor:bool, methodNames:list} $summary + * @throws LibresignException + */ + public function validateRequiredFactorsCompleted(array $summary): void { + if ($summary['identifiedRequiredFactors'] < $summary['requiredFactors']) { + throw new LibresignException($this->l10n->t('You need to complete all required identification factors before signing.')); + } + } + + /** + * Pure logic: validates that total identified factors meet minimum requirement. + * @param array{requiredFactors:int, identifiedRequiredFactors:int, identifiedFactors:int, hasOptionalFactor:bool, methodNames:list} $summary + * @param int $minimumTotalVerifiedFactors + * @throws LibresignException + */ + public function validateMinimumFactorsCompleted(array $summary, int $minimumTotalVerifiedFactors): void { + $requiredVerifiedFactors = max($summary['requiredFactors'], $minimumTotalVerifiedFactors); + if ($summary['identifiedFactors'] < $requiredVerifiedFactors) { + throw new LibresignException( + $this->l10n->t('You need to complete at least %s identification factors before signing.', [$requiredVerifiedFactors]) + ); + } + } + + /** + * Pure logic: resolves maximum minimum requirement from settings array. + * @param list $settings + * @param array $methodSet + * @return int|null + */ + public function resolveMinimumFromSettingsList(array $settings, array $methodSet): ?int { + return $this->resolveMinimumFromSettings($settings, $methodSet); + } + + /** + * @param list $methodNames + */ + private function resolveMinimumTotalVerifiedFactors(SignRequest $signRequest, array $methodNames, bool $hasOptionalFactor): ?int { + // Runtime minimum enforcement is enabled only when optional factors exist. + if (!$hasOptionalFactor) { + return null; + } + + $methodSet = array_fill_keys($methodNames, true); + + $minimumFromSnapshot = $this->resolveMinimumTotalVerifiedFactorsFromPolicySnapshot($signRequest, $methodSet); + if ($minimumFromSnapshot !== null) { + return $minimumFromSnapshot; + } + + return $this->resolveMinimumFromSettings( + $this->identifyMethodService->getIdentifyMethodsSettings(), + $methodSet, + ); + } + + /** + * @param list $settings + * @param array $methodSet + */ + private function resolveMinimumFromSettings(array $settings, array $methodSet): ?int { + $minimum = null; + foreach ($settings as $setting) { + $candidate = $this->resolveMinimumCandidate($setting, $methodSet); + if ($candidate === null) { + continue; + } + + $minimum = $minimum === null ? $candidate : max($minimum, $candidate); + } + + return $minimum; + } + + /** + * @param mixed $setting + * @param array $methodSet + */ + private function resolveMinimumCandidate(mixed $setting, array $methodSet): ?int { + if (!is_array($setting) || empty($setting['name']) || !isset($methodSet[$setting['name']])) { + return null; + } + if (!array_key_exists('minimumTotalVerifiedFactors', $setting) || !is_numeric($setting['minimumTotalVerifiedFactors'])) { + return null; + } + + $candidate = (int)$setting['minimumTotalVerifiedFactors']; + return $candidate < 1 ? null : $candidate; + } + + /** + * @param array $methodSet + */ + private function resolveMinimumTotalVerifiedFactorsFromPolicySnapshot(SignRequest $signRequest, array $methodSet): ?int { + try { + $file = $this->fileMapper->getById($signRequest->getFileId()); + } catch (\Throwable) { + return null; + } + + $metadata = $file->getMetadata() ?? []; + if (!isset($metadata['policy_snapshot']) || !is_array($metadata['policy_snapshot'])) { + return null; + } + + $entry = $metadata['policy_snapshot'][IdentifyMethodsPolicy::KEY] ?? null; + if (!is_array($entry) || !array_key_exists('effectiveValue', $entry)) { + return null; + } + + $normalized = IdentifyMethodsPolicyValue::normalize($entry['effectiveValue']); + $factors = IdentifyMethodsPolicyValue::extractFactors($normalized); + if (empty($factors)) { + return null; + } + + return $this->resolveMinimumFromSettings($factors, $methodSet); + } +} diff --git a/lib/Service/IdentifyMethod/TwofactorGateway.php b/lib/Service/IdentifyMethod/TwofactorGateway.php index f80c9ecf14..49e87cdebf 100644 --- a/lib/Service/IdentifyMethod/TwofactorGateway.php +++ b/lib/Service/IdentifyMethod/TwofactorGateway.php @@ -18,6 +18,7 @@ use OCP\IUserSession; use OCP\Server; use Psr\Log\LoggerInterface; +use Throwable; class TwofactorGateway extends AbstractIdentifyMethod { public function __construct( @@ -67,8 +68,17 @@ public function isTwofactorGatewayEnabled(): bool { $gatewayName = $this->getGatewayName(); - $gateway = $gatewayFactory->get($gatewayName); - return $gateway->isComplete(); + try { + $gateway = $gatewayFactory->get($gatewayName); + return $gateway->isComplete(); + } catch (Throwable $exception) { + $this->logger->warning('Unable to load twofactor gateway provider.', [ + 'gateway' => $gatewayName, + 'identifyMethod' => $this->getId(), + 'exception' => $exception, + ]); + return false; + } } private function getGatewayName(): string { diff --git a/lib/Service/IdentifyMethodService.php b/lib/Service/IdentifyMethodService.php index 8fa894766b..39366819c3 100644 --- a/lib/Service/IdentifyMethodService.php +++ b/lib/Service/IdentifyMethodService.php @@ -11,6 +11,7 @@ use OCA\Libresign\Db\IdentifyMethod; use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Db\SignRequest; +use OCA\Libresign\Enum\IdentifyMethodRequirement; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\ResponseDefinitions; use OCA\Libresign\Service\IdentifyMethod\Account; @@ -82,7 +83,7 @@ public function setIsRequest(bool $isRequest): self { return $this; } - public function getInstanceOfIdentifyMethod(string $name, ?string $identifyValue = null): IIdentifyMethod { + public function getInstanceOfIdentifyMethod(string $name, ?string $identifyValue = null, ?string $requirement = null): IIdentifyMethod { if ($identifyValue && isset($this->identifyMethods[$name])) { foreach ($this->identifyMethods[$name] as $identifyMethod) { if ($identifyMethod->getEntity()->getIdentifierValue() === $identifyValue) { @@ -97,7 +98,7 @@ public function getInstanceOfIdentifyMethod(string $name, ?string $identifyValue if (!$entity->getId()) { $entity->setIdentifierKey($name); $entity->setIdentifierValue($identifyValue); - $entity->setMandatory($this->isMandatoryMethod($name) ? 1 : 0); + $entity->setRequirement($requirement ?? $this->resolveMethodRequirement($name)); } if ($identifyValue && $this->isRequest) { $identifyMethod->validateToRequest(); @@ -142,17 +143,16 @@ private function getNewInstanceOfMethod(string $name): IIdentifyMethod { return $identifyMethod; } - private function setEntityData(string $method, string $identifyValue): void { - // @todo Replace by enum when PHP 8.1 is the minimum version acceptable - // at server. Check file lib/versioncheck.php of server repository - if (!in_array($method, IdentifyMethodService::IDENTIFY_METHODS)) { - // TRANSLATORS When is requested to a person to sign a file, is - // necessary identify what is the identification method. The - // identification method is used to define how will be the sign - // flow. - throw new LibresignException($this->l10n->t('Invalid identification method')); + public function exists(string $name): bool { + $className = 'OCA\\Libresign\\Service\\IdentifyMethod\\' . ucfirst($name); + if (class_exists($className)) { + return true; } - $identifyMethod = $this->getInstanceOfIdentifyMethod($method, $identifyValue); + return class_exists('OCA\\Libresign\\Service\\IdentifyMethod\\SignatureMethod\\' . ucfirst($name)); + } + + private function setEntityData(string $method, string $identifyValue, ?string $requirement = null): void { + $identifyMethod = $this->getInstanceOfIdentifyMethod($method, $identifyValue, $requirement); $identifyMethod->validateToRequest(); } @@ -161,18 +161,22 @@ public function setAllEntityData(array $user): void { if (!is_array($identifyMethod) || !isset($identifyMethod['method'], $identifyMethod['value'])) { continue; } - $this->setEntityData($identifyMethod['method'], $identifyMethod['value']); + $requirement = isset($identifyMethod['requirement']) && is_string($identifyMethod['requirement']) + ? $identifyMethod['requirement'] + : null; + $this->setEntityData($identifyMethod['method'], $identifyMethod['value'], $requirement); } } - private function isMandatoryMethod(string $methodName): bool { + private function resolveMethodRequirement(string $methodName): string { $settings = $this->getIdentifyMethodsSettings(); foreach ($settings as $setting) { if ($setting['name'] === $methodName) { - return $setting['mandatory']; + $requirement = IdentifyMethodRequirement::tryFrom((string)($setting['requirement'] ?? '')); + return $requirement?->value ?? IdentifyMethodRequirement::OPTIONAL->value; } } - return false; + return IdentifyMethodRequirement::OPTIONAL->value; } /** @@ -359,6 +363,19 @@ public function getIdentifyMethodsSettings(): array { return $this->identifyMethodsSettings; } + /** @return array */ + public function getFriendlyNamesMap(): array { + return [ + $this->account->getName() => $this->account->getFriendlyName(), + $this->email->getName() => $this->email->getFriendlyName(), + $this->signal->getName() => $this->signal->getFriendlyName(), + $this->sms->getName() => $this->sms->getFriendlyName(), + $this->telegram->getName() => $this->telegram->getFriendlyName(), + $this->Whatsapp->getName() => $this->Whatsapp->getFriendlyName(), + $this->xmpp->getName() => $this->xmpp->getFriendlyName(), + ]; + } + /** * Resolve UID from certificate chain data * diff --git a/lib/Service/Policy/Contract/IFilePolicyApplier.php b/lib/Service/Policy/Contract/IFilePolicyApplier.php new file mode 100644 index 0000000000..29fb035b9b --- /dev/null +++ b/lib/Service/Policy/Contract/IFilePolicyApplier.php @@ -0,0 +1,25 @@ + $data */ + public function apply(FileEntity $file, array $data): void; + + /** @param array $data */ + public function sync(FileEntity $file, array $data): void; + + /** + * Core flow sync is used on the UUID update path where only core flow policies + * should trigger recomputation. + */ + public function supportsCoreFlowSync(): bool; +} diff --git a/lib/Service/Policy/Contract/IPolicyDefinition.php b/lib/Service/Policy/Contract/IPolicyDefinition.php new file mode 100644 index 0000000000..88c0dd0d51 --- /dev/null +++ b/lib/Service/Policy/Contract/IPolicyDefinition.php @@ -0,0 +1,37 @@ + */ + public function allowedValues(PolicyContext $context): array; + + public function defaultSystemValue(): mixed; + + /** + * Whether this policy supports being saved as a user personal preference. + * Returns false for administrative-only policies (e.g. groups_request_sign) + * that must never appear in the user preferences screen. + */ + public function supportsUserPreference(): bool; +} 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..5151d89f35 --- /dev/null +++ b/lib/Service/Policy/Contract/IPolicySource.php @@ -0,0 +1,70 @@ + */ + public function loadGroupPolicies(string $policyKey, PolicyContext $context): array; + + /** @return list */ + public function loadCirclePolicies(string $policyKey, PolicyContext $context): array; + + public function loadUserPolicy(string $policyKey, PolicyContext $context): ?PolicyLayer; + + public function loadUserPreference(string $policyKey, PolicyContext $context): ?PolicyLayer; + + /** + * Bulk-load group policy layers for all known policy keys at once. + * + * @param list $policyKeys + * @return array> keyed by policyKey + */ + public function loadAllGroupPolicies(array $policyKeys, PolicyContext $context): array; + + /** + * Bulk-load user preference layers for all known policy keys at once. + * + * @param list $policyKeys + * @return array keyed by policyKey + */ + public function loadAllUserPolicies(array $policyKeys, PolicyContext $context): array; + + /** + * Bulk-load user preference layers for all known policy keys at once. + * + * @param list $policyKeys + * @return array keyed by policyKey + */ + public function loadAllUserPreferences(array $policyKeys, PolicyContext $context): array; + + 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 loadUserPolicyConfig(string $policyKey, string $userId): ?PolicyLayer; + + public function saveUserPolicy(string $policyKey, PolicyContext $context, mixed $value, bool $allowChildOverride): void; + + public function clearUserPolicy(string $policyKey, PolicyContext $context): 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/FilePolicyApplier.php b/lib/Service/Policy/FilePolicyApplier.php new file mode 100644 index 0000000000..0d9b075bcc --- /dev/null +++ b/lib/Service/Policy/FilePolicyApplier.php @@ -0,0 +1,96 @@ + */ + private readonly array $appliers; + + public function __construct( + private readonly PolicyService $policyService, + private readonly FileService $fileService, + private readonly IL10N $l10n, + ) { + $this->appliers = $this->discoverAppliers(); + } + + /** + * Apply all policies to a freshly built FileEntity before the first insert. + */ + public function applyAll(FileEntity $file, array $data): void { + foreach ($this->appliers as $applier) { + $applier->apply($file, $data); + } + } + + /** + * Re-evaluate and persist signature_flow + docmdp on an existing file. + * Use this when updating a file located by UUID. + */ + public function syncCoreFlowPolicies(FileEntity $file, array $data): void { + foreach ($this->appliers as $applier) { + if ($applier->supportsCoreFlowSync()) { + $applier->sync($file, $data); + } + } + } + + /** + * Re-evaluate and persist all three policies on an existing file. + * Use this when updating a file located by node ID. + */ + public function syncAllPolicies(FileEntity $file, array $data): void { + foreach ($this->appliers as $applier) { + $applier->sync($file, $data); + } + } + + /** @return list */ + private function discoverAppliers(): array { + $appliers = []; + + foreach (PolicyProviders::BY_KEY as $providerClass) { + $applierClass = $this->buildFileApplierClassFromProvider($providerClass); + if ($applierClass === null || !class_exists($applierClass)) { + continue; + } + + $instance = new $applierClass($this->policyService, $this->fileService, $this->l10n); + if (!$instance instanceof IFilePolicyApplier) { + continue; + } + + $appliers[] = $instance; + } + + return $appliers; + } + + /** @param class-string $providerClass */ + private function buildFileApplierClassFromProvider(string $providerClass): ?string { + $lastSeparator = strrpos($providerClass, '\\'); + if ($lastSeparator === false) { + return null; + } + + $namespace = substr($providerClass, 0, $lastSeparator); + $shortName = substr($providerClass, $lastSeparator + 1); + $baseName = str_ends_with($shortName, 'Policy') + ? substr($shortName, 0, -strlen('Policy')) + : $shortName; + + return $namespace . '\\FilePolicy\\' . $baseName . 'FilePolicyApplier'; + } +} diff --git a/lib/Service/Policy/IPolicyAuthorizationService.php b/lib/Service/Policy/IPolicyAuthorizationService.php new file mode 100644 index 0000000000..19ff45fc8b --- /dev/null +++ b/lib/Service/Policy/IPolicyAuthorizationService.php @@ -0,0 +1,32 @@ + + */ + public function getManageablePolicyGroupIds(?IUser $user): array; +} 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..c748576c8f --- /dev/null +++ b/lib/Service/Policy/Model/PolicySpec.php @@ -0,0 +1,113 @@ +|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, + private bool $supportsUserPreference = true, + ) { + $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; + } + + // Empty allowedValues means "no explicit restriction" for this policy key. + if ($this->allowedValues($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; + } + + #[\Override] + public function supportsUserPreference(): bool { + return $this->supportsUserPreference; + } +} diff --git a/lib/Service/Policy/Model/ResolvedPolicy.php b/lib/Service/Policy/Model/ResolvedPolicy.php new file mode 100644 index 0000000000..1a6521b3a0 --- /dev/null +++ b/lib/Service/Policy/Model/ResolvedPolicy.php @@ -0,0 +1,167 @@ + */ + 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 getEffectiveValueAsBool(bool $default = false): bool { + if (is_bool($this->effectiveValue)) { + return $this->effectiveValue; + } + + if ($this->effectiveValue === null) { + return $default; + } + + if (is_int($this->effectiveValue)) { + return $this->effectiveValue !== 0; + } + + if (is_float($this->effectiveValue)) { + return $this->effectiveValue !== 0.0; + } + + if (is_string($this->effectiveValue)) { + $parsed = filter_var($this->effectiveValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + return $parsed ?? $default; + } + + return $default; + } + + public function setInheritedValue(mixed $inheritedValue): self { + $this->inheritedValue = $inheritedValue; + return $this; + } + + public function getInheritedValue(): mixed { + return $this->inheritedValue; + } + + 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(), + 'inheritedValue' => $this->getInheritedValue(), + '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/PolicyAuthorizationService.php b/lib/Service/Policy/PolicyAuthorizationService.php new file mode 100644 index 0000000000..a561539ebf --- /dev/null +++ b/lib/Service/Policy/PolicyAuthorizationService.php @@ -0,0 +1,65 @@ +groupManager->isAdmin($user->getUID()) + || $this->subAdmin->isSubAdmin($user); + } + + /** + * Get list of group IDs manageable by the given user through subadmin scope. + * + * For instance admins: returns empty (they manage all groups at policy level). + * For subadmins: returns groups they are subadmin of. + * For regular users: returns empty. + * + * @return list + */ + #[\Override] + public function getManageablePolicyGroupIds(?IUser $user): array { + if ($user === null) { + return []; + } + + // Instance admins do not need a restricted group list + // (they have access to all groups at the policy layer) + if ($this->groupManager->isAdmin($user->getUID())) { + return []; + } + + // Only subadmins have a restricted manageable group scope + return array_values(array_map( + static fn ($group): string => $group->getGID(), + $this->subAdmin->getSubAdminsGroups($user), + )); + } +} diff --git a/lib/Service/Policy/PolicyService.php b/lib/Service/Policy/PolicyService.php new file mode 100644 index 0000000000..2b165e1780 --- /dev/null +++ b/lib/Service/Policy/PolicyService.php @@ -0,0 +1,192 @@ +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 getUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId): ?PolicyLayer { + $definition = $this->registry->get($policyKey); + return $this->source->loadUserPolicyConfig($definition->key(), $userId); + } + + 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); + $this->assertCurrentActorCanManageGroupOverride($definition->key()); + $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->assertCurrentActorCanManageGroupOverride($definition->key()); + $this->source->clearGroupPolicy($definition->key(), $groupId); + + return $this->source->loadGroupPolicyConfig($definition->key(), $groupId); + } + + private function assertCurrentActorCanManageGroupOverride(string $policyKey): void { + if ($this->contextFactory->isCurrentActorSystemAdmin()) { + return; + } + + $systemPolicy = $this->source->loadSystemPolicy($policyKey); + if ($systemPolicy !== null && !$systemPolicy->isAllowChildOverride()) { + throw new \DomainException($this->l10n->t('Lower-level overrides are not allowed for this policy')); + } + } + + 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($this->l10n->t('Saving a user preference is not allowed for {policyKey}', [ + 'policyKey' => $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 saveUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId, mixed $value, bool $allowChildOverride): ?PolicyLayer { + $context = $this->contextFactory->forUserId($userId); + $definition = $this->registry->get($policyKey); + $normalizedValue = $definition->normalizeValue($value); + $definition->validateValue($normalizedValue, $context); + $this->source->saveUserPolicy($definition->key(), $context, $normalizedValue, $allowChildOverride); + + return $this->source->loadUserPolicy($definition->key(), $context) + ?? (new PolicyLayer()) + ->setScope('user_policy') + ->setValue($normalizedValue) + ->setAllowChildOverride($allowChildOverride) + ->setVisibleToChild(true); + } + + public function clearUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId): ?PolicyLayer { + $context = $this->contextFactory->forUserId($userId); + $definition = $this->registry->get($policyKey); + $this->source->clearUserPolicy($definition->key(), $context); + + return $this->source->loadUserPolicy($definition->key(), $context); + } + + /** + * @param list $groupIds + * @param list $userIds + * @return array + */ + public function getRuleCounts(array $groupIds, array $userIds): array { + return $this->source->loadRuleCounts($groupIds, $userIds); + } + + /** @return array */ + public function getAllRuleCounts(): array { + return $this->source->loadAllRuleCounts(); + } +} diff --git a/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicy.php b/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicy.php new file mode 100644 index 0000000000..dc628e0f15 --- /dev/null +++ b/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicy.php @@ -0,0 +1,59 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: ApprovalGroupsPolicyValue::encode(ApprovalGroupsPolicyValue::DEFAULT_GROUPS), + allowedValues: static fn (PolicyContext $context): array => [], + normalizer: static fn (mixed $rawValue): mixed => ApprovalGroupsPolicyValue::encode($rawValue), + validator: static function (mixed $value): void { + if (!is_string($value)) { + throw new \InvalidArgumentException('Invalid value for ' . self::KEY); + } + + $decoded = ApprovalGroupsPolicyValue::decode($value); + if ($decoded === []) { + throw new \InvalidArgumentException('At least one authorized group is required for ' . self::KEY); + } + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportsUserPreference: false, + ), + 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/ApprovalGroups/ApprovalGroupsPolicyValue.php b/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicyValue.php new file mode 100644 index 0000000000..a714a9eca7 --- /dev/null +++ b/lib/Service/Policy/Provider/ApprovalGroups/ApprovalGroupsPolicyValue.php @@ -0,0 +1,66 @@ + */ + public const DEFAULT_GROUPS = ['admin']; + + /** @return list */ + public static function decode(mixed $rawValue): array { + if (is_array($rawValue)) { + return self::normalizeGroupIds($rawValue); + } + + if (!is_string($rawValue)) { + return []; + } + + $trimmed = trim($rawValue); + if ($trimmed === '') { + return []; + } + + $decoded = json_decode($trimmed, true); + if (is_array($decoded)) { + return self::normalizeGroupIds($decoded); + } + + return self::normalizeGroupIds(array_map('trim', explode(',', $trimmed))); + } + + public static function encode(mixed $rawValue): string { + return json_encode(self::decode($rawValue), JSON_THROW_ON_ERROR); + } + + /** + * @param array $rawGroups + * @return list + */ + private static function normalizeGroupIds(array $rawGroups): array { + $normalized = []; + foreach ($rawGroups as $groupId) { + if (!is_string($groupId)) { + continue; + } + + $trimmed = trim($groupId); + if ($trimmed === '') { + continue; + } + + $normalized[] = $trimmed; + } + + $unique = array_values(array_unique($normalized)); + sort($unique); + + return $unique; + } +} diff --git a/lib/Service/Policy/Provider/CollectMetadata/CollectMetadataPolicy.php b/lib/Service/Policy/Provider/CollectMetadata/CollectMetadataPolicy.php new file mode 100644 index 0000000000..63794cccc3 --- /dev/null +++ b/lib/Service/Policy/Provider/CollectMetadata/CollectMetadataPolicy.php @@ -0,0 +1,50 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: false, + allowedValues: [ + false, + true, + ], + normalizer: static fn (mixed $rawValue): bool => filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + 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/Confetti/ConfettiPolicy.php b/lib/Service/Policy/Provider/Confetti/ConfettiPolicy.php new file mode 100644 index 0000000000..bd9febd6c2 --- /dev/null +++ b/lib/Service/Policy/Provider/Confetti/ConfettiPolicy.php @@ -0,0 +1,50 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: true, + allowedValues: [ + false, + true, + ], + normalizer: static fn (mixed $rawValue): bool => filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + 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/CrlValidation/CrlValidationPolicy.php b/lib/Service/Policy/Provider/CrlValidation/CrlValidationPolicy.php new file mode 100644 index 0000000000..e9d448e696 --- /dev/null +++ b/lib/Service/Policy/Provider/CrlValidation/CrlValidationPolicy.php @@ -0,0 +1,50 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: true, + allowedValues: [ + false, + true, + ], + normalizer: static fn (mixed $rawValue): bool => filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + 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/DefaultUserFolder/DefaultUserFolderPolicy.php b/lib/Service/Policy/Provider/DefaultUserFolder/DefaultUserFolderPolicy.php new file mode 100644 index 0000000000..2fe24e7114 --- /dev/null +++ b/lib/Service/Policy/Provider/DefaultUserFolder/DefaultUserFolderPolicy.php @@ -0,0 +1,51 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: self::DEFAULT_FOLDER, + allowedValues: [], + normalizer: static function (mixed $rawValue): string { + $candidate = trim((string)$rawValue); + return $candidate !== '' ? $candidate : self::DEFAULT_FOLDER; + }, + 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/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/DocMdp/FilePolicy/DocMdpFilePolicyApplier.php b/lib/Service/Policy/Provider/DocMdp/FilePolicy/DocMdpFilePolicyApplier.php new file mode 100644 index 0000000000..102e4906b1 --- /dev/null +++ b/lib/Service/Policy/Provider/DocMdp/FilePolicy/DocMdpFilePolicyApplier.php @@ -0,0 +1,104 @@ +getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUser(DocMdpPolicy::KEY, $user, $requestOverrides) + : $this->policyService->resolveForUser(DocMdpPolicy::KEY, $user, $requestOverrides, $activeContext); + $file->setDocmdpLevelEnum(DocMdpLevel::tryFrom((int)$resolvedPolicy->getEffectiveValue()) ?? DocMdpLevel::NOT_CERTIFIED); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $requestOverrides = $this->getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUserId(DocMdpPolicy::KEY, $file->getUserId(), $requestOverrides) + : $this->policyService->resolveForUserId(DocMdpPolicy::KEY, $file->getUserId(), $requestOverrides, $activeContext); + $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); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return true; + } + + /** + * @param array{policyActiveContext?: array} $data + * @return array{type: string, id: string}|null + */ + private function extractActiveContext(array $data): ?array { + if (!isset($data['policyActiveContext']) || !is_array($data['policyActiveContext'])) { + return null; + } + + $type = $data['policyActiveContext']['type'] ?? null; + $id = $data['policyActiveContext']['id'] ?? null; + if (!is_string($type) || !is_string($id) || $type === '' || $id === '') { + return null; + } + + return [ + 'type' => $type, + 'id' => $id, + ]; + } + + /** @return array */ + private function getOverrides(array $data): array { + if (isset($data['policyOverrides']) && is_array($data['policyOverrides']) && array_key_exists(DocMdpPolicy::KEY, $data['policyOverrides'])) { + return [DocMdpPolicy::KEY => $data['policyOverrides'][DocMdpPolicy::KEY]]; + } + + return []; + } + + 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); + } +} diff --git a/lib/Service/Policy/Provider/Envelope/EnvelopePolicy.php b/lib/Service/Policy/Provider/Envelope/EnvelopePolicy.php new file mode 100644 index 0000000000..f53a4779f6 --- /dev/null +++ b/lib/Service/Policy/Provider/Envelope/EnvelopePolicy.php @@ -0,0 +1,50 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: true, + allowedValues: [ + false, + true, + ], + normalizer: static fn (mixed $rawValue): bool => filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + 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/ExpirationRules/ExpirationRulesPolicy.php b/lib/Service/Policy/Provider/ExpirationRules/ExpirationRulesPolicy.php new file mode 100644 index 0000000000..32cc6baf7d --- /dev/null +++ b/lib/Service/Policy/Provider/ExpirationRules/ExpirationRulesPolicy.php @@ -0,0 +1,109 @@ +normalizePolicyKey($policyKey); + + return match ($normalizedKey) { + self::KEY_MAXIMUM_VALIDITY => new PolicySpec( + key: self::KEY_MAXIMUM_VALIDITY, + defaultSystemValue: self::DEFAULT_MAXIMUM_VALIDITY, + allowedValues: [], + normalizer: static fn (mixed $rawValue): int => self::normalizeNonNegativeInt($rawValue, self::DEFAULT_MAXIMUM_VALIDITY), + appConfigKey: self::KEY_MAXIMUM_VALIDITY, + ), + self::KEY_RENEWAL_INTERVAL => new PolicySpec( + key: self::KEY_RENEWAL_INTERVAL, + defaultSystemValue: self::DEFAULT_RENEWAL_INTERVAL, + allowedValues: [], + normalizer: static fn (mixed $rawValue): int => self::normalizeNonNegativeInt($rawValue, self::DEFAULT_RENEWAL_INTERVAL), + appConfigKey: self::KEY_RENEWAL_INTERVAL, + ), + self::KEY_EXPIRY_IN_DAYS => new PolicySpec( + key: self::KEY_EXPIRY_IN_DAYS, + defaultSystemValue: self::DEFAULT_EXPIRY_IN_DAYS, + allowedValues: [], + normalizer: static fn (mixed $rawValue): int => self::normalizePositiveInt($rawValue, self::DEFAULT_EXPIRY_IN_DAYS), + appConfigKey: self::KEY_EXPIRY_IN_DAYS, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $normalizedKey), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } + + private static function normalizeNonNegativeInt(mixed $rawValue, int $fallback): int { + $parsed = self::parseInt($rawValue); + if ($parsed === null) { + return $fallback; + } + + return max(0, $parsed); + } + + private static function normalizePositiveInt(mixed $rawValue, int $fallback): int { + $parsed = self::parseInt($rawValue); + if ($parsed === null || $parsed <= 0) { + return $fallback; + } + + return $parsed; + } + + private static function parseInt(mixed $rawValue): ?int { + if (is_int($rawValue)) { + return $rawValue; + } + + if (is_float($rawValue) && is_finite($rawValue)) { + return (int)$rawValue; + } + + if (is_string($rawValue)) { + $trimmed = trim($rawValue); + if ($trimmed === '' || !is_numeric($trimmed)) { + return null; + } + + return (int)$trimmed; + } + + return null; + } +} diff --git a/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php b/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php new file mode 100644 index 0000000000..400443c7c8 --- /dev/null +++ b/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php @@ -0,0 +1,113 @@ +getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUser(FooterPolicy::KEY, $user, $requestOverrides) + : $this->policyService->resolveForUser(FooterPolicy::KEY, $user, $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $requestOverrides = $this->getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUserId(FooterPolicy::KEY, $file->getUserId(), $requestOverrides) + : $this->policyService->resolveForUserId(FooterPolicy::KEY, $file->getUserId(), $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $metadataBeforeUpdate = $file->getMetadata() ?? []; + $this->storePolicySnapshot($file, $resolvedPolicy); + $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate; + + if ($metadataChanged) { + $this->fileService->update($file); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return false; + } + + /** + * @param array{policyActiveContext?: array} $data + * @return array{type: string, id: string}|null + */ + private function extractActiveContext(array $data): ?array { + if (!isset($data['policyActiveContext']) || !is_array($data['policyActiveContext'])) { + return null; + } + + $type = $data['policyActiveContext']['type'] ?? null; + $id = $data['policyActiveContext']['id'] ?? null; + if (!is_string($type) || !is_string($id) || $type === '' || $id === '') { + return null; + } + + return [ + 'type' => $type, + 'id' => $id, + ]; + } + + /** @return array */ + private function getOverrides(array $data): array { + if (isset($data['policyOverrides']) && is_array($data['policyOverrides']) && array_key_exists(FooterPolicy::KEY, $data['policyOverrides'])) { + return [FooterPolicy::KEY => $data['policyOverrides'][FooterPolicy::KEY]]; + } + + return []; + } + + /** @param array $requestOverrides */ + private function assertOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void { + if ($requestOverrides === [] || $resolvedPolicy->canUseAsRequestOverride()) { + return; + } + + $blockedBy = $resolvedPolicy->getBlockedBy() ?? $resolvedPolicy->getSourceScope(); + throw new LibresignException($this->l10n->t('Footer template override is blocked by %s.', [$blockedBy]), 422); + } + + 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); + } +} diff --git a/lib/Service/Policy/Provider/Footer/FooterPolicy.php b/lib/Service/Policy/Provider/Footer/FooterPolicy.php new file mode 100644 index 0000000000..386b97397f --- /dev/null +++ b/lib/Service/Policy/Provider/Footer/FooterPolicy.php @@ -0,0 +1,74 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: FooterPolicyValue::encode(FooterPolicyValue::defaults()), + allowedValues: static fn (): array => [], + normalizer: static function (mixed $rawValue): mixed { + return FooterPolicyValue::encode(FooterPolicyValue::normalize($rawValue)); + }, + validator: static function (mixed $value, PolicyContext $context): 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); + } + + if (!self::canManageTechnicalFooterSettings($context)) { + $normalized = FooterPolicyValue::normalize($decoded); + if ($normalized['validationSite'] !== '') { + throw new \InvalidArgumentException('Validation URL override is not allowed for this actor'); + } + } + }, + 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; + } + + private static function canManageTechnicalFooterSettings(PolicyContext $context): bool { + $capabilities = $context->getActorCapabilities(); + + return ($capabilities['canManageSystemPolicies'] ?? false) === true + || ($capabilities['canManageGroupPolicies'] ?? false) === true; + } +} diff --git a/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php b/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php new file mode 100644 index 0000000000..214c0610c1 --- /dev/null +++ b/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php @@ -0,0 +1,128 @@ + true, + 'writeQrcodeOnFooter' => true, + 'validationSite' => '', + 'customizeFooterTemplate' => false, + 'footerTemplate' => $defaultTemplate, + 'previewWidth' => 595, + 'previewHeight' => 100, + 'previewZoom' => 100, + ]; + } + + /** @return array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool, footerTemplate: string, previewWidth: int, previewHeight: int, previewZoom: int} */ + public static function normalize(mixed $rawValue, string $defaultTemplate = ''): array { + $defaults = self::defaults($defaultTemplate); + + if (is_array($rawValue)) { + $normalized = [ + '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']), + 'footerTemplate' => self::toTemplateString($rawValue['footerTemplate'] ?? $rawValue['footer_template'] ?? $defaults['footerTemplate']), + 'previewWidth' => self::toInt($rawValue['previewWidth'] ?? $rawValue['preview_width'] ?? null, $defaults['previewWidth']), + 'previewHeight' => self::toInt($rawValue['previewHeight'] ?? $rawValue['preview_height'] ?? null, $defaults['previewHeight']), + 'previewZoom' => self::toInt($rawValue['previewZoom'] ?? $rawValue['preview_zoom'] ?? null, $defaults['previewZoom']), + ]; + + return $normalized; + } + + 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); + } + + private static function toTemplateString(mixed $rawValue): string { + if (is_string($rawValue)) { + return $rawValue; + } + + if (is_scalar($rawValue)) { + return (string)$rawValue; + } + + return ''; + } + + private static function toInt(mixed $rawValue, int $fallback): int { + if (is_int($rawValue)) { + return $rawValue; + } + + if (is_numeric($rawValue)) { + return (int)$rawValue; + } + + return $fallback; + } +} diff --git a/lib/Service/Policy/Provider/IdentificationDocuments/IdentificationDocumentsPolicy.php b/lib/Service/Policy/Provider/IdentificationDocuments/IdentificationDocumentsPolicy.php new file mode 100644 index 0000000000..736817ddf3 --- /dev/null +++ b/lib/Service/Policy/Provider/IdentificationDocuments/IdentificationDocumentsPolicy.php @@ -0,0 +1,64 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: [ + 'enabled' => false, + 'approvers' => ['admin'], + ], + allowedValues: static fn (): array => [], + normalizer: static fn (mixed $rawValue): array => \OCA\Libresign\Service\Policy\Provider\IdentificationDocuments\IdentificationDocumentsPolicyValue::normalize($rawValue, false), + validator: static function (mixed $value): void { + if (!is_array($value)) { + throw new \InvalidArgumentException('Invalid value for ' . self::KEY); + } + if (!array_key_exists('enabled', $value)) { + throw new \InvalidArgumentException('Missing "enabled" key in ' . self::KEY); + } + if (!array_key_exists('approvers', $value)) { + throw new \InvalidArgumentException('Missing "approvers" key in ' . self::KEY); + } + if (!is_array($value['approvers'])) { + throw new \InvalidArgumentException('Approvers must be an array'); + } + }, + 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/IdentificationDocuments/IdentificationDocumentsPolicyValue.php b/lib/Service/Policy/Provider/IdentificationDocuments/IdentificationDocumentsPolicyValue.php new file mode 100644 index 0000000000..c51042f047 --- /dev/null +++ b/lib/Service/Policy/Provider/IdentificationDocuments/IdentificationDocumentsPolicyValue.php @@ -0,0 +1,65 @@ + */ + private const DEFAULT_APPROVERS = ['admin']; + + /** + * Normalize payload structure. + * Expected format: {enabled: bool, approvers: string[]} + * + * @return array{enabled: bool, approvers: list} + */ + public static function normalize(mixed $rawValue, bool $enabledDefault = false): array { + if (!is_array($rawValue)) { + return [ + 'enabled' => $enabledDefault, + 'approvers' => self::DEFAULT_APPROVERS, + ]; + } + + $enabled = (bool)($rawValue['enabled'] ?? $enabledDefault); + $approvers = self::DEFAULT_APPROVERS; + + if (isset($rawValue['approvers']) && is_array($rawValue['approvers'])) { + $filtered = array_filter( + array_map('strval', $rawValue['approvers']), + static fn (string $v): bool => $v !== '' + ); + if (!empty($filtered)) { + $approvers = array_values($filtered); + } + } + + return [ + 'enabled' => $enabled, + 'approvers' => $approvers, + ]; + } + + /** + * Get enabled flag from payload. + */ + public static function isEnabled(mixed $rawValue, bool $default = false): bool { + $normalized = self::normalize($rawValue, $default); + return $normalized['enabled']; + } + + /** + * Get approvers from payload. + * + * @return list + */ + public static function getApprovers(mixed $rawValue): array { + $normalized = self::normalize($rawValue); + return $normalized['approvers']; + } +} diff --git a/lib/Service/Policy/Provider/IdentifyMethods/FilePolicy/IdentifyMethodsFilePolicyApplier.php b/lib/Service/Policy/Provider/IdentifyMethods/FilePolicy/IdentifyMethodsFilePolicyApplier.php new file mode 100644 index 0000000000..803d18e92c --- /dev/null +++ b/lib/Service/Policy/Provider/IdentifyMethods/FilePolicy/IdentifyMethodsFilePolicyApplier.php @@ -0,0 +1,62 @@ +policyService->resolveForUser(IdentifyMethodsPolicy::KEY, $user, []); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $resolvedPolicy = $this->policyService->resolveForUserId(IdentifyMethodsPolicy::KEY, $file->getUserId(), []); + $metadataBeforeUpdate = $file->getMetadata() ?? []; + $this->storePolicySnapshot($file, $resolvedPolicy); + $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate; + + if ($metadataChanged) { + $this->fileService->update($file); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return false; + } + + 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); + } +} diff --git a/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicy.php b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicy.php new file mode 100644 index 0000000000..83f5be69c9 --- /dev/null +++ b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicy.php @@ -0,0 +1,54 @@ +identifyMethodService; + return match ($this->normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: [], + allowedValues: static fn (): array => [], + normalizer: fn (mixed $rawValue): array => IdentifyMethodsPolicyValue::normalize($rawValue, $identifyMethodService), + 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/IdentifyMethods/IdentifyMethodsPolicyValue.php b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicyValue.php new file mode 100644 index 0000000000..650bda22d8 --- /dev/null +++ b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicyValue.php @@ -0,0 +1,232 @@ +>, can_create_account?: bool} + */ + public static function normalize(mixed $rawValue, ?IdentifyMethodService $identifyMethodService = null): array { + $globalCanCreateAccount = null; + $sharedMinimumTotalVerifiedFactors = null; + if (is_string($rawValue)) { + $decoded = json_decode($rawValue, true); + if (is_array($decoded)) { + if (array_is_list($decoded)) { + $rawValue = $decoded; + } elseif (isset($decoded['factors']) && is_array($decoded['factors'])) { + $rawValue = $decoded['factors']; + $sharedMinimumTotalVerifiedFactors = self::normalizeMinimumTotalVerifiedFactors($decoded['minimumTotalVerifiedFactors'] ?? null); + } + if (array_key_exists('can_create_account', $decoded)) { + $globalCanCreateAccount = (bool)$decoded['can_create_account']; + } + } + } elseif (is_array($rawValue) && !array_is_list($rawValue)) { + $candidate = $rawValue; + if (isset($candidate['factors']) && is_array($candidate['factors'])) { + $sharedMinimumTotalVerifiedFactors = self::normalizeMinimumTotalVerifiedFactors($candidate['minimumTotalVerifiedFactors'] ?? null); + $rawValue = $candidate['factors']; + } + if (array_key_exists('can_create_account', $candidate)) { + $globalCanCreateAccount = (bool)$candidate['can_create_account']; + } + } + + if (!is_array($rawValue)) { + return [ + 'factors' => [], + ]; + } + + $normalized = []; + $legacyGlobalCanCreateAccount = $globalCanCreateAccount; + foreach ($rawValue as $entry) { + if (is_string($entry)) { + $name = trim($entry); + if ($name === '') { + continue; + } + + $normalizedEntry = [ + 'name' => $name, + 'enabled' => true, + 'signatureMethods' => [], + ]; + + if ($sharedMinimumTotalVerifiedFactors !== null) { + $normalizedEntry['minimumTotalVerifiedFactors'] = $sharedMinimumTotalVerifiedFactors; + } + + $normalized[] = $normalizedEntry; + continue; + } + + if (!is_array($entry)) { + continue; + } + + $name = isset($entry['name']) && is_string($entry['name']) + ? trim($entry['name']) + : ''; + if ($name === '') { + continue; + } + + $signatureMethods = []; + if (isset($entry['signatureMethods']) && is_array($entry['signatureMethods'])) { + $isList = array_is_list($entry['signatureMethods']); + if ($isList) { + foreach ($entry['signatureMethods'] as $signatureMethodName) { + if (!is_string($signatureMethodName) || trim($signatureMethodName) === '') { + continue; + } + $signatureMethods[$signatureMethodName] = ['enabled' => false]; + } + } else { + foreach ($entry['signatureMethods'] as $signatureMethodName => $signatureMethodConfig) { + if (!is_string($signatureMethodName) || trim($signatureMethodName) === '') { + continue; + } + + if (is_string($signatureMethodConfig)) { + $signatureMethods[$signatureMethodName] = [ + 'enabled' => false, + 'label' => $signatureMethodConfig, + ]; + continue; + } + + if (!is_array($signatureMethodConfig)) { + continue; + } + + $normalizedSignatureMethod = []; + if (array_key_exists('enabled', $signatureMethodConfig)) { + $normalizedSignatureMethod['enabled'] = (bool)$signatureMethodConfig['enabled']; + } + + if (isset($signatureMethodConfig['label']) && is_string($signatureMethodConfig['label'])) { + $normalizedSignatureMethod['label'] = $signatureMethodConfig['label']; + } + + $signatureMethods[$signatureMethodName] = $normalizedSignatureMethod; + } + } + } + + if ($signatureMethods === [] && isset($entry['availableSignatureMethods']) && is_array($entry['availableSignatureMethods'])) { + foreach ($entry['availableSignatureMethods'] as $signatureMethodName) { + if (!is_string($signatureMethodName) || trim($signatureMethodName) === '') { + continue; + } + $signatureMethods[$signatureMethodName] = ['enabled' => false]; + } + } + + $normalizedEntry = [ + 'name' => $name, + 'enabled' => array_key_exists('enabled', $entry) ? (bool)$entry['enabled'] : true, + 'signatureMethods' => $signatureMethods, + ]; + + if (isset($entry['friendly_name']) && is_string($entry['friendly_name'])) { + $normalizedEntry['friendly_name'] = $entry['friendly_name']; + } + + if (array_key_exists('can_create_account', $entry)) { + if ($legacyGlobalCanCreateAccount === null) { + $legacyGlobalCanCreateAccount = (bool)$entry['can_create_account']; + } + } + + $minimumTotalVerifiedFactors = self::normalizeMinimumTotalVerifiedFactors($entry['minimumTotalVerifiedFactors'] ?? null) + ?? $sharedMinimumTotalVerifiedFactors; + if ($minimumTotalVerifiedFactors !== null) { + $normalizedEntry['minimumTotalVerifiedFactors'] = $minimumTotalVerifiedFactors; + } + + $requirement = IdentifyMethodRequirement::tryFrom((string)($entry['requirement'] ?? '')); + if ($requirement !== null) { + $normalizedEntry['requirement'] = $requirement->value; + } + + if (isset($entry['signatureMethodEnabled']) && is_string($entry['signatureMethodEnabled'])) { + $normalizedEntry['signatureMethodEnabled'] = $entry['signatureMethodEnabled']; + } + + $normalized[] = $normalizedEntry; + } + + if ($identifyMethodService !== null) { + $friendlyNames = $identifyMethodService->getFriendlyNamesMap(); + foreach ($normalized as &$entry) { + if (!isset($entry['friendly_name']) && isset($entry['name'], $friendlyNames[$entry['name']])) { + $entry['friendly_name'] = $friendlyNames[$entry['name']]; + } + } + unset($entry); + } + + $payload = [ + 'factors' => $normalized, + ]; + if ($legacyGlobalCanCreateAccount !== null) { + $payload['can_create_account'] = $legacyGlobalCanCreateAccount; + } + + return $payload; + } + + /** + * @return list> + */ + public static function extractFactors(array $normalizedPayload): array { + if (isset($normalizedPayload['factors']) && is_array($normalizedPayload['factors'])) { + return array_values(array_filter($normalizedPayload['factors'], static fn (mixed $entry): bool => is_array($entry))); + } + + if (array_is_list($normalizedPayload)) { + return array_values(array_filter($normalizedPayload, static fn (mixed $entry): bool => is_array($entry))); + } + + return []; + } + + public static function resolveGlobalCanCreateAccount(array $normalizedPayload): ?bool { + if (array_key_exists('can_create_account', $normalizedPayload)) { + return (bool)$normalizedPayload['can_create_account']; + } + + $factors = self::extractFactors($normalizedPayload); + foreach ($factors as $entry) { + if (array_key_exists('can_create_account', $entry)) { + return (bool)$entry['can_create_account']; + } + } + + return null; + } + + private static function normalizeMinimumTotalVerifiedFactors(mixed $value): ?int { + if (!is_numeric($value)) { + return null; + } + + $normalized = (int)$value; + if ($normalized < 1) { + return null; + } + + return $normalized; + } +} diff --git a/lib/Service/Policy/Provider/LegalInformation/LegalInformationPolicy.php b/lib/Service/Policy/Provider/LegalInformation/LegalInformationPolicy.php new file mode 100644 index 0000000000..1275d2a4e4 --- /dev/null +++ b/lib/Service/Policy/Provider/LegalInformation/LegalInformationPolicy.php @@ -0,0 +1,47 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: '', + allowedValues: [], + normalizer: static fn (mixed $rawValue): string => (string)$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/PolicyProviders.php b/lib/Service/Policy/Provider/PolicyProviders.php new file mode 100644 index 0000000000..d925814ef0 --- /dev/null +++ b/lib/Service/Policy/Provider/PolicyProviders.php @@ -0,0 +1,64 @@ + */ + public const BY_KEY = [ + ApprovalGroupsPolicy::KEY => ApprovalGroupsPolicy::class, + CollectMetadataPolicy::KEY => CollectMetadataPolicy::class, + ConfettiPolicy::KEY => ConfettiPolicy::class, + CrlValidationPolicy::KEY => CrlValidationPolicy::class, + FooterPolicy::KEY => FooterPolicy::class, + DocMdpPolicy::KEY => DocMdpPolicy::class, + EnvelopePolicy::KEY => EnvelopePolicy::class, + ExpirationRulesPolicy::KEY_MAXIMUM_VALIDITY => ExpirationRulesPolicy::class, + ExpirationRulesPolicy::KEY_RENEWAL_INTERVAL => ExpirationRulesPolicy::class, + ExpirationRulesPolicy::KEY_EXPIRY_IN_DAYS => ExpirationRulesPolicy::class, + RequestSignGroupsPolicy::KEY => RequestSignGroupsPolicy::class, + ReminderPolicy::KEY => ReminderPolicy::class, + DefaultUserFolderPolicy::KEY => DefaultUserFolderPolicy::class, + LegalInformationPolicy::KEY => LegalInformationPolicy::class, + SignatureHashAlgorithmPolicy::KEY => SignatureHashAlgorithmPolicy::class, + ValidationAccessPolicy::KEY => ValidationAccessPolicy::class, + SignatureFlowPolicy::KEY => SignatureFlowPolicy::class, + SignatureBackgroundPolicy::KEY => SignatureBackgroundPolicy::class, + IdentificationDocumentsPolicy::KEY => IdentificationDocumentsPolicy::class, + IdentifyMethodsPolicy::KEY => IdentifyMethodsPolicy::class, + SignatureTextPolicy::KEY => SignatureTextPolicy::class, + TsaPolicy::KEY => TsaPolicy::class, + SignatureTextPolicy::KEY_TEMPLATE => SignatureTextPolicy::class, + SignatureTextPolicy::KEY_TEMPLATE_FONT_SIZE => SignatureTextPolicy::class, + SignatureTextPolicy::KEY_SIGNATURE_WIDTH => SignatureTextPolicy::class, + SignatureTextPolicy::KEY_SIGNATURE_HEIGHT => SignatureTextPolicy::class, + SignatureTextPolicy::KEY_SIGNATURE_FONT_SIZE => SignatureTextPolicy::class, + SignatureTextPolicy::KEY_RENDER_MODE => SignatureTextPolicy::class, + ]; +} diff --git a/lib/Service/Policy/Provider/Reminder/ReminderPolicy.php b/lib/Service/Policy/Provider/Reminder/ReminderPolicy.php new file mode 100644 index 0000000000..7e733c440f --- /dev/null +++ b/lib/Service/Policy/Provider/Reminder/ReminderPolicy.php @@ -0,0 +1,47 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: ReminderPolicyValue::encode(ReminderPolicyValue::defaults()), + allowedValues: static fn (): array => [], + normalizer: static fn (mixed $rawValue): string => ReminderPolicyValue::encode(ReminderPolicyValue::normalize($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/Reminder/ReminderPolicyValue.php b/lib/Service/Policy/Provider/Reminder/ReminderPolicyValue.php new file mode 100644 index 0000000000..580f0d4298 --- /dev/null +++ b/lib/Service/Policy/Provider/Reminder/ReminderPolicyValue.php @@ -0,0 +1,86 @@ + 0, + 'days_between' => 0, + 'max' => 0, + 'send_timer' => '10:00', + ]; + } + + /** @return array{days_before: int, days_between: int, max: int, send_timer: string} */ + public static function normalize(mixed $rawValue): array { + $defaults = self::defaults(); + + if (is_string($rawValue)) { + $trimmed = trim($rawValue); + if ($trimmed !== '') { + $decoded = json_decode($trimmed, true); + if (is_array($decoded)) { + $rawValue = $decoded; + } + } + } + + if (!is_array($rawValue)) { + return $defaults; + } + + $daysBefore = self::toNonNegativeInt($rawValue['days_before'] ?? $defaults['days_before']); + $daysBetween = self::toNonNegativeInt($rawValue['days_between'] ?? $defaults['days_between']); + $max = self::toNonNegativeInt($rawValue['max'] ?? $defaults['max']); + $sendTimer = self::normalizeSendTimer($rawValue['send_timer'] ?? $defaults['send_timer']); + + return [ + 'days_before' => $daysBefore, + 'days_between' => $daysBetween, + 'max' => $max, + 'send_timer' => $sendTimer, + ]; + } + + /** @param array $value */ + public static function encode(array $value): string { + return (string)json_encode(self::normalize($value), JSON_UNESCAPED_SLASHES); + } + + private static function toNonNegativeInt(mixed $rawValue): int { + if (is_int($rawValue)) { + return max(0, $rawValue); + } + + if (is_numeric($rawValue)) { + return max(0, (int)$rawValue); + } + + return 0; + } + + private static function normalizeSendTimer(mixed $rawValue): string { + if (!is_scalar($rawValue)) { + return '10:00'; + } + + $value = trim((string)$rawValue); + if ($value === '') { + return ''; + } + + if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $value)) { + return '10:00'; + } + + return $value; + } +} diff --git a/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicy.php b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicy.php new file mode 100644 index 0000000000..c2e285747b --- /dev/null +++ b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicy.php @@ -0,0 +1,59 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: RequestSignGroupsPolicyValue::encode(RequestSignGroupsPolicyValue::DEFAULT_GROUPS), + allowedValues: static fn (PolicyContext $context): array => [], + normalizer: static fn (mixed $rawValue): mixed => RequestSignGroupsPolicyValue::encode($rawValue), + validator: static function (mixed $value): void { + if (!is_string($value)) { + throw new \InvalidArgumentException('Invalid value for ' . self::KEY); + } + + $decoded = RequestSignGroupsPolicyValue::decode($value); + if ($decoded === []) { + throw new \InvalidArgumentException('At least one authorized group is required for ' . self::KEY); + } + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportsUserPreference: false, + ), + 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/RequestSignGroups/RequestSignGroupsPolicyGuard.php b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyGuard.php new file mode 100644 index 0000000000..d2ad5520e7 --- /dev/null +++ b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyGuard.php @@ -0,0 +1,83 @@ +l10n->t(self::USER_SCOPE_NOT_SUPPORTED_MESSAGE)); + } + + public function normalizeManagedValue(string $policyKey, mixed $value, bool $allowNullReset = false): mixed { + if ($policyKey !== RequestSignGroupsPolicy::KEY) { + return $value; + } + + if ($allowNullReset && $value === null) { + return null; + } + + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + throw new \InvalidArgumentException($this->l10n->t('Not allowed to manage this policy')); + } + + $groupIds = RequestSignGroupsPolicyValue::decode($value); + if ($groupIds === []) { + throw new \InvalidArgumentException($this->l10n->t('At least one authorized group is required')); + } + + $allowedGroupIds = $this->resolveAllowedGroupIdsForActor($user); + $unknownGroupIds = array_values(array_diff($groupIds, $allowedGroupIds)); + if ($unknownGroupIds !== []) { + throw new \InvalidArgumentException($this->l10n->t('One or more selected groups are not allowed for your administration scope')); + } + + return RequestSignGroupsPolicyValue::encode($groupIds); + } + + /** @return list */ + private function resolveAllowedGroupIdsForActor(IUser $user): array { + if ($this->groupManager->isAdmin($user->getUID())) { + return array_values(array_map( + static fn (IGroup $group): string => $group->getGID(), + $this->groupManager->search(''), + )); + } + + if (!$this->subAdmin->isSubAdmin($user)) { + return []; + } + + return array_values(array_map( + static fn (IGroup $group): string => $group->getGID(), + $this->subAdmin->getSubAdminsGroups($user), + )); + } +} diff --git a/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyValue.php b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyValue.php new file mode 100644 index 0000000000..8698853df9 --- /dev/null +++ b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyValue.php @@ -0,0 +1,65 @@ + */ + public const DEFAULT_GROUPS = ['admin']; + + /** @return list */ + public static function decode(mixed $rawValue): array { + if (is_array($rawValue)) { + return self::normalizeGroupIds($rawValue); + } + + if (!is_string($rawValue)) { + return []; + } + + $trimmed = trim($rawValue); + if ($trimmed === '') { + return []; + } + + $decoded = json_decode($trimmed, true); + if (is_array($decoded)) { + return self::normalizeGroupIds($decoded); + } + + return self::normalizeGroupIds(array_map('trim', explode(',', $trimmed))); + } + + public static function encode(mixed $rawValue): string { + return json_encode(self::decode($rawValue), JSON_THROW_ON_ERROR); + } + + /** @param array $rawGroups + * @return list + */ + private static function normalizeGroupIds(array $rawGroups): array { + $normalized = []; + foreach ($rawGroups as $groupId) { + if (!is_string($groupId)) { + continue; + } + + $trimmed = trim($groupId); + if ($trimmed === '') { + continue; + } + + $normalized[] = $trimmed; + } + + $unique = array_values(array_unique($normalized)); + sort($unique); + + return $unique; + } +} diff --git a/lib/Service/Policy/Provider/Signature/FilePolicy/SignatureFlowFilePolicyApplier.php b/lib/Service/Policy/Provider/Signature/FilePolicy/SignatureFlowFilePolicyApplier.php new file mode 100644 index 0000000000..87f8efebb6 --- /dev/null +++ b/lib/Service/Policy/Provider/Signature/FilePolicy/SignatureFlowFilePolicyApplier.php @@ -0,0 +1,117 @@ +getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUser(SignatureFlowPolicy::KEY, $user, $requestOverrides) + : $this->policyService->resolveForUser(SignatureFlowPolicy::KEY, $user, $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $file->setSignatureFlowEnum(SignatureFlow::from((string)$resolvedPolicy->getEffectiveValue())); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $requestOverrides = $this->getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUserId(SignatureFlowPolicy::KEY, $file->getUserId(), $requestOverrides) + : $this->policyService->resolveForUserId(SignatureFlowPolicy::KEY, $file->getUserId(), $requestOverrides, $activeContext); + $this->assertOverrideAllowed($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); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return true; + } + + /** + * @param array{policyActiveContext?: array} $data + * @return array{type: string, id: string}|null + */ + private function extractActiveContext(array $data): ?array { + if (!isset($data['policyActiveContext']) || !is_array($data['policyActiveContext'])) { + return null; + } + + $type = $data['policyActiveContext']['type'] ?? null; + $id = $data['policyActiveContext']['id'] ?? null; + if (!is_string($type) || !is_string($id) || $type === '' || $id === '') { + return null; + } + + return [ + 'type' => $type, + 'id' => $id, + ]; + } + + /** @return array */ + private function getOverrides(array $data): array { + if (isset($data['policyOverrides']) && is_array($data['policyOverrides']) && array_key_exists(SignatureFlowPolicy::KEY, $data['policyOverrides'])) { + return [SignatureFlowPolicy::KEY => $data['policyOverrides'][SignatureFlowPolicy::KEY]]; + } + + return []; + } + + /** @param array $requestOverrides */ + private function assertOverrideAllowed(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 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); + } +} 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/Provider/SignatureBackground/SignatureBackgroundPolicy.php b/lib/Service/Policy/Provider/SignatureBackground/SignatureBackgroundPolicy.php new file mode 100644 index 0000000000..b900cc4054 --- /dev/null +++ b/lib/Service/Policy/Provider/SignatureBackground/SignatureBackgroundPolicy.php @@ -0,0 +1,64 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: 'default', + allowedValues: [ + 'default', + 'custom', + 'deleted', + ], + normalizer: static fn (mixed $rawValue): string => self::normalizeBackgroundType($rawValue), + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private static function normalizeBackgroundType(mixed $rawValue): string { + if (!is_string($rawValue)) { + return 'default'; + } + + $normalized = trim(strtolower($rawValue)); + if (in_array($normalized, ['default', 'custom', 'deleted'], true)) { + return $normalized; + } + + return 'default'; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } +} diff --git a/lib/Service/Policy/Provider/SignatureHashAlgorithm/SignatureHashAlgorithmPolicy.php b/lib/Service/Policy/Provider/SignatureHashAlgorithm/SignatureHashAlgorithmPolicy.php new file mode 100644 index 0000000000..00cddad1fb --- /dev/null +++ b/lib/Service/Policy/Provider/SignatureHashAlgorithm/SignatureHashAlgorithmPolicy.php @@ -0,0 +1,59 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: 'SHA256', + allowedValues: self::ALGORITHMS, + normalizer: function (mixed $rawValue): string { + $candidate = strtoupper(trim((string)$rawValue)); + return in_array($candidate, self::ALGORITHMS, true) ? $candidate : 'SHA256'; + }, + 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/SignatureText/SignatureTextPolicy.php b/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicy.php new file mode 100644 index 0000000000..dd36bd2811 --- /dev/null +++ b/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicy.php @@ -0,0 +1,173 @@ +normalizePolicyKey($policyKey); + + return match ($normalizedKey) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: $this->encodeConsolidatedValue($this->defaultConsolidatedValue()), + allowedValues: [], + normalizer: fn (mixed $rawValue): string => $this->encodeConsolidatedValue( + $this->normalizeConsolidatedValue($rawValue), + ), + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + self::KEY_TEMPLATE => new PolicySpec( + key: self::KEY_TEMPLATE, + defaultSystemValue: '', + allowedValues: [], + normalizer: fn (mixed $rawValue): string => (string)$rawValue, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_TEMPLATE, + ), + self::KEY_TEMPLATE_FONT_SIZE => new PolicySpec( + key: self::KEY_TEMPLATE_FONT_SIZE, + defaultSystemValue: 9.0, + allowedValues: [], + normalizer: fn (mixed $rawValue): float => (float)$rawValue, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_TEMPLATE_FONT_SIZE, + ), + self::KEY_SIGNATURE_WIDTH => new PolicySpec( + key: self::KEY_SIGNATURE_WIDTH, + defaultSystemValue: 90.0, + allowedValues: [], + normalizer: fn (mixed $rawValue): float => (float)$rawValue, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_SIGNATURE_WIDTH, + ), + self::KEY_SIGNATURE_HEIGHT => new PolicySpec( + key: self::KEY_SIGNATURE_HEIGHT, + defaultSystemValue: 60.0, + allowedValues: [], + normalizer: fn (mixed $rawValue): float => (float)$rawValue, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_SIGNATURE_HEIGHT, + ), + self::KEY_SIGNATURE_FONT_SIZE => new PolicySpec( + key: self::KEY_SIGNATURE_FONT_SIZE, + defaultSystemValue: 9.0, + allowedValues: [], + normalizer: fn (mixed $rawValue): float => (float)$rawValue, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_SIGNATURE_FONT_SIZE, + ), + self::KEY_RENDER_MODE => new PolicySpec( + key: self::KEY_RENDER_MODE, + defaultSystemValue: 'default', + allowedValues: ['default', 'graphic', 'text'], + normalizer: function (mixed $rawValue): string { + $value = (string)$rawValue; + $allowed = ['default', 'graphic', 'text']; + return in_array($value, $allowed, true) ? $value : 'default'; + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY_RENDER_MODE, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $normalizedKey), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } + + /** + * @return array + */ + private function defaultConsolidatedValue(): array { + return [ + 'template' => '', + 'template_font_size' => 9.0, + 'signature_font_size' => 9.0, + 'signature_width' => 90.0, + 'signature_height' => 60.0, + 'render_mode' => 'default', + ]; + } + + /** + * @return array + */ + private function normalizeConsolidatedValue(mixed $rawValue): array { + $defaults = $this->defaultConsolidatedValue(); + + if (is_string($rawValue)) { + $decoded = json_decode($rawValue, true); + if (is_array($decoded)) { + $rawValue = $decoded; + } + } + + if (!is_array($rawValue)) { + return $defaults; + } + + $renderMode = (string)($rawValue['render_mode'] ?? $defaults['render_mode']); + if (!in_array($renderMode, ['default', 'graphic', 'text'], true)) { + $renderMode = 'default'; + } + + return [ + 'template' => (string)($rawValue['template'] ?? $defaults['template']), + 'template_font_size' => max(0.1, (float)($rawValue['template_font_size'] ?? $defaults['template_font_size'])), + 'signature_font_size' => max(0.1, (float)($rawValue['signature_font_size'] ?? $defaults['signature_font_size'])), + 'signature_width' => max(0.1, (float)($rawValue['signature_width'] ?? $defaults['signature_width'])), + 'signature_height' => max(0.1, (float)($rawValue['signature_height'] ?? $defaults['signature_height'])), + 'render_mode' => $renderMode, + ]; + } + + /** + * @param array $value + */ + private function encodeConsolidatedValue(array $value): string { + return json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } +} diff --git a/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicyValue.php b/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicyValue.php new file mode 100644 index 0000000000..72fc32503f --- /dev/null +++ b/lib/Service/Policy/Provider/SignatureText/SignatureTextPolicyValue.php @@ -0,0 +1,78 @@ + */ + public const DEFAULTS = [ + 'template' => '', + 'template_font_size' => 9.0, + 'signature_font_size' => 9.0, + 'signature_width' => 90.0, + 'signature_height' => 60.0, + 'render_mode' => 'default', + ]; + + /** + * @param mixed $rawValue + * @return array + */ + public static function normalize(mixed $rawValue, ?array $defaults = null): array { + $defaults ??= self::DEFAULTS; + + if (is_string($rawValue)) { + try { + $decoded = json_decode($rawValue, true); + if (is_array($decoded)) { + $rawValue = $decoded; + } + } catch (\JsonException) { + // Fallback to defaults + } + } + + if (!is_array($rawValue)) { + return $defaults; + } + + return [ + 'template' => self::normalizeString($rawValue['template'] ?? $defaults['template']), + 'template_font_size' => self::normalizeFloat($rawValue['template_font_size'] ?? $defaults['template_font_size']), + 'signature_font_size' => self::normalizeFloat($rawValue['signature_font_size'] ?? $defaults['signature_font_size']), + 'signature_width' => self::normalizeFloat($rawValue['signature_width'] ?? $defaults['signature_width']), + 'signature_height' => self::normalizeFloat($rawValue['signature_height'] ?? $defaults['signature_height']), + 'render_mode' => self::normalizeRenderMode($rawValue['render_mode'] ?? $defaults['render_mode']), + ]; + } + + /** + * @param array $value + */ + public static function encode(array $value): string { + $normalized = self::normalize($value); + return json_encode($normalized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } + + private static function normalizeString(mixed $value): string { + return (string)($value ?? ''); + } + + private static function normalizeFloat(mixed $value): float { + $float = (float)($value ?? 0); + return max(0.1, $float); + } + + private static function normalizeRenderMode(mixed $value): string { + $mode = (string)($value ?? 'default'); + return match ($mode) { + 'default', 'graphic', 'text' => $mode, + default => 'default', + }; + } +} diff --git a/lib/Service/Policy/Provider/Tsa/TsaPolicy.php b/lib/Service/Policy/Provider/Tsa/TsaPolicy.php new file mode 100644 index 0000000000..275d5a5b40 --- /dev/null +++ b/lib/Service/Policy/Provider/Tsa/TsaPolicy.php @@ -0,0 +1,48 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: TsaPolicyValue::encode(TsaPolicyValue::defaults()), + allowedValues: static fn (): array => [], + normalizer: static fn (mixed $rawValue): string => TsaPolicyValue::encode($rawValue), + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + supportsUserPreference: false, + ), + 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/Tsa/TsaPolicyValue.php b/lib/Service/Policy/Provider/Tsa/TsaPolicyValue.php new file mode 100644 index 0000000000..0608b0b05e --- /dev/null +++ b/lib/Service/Policy/Provider/Tsa/TsaPolicyValue.php @@ -0,0 +1,73 @@ + '', + 'policy_oid' => '', + 'auth_type' => 'none', + 'username' => '', + ]; + } + + /** @return array{url: string, policy_oid: string, auth_type: string, username: string} */ + public static function decode(mixed $rawValue): array { + if (is_string($rawValue)) { + $decoded = json_decode($rawValue, true); + if (is_array($decoded)) { + $rawValue = $decoded; + } + } + + if (!is_array($rawValue)) { + return self::defaults(); + } + + $url = isset($rawValue['url']) && is_string($rawValue['url']) + ? trim($rawValue['url']) + : ''; + + $policyOid = isset($rawValue['policy_oid']) && is_string($rawValue['policy_oid']) + ? trim($rawValue['policy_oid']) + : ''; + + if ($policyOid !== '' && !preg_match('/^[0-9]+(\.[0-9]+)*$/', $policyOid)) { + $policyOid = ''; + } + + $authType = isset($rawValue['auth_type']) && is_string($rawValue['auth_type']) + ? trim($rawValue['auth_type']) + : 'none'; + if (!in_array($authType, ['none', 'basic'], true)) { + $authType = 'none'; + } + + $username = isset($rawValue['username']) && is_string($rawValue['username']) + ? trim($rawValue['username']) + : ''; + + if ($authType !== 'basic') { + $username = ''; + } + + return [ + 'url' => $url, + 'policy_oid' => $policyOid, + 'auth_type' => $authType, + 'username' => $username, + ]; + } + + public static function encode(mixed $rawValue): string { + return json_encode(self::decode($rawValue), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } +} diff --git a/lib/Service/Policy/Provider/ValidationAccess/ValidationAccessPolicy.php b/lib/Service/Policy/Provider/ValidationAccess/ValidationAccessPolicy.php new file mode 100644 index 0000000000..83d71fef0d --- /dev/null +++ b/lib/Service/Policy/Provider/ValidationAccess/ValidationAccessPolicy.php @@ -0,0 +1,50 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: false, + allowedValues: [ + false, + true, + ], + normalizer: static fn (mixed $rawValue): bool => filter_var($rawValue, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + 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/RequestSignAuthorizationService.php b/lib/Service/Policy/RequestSignAuthorizationService.php new file mode 100644 index 0000000000..e5eaed8d2e --- /dev/null +++ b/lib/Service/Policy/RequestSignAuthorizationService.php @@ -0,0 +1,37 @@ +policyService->resolveForUser(RequestSignGroupsPolicy::KEY, $user); + $authorizedGroups = RequestSignGroupsPolicyValue::decode($resolvedPolicy->getEffectiveValue()); + if ($authorizedGroups === []) { + return false; + } + + $userGroups = $this->groupManager->getUserGroupIds($user); + return array_intersect($userGroups, $authorizedGroups) !== []; + } +} diff --git a/lib/Service/Policy/Runtime/DefaultPolicyResolver.php b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php new file mode 100644 index 0000000000..3c4c1c7e66 --- /dev/null +++ b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php @@ -0,0 +1,389 @@ +resolveCore( + $definition, + $context, + $this->source->loadGroupPolicies($definition->key(), $context), + $this->source->loadUserPolicy($definition->key(), $context), + $this->source->loadUserPreference($definition->key(), $context), + ); + } + + /** + * @param list $groupLayers Pre-fetched group layers (avoids repeat DB calls in bulk resolution) + */ + private function resolveCore( + IPolicyDefinition $definition, + PolicyContext $context, + array $groupLayers, + ?PolicyLayer $userPolicy, + ?PolicyLayer $userPreference, + ): ResolvedPolicy { + $policyKey = $definition->key(); + $resolved = (new ResolvedPolicy()) + ->setPolicyKey($policyKey) + ->setAllowedValues($definition->allowedValues($context)); + + $systemLayer = $this->source->loadSystemPolicy($policyKey); + + $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, + ); + } + } + + if ($userPolicy !== null) { + [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer( + $definition, + $resolved, + $userPolicy, + $context, + $currentValue, + $currentSourceScope, + $canOverrideBelow, + $visible, + ); + } + + $inheritedValue = $currentValue; + + 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) + ->setInheritedValue($inheritedValue) + ->setSourceScope($currentSourceScope) + ->setVisible($visible) + ->setEditableByCurrentActor($visible && $this->canManagePolicyAtCurrentScope($context)) + ->setCanSaveAsUserDefault($visible && $canOverrideBelow && $definition->supportsUserPreference()) + ->setCanUseAsRequestOverride($visible && $canOverrideBelow && $definition->supportsUserPreference()) + ->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 { + $validDefinitions = array_filter( + $definitions, + static fn (mixed $d): bool => $d instanceof IPolicyDefinition, + ); + + $policyKeys = array_map( + static fn (IPolicyDefinition $d): string => $d->key(), + $validDefinitions, + ); + + $allGroupLayers = $this->source->loadAllGroupPolicies($policyKeys, $context); + $allUserPolicies = $this->source->loadAllUserPolicies($policyKeys, $context); + $allUserPrefs = $this->source->loadAllUserPreferences($policyKeys, $context); + + $resolved = []; + foreach ($validDefinitions as $definition) { + $key = $definition->key(); + $resolved[$key] = $this->resolveCore( + $definition, + $context, + $allGroupLayers[$key] ?? [], + $allUserPolicies[$key] ?? null, + $allUserPrefs[$key] ?? null, + ); + } + 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; + } + + private function canManagePolicyAtCurrentScope(PolicyContext $context): bool { + $actorCapabilities = $context->getActorCapabilities(); + + return ($actorCapabilities['canManageSystemPolicies'] ?? false) === true + || ($actorCapabilities['canManageGroupPolicies'] ?? false) === 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..5b7f5d7b7e --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicyContextFactory.php @@ -0,0 +1,123 @@ + $requestOverrides */ + public function forCurrentUser(array $requestOverrides = [], ?array $activeContext = null): PolicyContext { + $user = $this->userSession->getUser(); + return $this->build($user?->getUID(), $user, $requestOverrides, $activeContext, $user); + } + + 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, $this->userSession->getUser()); + } + + /** @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, $this->userSession->getUser()); + } + + /** @param array $requestOverrides */ + private function build(?string $userId, ?IUser $user, array $requestOverrides = [], ?array $activeContext = null, ?IUser $currentActor = null): PolicyContext { + $validatedActiveContext = $this->validateActiveContext($activeContext, $currentActor); + + $context = (new PolicyContext()) + ->setRequestOverrides($requestOverrides) + ->setActiveContext($validatedActiveContext) + ->setActorCapabilities($this->resolveActorCapabilities($currentActor)); + + if ($userId !== null && $userId !== '') { + $context->setUserId($userId); + if ($user instanceof IUser) { + $context->setGroups($this->groupManager->getUserGroupIds($user)); + } + } + + return $context; + } + + /** @param array|null $activeContext + * @return array|null + */ + private function validateActiveContext(?array $activeContext, ?IUser $currentActor): ?array { + if ($activeContext === null) { + return null; + } + + $type = $activeContext['type'] ?? null; + $id = $activeContext['id'] ?? null; + if ($type !== 'group' || !is_string($id) || trim($id) === '') { + throw new LibresignException('Only group active context is supported for policy overrides.', Http::STATUS_UNPROCESSABLE_ENTITY); + } + + $groupId = trim($id); + if (!$currentActor instanceof IUser || !in_array($groupId, $this->groupManager->getUserGroupIds($currentActor), true)) { + throw new LibresignException('You are not allowed to use this policy context.', Http::STATUS_UNPROCESSABLE_ENTITY); + } + + return [ + 'type' => 'group', + 'id' => $groupId, + ]; + } + + /** @return array */ + private function resolveActorCapabilities(?IUser $currentActor): array { + if (!$currentActor instanceof IUser) { + return [ + 'canManageSystemPolicies' => false, + 'canManageGroupPolicies' => false, + ]; + } + + $userId = $currentActor->getUID(); + $canManageSystemPolicies = $this->groupManager->isAdmin($userId) === true; + + return [ + 'canManageSystemPolicies' => $canManageSystemPolicies, + 'canManageGroupPolicies' => $canManageSystemPolicies || $this->subAdmin->isSubAdmin($currentActor) === true, + ]; + } +} 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..da3c222306 --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicySource.php @@ -0,0 +1,841 @@ +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 loadUserPolicy(string $policyKey, PolicyContext $context): ?PolicyLayer { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return null; + } + + return $this->loadUserPolicyConfig($policyKey, $userId); + } + + #[\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($this->deserializeStoredValue($value))); + } + + #[\Override] + public function loadUserPolicyConfig(string $policyKey, string $userId): ?PolicyLayer { + if ($userId === '') { + return null; + } + + $definition = $this->registry->get($policyKey); + $storedPayload = $this->appConfig->getUserValue($userId, $this->getAssignedUserPolicyKey($definition->getUserPreferenceKey()), ''); + if ($storedPayload === '') { + return null; + } + + $decodedPayload = $this->deserializeStoredUserPolicyPayload($storedPayload); + if (!is_array($decodedPayload) || !array_key_exists('value', $decodedPayload) || $decodedPayload['value'] === null) { + return null; + } + + return (new PolicyLayer()) + ->setScope('user_policy') + ->setValue($definition->normalizeValue($decodedPayload['value'])) + ->setAllowChildOverride((bool)($decodedPayload['allowChildOverride'] ?? false)) + ->setVisibleToChild(true) + ->setAllowedValues(((bool)($decodedPayload['allowChildOverride'] ?? false)) ? [] : [$definition->normalizeValue($decodedPayload['value'])]); + } + + /** + * @param list $policyKeys + * @return array> + */ + #[\Override] + public function loadAllGroupPolicies(array $policyKeys, PolicyContext $context): array { + /** @var array> $result */ + $result = array_fill_keys($policyKeys, []); + + $groupIds = $this->resolveGroupIds($context); + if ($groupIds === []) { + return $result; + } + + $bindingsByTargetId = []; + foreach ($this->bindingMapper->findByTargets('group', $groupIds) as $binding) { + $bindingsByTargetId[$binding->getTargetId()] = $binding; + } + + $permissionSetIds = array_values(array_unique(array_map( + static fn (PermissionSetBinding $b): int => $b->getPermissionSetId(), + $bindingsByTargetId, + ))); + + $permissionSetsById = []; + foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) { + $permissionSetsById[$permissionSet->getId()] = $permissionSet; + } + + foreach ($groupIds as $groupId) { + $binding = $bindingsByTargetId[$groupId] ?? null; + if (!$binding instanceof PermissionSetBinding) { + continue; + } + + $permissionSet = $permissionSetsById[$binding->getPermissionSetId()] ?? null; + if (!$permissionSet instanceof PermissionSet) { + continue; + } + + $policyJson = $permissionSet->getDecodedPolicyJson(); + foreach ($policyKeys as $policyKey) { + $policyConfig = $policyJson[$policyKey] ?? null; + if (!is_array($policyConfig)) { + continue; + } + + $result[$policyKey][] = (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 $result; + } + + /** + * @param list $policyKeys + * @return array + */ + #[\Override] + public function loadAllUserPolicies(array $policyKeys, PolicyContext $context): array { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return []; + } + + $userPolicyKeyByPolicy = []; + foreach ($policyKeys as $policyKey) { + $userPolicyKeyByPolicy[$policyKey] = $this->getAssignedUserPolicyKey($this->registry->get($policyKey)->getUserPreferenceKey()); + } + $policyKeyByAssignedKey = array_flip($userPolicyKeyByPolicy); + + $query = $this->db->getQueryBuilder(); + $query->select('configkey', 'configvalue') + ->from('preferences') + ->where($query->expr()->eq('userid', $query->createNamedParameter($userId))) + ->andWhere($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID))) + ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPolicyKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter(''))); + + $result = $query->executeQuery(); + $layers = []; + try { + while ($row = $result->fetchAssociative()) { + $policyKey = $policyKeyByAssignedKey[$row['configkey']] ?? null; + if ($policyKey === null) { + continue; + } + + $definition = $this->registry->get($policyKey); + $decodedPayload = $this->deserializeStoredUserPolicyPayload($row['configvalue']); + if (!is_array($decodedPayload) || !array_key_exists('value', $decodedPayload) || $decodedPayload['value'] === null) { + continue; + } + + $normalizedValue = $definition->normalizeValue($decodedPayload['value']); + $allowChildOverride = (bool)($decodedPayload['allowChildOverride'] ?? false); + $layers[$policyKey] = (new PolicyLayer()) + ->setScope('user_policy') + ->setValue($normalizedValue) + ->setAllowChildOverride($allowChildOverride) + ->setVisibleToChild(true) + ->setAllowedValues($allowChildOverride ? [] : [$normalizedValue]); + } + } finally { + $result->closeCursor(); + } + + return $layers; + } + + /** + * @param list $policyKeys + * @return array + */ + #[\Override] + public function loadAllUserPreferences(array $policyKeys, PolicyContext $context): array { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return []; + } + + $userPreferenceKeyByPolicy = []; + foreach ($policyKeys as $policyKey) { + $userPreferenceKeyByPolicy[$policyKey] = $this->registry->get($policyKey)->getUserPreferenceKey(); + } + $policyKeyByPreferenceKey = array_flip($userPreferenceKeyByPolicy); + + $query = $this->db->getQueryBuilder(); + $query->select('configkey', 'configvalue') + ->from('preferences') + ->where($query->expr()->eq('userid', $query->createNamedParameter($userId))) + ->andWhere($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID))) + ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPreferenceKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter(''))); + + $result = $query->executeQuery(); + $layers = []; + try { + while ($row = $result->fetchAssociative()) { + $policyKey = $policyKeyByPreferenceKey[$row['configkey']] ?? null; + if ($policyKey === null) { + continue; + } + + $definition = $this->registry->get($policyKey); + $layers[$policyKey] = (new PolicyLayer()) + ->setScope('user') + ->setValue($definition->normalizeValue($this->deserializeStoredValue($row['configvalue']))); + } + } finally { + $result->closeCursor(); + } + + return $layers; + } + + #[\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); + } + + /** + * @param list $groupIds + * @param list $userIds + * @return array + */ + public function loadRuleCounts(array $groupIds, array $userIds): array { + $policyKeys = array_keys(PolicyProviders::BY_KEY); + /** @var array $counts */ + $counts = []; + foreach ($policyKeys as $policyKey) { + $counts[$policyKey] = [ + 'groupCount' => 0, + 'userCount' => 0, + ]; + } + + $groupIds = array_values(array_unique(array_filter($groupIds, static fn (string $groupId): bool => $groupId !== ''))); + if ($groupIds !== []) { + $groupBindings = $this->bindingMapper->findByTargets('group', $groupIds); + $permissionSetIds = array_values(array_unique(array_map( + static fn (PermissionSetBinding $binding): int => $binding->getPermissionSetId(), + $groupBindings, + ))); + + $permissionSetsById = []; + foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) { + $permissionSetsById[$permissionSet->getId()] = $permissionSet; + } + + foreach ($groupBindings as $binding) { + $policyJson = $permissionSetsById[$binding->getPermissionSetId()]?->getDecodedPolicyJson() ?? []; + foreach ($policyJson as $policyKey => $policyConfig) { + if (!isset($counts[$policyKey]) || !is_array($policyConfig)) { + continue; + } + + if (!array_key_exists('defaultValue', $policyConfig) || $policyConfig['defaultValue'] === null) { + continue; + } + + $counts[$policyKey]['groupCount']++; + } + } + } + + $userIds = array_values(array_unique(array_filter($userIds, static fn (string $userId): bool => $userId !== ''))); + if ($userIds === []) { + return $counts; + } + + $userPolicyKeyByPolicy = []; + foreach ($policyKeys as $policyKey) { + $userPolicyKeyByPolicy[$policyKey] = $this->getAssignedUserPolicyKey($this->registry->get($policyKey)->getUserPreferenceKey()); + } + $policyKeyByUserPreference = array_flip($userPolicyKeyByPolicy); + + $query = $this->db->getQueryBuilder(); + $query->select('configkey') + ->selectAlias($query->createFunction('COUNT(DISTINCT userid)'), 'user_count') + ->from('preferences') + ->where($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID))) + ->andWhere($query->expr()->in('userid', $query->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPolicyKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter(''))) + ->groupBy('configkey'); + + $result = $query->executeQuery(); + try { + while ($row = $result->fetchAssociative()) { + $policyKey = $policyKeyByUserPreference[$row['configkey']] ?? null; + if (!is_string($policyKey) || !isset($counts[$policyKey])) { + continue; + } + + $counts[$policyKey]['userCount'] = (int)($row['user_count'] ?? 0); + } + } finally { + $result->closeCursor(); + } + + return $counts; + } + + /** + * Count group/user rules for ALL known targets (no ID filter). Suitable for system admins. + * + * @return array + */ + public function loadAllRuleCounts(): array { + $policyKeys = array_keys(PolicyProviders::BY_KEY); + /** @var array $counts */ + $counts = []; + foreach ($policyKeys as $policyKey) { + $counts[$policyKey] = ['groupCount' => 0, 'userCount' => 0]; + } + + $groupBindings = $this->bindingMapper->findByTargetType('group'); + if ($groupBindings !== []) { + $permissionSetIds = array_values(array_unique(array_map( + static fn (PermissionSetBinding $binding): int => $binding->getPermissionSetId(), + $groupBindings, + ))); + + $permissionSetsById = []; + foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) { + $permissionSetsById[$permissionSet->getId()] = $permissionSet; + } + + foreach ($groupBindings as $binding) { + $policyJson = $permissionSetsById[$binding->getPermissionSetId()]?->getDecodedPolicyJson() ?? []; + foreach ($policyJson as $policyKey => $policyConfig) { + if (!isset($counts[$policyKey]) || !is_array($policyConfig)) { + continue; + } + + if (!array_key_exists('defaultValue', $policyConfig) || $policyConfig['defaultValue'] === null) { + continue; + } + + $counts[$policyKey]['groupCount']++; + } + } + } + + $userPolicyKeyByPolicy = []; + foreach ($policyKeys as $policyKey) { + $userPolicyKeyByPolicy[$policyKey] = $this->getAssignedUserPolicyKey($this->registry->get($policyKey)->getUserPreferenceKey()); + } + $policyKeyByUserPreference = array_flip($userPolicyKeyByPolicy); + + $query = $this->db->getQueryBuilder(); + $query->select('configkey') + ->selectAlias($query->createFunction('COUNT(DISTINCT userid)'), 'user_count') + ->from('preferences') + ->where($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID))) + ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPolicyKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter(''))) + ->groupBy('configkey'); + + $result = $query->executeQuery(); + try { + while ($row = $result->fetchAssociative()) { + $policyKey = $policyKeyByUserPreference[$row['configkey']] ?? null; + if (!is_string($policyKey) || !isset($counts[$policyKey])) { + continue; + } + + $counts[$policyKey]['userCount'] = (int)($row['user_count'] ?? 0); + } + } finally { + $result->closeCursor(); + } + + return $counts; + } + + #[\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()); + + $valuesAreEqual = $normalizedValue === $defaultValue; + if (!$valuesAreEqual && is_string($normalizedValue) && is_string($defaultValue)) { + $d1 = json_decode($normalizedValue, true); + $d2 = json_decode($defaultValue, true); + if (is_array($d1) && is_array($d2)) { + $valuesAreEqual = $d1 === $d2; + } + } + if ($valuesAreEqual) { + 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 { + try { + 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); + } catch (AppConfigTypeConflictException $exception) { + if (is_string($defaultValue)) { + try { + $arrayValue = $this->appConfig->getAppValueArray($key, []); + return json_encode($arrayValue, JSON_THROW_ON_ERROR); + } catch (AppConfigTypeConflictException) { + return $this->appConfig->getAppValueBool($key, in_array(strtolower(trim($defaultValue)), ['1', 'true', 'yes', 'on'], true)); + } catch (\JsonException) { + return (string)$defaultValue; + } + } + + if (is_bool($defaultValue)) { + return $this->appConfig->getAppValueString($key, $defaultValue ? '1' : '0'); + } + + if (is_array($defaultValue)) { + // Value was stored as a scalar (e.g., by Nextcloud provisioning API). + // Return the raw string so the policy normalizer can decode it. + try { + return $this->appConfig->getAppValueString($key, ''); + } catch (AppConfigTypeConflictException) { + return $defaultValue; + } + } + + throw $exception; + } + } + + 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'; + } + + private function getAssignedUserPolicyKey(string $policyConfigKey): string { + return $policyConfigKey . '.assigned'; + } + + private function serializeStoredValue(mixed $value): string { + return json_encode($value, JSON_THROW_ON_ERROR); + } + + private function deserializeStoredValue(string $value): mixed { + try { + return json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $value; + } + } + + private function serializeStoredUserPolicyPayload(mixed $value, bool $allowChildOverride): string { + return json_encode([ + 'value' => $value, + 'allowChildOverride' => $allowChildOverride, + ], JSON_THROW_ON_ERROR); + } + + private function deserializeStoredUserPolicyPayload(string $payload): mixed { + try { + return json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + } + + #[\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($this->l10n->t('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(), $this->serializeStoredValue($normalizedValue)); + } + + #[\Override] + public function saveUserPolicy(string $policyKey, PolicyContext $context, mixed $value, bool $allowChildOverride): void { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + throw new \InvalidArgumentException($this->l10n->t('A target user is required to save a user policy.')); + } + + $definition = $this->registry->get($policyKey); + $normalizedValue = $definition->normalizeValue($value); + $this->appConfig->setUserValue( + $userId, + $this->getAssignedUserPolicyKey($definition->getUserPreferenceKey()), + $this->serializeStoredUserPolicyPayload($normalizedValue, $allowChildOverride), + ); + } + + #[\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()); + } + + #[\Override] + public function clearUserPolicy(string $policyKey, PolicyContext $context): void { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return; + } + + $definition = $this->registry->get($policyKey); + $this->appConfig->deleteUserValue($userId, $this->getAssignedUserPolicyKey($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/ReminderService.php b/lib/Service/ReminderService.php index 729e22d4b4..d32edcf384 100644 --- a/lib/Service/ReminderService.php +++ b/lib/Service/ReminderService.php @@ -9,21 +9,22 @@ namespace OCA\Libresign\Service; use DateTime; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\BackgroundJob\Reminder; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Reminder\ReminderPolicy; +use OCA\Libresign\Service\Policy\Provider\Reminder\ReminderPolicyValue; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJobList; -use OCP\IAppConfig; use OCP\IDateTimeZone; use Psr\Log\LoggerInterface; class ReminderService { public function __construct( protected IJobList $jobList, - protected IAppConfig $appConfig, + protected PolicyService $policyService, protected IDateTimeZone $dateTimeZone, protected ITimeFactory $time, protected SignRequestMapper $signRequestMapper, @@ -33,13 +34,8 @@ public function __construct( } public function getSettings(): array { - $settings = [ - 'days_before' => $this->appConfig->getValueInt(Application::APP_ID, 'reminder_days_before', 0), - 'days_between' => $this->appConfig->getValueInt(Application::APP_ID, 'reminder_days_between', 0), - 'max' => $this->appConfig->getValueInt(Application::APP_ID, 'reminder_max', 0), - 'send_timer' => $this->appConfig->getValueString(Application::APP_ID, 'reminder_send_timer', '10:00'), - 'next_run' => null, - ]; + $settings = $this->getEffectiveSettings(); + $settings['next_run'] = null; foreach ($this->jobList->getJobsIterator(Reminder::class, 1, 0) as $job) { $details = $this->jobList->getDetailsById($job->getId()); $settings['next_run'] = new \DateTime('@' . $details['last_checked'], new \DateTimeZone('UTC')); @@ -68,31 +64,28 @@ protected function saveConfig( || $daysBefore <= 0 || $max <= 0 ) { - $this->appConfig->deleteKey(Application::APP_ID, 'reminder_days_before'); - $this->appConfig->deleteKey(Application::APP_ID, 'reminder_days_between'); - $this->appConfig->deleteKey(Application::APP_ID, 'reminder_max'); - $this->appConfig->deleteKey(Application::APP_ID, 'reminder_send_timer'); - return [ + $normalized = [ 'days_before' => 0, 'days_between' => 0, 'max' => 0, 'send_timer' => '', ]; + + $this->saveSystemSettings($normalized); + return $normalized; } $sendTimer = $this->normalizeTime($sendTimer); - $this->setIfChangedInt('reminder_days_before', $daysBefore); - $this->setIfChangedInt('reminder_days_between', $daysBetween); - $this->setIfChangedInt('reminder_max', $max); - $this->setIfChangedString('reminder_send_timer', $sendTimer); - - return [ + $normalized = [ 'days_before' => $daysBefore, 'days_between' => $daysBetween, 'max' => $max, 'send_timer' => $sendTimer, ]; + + $this->saveSystemSettings($normalized); + return $normalized; } private function normalizeTime(string $time): string { @@ -102,18 +95,20 @@ private function normalizeTime(string $time): string { return $time; } - private function setIfChangedInt(string $key, int $value, int $default = 0): void { - $prev = $this->appConfig->getValueInt(Application::APP_ID, $key, $default); - if ($prev !== $value) { - $this->appConfig->setValueInt(Application::APP_ID, $key, $value); - } + /** @return array{days_before: int, days_between: int, max: int, send_timer: string} */ + private function getEffectiveSettings(): array { + $resolvedValue = $this->policyService->resolve(ReminderPolicy::KEY)->getEffectiveValue(); + return ReminderPolicyValue::normalize($resolvedValue); } - private function setIfChangedString(string $key, string $value, string $default = ''): void { - $prev = $this->appConfig->getValueString(Application::APP_ID, $key, $default); - if ($prev !== $value) { - $this->appConfig->setValueString(Application::APP_ID, $key, $value); - } + /** @param array{days_before: int, days_between: int, max: int, send_timer: string} $settings */ + private function saveSystemSettings(array $settings): void { + $allowChildOverride = $this->policyService->getSystemPolicy(ReminderPolicy::KEY)?->isAllowChildOverride() ?? false; + $this->policyService->saveSystem( + ReminderPolicy::KEY, + ReminderPolicyValue::encode($settings), + $allowChildOverride, + ); } protected function scheduleJob(string $startTime): ?DateTime { @@ -160,15 +155,19 @@ protected function getStartTime(string $startTime): ?\DateTime { } public function sendReminders(): void { - $daysBefore = $this->appConfig->getValueInt(Application::APP_ID, 'reminder_days_before', 0); + $settings = $this->getEffectiveSettings(); + + $daysBefore = $settings['days_before']; if ($daysBefore <= 0) { return; } - $daysBetween = $this->appConfig->getValueInt(Application::APP_ID, 'reminder_days_between', 0); + + $daysBetween = $settings['days_between']; if ($daysBetween <= 0) { return; } - $max = $this->appConfig->getValueInt(Application::APP_ID, 'reminder_max', 0); + + $max = $settings['max']; if ($max === 0) { return; } diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 78320822ec..f4ea362a06 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -8,7 +8,6 @@ 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; @@ -16,7 +15,6 @@ use OCA\Libresign\Db\SignRequest as SignRequestEntity; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Enum\FileStatus; -use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\Events\SignRequestCanceledEvent; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\DocMdpHandler; @@ -27,6 +25,7 @@ use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\File\Pdf\PdfMetadataExtractor; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; +use OCA\Libresign\Service\Policy\FilePolicyApplier; use OCA\Libresign\Service\SignRequest\SignRequestService; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IMimeTypeDetector; @@ -67,6 +66,7 @@ public function __construct( protected EnvelopeFileRelocator $envelopeFileRelocator, protected FileUploadHelper $uploadHelper, protected SignRequestService $signRequestService, + protected FilePolicyApplier $filePolicyApplier, ) { } @@ -88,6 +88,8 @@ public function saveFiles(array $data): array { 'userManager' => $data['userManager'], 'status' => FileStatus::DRAFT->value, 'settings' => $data['settings'], + 'policyOverrides' => $data['policyOverrides'] ?? [], + 'policyActiveContext' => $data['policyActiveContext'] ?? null, ]; if (isset($fileData['uploadedFile'])) { @@ -114,7 +116,8 @@ public function saveFiles(array $data): array { 'signers' => $data['signers'] ?? [], 'status' => $data['status'] ?? FileStatus::DRAFT->value, 'visibleElements' => $data['visibleElements'] ?? [], - 'signatureFlow' => $data['signatureFlow'] ?? null, + 'policyOverrides' => $data['policyOverrides'] ?? [], + 'policyActiveContext' => $data['policyActiveContext'] ?? null, ]); return [ @@ -185,7 +188,13 @@ public function saveEnvelope(array $data): array { $createdNodes[] = $node; $fileData['node'] = $node; - $fileEntity = $this->createFileForEnvelope($fileData, $userManager, $envelopeSettings); + $fileEntity = $this->createFileForEnvelope( + $fileData, + $userManager, + $envelopeSettings, + $data['policyOverrides'] ?? [], + $data['policyActiveContext'] ?? null, + ); $this->envelopeService->addFileToEnvelope($envelope->getId(), $fileEntity); $files[] = $fileEntity; } @@ -291,7 +300,13 @@ private function rollbackEnvelope(?FileEntity $envelope): void { } } - private function createFileForEnvelope(array $fileData, ?IUser $userManager, array $settings): FileEntity { + private function createFileForEnvelope( + array $fileData, + ?IUser $userManager, + array $settings, + array $policyOverrides = [], + ?array $policyActiveContext = null, + ): FileEntity { if (!isset($fileData['node'])) { throw new \InvalidArgumentException('Node not provided in file data'); } @@ -305,6 +320,8 @@ private function createFileForEnvelope(array $fileData, ?IUser $userManager, arr 'userManager' => $userManager, 'status' => FileStatus::DRAFT->value, 'settings' => $settings, + 'policyOverrides' => $policyOverrides, + 'policyActiveContext' => $policyActiveContext, ]); } @@ -316,7 +333,7 @@ private function createFileForEnvelope(array $fileData, ?IUser $userManager, arr public function saveFile(array $data): FileEntity { if (!empty($data['uuid'])) { $file = $this->fileMapper->getByUuid($data['uuid']); - $this->updateSignatureFlowIfAllowed($file, $data); + $this->filePolicyApplier->syncCoreFlowPolicies($file, $data); if (!empty($data['name'])) { $file->setName($data['name']); $this->fileService->update($file); @@ -332,7 +349,7 @@ public function saveFile(array $data): FileEntity { if (!is_null($fileId)) { try { $file = $this->fileMapper->getByNodeId($fileId); - $this->updateSignatureFlowIfAllowed($file, $data); + $this->filePolicyApplier->syncAllPolicies($file, $data); return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0); } catch (\Throwable) { } @@ -373,54 +390,12 @@ public function saveFile(array $data): FileEntity { $file->setParentFileId($data['parentFileId']); } - $this->setSignatureFlow($file, $data); - $this->setDocMdpLevelFromGlobalConfig($file); + $this->filePolicyApplier->applyAll($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; - - if ($adminForcedConfig) { - $adminFlowEnum = SignatureFlow::from($adminFlow); - if ($file->getSignatureFlowEnum() !== $adminFlowEnum) { - $file->setSignatureFlowEnum($adminFlowEnum); - $this->fileService->update($file); - } - return; - } - - if (isset($data['signatureFlow']) && !empty($data['signatureFlow'])) { - $newFlow = SignatureFlow::from($data['signatureFlow']); - if ($file->getSignatureFlowEnum() !== $newFlow) { - $file->setSignatureFlowEnum($newFlow); - $this->fileService->update($file); - } - } - } - - private function setSignatureFlow(FileEntity $file, array $data): void { - $adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value); - - 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 setDocMdpLevelFromGlobalConfig(FileEntity $file): void { - if ($this->docMdpConfigService->isEnabled()) { - $docmdpLevel = $this->docMdpConfigService->getLevel(); - $file->setDocmdpLevelEnum($docmdpLevel); - } - } - private function getFileMetadata(\OCP\Files\Node $node): array { $metadata = []; if ($extension = strtolower($node->getExtension())) { diff --git a/lib/Service/SessionService.php b/lib/Service/SessionService.php index 3e62b9a747..8feff30120 100644 --- a/lib/Service/SessionService.php +++ b/lib/Service/SessionService.php @@ -8,8 +8,6 @@ namespace OCA\Libresign\Service; -use OCA\Libresign\AppInfo\Application; -use OCP\IAppConfig; use OCP\ISession; class SessionService { @@ -18,7 +16,6 @@ class SessionService { public function __construct( protected ISession $session, - protected IAppConfig $appConfig, ) { } @@ -46,8 +43,7 @@ public function getIdentifyMethodId(): ?int { return $id; } - public function resetDurationOfSignPage(): void { - $renewalInterval = $this->appConfig->getValueInt(Application::APP_ID, 'renewal_interval', self::NO_RENEWAL_INTERVAL); + public function resetDurationOfSignPage(int $renewalInterval): void { if ($renewalInterval <= self::NO_RENEWAL_INTERVAL) { return; } diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index 3a37105c47..c34d72f259 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -28,7 +28,9 @@ use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Db\UserElementMapper; use OCA\Libresign\Enum\FileStatus; +use OCA\Libresign\Enum\IdentifyMethodRequirement; use OCA\Libresign\Events\SignedEventFactory; +use OCA\Libresign\Exception\FooterStampUnavailableException; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Handler\FooterHandler; @@ -41,6 +43,10 @@ use OCA\Libresign\Service\Envelope\EnvelopeStatusDeterminer; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\IToken; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\CollectMetadata\CollectMetadataPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; use OCA\Libresign\Service\SignRequest\SignRequestService; use OCA\Libresign\Service\SignRequest\StatusService; use OCP\AppFramework\Db\DoesNotExistException; @@ -121,6 +127,7 @@ public function __construct( private PfxProvider $pfxProvider, private SubjectAlternativeNameService $subjectAlternativeNameService, private SignRequestService $signRequestService, + private PolicyService $policyService, ) { } @@ -594,6 +601,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 +637,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); @@ -944,7 +971,10 @@ private function buildBaseSignatureParams(array $certificateData): array { } private function buildValidationUrl(string $uuid): string { - $validationSite = trim($this->appConfig->getValueString(Application::APP_ID, 'validation_site', '')); + $footerPolicy = FooterPolicyValue::normalize( + $this->policyService->resolve(FooterPolicy::KEY)->getEffectiveValue() + ); + $validationSite = trim($footerPolicy['validationSite']); if ($validationSite !== '') { return rtrim($validationSite, '/') . '/' . $uuid; } @@ -1009,7 +1039,7 @@ private function addMetadataToSignatureParams(array $signatureParams): array { } public function storeUserMetadata(array $metadata = []): self { - $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false); + $collectMetadata = $this->isCollectMetadataEnabled(); if (!$collectMetadata || !$metadata) { return $this; } @@ -1021,6 +1051,10 @@ public function storeUserMetadata(array $metadata = []): self { return $this; } + private function isCollectMetadataEnabled(): bool { + return (bool)$this->policyService->resolve(CollectMetadataPolicy::KEY)->getEffectiveValue(); + } + /** * @return SignRequestEntity[] */ @@ -1339,6 +1373,7 @@ protected function getPdfToSign(File $originalFile): File { return $this->createSignedFile($originalFile, $originalContent); } $metadata = $this->footerHandler->getMetadata($originalFile, $this->libreSignFile); + $this->footerHandler->setRequestPolicyOverrides($this->resolveFooterPolicyRequestOverridesFromFileMetadata()); $footer = $this->footerHandler ->setTemplateVar('uuid', $this->libreSignFile->getUuid()) ->setTemplateVar('signers', array_map(fn (SignRequestEntity $signer) => [ @@ -1357,7 +1392,12 @@ protected function getPdfToSign(File $originalFile): File { try { $pdfContent = $this->pdf->applyStamp($input, $stamp); - } catch (RuntimeException $e) { + } catch (FooterStampUnavailableException $e) { + $this->logger->warning('Using original PDF because footer stamping is unavailable.', [ + 'exception' => $e, + ]); + $pdfContent = $originalContent; + } catch (RuntimeException|LibresignException $e) { throw new LibresignException($e->getMessage()); } } else { @@ -1366,6 +1406,33 @@ protected function getPdfToSign(File $originalFile): File { return $this->createSignedFile($originalFile, $pdfContent); } + /** @return array */ + private function resolveFooterPolicyRequestOverridesFromFileMetadata(): array { + $metadata = $this->libreSignFile->getMetadata(); + if (!is_array($metadata)) { + return []; + } + + $policySnapshot = $metadata['policy_snapshot'] ?? null; + if (!is_array($policySnapshot)) { + return []; + } + + $footerSnapshot = $policySnapshot[FooterPolicy::KEY] ?? null; + if (!is_array($footerSnapshot)) { + return []; + } + + $effectiveValue = $footerSnapshot['effectiveValue'] ?? null; + if (!is_string($effectiveValue) || trim($effectiveValue) === '') { + return []; + } + + return [ + FooterPolicy::KEY => $effectiveValue, + ]; + } + protected function getSignedFile(): ?File { $nodeId = $this->libreSignFile->getSignedNodeId(); if (!$nodeId) { @@ -1457,7 +1524,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(); @@ -1465,9 +1533,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) { @@ -1566,7 +1637,8 @@ public function getSignerData(?IUser $user, ?SignRequestEntity $signRequest = nu public function getAvailableIdentifyMethodsFromSettings(): array { $identifyMethods = $this->identifyMethodService->getIdentifyMethodsSettings(); $return = array_map(fn (array $identifyMethod): array => [ - 'mandatory' => $identifyMethod['mandatory'], + 'requirement' => IdentifyMethodRequirement::tryFrom((string)($identifyMethod['requirement'] ?? ''))?->value + ?? IdentifyMethodRequirement::OPTIONAL->value, 'identifiedAtDate' => null, 'validateCode' => false, 'method' => $identifyMethod['name'], diff --git a/lib/Service/SignatureBackgroundService.php b/lib/Service/SignatureBackgroundService.php index e6a0d7534b..1d0a4d50e7 100644 --- a/lib/Service/SignatureBackgroundService.php +++ b/lib/Service/SignatureBackgroundService.php @@ -11,8 +11,9 @@ use Exception; use Imagick; use ImagickPixel; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Files\TSimpleFile; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\SignatureBackground\SignatureBackgroundPolicy; use OCP\Files\IAppData; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; @@ -32,6 +33,8 @@ public function __construct( private IAppConfig $appConfig, private IConfig $config, private ITempManager $tempManager, + private SignatureTextService $signatureTextService, + private PolicyService $policyService, ) { } @@ -51,13 +54,23 @@ public function updateImage(string $tmpFile): void { $content = $this->optmizeImage(file_get_contents($tmpFile)); - $this->appConfig->setValueString(Application::APP_ID, 'signature_background_type', 'custom'); + $this->saveSystemBackgroundType('custom'); $target = $folder->newFile('background.png'); $target->putContent($content); } public function getSignatureBackgroundType(): string { - return $this->appConfig->getValueString(Application::APP_ID, 'signature_background_type', 'default'); + $value = $this->policyService->resolve(SignatureBackgroundPolicy::KEY)->getEffectiveValue(); + if (!is_string($value)) { + return 'default'; + } + + $normalized = trim(strtolower($value)); + if (!in_array($normalized, ['default', 'custom', 'deleted'], true)) { + return 'default'; + } + + return $normalized; } public function isEnabled(): bool { @@ -87,8 +100,8 @@ private function optmizeImage(string $content, float $opacity = 1): string { } private function scaleDimensions(int $width, int $height): array { - $signatureWidth = $this->appConfig->getValueFloat(Application::APP_ID, 'signature_width', SignatureTextService::DEFAULT_SIGNATURE_WIDTH); - $signatureHeight = $this->appConfig->getValueFloat(Application::APP_ID, 'signature_height', SignatureTextService::DEFAULT_SIGNATURE_HEIGHT); + $signatureWidth = $this->signatureTextService->getFullSignatureWidth(); + $signatureHeight = $this->signatureTextService->getFullSignatureHeight(); $maxWidth = $signatureWidth * self::SCALE_FACTOR; $maxHeight = $signatureHeight * self::SCALE_FACTOR; @@ -109,7 +122,7 @@ private function scaleDimensions(int $width, int $height): array { public function delete(): void { try { - $this->appConfig->setValueString(Application::APP_ID, 'signature_background_type', 'deleted'); + $this->saveSystemBackgroundType('deleted'); $file = $this->getRootFolder()->getFile('background.png'); $file->delete(); } catch (NotFoundException|NotPermittedException) { @@ -118,13 +131,18 @@ public function delete(): void { public function reset(): void { try { - $this->appConfig->deleteKey(Application::APP_ID, 'signature_background_type'); + $this->saveSystemBackgroundType('default'); $file = $this->getRootFolder()->getFile('background.png'); $file->delete(); } catch (NotFoundException|NotPermittedException) { } } + private function saveSystemBackgroundType(string $value): void { + $allowChildOverride = $this->policyService->getSystemPolicy(SignatureBackgroundPolicy::KEY)?->isAllowChildOverride() ?? false; + $this->policyService->saveSystem(SignatureBackgroundPolicy::KEY, $value, $allowChildOverride); + } + public function getImage(): ISimpleFile { try { $file = $this->getRootFolder()->getFile('background.png'); diff --git a/lib/Service/SignatureTextService.php b/lib/Service/SignatureTextService.php index 2e4543c1e8..72e55a9897 100644 --- a/lib/Service/SignatureTextService.php +++ b/lib/Service/SignatureTextService.php @@ -13,8 +13,12 @@ use Imagick; use ImagickDraw; use ImagickPixel; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\CollectMetadata\CollectMetadataPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; +use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicy as SignatureTextPolicyProvider; use OCA\Libresign\Vendor\Endroid\QrCode\Color\Color; use OCA\Libresign\Vendor\Endroid\QrCode\Encoding\Encoding; use OCA\Libresign\Vendor\Endroid\QrCode\ErrorCorrectionLevel; @@ -24,7 +28,6 @@ use OCA\Libresign\Vendor\Twig\Environment; use OCA\Libresign\Vendor\Twig\Error\SyntaxError; use OCA\Libresign\Vendor\Twig\Loader\FilesystemLoader; -use OCP\IAppConfig; use OCP\IDateTimeZone; use OCP\IL10N; use OCP\IRequest; @@ -43,13 +46,13 @@ class SignatureTextService { public const DEFAULT_SIGNATURE_HEIGHT = 100; private const QRCODE_SIZE = 100; public function __construct( - private IAppConfig $appConfig, private IL10N $l10n, private IDateTimeZone $dateTimeZone, private IRequest $request, private IUserSession $userSession, private IURLGenerator $urlGenerator, protected LoggerInterface $logger, + private PolicyService $policyService, ) { } @@ -111,12 +114,12 @@ public function save( $template = strip_tags((string)$template); $template = trim($template); $template = html_entity_decode($template); - $this->appConfig->setValueString(Application::APP_ID, 'signature_text_template', $template); - $this->appConfig->setValueFloat(Application::APP_ID, 'signature_width', $signatureWidth); - $this->appConfig->setValueFloat(Application::APP_ID, 'signature_height', $signatureHeight); - $this->appConfig->setValueFloat(Application::APP_ID, 'template_font_size', $templateFontSize); - $this->appConfig->setValueFloat(Application::APP_ID, 'signature_font_size', $signatureFontSize); - $this->appConfig->setValueString(Application::APP_ID, 'signature_render_mode', $renderMode); + $this->policyService->saveSystem(SignatureTextPolicyProvider::KEY_TEMPLATE, $template); + $this->policyService->saveSystem(SignatureTextPolicyProvider::KEY_SIGNATURE_WIDTH, $signatureWidth); + $this->policyService->saveSystem(SignatureTextPolicyProvider::KEY_SIGNATURE_HEIGHT, $signatureHeight); + $this->policyService->saveSystem(SignatureTextPolicyProvider::KEY_TEMPLATE_FONT_SIZE, $templateFontSize); + $this->policyService->saveSystem(SignatureTextPolicyProvider::KEY_SIGNATURE_FONT_SIZE, $signatureFontSize); + $this->policyService->saveSystem(SignatureTextPolicyProvider::KEY_RENDER_MODE, $renderMode); return $this->parse($template); } @@ -192,10 +195,7 @@ public function parse(string $template = '', array $context = []): array { } public function getTemplate(): string { - if ($this->appConfig->hasKey(Application::APP_ID, 'signature_text_template')) { - return $this->appConfig->getValueString(Application::APP_ID, 'signature_text_template'); - } - return $this->getDefaultTemplate(); + return (string)$this->policyService->resolve(SignatureTextPolicyProvider::KEY_TEMPLATE)->getEffectiveValue(); } public function getAvailableVariables(): array { @@ -217,7 +217,7 @@ public function getAvailableVariables(): array { // '{{qrcode}}' => $this->l10n->t('Base64-encoded PNG QR code for the validation URL. In HTML/Twig, use . In plain-text templates, use {{ValidationURL}}.'), ]; - $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false); + $collectMetadata = $this->isCollectMetadataEnabled(); if ($collectMetadata) { $list['{{SignerIP}}'] = $this->l10n->t('IP address of the person who signed the document.'); $list['{{SignerUserAgent}}'] = $this->l10n->t('Browser and device information of the person who signed the document.'); @@ -445,7 +445,7 @@ private function mbWordwrap(string $text, int $width, string $break = "\n", bool } public function getDefaultTemplate(): string { - $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false); + $collectMetadata = $this->isCollectMetadataEnabled(); if ($collectMetadata) { // TRANSLATORS Variables enclosed in double curly braces {{variableName}} are template placeholders. // @@ -491,15 +491,15 @@ public function getDefaultTemplate(): string { } public function getFullSignatureWidth(): float { - return $this->getSanitizedDimension('signature_width', self::DEFAULT_SIGNATURE_WIDTH); + return $this->getSanitizedDimension(SignatureTextPolicyProvider::KEY_SIGNATURE_WIDTH, self::DEFAULT_SIGNATURE_WIDTH); } public function getFullSignatureHeight(): float { - return $this->getSanitizedDimension('signature_height', self::DEFAULT_SIGNATURE_HEIGHT); + return $this->getSanitizedDimension(SignatureTextPolicyProvider::KEY_SIGNATURE_HEIGHT, self::DEFAULT_SIGNATURE_HEIGHT); } public function getSignatureWidth(): float { - $current = $this->appConfig->getValueFloat(Application::APP_ID, 'signature_width', self::DEFAULT_SIGNATURE_WIDTH); + $current = (float)$this->policyService->resolve(SignatureTextPolicyProvider::KEY_SIGNATURE_WIDTH)->getEffectiveValue(); if ($this->getRenderMode() === SignerElementsService::RENDER_MODE_GRAPHIC_ONLY || !$this->getTemplate()) { return $current; } @@ -511,10 +511,9 @@ public function getSignatureHeight(): float { } private function getSanitizedDimension(string $key, float $default): float { - $value = $this->appConfig->getValueFloat(Application::APP_ID, $key, $default); + $value = (float)$this->policyService->resolve($key)->getEffectiveValue(); if (!is_finite($value) || $value < self::SIGNATURE_DIMENSION_MINIMUM) { - $this->appConfig->setValueFloat(Application::APP_ID, $key, $default); - $this->logger->warning('Invalid signature dimension found in app config. Falling back to default.', [ + $this->logger->warning('Invalid signature dimension found in policy resolution. Falling back to default value in memory.', [ 'key' => $key, 'value' => $value, 'default' => $default, @@ -525,27 +524,27 @@ private function getSanitizedDimension(string $key, float $default): float { } public function getTemplateFontSize(): float { - $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false); - if ($collectMetadata) { - return $this->appConfig->getValueFloat(Application::APP_ID, 'template_font_size', self::TEMPLATE_DEFAULT_FONT_SIZE - 1); - } - return $this->appConfig->getValueFloat(Application::APP_ID, 'template_font_size', self::TEMPLATE_DEFAULT_FONT_SIZE); + return (float)$this->policyService->resolve(SignatureTextPolicyProvider::KEY_TEMPLATE_FONT_SIZE)->getEffectiveValue(); } public function getDefaultTemplateFontSize(): float { - $collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false); + $collectMetadata = $this->isCollectMetadataEnabled(); if ($collectMetadata) { return self::TEMPLATE_DEFAULT_FONT_SIZE - 0.2; } return self::TEMPLATE_DEFAULT_FONT_SIZE; } + private function isCollectMetadataEnabled(): bool { + return (bool)$this->policyService->resolve(CollectMetadataPolicy::KEY)->getEffectiveValue(); + } + public function getSignatureFontSize(): float { - return $this->appConfig->getValueFloat(Application::APP_ID, 'signature_font_size', self::SIGNATURE_DEFAULT_FONT_SIZE); + return (float)$this->policyService->resolve(SignatureTextPolicyProvider::KEY_SIGNATURE_FONT_SIZE)->getEffectiveValue(); } public function getRenderMode(): string { - return $this->appConfig->getValueString(Application::APP_ID, 'signature_render_mode', SignerElementsService::RENDER_MODE_DEFAULT); + return (string)$this->policyService->resolve(SignatureTextPolicyProvider::KEY_RENDER_MODE)->getEffectiveValue(); } public function isEnabled(): bool { @@ -553,7 +552,10 @@ public function isEnabled(): bool { } private function buildValidationUrl(string $uuid): string { - $validationSite = trim($this->appConfig->getValueString(Application::APP_ID, 'validation_site', '')); + $footerPolicy = FooterPolicyValue::normalize( + $this->policyService->resolve(FooterPolicy::KEY)->getEffectiveValue() + ); + $validationSite = trim($footerPolicy['validationSite']); if ($validationSite !== '') { return rtrim($validationSite, '/') . '/' . $uuid; } diff --git a/lib/Service/TsaValidationService.php b/lib/Service/TsaValidationService.php index 4f9204cfd1..f8c51b2a77 100644 --- a/lib/Service/TsaValidationService.php +++ b/lib/Service/TsaValidationService.php @@ -8,13 +8,14 @@ namespace OCA\Libresign\Service; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Exception\LibresignException; -use OCP\IAppConfig; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicy; +use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicyValue; class TsaValidationService { public function __construct( - private IAppConfig $appConfig, + private PolicyService $policyService, ) { } @@ -34,7 +35,9 @@ public function validateConfiguration(): void { } private function getTsaUrl(): string { - return $this->appConfig->getValueString(Application::APP_ID, 'tsa_url', ''); + $rawPolicyValue = $this->policyService->resolve(TsaPolicy::KEY)->getEffectiveValue(); + $decoded = TsaPolicyValue::decode($rawPolicyValue); + return $decoded['url']; } private function validateTsaUrlFormat(string $tsaUrl): void { diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 411b28d16a..5caa56e5a9 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -11,15 +11,17 @@ 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\SignatureBackgroundService; +use OCA\Libresign\Service\Policy\PolicyService; use OCA\Libresign\Service\SignatureTextService; +use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\IAppConfig; +use OCP\IUserSession; use OCP\Settings\ISettings; use OCP\Util; @@ -33,20 +35,22 @@ class Admin implements ISettings { public function __construct( private IInitialState $initialState, - private IdentifyMethodService $identifyMethodService, + private AccountService $accountService, + private IUserSession $userSession, private CertificateEngineFactory $certificateEngineFactory, private CertificatePolicyService $certificatePolicyService, private IAppConfig $appConfig, private SignatureTextService $signatureTextService, - private SignatureBackgroundService $signatureBackgroundService, private FooterService $footerService, - private DocMdpConfigService $docMdpConfigService, + private PolicyService $policyService, + private IdentifyMethodService $identifyMethodService, ) { } #[\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']); @@ -58,46 +62,32 @@ public function getForm(): TemplateResponse { $this->initialState->provideInitialState('certificate_policies_oid', $this->certificatePolicyService->getOid()); $this->initialState->provideInitialState('certificate_policies_cps', $this->certificatePolicyService->getCps()); $this->initialState->provideInitialState('config_path', $this->appConfig->getValueString(Application::APP_ID, 'config_path')); - $this->initialState->provideInitialState('default_signature_font_size', SignatureTextService::SIGNATURE_DEFAULT_FONT_SIZE); - $this->initialState->provideInitialState('default_signature_height', SignatureTextService::DEFAULT_SIGNATURE_HEIGHT); - $this->initialState->provideInitialState('default_signature_text_template', $this->signatureTextService->getDefaultTemplate()); - $this->initialState->provideInitialState('default_signature_width', SignatureTextService::DEFAULT_SIGNATURE_WIDTH); - $this->initialState->provideInitialState('default_template_font_size', $this->signatureTextService->getDefaultTemplateFontSize()); - $this->initialState->provideInitialState('identify_methods', $this->identifyMethodService->getIdentifyMethodsSettings()); - $this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information', '')); $this->initialState->provideInitialState('signature_available_variables', $this->signatureTextService->getAvailableVariables()); - $this->initialState->provideInitialState('signature_background_type', $this->signatureBackgroundService->getSignatureBackgroundType()); - $this->initialState->provideInitialState('signature_font_size', $this->signatureTextService->getSignatureFontSize()); - $this->initialState->provideInitialState('signature_height', $this->signatureTextService->getFullSignatureHeight()); - $this->initialState->provideInitialState('signature_preview_zoom_level', $this->appConfig->getValueFloat(Application::APP_ID, 'signature_preview_zoom_level', 100)); - $this->initialState->provideInitialState('footer_preview_zoom_level', $this->appConfig->getValueFloat(Application::APP_ID, 'footer_preview_zoom_level', 100)); - $this->initialState->provideInitialState('footer_preview_width', $this->appConfig->getValueInt(Application::APP_ID, 'footer_preview_width', 595)); - $this->initialState->provideInitialState('footer_preview_height', $this->appConfig->getValueInt(Application::APP_ID, 'footer_preview_height', 100)); $this->initialState->provideInitialState('footer_template_variables', $this->footerService->getTemplateVariablesMetadata()); + $this->initialState->provideInitialState('footer_default_template', $this->footerService->getDefaultTemplate()); $this->initialState->provideInitialState('footer_template', $this->footerService->getTemplate()); $this->initialState->provideInitialState('footer_template_is_default', $this->footerService->isDefaultTemplate()); $this->initialState->provideInitialState('signature_engine', $this->getSignatureEngineInitialState()); - $this->initialState->provideInitialState('signature_render_mode', $this->signatureTextService->getRenderMode()); - $this->initialState->provideInitialState('signature_text_template', $this->signatureTextService->getTemplate()); - $this->initialState->provideInitialState('signature_width', $this->signatureTextService->getFullSignatureWidth()); - $this->initialState->provideInitialState('template_font_size', $this->signatureTextService->getTemplateFontSize()); - $this->initialState->provideInitialState('tsa_url', $this->appConfig->getValueString(Application::APP_ID, 'tsa_url', '')); - $this->initialState->provideInitialState('tsa_policy_oid', $this->appConfig->getValueString(Application::APP_ID, 'tsa_policy_oid', '')); - $this->initialState->provideInitialState('tsa_auth_type', $this->appConfig->getValueString(Application::APP_ID, 'tsa_auth_type', 'none')); - $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)); + $this->initialState->provideInitialState('identify_methods', $this->identifyMethodService->getIdentifyMethodsSettings()); + $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)); - $this->initialState->provideInitialState('approval_group', $this->appConfig->getValueArray(Application::APP_ID, 'approval_group', ['admin'])); - $this->initialState->provideInitialState('envelope_enabled', $this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true)); $this->initialState->provideInitialState('parallel_workers', $this->appConfig->getValueString(Application::APP_ID, 'parallel_workers', '4')); - $this->initialState->provideInitialState('show_confetti_after_signing', $this->appConfig->getValueBool(Application::APP_ID, 'show_confetti_after_signing', true)); - $this->initialState->provideInitialState('crl_external_validation_enabled', $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true)); $this->initialState->provideInitialState('ldap_extension_available', function_exists('ldap_connect')); - return new TemplateResponse(Application::APP_ID, 'admin_settings'); + + $response = new TemplateResponse(Application::APP_ID, 'admin_settings'); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedWorkerSrcDomain("'self'"); + $policy->addAllowedWorkerSrcDomain('blob:'); + $response->setContentSecurityPolicy($policy); + + return $response; } /** @@ -142,4 +132,5 @@ private function getWorkerTypeInitialState(): string { } return 'local'; } + } diff --git a/openapi-administration.json b/openapi-administration.json index 1cf0396517..9c82918dbe 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -378,6 +378,105 @@ } } }, + "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", + "groupCount", + "userCount" + ], + "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 + }, + "groupCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "userCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "EffectivePolicyValue": { + "nullable": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + }, "EngineHandler": { "type": "object", "required": [ @@ -467,7 +566,8 @@ "template", "isDefault", "preview_width", - "preview_height" + "preview_height", + "preview_zoom" ], "properties": { "template": { @@ -483,6 +583,10 @@ "preview_height": { "type": "integer", "format": "int64" + }, + "preview_zoom": { + "type": "integer", + "format": "int64" } } }, @@ -497,13 +601,20 @@ } } }, + "IdentifyMethodRequirement": { + "type": "string", + "enum": [ + "required", + "optional" + ] + }, "IdentifyMethodSetting": { "type": "object", "required": [ "name", "friendly_name", "enabled", - "mandatory" + "requirement" ], "properties": { "name": { @@ -515,8 +626,13 @@ "enabled": { "type": "boolean" }, - "mandatory": { - "type": "boolean" + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" + }, + "minimumTotalVerifiedFactors": { + "type": "integer", + "format": "int64", + "minimum": 1 }, "signatureMethods": { "$ref": "#/components/schemas/SignatureMethods" @@ -642,7 +758,18 @@ "type": "string" }, "value": { - "type": "string" + "nullable": true, + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } } }, @@ -803,6 +930,118 @@ ] } } + }, + "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", + "allowChildOverride" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "user_policy" + ] + }, + "targetId": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + } + } + }, + "UserPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/UserPolicyResponse" + } + ] } } }, @@ -3321,10 +3560,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" @@ -3349,12 +3588,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)" } } } @@ -3417,7 +3657,7 @@ } }, "400": { - "description": "Invalid signature flow mode provided", + "description": "Invalid DocMDP level provided", "content": { "application/json": { "schema": { @@ -3479,10 +3719,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" @@ -3495,31 +3735,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", @@ -3546,37 +3761,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": { @@ -3596,7 +3781,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ActiveSigningsResponse" } } } @@ -3606,7 +3791,7 @@ } }, "500": { - "description": "Internal server error", + "description": "", "content": { "application/json": { "schema": { @@ -3638,13 +3823,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" ], "security": [ { @@ -3668,117 +3853,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" - ], - "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 } }, { @@ -4082,6 +4163,716 @@ } } }, + "/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" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + }, + "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 an explicit user-level policy 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 assignment.", + "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" + } + } + } + } + } + } + } + }, + "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-user-policy-for-user", + "summary": "Save an explicit user policy 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 assigned target user policy.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + }, + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether the target user may still override the assigned value in personal preferences." + } + } + } + } + } + }, + "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 assignment.", + "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" + } + } + } + } + } + } + } + }, + "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-user-policy-for-user", + "summary": "Clear an explicit user policy 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 assignment 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" + } + } + } + } + } + } + } + }, + "400": { + "description": "User-scope not supported", + "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" + } + } + } + } + } + } + } + } + } + } + }, "/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 b85061ffb0..06e3dd3c8b 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -20,6 +20,25 @@ } }, "schemas": { + "AccountCapabilitySettings": { + "type": "object", + "required": [ + "canRequestSign", + "hasSignatureFile", + "isApprover" + ], + "properties": { + "canRequestSign": { + "type": "boolean" + }, + "hasSignatureFile": { + "type": "boolean" + }, + "isApprover": { + "type": "boolean" + } + } + }, "AccountMeResponse": { "type": "object", "required": [ @@ -956,7 +975,7 @@ "DynamicMetadataRecord": { "type": "object", "additionalProperties": { - "$ref": "#/components/schemas/DynamicMetadataScalar" + "type": "object" } }, "DynamicMetadataScalar": { @@ -979,23 +998,117 @@ ] }, "DynamicMetadataValue": { - "anyOf": [ - { - "$ref": "#/components/schemas/DynamicMetadataScalar" + "type": "object" + }, + "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", + "groupCount", + "userCount" + ], + "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/DynamicMetadataScalar" + "$ref": "#/components/schemas/EffectivePolicyValue" } }, + "canSaveAsUserDefault": { + "type": "boolean" + }, + "canUseAsRequestOverride": { + "type": "boolean" + }, + "preferenceWasCleared": { + "type": "boolean" + }, + "blockedBy": { + "type": "string", + "nullable": true + }, + "groupCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "userCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "EffectivePolicyValue": { + "nullable": true, + "anyOf": [ + { + "type": "boolean" + }, { - "$ref": "#/components/schemas/DynamicMetadataRecord" + "type": "integer", + "format": "int64" }, { - "type": "array", - "items": { - "$ref": "#/components/schemas/DynamicMetadataRecord" + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" } } ] @@ -1499,7 +1612,8 @@ "template", "isDefault", "preview_width", - "preview_height" + "preview_height", + "preview_zoom" ], "properties": { "template": { @@ -1515,9 +1629,91 @@ "preview_height": { "type": "integer", "format": "int64" + }, + "preview_zoom": { + "type": "integer", + "format": "int64" + } + } + }, + "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": [ @@ -1679,7 +1875,7 @@ "required": [ "method", "value", - "mandatory" + "requirement" ], "properties": { "method": { @@ -1697,20 +1893,25 @@ "value": { "type": "string" }, - "mandatory": { - "type": "integer", - "format": "int64", - "minimum": 0 + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" } } }, + "IdentifyMethodRequirement": { + "type": "string", + "enum": [ + "required", + "optional" + ] + }, "IdentifyMethodSetting": { "type": "object", "required": [ "name", "friendly_name", "enabled", - "mandatory" + "requirement" ], "properties": { "name": { @@ -1722,8 +1923,13 @@ "enabled": { "type": "boolean" }, - "mandatory": { - "type": "boolean" + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" + }, + "minimumTotalVerifiedFactors": { + "type": "integer", + "format": "int64", + "minimum": 1 }, "signatureMethods": { "$ref": "#/components/schemas/SignatureMethods" @@ -1805,7 +2011,7 @@ "required": [ "method", "value", - "mandatory" + "requirement" ], "properties": { "method": { @@ -1814,10 +2020,8 @@ "value": { "type": "string" }, - "mandatory": { - "type": "integer", - "format": "int64", - "minimum": 0 + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" } } } @@ -1946,6 +2150,55 @@ } } }, + "PolicySnapshotEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "string" + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotIdentifyMethodsEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IdentifyMethodSetting" + } + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotNumericEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "integer", + "format": "int64" + }, + "sourceScope": { + "type": "string" + } + } + }, "ProgressError": { "type": "object", "required": [ @@ -2203,7 +2456,18 @@ "type": "string" }, "value": { - "type": "string" + "nullable": true, + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] } } }, @@ -2648,6 +2912,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": [ @@ -2725,17 +3060,69 @@ } } }, - "ValidateMetadata": { + "UserPolicyResponse": { "type": "object", "required": [ - "extension", - "p" + "policy" ], "properties": { - "extension": { + "policy": { + "$ref": "#/components/schemas/UserPolicyState" + } + } + }, + "UserPolicyState": { + "type": "object", + "required": [ + "policyKey", + "scope", + "targetId", + "value", + "allowChildOverride" + ], + "properties": { + "policyKey": { "type": "string" }, - "p": { + "scope": { + "type": "string", + "enum": [ + "user_policy" + ] + }, + "targetId": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + } + } + }, + "UserPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/UserPolicyResponse" + } + ] + }, + "ValidateMetadata": { + "type": "object", + "required": [ + "extension", + "p" + ], + "properties": { + "extension": { + "type": "string" + }, + "p": { "type": "integer", "format": "int64" }, @@ -2762,6 +3149,9 @@ "original_file_deleted": { "type": "boolean" }, + "policy_snapshot": { + "$ref": "#/components/schemas/ValidatePolicySnapshot" + }, "pdfVersion": { "type": "string" }, @@ -2770,6 +3160,23 @@ } } }, + "ValidatePolicySnapshot": { + "type": "object", + "properties": { + "docmdp": { + "$ref": "#/components/schemas/PolicySnapshotNumericEntry" + }, + "signature_flow": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "add_footer": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "identify_methods": { + "$ref": "#/components/schemas/PolicySnapshotIdentifyMethodsEntry" + } + } + }, "ValidatedChildFile": { "type": "object", "required": [ @@ -4274,6 +4681,11 @@ "format": "int64", "default": 50, "description": "Height of preview in points (default: 50)" + }, + "writeQrcodeOnFooter": { + "type": "boolean", + "nullable": true, + "description": "Whether to force QR code rendering in footer preview (null uses policy)" } } } @@ -7521,13 +7933,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { - "post": { - "operationId": "request_signature-request-signature", - "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": [ - "signing" + "policy" ], "security": [ { @@ -7537,66 +7948,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", @@ -7643,44 +7994,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" } } } @@ -7690,13 +8004,14 @@ } } } - }, - "patch": { - "operationId": "request_signature-update-signature-request", - "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": [ - "signing" + "policy" ], "security": [ { @@ -7706,74 +8021,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": { - "$ref": "#/components/schemas/NewFile", - "nullable": true, - "description": "File object. Supports nodeId, url, base64 or path when creating a new request." - }, - "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", @@ -7787,6 +8034,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", @@ -7820,7 +8087,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DetailedFileResponse" + "$ref": "#/components/schemas/GroupPolicyResponse" } } } @@ -7829,8 +8096,8 @@ } } }, - "422": { - "description": "Unauthorized", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -7850,14 +8117,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "anyOf": [ - { - "$ref": "#/components/schemas/MessageResponse" - }, - { - "$ref": "#/components/schemas/ActionErrorResponse" - } - ] + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -7867,15 +8127,12 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/{signRequestId}": { - "delete": { - "operationId": "request_signature-remove-signer", - "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": [ - "signing" + "policy" ], "security": [ { @@ -7885,6 +8142,49 @@ "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" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + }, + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether users and requests below this group may override the group default." + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -7899,23 +8199,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_]+$" } }, { @@ -7951,7 +8251,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/GroupPolicyWriteResponse" } } } @@ -7960,8 +8260,8 @@ } } }, - "401": { - "description": "Failed", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -7981,7 +8281,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -7990,8 +8290,8 @@ } } }, - "422": { - "description": "Failed", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8011,7 +8311,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8021,15 +8321,12 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}": { + }, "delete": { - "operationId": "request_signature-delete-signature-request", - "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": [ - "signing" + "policy" ], "security": [ { @@ -8053,13 +8350,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": "policyKey", + "in": "path", + "description": "Policy identifier to clear for the selected group.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -8095,37 +8402,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" } } } @@ -8134,8 +8411,8 @@ } } }, - "422": { - "description": "Failed", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8155,7 +8432,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8165,15 +8442,16 @@ } } } - }, - "post": { - "operationId": "sign_file-sign-by-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": [ - "signing" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8182,41 +8460,37 @@ } ], "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" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] } } } @@ -8237,13 +8511,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_]+$" } }, { @@ -8279,7 +8553,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionResponse" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -8288,8 +8562,8 @@ } } }, - "422": { - "description": "Error", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -8309,7 +8583,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8319,17 +8593,14 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}": { - "post": { - "operationId": "sign_file-sign-by-signer-uuid", - "summary": "Sign a file using file UUID", + }, + "delete": { + "operationId": "policy-clear-user-preference", + "summary": "Clear a user policy preference", "tags": [ - "signing" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8337,48 +8608,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", @@ -8393,12 +8622,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_]+$" } }, { @@ -8434,7 +8664,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionResponse" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -8443,8 +8673,8 @@ } } }, - "422": { - "description": "Error", + "400": { + "description": "User-scope not supported", "content": { "application/json": { "schema": { @@ -8464,7 +8694,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8476,106 +8706,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-signature", + "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": [ "signing" ], "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-request-code-by-signer-uuid", - "summary": "Get code to sign the document using UUID", - "tags": [ - "signing" - ], - "security": [ - {}, { "bearer_auth": [] }, @@ -8590,24 +8729,56 @@ "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": { - "type": "string", + "status": { + "type": "integer", + "format": "int64", "nullable": true, - "description": "Identify value, i.e. the signer email, account or phone number" + "default": 1, + "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" + }, + "policy": { + "type": "object", + "nullable": true, + "description": "Structured policy payload with request-level overrides and active context.", + "additionalProperties": { + "type": "object" + } } } } @@ -8627,15 +8798,6 @@ "default": "v1" } }, - { - "name": "uuid", - "in": "path", - "description": "UUID of LibreSign file", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -8669,7 +8831,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/DetailedFileResponse" } } } @@ -8679,7 +8841,7 @@ } }, "422": { - "description": "Error", + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -8699,7 +8861,14 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "anyOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/ActionErrorResponse" + } + ] } } } @@ -8709,17 +8878,15 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/code": { - "post": { - "operationId": "sign_file-request-code-by-file-id", - "summary": "Get code to sign the document using FileID", + }, + "patch": { + "operationId": "request_signature-update-signature-request", + "summary": "Updates signatures data", + "description": "It is necessary to inform the UUID of the file and a list of signers.", "tags": [ "signing" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8734,24 +8901,64 @@ "schema": { "type": "object", "properties": { - "identifyMethod": { - "type": "string", + "signers": { + "type": "array", "nullable": true, - "enum": [ - "account", - "email" - ], - "description": "Identify signer method" + "default": [], + "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format.", + "items": { + "$ref": "#/components/schemas/NewSigner" + } }, - "signMethod": { + "uuid": { "type": "string", "nullable": true, - "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" + "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." }, - "identify": { + "visibleElements": { + "type": "array", + "nullable": true, + "description": "Visible elements on document", + "items": { + "$ref": "#/components/schemas/VisibleElement" + } + }, + "file": { + "$ref": "#/components/schemas/NewFile", + "nullable": true, + "description": "File object. Supports nodeId, url, base64 or path when creating a new request." + }, + "status": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" + }, + "policy": { + "type": "object", + "nullable": true, + "description": "Structured policy payload with request-level overrides and active context.", + "additionalProperties": { + "type": "object" + } + }, + "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" + } } } } @@ -8771,16 +8978,6 @@ "default": "v1" } }, - { - "name": "fileId", - "in": "path", - "description": "Id of LibreSign file", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -8814,7 +9011,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/DetailedFileResponse" } } } @@ -8824,7 +9021,7 @@ } }, "422": { - "description": "Error", + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -8844,7 +9041,14 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "anyOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/ActionErrorResponse" + } + ] } } } @@ -8856,15 +9060,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements": { - "post": { - "operationId": "signature_elements-create-signature-element", - "summary": "Create signature element", - "tags": [ - "signature_elements" - ], - "security": [ - {}, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/{signRequestId}": { + "delete": { + "operationId": "request_signature-remove-signer", + "summary": "Delete sign request", + "description": "You can only request exclusion as any sign", + "tags": [ + "signing" + ], + "security": [ { "bearer_auth": [] }, @@ -8872,28 +9076,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", @@ -8907,6 +9089,26 @@ "default": "v1" } }, + { + "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", @@ -8940,7 +9142,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsMessageResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -8949,8 +9151,8 @@ } } }, - "422": { - "description": "Invalid data", + "401": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -8978,17 +9180,49 @@ } } } + }, + "422": { + "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/ActionErrorResponse" + } + } + } + } + } + } + } } } - }, - "get": { - "operationId": "signature_elements-get-signature-elements", - "summary": "Get signature elements", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}": { + "delete": { + "operationId": "request_signature-delete-signature-request", + "summary": "Delete sign request", + "description": "You can only request exclusion as any sign", "tags": [ - "signature_elements" + "signing" ], "security": [ - {}, { "bearer_auth": [] }, @@ -9009,6 +9243,16 @@ "default": "v1" } }, + { + "name": "fileId", + "in": "path", + "description": "LibreSign file ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -9042,7 +9286,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9051,8 +9295,8 @@ } } }, - "404": { - "description": "Invalid data", + "401": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -9080,74 +9324,9 @@ } } } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/preview/{nodeId}": { - "get": { - "operationId": "signature_elements-preview-signature-element", - "summary": "Get preview of signature elements of", - "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", - "content": { - "*/*": { - "schema": { - "type": "string", - "format": "binary" - } - } - } }, - "404": { - "description": "Invalid data", + "422": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -9167,7 +9346,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/ActionErrorResponse" } } } @@ -9177,16 +9356,15 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/{nodeId}": { - "get": { - "operationId": "signature_elements-get-signature-element", - "summary": "Get signature element of signer", + }, + "post": { + "operationId": "sign_file-sign-by-file-id", + "summary": "Sign a file using file Id", "tags": [ - "signature_elements" + "signing" ], "security": [ + {}, { "bearer_auth": [] }, @@ -9194,6 +9372,48 @@ "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", @@ -9208,9 +9428,9 @@ } }, { - "name": "nodeId", + "name": "fileId", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "Id of LibreSign file", "required": true, "schema": { "type": "integer", @@ -9250,7 +9470,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElement" + "$ref": "#/components/schemas/SignActionResponse" } } } @@ -9259,8 +9479,8 @@ } } }, - "404": { - "description": "Invalid data", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -9280,7 +9500,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SignActionErrorResponse" } } } @@ -9290,12 +9510,14 @@ } } } - }, - "patch": { - "operationId": "signature_elements-patch-signature-element", - "summary": "Update signature element", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}": { + "post": { + "operationId": "sign_file-sign-by-signer-uuid", + "summary": "Sign a file using file UUID", "tags": [ - "signature_elements" + "signing" ], "security": [ {}, @@ -9307,24 +9529,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" } } } @@ -9345,13 +9584,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" } }, { @@ -9387,7 +9625,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsMessageResponse" + "$ref": "#/components/schemas/SignActionResponse" } } } @@ -9417,7 +9655,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SignActionErrorResponse" } } } @@ -9427,12 +9665,14 @@ } } } - }, - "delete": { - "operationId": "signature_elements-delete-signature-element", - "summary": "Delete signature element", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/renew/{method}": { + "post": { + "operationId": "sign_file-sign-renew", + "summary": "Renew the signature method", "tags": [ - "signature_elements" + "signing" ], "security": [ {}, @@ -9457,13 +9697,20 @@ } }, { - "name": "nodeId", + "name": "uuid", "in": "path", - "description": "Node id of a Nextcloud file", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string" + } + }, + { + "name": "method", + "in": "path", + "description": "Signature method", + "required": true, + "schema": { + "type": "string" } }, { @@ -9507,49 +9754,19 @@ } } } - }, - "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" - } - } - } - } - } - } - } } } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/cfssl": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/code": { "post": { - "operationId": "admin-generate-certificate-cfssl", - "summary": "Generate certificate using CFSSL engine", - "description": "This endpoint requires admin access", + "operationId": "sign_file-request-code-by-signer-uuid", + "summary": "Get code to sign the document using UUID", "tags": [ - "admin" + "signing" ], "security": [ + {}, { "bearer_auth": [] }, @@ -9558,61 +9775,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" }, - "cfsslUri": { + "signMethod": { "type": "string", - "default": "", - "description": "URI of CFSSL API" + "nullable": true, + "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" }, - "configPath": { + "identify": { "type": "string", - "default": "", - "description": "Path of config files of CFSSL" + "nullable": true, + "description": "Identify value, i.e. the signer email, account or phone number" } } } @@ -9632,6 +9818,15 @@ "default": "v1" } }, + { + "name": "uuid", + "in": "path", + "description": "UUID of LibreSign file", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -9665,7 +9860,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/EngineHandlerResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9674,8 +9869,8 @@ } } }, - "401": { - "description": "Account not found", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -9707,15 +9902,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/openssl": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/code": { "post": { - "operationId": "admin-generate-certificate-open-ssl", - "summary": "Generate certificate using OpenSSL engine", - "description": "This endpoint requires admin access", + "operationId": "sign_file-request-code-by-file-id", + "summary": "Get code to sign the document using FileID", "tags": [ - "admin" + "signing" ], "security": [ + {}, { "bearer_auth": [] }, @@ -9724,58 +9919,32 @@ } ], "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" + } + } } } } @@ -9793,6 +9962,16 @@ "default": "v1" } }, + { + "name": "fileId", + "in": "path", + "description": "Id of LibreSign file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -9826,7 +10005,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/EngineHandlerResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9835,8 +10014,8 @@ } } }, - "401": { - "description": "Account not found", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -9868,15 +10047,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/engine": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements": { "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": "signature_elements-create-signature-element", + "summary": "Create signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -9891,12 +10070,15 @@ "schema": { "type": "object", "required": [ - "engine" + "elements" ], "properties": { - "engine": { - "type": "string", - "description": "The certificate engine to use (openssl, cfssl, or none)" + "elements": { + "type": "object", + "description": "Element object", + "additionalProperties": { + "type": "object" + } } } } @@ -9949,7 +10131,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CertificateEngineConfigResponse" + "$ref": "#/components/schemas/UserElementsMessageResponse" } } } @@ -9958,8 +10140,8 @@ } } }, - "400": { - "description": "Invalid engine", + "422": { + "description": "Invalid data", "content": { "application/json": { "schema": { @@ -9989,17 +10171,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", + "operationId": "signature_elements-get-signature-elements", + "summary": "Get signature elements", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10053,7 +10233,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CertificateDataGenerated" + "$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" } } } @@ -10065,15 +10275,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/configure-check": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/preview/{nodeId}": { "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-preview-signature-element", + "summary": "Get preview of signature elements of", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10094,6 +10304,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", @@ -10108,6 +10328,17 @@ "responses": { "200": { "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Invalid data", "content": { "application/json": { "schema": { @@ -10127,7 +10358,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ConfigureChecksResponse" + "type": "object" } } } @@ -10139,13 +10370,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/disable-hate-limit": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/{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", + "summary": "Get signature element of signer", "tags": [ - "admin" + "signature_elements" ], "security": [ { @@ -10168,6 +10398,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", @@ -10201,7 +10441,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/UserElement" + } + } + } + } + } + } + } + }, + "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" } } } @@ -10211,17 +10481,15 @@ } } } - } - }, - "/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", + }, + "patch": { + "operationId": "signature_elements-patch-signature-element", + "summary": "Update signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10229,6 +10497,31 @@ "basic_auth": [] } ], + "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" + } + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -10242,6 +10535,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", @@ -10275,7 +10578,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/UserElementsMessageResponse" } } } @@ -10305,7 +10608,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FailureStatusResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -10316,14 +10619,14 @@ } } }, - "get": { - "operationId": "admin-signature-background-get", - "summary": "Get custom background image", - "description": "This endpoint requires admin access", + "delete": { + "operationId": "signature_elements-delete-signature-element", + "summary": "Delete signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10344,6 +10647,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", @@ -10357,21 +10670,1547 @@ ], "responses": { "200": { - "description": "Image returned", + "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" + } + } + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/cfssl": { + "post": { + "operationId": "admin-generate-certificate-cfssl", + "summary": "Generate certificate using CFSSL engine", + "description": "This endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "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", + "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/EngineHandlerResponse" + } + } + } + } + } + } + } + }, + "401": { + "description": "Account 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" + } + } + } + } + } + } + } + } + } + } + }, + "/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" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "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" + } + } + ] + } + } + } + } + } + }, + "configPath": { + "type": "string", + "default": "", + "description": "Path of config files of CFSSL" + } + } + } + } + } + }, + "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/EngineHandlerResponse" + } + } + } + } + } + } + } + }, + "401": { + "description": "Account 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" + } + } + } + } + } + } + } + } + } + } + }, + "/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" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "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", + "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/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" + } + } + } + } + } + } + } + } + } + } + }, + "/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", + "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/CertificateDataGenerated" + } + } + } + } + } + } + } + } + } + } + }, + "/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" + ], + "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/ConfigureChecksResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/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" + ], + "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/signature-background": { + "post": { + "operationId": "admin-signature-background-save", + "summary": "Add custom background image", + "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/SuccessStatusResponse" + } + } + } + } + } + } + } + }, + "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/FailureStatusResponse" + } + } + } + } + } + } + } + } + } + }, + "get": { + "operationId": "admin-signature-background-get", + "summary": "Get custom background image", + "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 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": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SuccessStatusResponse" + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "admin-signature-background-delete", + "summary": "Delete background image", + "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": "Deleted with success", + "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/signature-text": { + "post": { + "operationId": "admin-signature-text-save", + "summary": "Save signature text service", + "description": "This endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "template" + ], + "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" + }, + "signatureHeight": { + "type": "number", + "format": "double", + "default": 100, + "description": "Signature box height, minimum 1" + }, + "renderMode": { + "type": "string", + "default": "GRAPHIC_AND_DESCRIPTION", + "description": "Signature render mode" + } + } + } + } + } + }, + "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/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" + } + } + } + } + } + } + } + } + } + }, + "get": { + "operationId": "admin-signature-text-get", + "summary": "Get parsed signature text service", + "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": "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 + } + } + ], + "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/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" + } + } + } + } + } + } + } + } + } + } + }, + "/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", + "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/SignatureTemplateSettingsResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/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" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "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", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "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": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } } } - }, - "patch": { - "operationId": "admin-signature-background-reset", - "summary": "Reset the background image to be the default of LibreSign", + } + }, + "/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", "tags": [ "admin" @@ -10410,7 +12249,7 @@ ], "responses": { "200": { - "description": "Image reseted to default", + "description": "OK", "content": { "application/json": { "schema": { @@ -10430,7 +12269,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$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" } } } @@ -10442,8 +12311,8 @@ } }, "delete": { - "operationId": "admin-signature-background-delete", - "summary": "Delete background image", + "operationId": "admin-delete-certificate-policy", + "summary": "Delete certificate policy of this instance", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -10482,7 +12351,7 @@ ], "responses": { "200": { - "description": "Deleted with success", + "description": "OK", "content": { "application/json": { "schema": { @@ -10502,7 +12371,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "type": "object" } } } @@ -10514,10 +12383,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-text": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { "post": { - "operationId": "admin-signature-text-save", - "summary": "Save signature text service", + "operationId": "admin-update-oid", + "summary": "Update OID", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -10537,41 +12406,12 @@ "schema": { "type": "object", "required": [ - "template" + "oid" ], "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" - }, - "signatureHeight": { - "type": "number", - "format": "double", - "default": 100, - "description": "Signature box height, minimum 1" - }, - "renderMode": { + "oid": { "type": "string", - "default": "GRAPHIC_AND_DESCRIPTION", - "description": "Signature render mode" + "description": "OID is a unique numeric identifier for certificate policies in digital certificates." } } } @@ -10624,7 +12464,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTextSettingsResponse" + "$ref": "#/components/schemas/SuccessStatusResponse" } } } @@ -10633,8 +12473,8 @@ } } }, - "400": { - "description": "Bad request", + "422": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -10654,7 +12494,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -10664,10 +12504,12 @@ } } } - }, + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/reminder": { "get": { - "operationId": "admin-signature-text-get", - "summary": "Get parsed signature text service", + "operationId": "admin-reminder-fetch", + "summary": "Get reminder settings", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -10693,24 +12535,6 @@ "default": "v1" } }, - { - "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", @@ -10718,43 +12542,13 @@ "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/SignatureTextSettingsResponse" - } - } - } - } - } - } - } - }, - "400": { - "description": "Bad request", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { @@ -10774,7 +12568,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ReminderSettings" } } } @@ -10784,12 +12578,10 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-settings": { - "get": { - "operationId": "admin-get-signature-settings", - "summary": "Get signature settings", + }, + "post": { + "operationId": "admin-reminder-save", + "summary": "Save reminder", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -10802,6 +12594,43 @@ "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", @@ -10848,7 +12677,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTemplateSettingsResponse" + "$ref": "#/components/schemas/ReminderSettings" } } } @@ -10860,11 +12689,11 @@ } } }, - "/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", + "/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" ], @@ -10876,6 +12705,43 @@ "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", @@ -10889,67 +12755,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", @@ -10964,27 +12769,36 @@ "responses": { "200": { "description": "OK", - "headers": { - "Content-Disposition": { - "schema": { - "type": "string", - "enum": [ - "inline; filename=\"signer-name.png\"" - ] - } - } - }, "content": { - "image/png": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SuccessStatusResponse" + } + } + } + } } } } }, "400": { - "description": "Bad request", + "description": "Validation error", "content": { "application/json": { "schema": { @@ -11004,7 +12818,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ErrorStatusResponse" } } } @@ -11014,13 +12828,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", + }, + "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" ], @@ -11078,7 +12890,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CertificatePolicyResponse" + "$ref": "#/components/schemas/SuccessStatusResponse" } } } @@ -11086,9 +12898,53 @@ } } } - }, - "422": { - "description": "Not found", + } + } + } + }, + "/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": { @@ -11108,7 +12964,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FailureStatusResponse" + "$ref": "#/components/schemas/FooterTemplateResponse" } } } @@ -11119,10 +12975,10 @@ } } }, - "delete": { - "operationId": "admin-delete-certificate-policy", - "summary": "Delete certificate policy of this instance", - "description": "This endpoint requires admin access", + "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" ], @@ -11134,6 +12990,35 @@ "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", @@ -11161,6 +13046,17 @@ "responses": { "200": { "description": "OK", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { @@ -11180,7 +13076,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11192,11 +13088,11 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signing-mode/config": { "post": { - "operationId": "admin-update-oid", - "summary": "Update OID", - "description": "This endpoint requires admin access", + "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" ], @@ -11215,12 +13111,17 @@ "schema": { "type": "object", "required": [ - "oid" + "mode" ], "properties": { - "oid": { + "mode": { "type": "string", - "description": "OID is a unique numeric identifier for certificate policies in digital certificates." + "description": "Signing mode: \"sync\" or \"async\"" + }, + "workerType": { + "type": "string", + "nullable": true, + "description": "Worker type when async: \"local\" or \"external\" (optional)" } } } @@ -11253,7 +13154,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Settings saved", "content": { "application/json": { "schema": { @@ -11273,7 +13174,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -11282,8 +13183,8 @@ } } }, - "422": { - "description": "Validation error", + "400": { + "description": "Invalid parameters", "content": { "application/json": { "schema": { @@ -11303,7 +13204,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FailureStatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11311,53 +13212,9 @@ } } } - } - } - } - }, - "/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", + "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { @@ -11377,7 +13234,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ReminderSettings" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11387,10 +13244,12 @@ } } } - }, + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": { "post": { - "operationId": "admin-reminder-save", - "summary": "Save reminder", + "operationId": "admin-set-doc-mdp-config", + "summary": "Configure DocMDP signature restrictions", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11410,30 +13269,18 @@ "schema": { "type": "object", "required": [ - "daysBefore", - "daysBetween", - "max", - "sendTimer" + "enabled" ], "properties": { - "daysBefore": { - "type": "integer", - "format": "int64", - "description": "First reminder after (days)" - }, - "daysBetween": { - "type": "integer", - "format": "int64", - "description": "Days between reminders" + "enabled": { + "type": "boolean", + "description": "Whether to enable DocMDP restrictions" }, - "max": { + "defaultLevel": { "type": "integer", "format": "int64", - "description": "Max reminders per signer" - }, - "sendTimer": { - "type": "string", - "description": "Send time (HH:mm)" + "default": 2, + "description": "DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations)" } } } @@ -11466,7 +13313,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Configuration saved successfully", "content": { "application/json": { "schema": { @@ -11486,98 +13333,17 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ReminderSettings" + "$ref": "#/components/schemas/MessageResponse" } - } - } - } - } - } - } - } - } - } - }, - "/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", + "400": { + "description": "Invalid DocMDP level provided", "content": { "application/json": { "schema": { @@ -11597,7 +13363,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11606,8 +13372,8 @@ } } }, - "400": { - "description": "Validation error", + "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { @@ -11627,7 +13393,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorStatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11637,11 +13403,13 @@ } } } - }, - "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", + } + }, + "/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" ], @@ -11679,7 +13447,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "List of active signings", "content": { "application/json": { "schema": { @@ -11699,7 +13467,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/ActiveSigningsResponse" } } } @@ -11707,53 +13475,9 @@ } } } - } - } - } - }, - "/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", + "500": { + "description": "", "content": { "application/json": { "schema": { @@ -11773,7 +13497,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FooterTemplateResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11783,13 +13507,15 @@ } } } - }, - "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}/crl/list": { + "get": { + "operationId": "crl_api-list", + "summary": "List CRL entries with pagination and filters", + "description": "This endpoint requires admin access", "tags": [ - "admin" + "crl" ], "security": [ { @@ -11799,35 +13525,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", @@ -11841,6 +13538,108 @@ "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": "sortBy", + "in": "query", + "description": "Sort field (e.g., 'revoked_at', 'issued_at', 'serial_number')", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Sort order (ASC or DESC)", + "schema": { + "type": "string", + "nullable": true + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -11854,18 +13653,7 @@ ], "responses": { "200": { - "description": "OK", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Bad request", + "description": "CRL entries retrieved successfully", "content": { "application/json": { "schema": { @@ -11885,7 +13673,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/CrlListResponse" } } } @@ -11897,13 +13685,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signing-mode/config": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/revoke": { "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": "crl_api-revoke", + "summary": "Revoke a certificate by serial number", + "description": "This endpoint requires admin access", "tags": [ - "admin" + "crl" ], "security": [ { @@ -11920,17 +13708,23 @@ "schema": { "type": "object", "required": [ - "mode" + "serialNumber" ], "properties": { - "mode": { + "serialNumber": { "type": "string", - "description": "Signing mode: \"sync\" or \"async\"" + "description": "Certificate serial number to revoke" }, - "workerType": { + "reasonCode": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Revocation reason code (0-10, see RFC 5280)" + }, + "reasonText": { "type": "string", "nullable": true, - "description": "Worker type when async: \"local\" or \"external\" (optional)" + "description": "Optional text describing the reason" } } } @@ -11963,7 +13757,7 @@ ], "responses": { "200": { - "description": "Settings saved", + "description": "Certificate revoked successfully", "content": { "application/json": { "schema": { @@ -11983,7 +13777,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/CrlRevokeResponse" } } } @@ -12013,7 +13807,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/CrlRevokeResponse" } } } @@ -12022,8 +13816,8 @@ } } }, - "500": { - "description": "Internal server error", + "404": { + "description": "Certificate not found", "content": { "application/json": { "schema": { @@ -12043,7 +13837,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/CrlRevokeResponse" } } } @@ -12055,13 +13849,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-flow/config": { - "post": { - "operationId": "admin-set-signature-flow-config", - "summary": "Set signature flow configuration", + "/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": [ { @@ -12071,30 +13865,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "enabled" - ], - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether to force a signature flow for all documents" - }, - "mode": { - "type": "string", - "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric' (only used when enabled is true)" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -12108,6 +13878,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", @@ -12121,7 +13901,7 @@ ], "responses": { "200": { - "description": "Configuration saved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -12141,7 +13921,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SystemPolicyResponse" } } } @@ -12149,9 +13929,104 @@ } } } + } + } + }, + "post": { + "operationId": "policy-set-system", + "summary": "Save a system-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] }, - "400": { - "description": "Invalid signature flow mode provided", + { + "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" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + }, + "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": { @@ -12171,7 +14046,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -12180,8 +14055,8 @@ } } }, - "500": { - "description": "Internal server error", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -12213,13 +14088,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/user/{userId}/{policyKey}": { + "get": { + "operationId": "policy-get-user-policy-for-user", + "summary": "Read an explicit user-level policy for a target user (admin scope)", "description": "This endpoint requires admin access", "tags": [ - "admin" + "policy" ], "security": [ { @@ -12229,31 +14104,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", @@ -12267,6 +14117,26 @@ "default": "v1" } }, + { + "name": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment.", + "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", @@ -12280,37 +14150,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": { @@ -12330,7 +14170,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/UserPolicyResponse" } } } @@ -12339,8 +14179,8 @@ } } }, - "500": { - "description": "Internal server error", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -12370,15 +14210,13 @@ } } } - } - }, - "/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)", + }, + "put": { + "operationId": "policy-set-user-policy-for-user", + "summary": "Save an explicit user policy for a target user (admin scope)", "description": "This endpoint requires admin access", "tags": [ - "admin" + "policy" ], "security": [ { @@ -12387,7 +14225,50 @@ { "basic_auth": [] } - ], + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Policy value to persist as assigned target user policy.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + }, + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether the target user may still override the assigned value in personal preferences." + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -12401,6 +14282,26 @@ "default": "v1" } }, + { + "name": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment.", + "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", @@ -12414,7 +14315,7 @@ ], "responses": { "200": { - "description": "List of active signings", + "description": "OK", "content": { "application/json": { "schema": { @@ -12434,7 +14335,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActiveSigningsResponse" + "$ref": "#/components/schemas/UserPolicyWriteResponse" } } } @@ -12443,8 +14344,8 @@ } } }, - "500": { - "description": "", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -12472,155 +14373,9 @@ } } } - } - } - } - }, - "/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" - ], - "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": "sortBy", - "in": "query", - "description": "Sort field (e.g., 'revoked_at', 'issued_at', 'serial_number')", - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "sortOrder", - "in": "query", - "description": "Sort order (ASC or DESC)", - "schema": { - "type": "string", - "nullable": true - } }, - { - "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": "CRL entries retrieved successfully", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -12640,7 +14395,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlListResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12650,15 +14405,13 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/revoke": { - "post": { - "operationId": "crl_api-revoke", - "summary": "Revoke a certificate by serial number", + }, + "delete": { + "operationId": "policy-clear-user-policy-for-user", + "summary": "Clear an explicit user policy for a target user (admin scope)", "description": "This endpoint requires admin access", "tags": [ - "crl" + "policy" ], "security": [ { @@ -12668,36 +14421,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "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", - "nullable": true, - "description": "Optional text describing the reason" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -12711,6 +14434,26 @@ "default": "v1" } }, + { + "name": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment 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", @@ -12724,7 +14467,7 @@ ], "responses": { "200": { - "description": "Certificate revoked successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -12744,7 +14487,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlRevokeResponse" + "$ref": "#/components/schemas/UserPolicyWriteResponse" } } } @@ -12754,7 +14497,7 @@ } }, "400": { - "description": "Invalid parameters", + "description": "User-scope not supported", "content": { "application/json": { "schema": { @@ -12774,7 +14517,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlRevokeResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12783,8 +14526,8 @@ } } }, - "404": { - "description": "Certificate not found", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -12804,7 +14547,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlRevokeResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } diff --git a/openapi.json b/openapi.json index c8588d072d..ad2405cb1d 100644 --- a/openapi.json +++ b/openapi.json @@ -622,49 +622,118 @@ } ] }, - "DynamicMetadataRecord": { + "DynamicMetadataValue": { + "type": "object" + }, + "EffectivePoliciesResponse": { "type": "object", - "additionalProperties": { - "$ref": "#/components/schemas/DynamicMetadataScalar" + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } } }, - "DynamicMetadataScalar": { - "nullable": true, - "anyOf": [ - { + "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", + "groupCount", + "userCount" + ], + "properties": { + "policyKey": { "type": "string" }, - { - "type": "integer", - "format": "int64" + "effectiveValue": { + "$ref": "#/components/schemas/EffectivePolicyValue" }, - { - "type": "number", - "format": "double" + "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 + }, + "groupCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "userCount": { + "type": "integer", + "format": "int64", + "minimum": 0 } - ] + } }, - "DynamicMetadataValue": { + "EffectivePolicyValue": { + "nullable": true, "anyOf": [ { - "$ref": "#/components/schemas/DynamicMetadataScalar" + "type": "boolean" }, { - "type": "array", - "items": { - "$ref": "#/components/schemas/DynamicMetadataScalar" - } + "type": "integer", + "format": "int64" }, { - "$ref": "#/components/schemas/DynamicMetadataRecord" + "type": "number", + "format": "double" }, { - "type": "array", - "items": { - "$ref": "#/components/schemas/DynamicMetadataRecord" + "type": "string" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" } } ] @@ -1090,6 +1159,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": [ @@ -1240,7 +1372,7 @@ "required": [ "method", "value", - "mandatory" + "requirement" ], "properties": { "method": { @@ -1258,10 +1390,46 @@ "value": { "type": "string" }, - "mandatory": { + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" + } + } + }, + "IdentifyMethodRequirement": { + "type": "string", + "enum": [ + "required", + "optional" + ] + }, + "IdentifyMethodSetting": { + "type": "object", + "required": [ + "name", + "friendly_name", + "enabled", + "requirement" + ], + "properties": { + "name": { + "type": "string" + }, + "friendly_name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" + }, + "minimumTotalVerifiedFactors": { "type": "integer", "format": "int64", - "minimum": 0 + "minimum": 1 + }, + "signatureMethods": { + "$ref": "#/components/schemas/SignatureMethods" } } }, @@ -1340,7 +1508,7 @@ "required": [ "method", "value", - "mandatory" + "requirement" ], "properties": { "method": { @@ -1349,10 +1517,8 @@ "value": { "type": "string" }, - "mandatory": { - "type": "integer", - "format": "int64", - "minimum": 0 + "requirement": { + "$ref": "#/components/schemas/IdentifyMethodRequirement" } } } @@ -1466,6 +1632,55 @@ } } }, + "PolicySnapshotEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "string" + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotIdentifyMethodsEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IdentifyMethodSetting" + } + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotNumericEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "integer", + "format": "int64" + }, + "sourceScope": { + "type": "string" + } + } + }, "ProgressError": { "type": "object", "required": [ @@ -2032,6 +2247,16 @@ } } }, + "SystemPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/EffectivePolicyResponse" + } + ] + }, "UserElement": { "type": "object", "required": [ @@ -2146,6 +2371,9 @@ "original_file_deleted": { "type": "boolean" }, + "policy_snapshot": { + "$ref": "#/components/schemas/ValidatePolicySnapshot" + }, "pdfVersion": { "type": "string" }, @@ -2154,6 +2382,23 @@ } } }, + "ValidatePolicySnapshot": { + "type": "object", + "properties": { + "docmdp": { + "$ref": "#/components/schemas/PolicySnapshotNumericEntry" + }, + "signature_flow": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "add_footer": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "identify_methods": { + "$ref": "#/components/schemas/PolicySnapshotIdentifyMethodsEntry" + } + } + }, "ValidatedChildFile": { "type": "object", "required": [ @@ -3658,6 +3903,11 @@ "format": "int64", "default": 50, "description": "Height of preview in points (default: 50)" + }, + "writeQrcodeOnFooter": { + "type": "boolean", + "nullable": true, + "description": "Whether to force QR code rendering in footer preview (null uses policy)" } } } @@ -6905,13 +7155,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { - "post": { - "operationId": "request_signature-request-signature", - "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": [ - "signing" + "policy" ], "security": [ { @@ -6921,66 +7170,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", @@ -7027,7 +7216,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DetailedFileResponse" + "$ref": "#/components/schemas/EffectivePoliciesResponse" } } } @@ -7035,9 +7224,846 @@ } } } - }, - "422": { - "description": "Unauthorized", + } + } + } + }, + "/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" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + }, + "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" + }, + { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + ] + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "400": { + "description": "User-scope not supported", + "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}/request-signature": { + "post": { + "operationId": "request_signature-request-signature", + "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": [ + "signing" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "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" + }, + "policy": { + "type": "object", + "nullable": true, + "description": "Structured policy payload with request-level overrides and active context.", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, + "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/DetailedFileResponse" + } + } + } + } + } + } + } + }, + "422": { + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -7130,10 +8156,13 @@ "nullable": true, "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" }, - "signatureFlow": { - "type": "string", + "policy": { + "type": "object", "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration" + "description": "Structured policy payload with request-level overrides and active context.", + "additionalProperties": { + "type": "object" + } }, "name": { "type": "string", diff --git a/package-lock.json b/package-lock.json index dec335b4e4..1a161cb6de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "vuedraggable": "^4.1.0" }, "devDependencies": { + "@nextcloud/babel-config": "^1.3.0", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/eslint-config": "^8.4.2", "@nextcloud/stylelint-config": "^3.2.1", @@ -176,126 +177,1487 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "dev": true, "license": "MIT", "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-module-transforms": { + "node_modules/@babel/plugin-transform-spread": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-string-parser": { + "node_modules/@babel/plugin-transform-sticky-regex": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-validator-option": { + "node_modules/@babel/plugin-transform-unicode-escapes": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "dev": true, "license": "MIT", "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/types": "^7.29.0" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, - "bin": { - "parser": "bin/babel-parser.js" + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { - "node": ">=6.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", + "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/compat-data": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.29.0", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, "node_modules/@babel/runtime": { @@ -2053,6 +3415,21 @@ "node": "^20.0.0 || ^22.0.0 || ^24.0.0" } }, + "node_modules/@nextcloud/babel-config": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@nextcloud/babel-config/-/babel-config-1.3.0.tgz", + "integrity": "sha512-qk4mBJahzp2mkiizU9RbeABa6JhqSwR43SXptNQhM3kpxAuP2OAQQhomYnxog/XfFcYExZzOkgRBPlcLEoik0w==", + "dev": true, + "license": "AGPL-3.0-or-later", + "engines": { + "node": "^20 || ^22 || ^24" + }, + "peerDependencies": { + "@babel/core": "^7.27.4", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/preset-env": "^7.27.2" + } + }, "node_modules/@nextcloud/browser-storage": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@nextcloud/browser-storage/-/browser-storage-0.5.0.tgz", @@ -5741,6 +7118,51 @@ "axios": "0.x || 1.x" } }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -10545,6 +11967,14 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -12944,6 +14374,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -12965,6 +14417,47 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/regjsparser": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/rehype-external-links": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/rehype-external-links/-/rehype-external-links-3.0.0.tgz", @@ -15806,6 +17299,54 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, "node_modules/unicorn-magic": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", diff --git a/package.json b/package.json index 7296299fdf..36225bb61e 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "npm": "^11.3.0" }, "devDependencies": { + "@nextcloud/babel-config": "^1.3.0", "@nextcloud/browserslist-config": "^3.1.2", "@nextcloud/eslint-config": "^8.4.2", "@nextcloud/stylelint-config": "^3.2.1", diff --git a/playwright.config.ts b/playwright.config.ts index bbf3e6ec18..b51590882c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -33,6 +33,12 @@ export default defineConfig({ /* Base URL to use in actions like `await page.goto('./apps/libresign')`. */ baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost', + /* Force E2E execution in English regardless of container/user locale. */ + locale: 'en-US', + extraHTTPHeaders: { + 'Accept-Language': 'en-US,en;q=0.9', + }, + /* Ignore HTTPS errors on local self-signed certificates */ ignoreHTTPSErrors: true, diff --git a/playwright/e2e/delete-pending-request.spec.ts b/playwright/e2e/delete-pending-request.spec.ts index 90ad529562..915edac443 100644 --- a/playwright/e2e/delete-pending-request.spec.ts +++ b/playwright/e2e/delete-pending-request.spec.ts @@ -33,6 +33,7 @@ test('delete pending signature request', async ({ page }) => { ) await page.goto('./apps/libresign') + await expect(page.getByRole('button', { name: 'Upload from URL' })).toBeVisible({ timeout: 20000 }) 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() @@ -58,7 +59,7 @@ test('delete pending signature request', async ({ page }) => { // The most recently uploaded document is first — rename it to a unique name // so it can be unambiguously identified regardless of other documents in the list. // NcActionButton inside NcActions renders as role="menuitem", not role="button". - const uniqueName = `delete-pending-test-${Date.now()}` + const uniqueName = `delete-pending-test-${Date.now()}.pdf` const firstRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row') .filter({ hasText: 'small_valid' }) .first() @@ -70,6 +71,7 @@ test('delete pending signature request', async ({ page }) => { // Find the row by its unique name and assert the status const targetRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row') .filter({ hasText: uniqueName }) + await expect(targetRow).toBeVisible({ timeout: 20000 }) await expect(targetRow.locator('.status-chip__text')).toHaveText('Ready to sign') // Delete it @@ -79,9 +81,13 @@ test('delete pending signature request', async ({ page }) => { // Confirm the deletion in the dialog await expect(page.getByRole('dialog', { name: 'Confirm' })).toBeVisible() await expect(page.getByText('The signature request will be deleted. Do you confirm this action?')).toBeVisible() - await page.getByRole('button', { name: 'Ok' }).click() + await Promise.all([ + page.waitForResponse((response) => response.request().method() === 'DELETE' && response.url().includes('/apps/libresign/api/v1/file/file_id/') && response.ok()), + page.getByRole('button', { name: 'Ok' }).click(), + ]) - // The specific row we deleted must disappear from the list - await expect(targetRow).toBeHidden() + // The list updates asynchronously after the backend deletion completes. + await page.reload() + await expect(targetRow).toBeHidden({ timeout: 20000 }) }) diff --git a/playwright/e2e/files-open-in-libresign-context-menu.spec.ts b/playwright/e2e/files-open-in-libresign-context-menu.spec.ts index d9b716d6ed..90fc0753c5 100644 --- a/playwright/e2e/files-open-in-libresign-context-menu.spec.ts +++ b/playwright/e2e/files-open-in-libresign-context-menu.spec.ts @@ -39,8 +39,11 @@ test('open PDF in LibreSign from Files context menu', async ({ page }) => { const filesTable = page.getByRole('table', { name: /List of your files and folders/i, }) + const filesSearch = page.getByRole('searchbox', { name: /Search here/i }).first() + await expect(filesSearch).toBeVisible({ timeout: 15000 }) + await filesSearch.fill(fileName) const fileRow = filesTable.getByRole('row', { name: new RegExp(fileName) }) - await expect(fileRow).toBeVisible({ timeout: 15000 }) + await expect(fileRow).toBeVisible({ timeout: 30000 }) await fileRow.click({ button: 'right' }) diff --git a/playwright/e2e/footer-policy-hierarchy-ui.spec.ts b/playwright/e2e/footer-policy-hierarchy-ui.spec.ts new file mode 100644 index 0000000000..e798082136 --- /dev/null +++ b/playwright/e2e/footer-policy-hierarchy-ui.spec.ts @@ -0,0 +1,386 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test as base, type APIRequestContext, type Locator, type Page } from '@playwright/test' +import { login } from '../support/nc-login' +import { + configureOpenSsl, + ensureGroupExists, + ensureUserExists, + ensureUserInGroup, + setAppConfig, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + getEffectivePolicy, + policyRequest, + setSystemPolicyEntry, +} from '../support/policy-api' + +const test = base.extend<{ + adminRequestContext: APIRequestContext + endUserRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, + endUserRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(END_USER, DEFAULT_TEST_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 120000 }) + +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 = 'libresign-footer-ui-flow-group' +const END_USER = 'signer1' +const FOOTER_POLICY_KEY = 'add_footer' +const REQUEST_SIGN_GROUPS = JSON.stringify(['admin', GROUP_ID]) +const DEFAULT_REQUEST_SIGN_GROUPS = JSON.stringify(['admin']) +const SYSTEM_FOOTER_DISABLED_VALUE = JSON.stringify({ + enabled: false, + writeQrcodeOnFooter: false, + validationSite: '', + customizeFooterTemplate: false, +}) + +function buildFooterPolicyValue(template: string): string { + return JSON.stringify({ + enabled: true, + writeQrcodeOnFooter: true, + validationSite: '', + customizeFooterTemplate: true, + footerTemplate: template, + }) +} + +function normalizeFooterPolicyValue(value: unknown): Record { + if (typeof value === 'string') { + return JSON.parse(value) as Record + } + + if (value && typeof value === 'object') { + return value as Record + } + + return {} +} + +function getTrimmedFooterTemplate(value: unknown): string { + const parsed = normalizeFooterPolicyValue(value) + const template = parsed.footerTemplate + return typeof template === 'string' ? template.trim() : '' +} + +async function deleteGroupPolicyEntry( + ctx: APIRequestContext, + groupId: string, + policyKey: string, +): Promise { + const response = await policyRequest(ctx, 'DELETE', `/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`) + expect([200, 404, 500]).toContain(response.httpStatus) +} + +async function deleteUserPolicyEntry( + ctx: APIRequestContext, + userId: string, + policyKey: string, +): Promise { + const response = await policyRequest(ctx, 'DELETE', `/apps/libresign/api/v1/policies/user/${userId}/${policyKey}`) + expect([200, 404, 500]).toContain(response.httpStatus) +} + +async function setUserPolicyEntry( + ctx: APIRequestContext, + userId: string, + policyKey: string, + value: string, + allowChildOverride: boolean, +): Promise { + const response = await policyRequest(ctx, 'PUT', `/apps/libresign/api/v1/policies/user/${userId}/${policyKey}`, { + value, + allowChildOverride, + }) + expect(response.httpStatus, `setUserPolicyEntry(${userId}/${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} + +async function resetFooterHierarchyState( + adminRequestContext: APIRequestContext, + endUserRequestContext: APIRequestContext, +): Promise { + await clearUserPolicyPreference(endUserRequestContext, FOOTER_POLICY_KEY, [200, 401, 500]) + await deleteUserPolicyEntry(adminRequestContext, END_USER, FOOTER_POLICY_KEY) + await deleteGroupPolicyEntry(adminRequestContext, GROUP_ID, FOOTER_POLICY_KEY) + await setSystemPolicyEntry(adminRequestContext, FOOTER_POLICY_KEY, SYSTEM_FOOTER_DISABLED_VALUE, true) +} + +async function waitForPolicyRequest(page: Page, method: 'PUT' | 'DELETE', urlPart: string, action: () => Promise) { + const requestPromise = page.waitForRequest((request) => { + return request.method() === method + && request.url().includes(urlPart) + }) + + await action() + return requestPromise +} + +async function openFooterPolicyDialog(page: Page): Promise { + await page.goto('/apps/libresign/f/policies') + await expect(page).toHaveURL(/\/apps\/libresign\/f\/policies/, { timeout: 20000 }) + + const searchField = page.getByRole('textbox', { name: 'Search settings' }) + await expect(searchField).toBeVisible({ timeout: 20000 }) + await searchField.fill('Signature footer') + + const configureButton = page.getByRole('button', { name: 'Configure setting' }).first() + await expect(configureButton).toBeVisible({ timeout: 20000 }) + await configureButton.click() + + const dialog = page.getByRole('dialog', { name: 'Signature footer' }).first() + await expect(dialog).toBeVisible({ timeout: 20000 }) + return dialog +} + +async function openCreateRuleEditor(dialog: Locator, scopeName: 'Group' | 'User'): Promise { + await dialog.getByRole('button', { name: 'Create rule' }).click() + + const scopeDialog = dialog.page().getByRole('dialog').last() + await expect(scopeDialog).toBeVisible({ timeout: 10000 }) + await scopeDialog.getByRole('option', { name: new RegExp(`^${scopeName}`) }).click() +} + +async function selectTarget(dialogScope: Locator, label: 'Target groups' | 'Target users', _placeholder: 'Search groups' | 'Search users', target: string): Promise { + const page = dialogScope.page() + const combobox = dialogScope.getByRole('combobox', { name: /Search for option/i }).first() + const labeledInput = dialogScope.getByLabel(label).first() + const targetInput = await combobox.count() ? combobox : labeledInput + const selectedTarget = dialogScope.locator('.vs__selected').filter({ hasText: new RegExp(target, 'i') }).first() + + await expect(targetInput).toBeVisible({ timeout: 8000 }) + if (await selectedTarget.isVisible({ timeout: 1000 }).catch(() => false)) { + await expect(selectedTarget).toBeVisible() + return + } + + await targetInput.click() + + const searchInput = targetInput.locator('input').first() + if (await searchInput.count()) { + for (let attempt = 0; attempt < 3; attempt += 1) { + await searchInput.fill(target) + await page.waitForTimeout(250) + + const matchingOption = page.getByRole('option', { name: new RegExp(target, 'i') }).first() + const matchingVisible = await matchingOption.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false) + if (matchingVisible) { + await matchingOption.click() + } else { + const floatingOption = page.locator('ul[role="listbox"] li, .vs__dropdown-menu--floating li').filter({ hasText: new RegExp(target, 'i') }).first() + if (await floatingOption.isVisible({ timeout: 2000 }).catch(() => false)) { + await floatingOption.click() + } else { + await searchInput.press('ArrowDown') + await searchInput.press('Enter') + } + } + + await page.keyboard.press('Escape').catch(() => {}) + await searchInput.press('Tab').catch(() => {}) + if (await selectedTarget.isVisible({ timeout: 2000 }).catch(() => false)) { + break + } + } + await expect(selectedTarget).toBeVisible({ timeout: 5000 }) + } else { + const fallbackTextbox = dialogScope.getByRole('textbox').first() + await fallbackTextbox.fill(target) + await fallbackTextbox.press('ArrowDown') + await fallbackTextbox.press('Enter') + await fallbackTextbox.press('Tab').catch(() => {}) + } + + await expect(page.locator('ul[role="listbox"].vs__dropdown-menu--floating')).toHaveCount(0) +} + +async function ensureCheckboxEnabled(scope: Page | Locator, checkboxLabel: string): Promise { + const checkbox = scope.getByRole('checkbox', { name: checkboxLabel }).first() + await expect(checkbox).toBeVisible({ timeout: 10000 }) + const checked = await checkbox.isChecked().catch(() => false) + if (!checked) { + await checkbox.setChecked(true, { force: true }) + } + await expect(checkbox).toBeChecked() +} + +async function ensureFooterTemplateEditorVisible(scope: Page | Locator): Promise { + await ensureCheckboxEnabled(scope, 'Add visible footer with signature details') + await ensureCheckboxEnabled(scope, 'Customize footer template') + + const editorContainer = scope.locator('.code-editor').first() + const footerTemplateField = editorContainer.locator('.cm-content[contenteditable="true"]').first() + await expect(footerTemplateField).toBeVisible({ timeout: 10000 }) + return footerTemplateField +} + +async function createFooterRuleViaUi( + page: Page, + scopeName: 'Group' | 'User', + target: string, + template: string, + requestUrlPart: string, +): Promise { + const dialog = await openFooterPolicyDialog(page) + await openCreateRuleEditor(dialog, scopeName) + + const createRuleDialog = page.getByRole('dialog', { name: 'Create rule' }).last() + await expect(createRuleDialog).toBeVisible({ timeout: 10000 }) + + if (scopeName === 'Group') { + await selectTarget(createRuleDialog, 'Target groups', 'Search groups', target) + } else { + await selectTarget(createRuleDialog, 'Target users', 'Search users', target) + } + + const footerTemplateField = await ensureFooterTemplateEditorVisible(createRuleDialog) + await footerTemplateField.click() + await footerTemplateField.press('Control+a') + await footerTemplateField.fill(template) + + await waitForPolicyRequest(page, 'PUT', requestUrlPart, async () => { + await page.getByRole('button', { name: 'Create rule' }).last().click() + }) + await dialog.getByRole('button', { name: 'Close' }).first().click() + await expect(dialog).toBeHidden({ timeout: 10000 }) +} + +async function expectFooterTemplateValue(page: Page, expectedValue: string): Promise { + const footerTemplateField = await ensureFooterTemplateEditorVisible(page) + await expect.poll(async () => { + const text = await footerTemplateField.textContent() + return (text ?? '').trim() + }, { timeout: 10000 }).toContain(expectedValue) +} + +test.beforeEach(async ({ page, adminRequestContext, endUserRequestContext }) => { + await ensureUserExists(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await ensureGroupExists(page.request, GROUP_ID) + await ensureUserInGroup(page.request, END_USER, GROUP_ID) + await setAppConfig(adminRequestContext, 'libresign', 'groups_request_sign', REQUEST_SIGN_GROUPS) + await configureOpenSsl(adminRequestContext, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + await resetFooterHierarchyState(adminRequestContext, endUserRequestContext) +}) + +test.afterEach(async ({ adminRequestContext, endUserRequestContext }) => { + await resetFooterHierarchyState(adminRequestContext, endUserRequestContext) + await setAppConfig(adminRequestContext, 'libresign', 'groups_request_sign', DEFAULT_REQUEST_SIGN_GROUPS) +}) + +test('footer hierarchy works through policies and preferences UI', async ({ page, adminRequestContext, endUserRequestContext }) => { + const uniqueId = Date.now() + const groupTemplate = `
Group footer ${uniqueId}
` + const userTemplate = `
User footer ${uniqueId}
` + const adminUserTemplate = `
Admin override ${uniqueId}
` + + await login(page.request, ADMIN_USER, ADMIN_PASSWORD) + await createFooterRuleViaUi( + page, + 'Group', + GROUP_ID, + groupTemplate, + `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${FOOTER_POLICY_KEY}`, + ) + + let effectivePolicy = await getEffectivePolicy(endUserRequestContext, FOOTER_POLICY_KEY) + expect(normalizeFooterPolicyValue(effectivePolicy?.effectiveValue)).toMatchObject({ + enabled: true, + writeQrcodeOnFooter: true, + customizeFooterTemplate: true, + }) + expect(getTrimmedFooterTemplate(effectivePolicy?.effectiveValue)).toBe(groupTemplate) + expect(effectivePolicy?.sourceScope).toBe('group') + + await login(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await page.goto('/apps/libresign/f/preferences') + await expect(page).toHaveURL(/\/apps\/libresign\/f\/preferences/, { timeout: 20000 }) + await expectFooterTemplateValue(page, groupTemplate) + + await waitForPolicyRequest(page, 'PUT', `/apps/libresign/api/v1/policies/user/${FOOTER_POLICY_KEY}`, async () => { + const footerTemplateField = await ensureFooterTemplateEditorVisible(page) + await footerTemplateField.click() + await footerTemplateField.press('Control+a') + await footerTemplateField.fill(userTemplate) + await footerTemplateField.press('Tab') + }) + await expect(page.getByText('Preference saved', { exact: true })).toBeVisible({ timeout: 20000 }) + + effectivePolicy = await getEffectivePolicy(endUserRequestContext, FOOTER_POLICY_KEY) + expect(normalizeFooterPolicyValue(effectivePolicy?.effectiveValue)).toMatchObject({ + enabled: true, + writeQrcodeOnFooter: true, + customizeFooterTemplate: true, + }) + expect(getTrimmedFooterTemplate(effectivePolicy?.effectiveValue)).toBe(userTemplate) + expect(effectivePolicy?.sourceScope).toBe('user') + await expectFooterTemplateValue(page, userTemplate) + + await waitForPolicyRequest(page, 'DELETE', `/apps/libresign/api/v1/policies/user/${FOOTER_POLICY_KEY}`, async () => { + await page.getByRole('button', { name: 'Reset to default' }).first().click() + }) + await expectFooterTemplateValue(page, groupTemplate) + + effectivePolicy = await getEffectivePolicy(endUserRequestContext, FOOTER_POLICY_KEY) + expect(normalizeFooterPolicyValue(effectivePolicy?.effectiveValue)).toMatchObject({ + enabled: true, + writeQrcodeOnFooter: true, + customizeFooterTemplate: true, + }) + expect(getTrimmedFooterTemplate(effectivePolicy?.effectiveValue)).toBe(groupTemplate) + expect(effectivePolicy?.sourceScope).toBe('group') + + await login(page.request, ADMIN_USER, ADMIN_PASSWORD) + await setUserPolicyEntry(adminRequestContext, END_USER, FOOTER_POLICY_KEY, buildFooterPolicyValue(adminUserTemplate), true) + + effectivePolicy = await getEffectivePolicy(endUserRequestContext, FOOTER_POLICY_KEY) + expect(normalizeFooterPolicyValue(effectivePolicy?.effectiveValue)).toMatchObject({ + enabled: true, + writeQrcodeOnFooter: true, + customizeFooterTemplate: true, + }) + expect(getTrimmedFooterTemplate(effectivePolicy?.effectiveValue)).toBe(adminUserTemplate) + expect(effectivePolicy?.sourceScope).toBe('user_policy') + + await login(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await page.goto('/apps/libresign/f/preferences') + await expectFooterTemplateValue(page, adminUserTemplate) + + await deleteUserPolicyEntry(adminRequestContext, END_USER, FOOTER_POLICY_KEY) + + await page.reload() + await expectFooterTemplateValue(page, groupTemplate) + + effectivePolicy = await getEffectivePolicy(endUserRequestContext, FOOTER_POLICY_KEY) + expect(normalizeFooterPolicyValue(effectivePolicy?.effectiveValue)).toMatchObject({ + enabled: true, + writeQrcodeOnFooter: true, + customizeFooterTemplate: true, + }) + expect(getTrimmedFooterTemplate(effectivePolicy?.effectiveValue)).toBe(groupTemplate) + expect(effectivePolicy?.sourceScope).toBe('group') + await expect(page.getByText('Preference saved', { exact: true })).toHaveCount(0) +}) diff --git a/playwright/e2e/footer-reset-persistence.spec.ts b/playwright/e2e/footer-reset-persistence.spec.ts new file mode 100644 index 0000000000..cd5053b09a --- /dev/null +++ b/playwright/e2e/footer-reset-persistence.spec.ts @@ -0,0 +1,73 @@ +/** + * 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 { + bootstrapLibreSignAdmin, + ensureFooterTemplateEnabled, + fillTemplateEditor, + openSystemFooterRuleEditor, +} from '../support/footer-policy-workbench' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +async function waitForFooterTemplateRequest(page: Page, action: () => Promise) { + const requestPromise = page.waitForRequest((request) => { + return request.method() === 'POST' && request.url().includes('/admin/footer-template/preview-pdf') + }) + + await action() + const request = await requestPromise + return request.postDataJSON() as { + template: string + width: number + height: number + } +} + +async function saveRule(page: Page, ruleDialog: Locator): Promise { + const saveButton = ruleDialog.getByRole('button', { name: /Create rule|Save changes|Save policy rule changes|Save rule changes/i }).last() + await expect(saveButton).toBeVisible({ timeout: 10000 }) + await expect(saveButton).toBeEnabled({ timeout: 10000 }) + const saveResponsePromise = page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes('/apps/libresign/api/v1/policies/system/add_footer') + }) + await saveButton.click() + const saveResponse = await saveResponsePromise + await expect(saveResponse.status()).toBe(200) +} + +test('footer template persists after reset and page reload', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + let ruleDialog = await openSystemFooterRuleEditor(page) + await ensureFooterTemplateEnabled(ruleDialog) + const templateEditor = ruleDialog.locator('.code-editor .cm-content[contenteditable="true"]').first() + + // Save custom template + const customTemplate = `
E2E_TEST_${Date.now()}
` + await waitForFooterTemplateRequest(page, async () => { + await fillTemplateEditor(ruleDialog, customTemplate) + }) + await expect(templateEditor).toContainText('E2E_TEST_') + + // Click reset template to inherited default + const resetButton = ruleDialog.getByRole('button', { name: /Reset template to inherited default/i }).first() + await expect(resetButton).toBeVisible({ timeout: 10000 }) + await waitForFooterTemplateRequest(page, async () => { + await resetButton.click() + }) + + // Persist rule and verify reset survives reload + await saveRule(page, ruleDialog) + + await page.reload() + ruleDialog = await openSystemFooterRuleEditor(page) + await ensureFooterTemplateEnabled(ruleDialog) + const templateAfterReload = ruleDialog.locator('.code-editor .cm-content[contenteditable="true"]').first() + await expect(templateAfterReload).toBeVisible({ timeout: 10000 }) + await expect(templateAfterReload).not.toContainText('E2E_TEST_') +}) diff --git a/playwright/e2e/mobile-pdf-horizontal-scroll.spec.ts b/playwright/e2e/mobile-pdf-horizontal-scroll.spec.ts index 97dc556a6b..72c7c142dd 100644 --- a/playwright/e2e/mobile-pdf-horizontal-scroll.spec.ts +++ b/playwright/e2e/mobile-pdf-horizontal-scroll.spec.ts @@ -4,34 +4,29 @@ */ import { devices, expect, test } from '@playwright/test' -import { login } from '../support/nc-login' -import { setAppConfig } from '../support/nc-provisioning' +import { + bootstrapLibreSignAdmin, + ensureFooterTemplateEnabled, + openSystemFooterRuleEditor, +} from '../support/footer-policy-workbench' test.use({ ...devices['Pixel 7'], }) test('PDF viewer allows horizontal scrolling on mobile viewport', async ({ page }) => { - await login( - page.request, - process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', - process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', - ) - - await setAppConfig( - page.request, - 'libresign', 'add_footer', '1', - ) - - await setAppConfig( - page.request, - 'libresign', 'footer_template_is_default', '0', - ) - - await page.goto('./settings/admin/libresign') - const pdfRoot = page.locator('.footer-template-section .pdf-elements-root').first() + await bootstrapLibreSignAdmin(page) + const ruleDialog = await openSystemFooterRuleEditor(page) + await ensureFooterTemplateEnabled(ruleDialog) + + const pdfRoot = ruleDialog.locator('.signature-footer-rule-editor__preview .pdf-elements-root').first() await expect(pdfRoot).toBeVisible({ timeout: 15000 }) + const widthField = ruleDialog.getByRole('spinbutton', { name: 'Width' }).first() + await expect(widthField).toBeVisible({ timeout: 10000 }) + await widthField.fill('900') + await widthField.press('Tab') + // Check that overflow-x is set to auto (not hidden). const computedStyle = await pdfRoot.evaluate((el) => { return window.getComputedStyle(el).overflowX @@ -49,6 +44,13 @@ test('PDF viewer allows horizontal scrolling on mobile viewport', async ({ page expect(touchAction).not.toContain('pinch-zoom') // Validate real horizontal scrolling capability, not only style declarations. + await expect.poll(async () => { + return pdfRoot.evaluate((el) => el.scrollWidth > el.clientWidth) + }, { + timeout: 15000, + message: 'Expected footer preview to become horizontally scrollable after widening the preview', + }).toBe(true) + const before = await pdfRoot.evaluate((el) => { el.scrollLeft = 0 return { @@ -58,8 +60,6 @@ test('PDF viewer allows horizontal scrolling on mobile viewport', async ({ page } }) - expect(before.scrollWidth).toBeGreaterThan(before.clientWidth) - const box = await pdfRoot.boundingBox() expect(box).not.toBeNull() diff --git a/playwright/e2e/multi-signer-sequential.spec.ts b/playwright/e2e/multi-signer-sequential.spec.ts index 8cfbc241f5..5285d2c4d0 100644 --- a/playwright/e2e/multi-signer-sequential.spec.ts +++ b/playwright/e2e/multi-signer-sequential.spec.ts @@ -3,11 +3,70 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Page } from '@playwright/test' -import { expect, test } from '@playwright/test' +import type { APIRequestContext, Page } from '@playwright/test' +import { expect, test as base } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, deleteAppConfig, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, deleteAppConfig, getAppConfig, setAppConfig } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink } from '../support/mailpit' +import { makeAdminContext } from '../support/system-policies' +import { setSystemPolicyEntry } from '../support/policy-api' + +const FOOTER_POLICY_KEY = 'add_footer' +const FOOTER_DISABLED_VALUE = JSON.stringify({ + enabled: false, + writeQrcodeOnFooter: false, + validationSite: '', + customizeFooterTemplate: false, +}) + +type OriginalConfigSnapshot = { + identifyMethods: string | null + signatureEngine: string | null + tsaUrl: string | null + footerPolicy: string | null +} + +const test = base.extend<{ + adminContext: APIRequestContext + originalConfigSnapshot: OriginalConfigSnapshot +}>({ + adminContext: async ({}, use) => { + const ctx = await makeAdminContext() + await use(ctx) + await ctx.dispose() + }, + originalConfigSnapshot: async ({ request, adminContext }, use) => { + const response = await adminContext.get(`./ocs/v2.php/apps/libresign/api/v1/policies/system/${FOOTER_POLICY_KEY}`, { + failOnStatusCode: false, + }) + expect(response.status(), `getSystemFooterPolicy: expected 200 but got ${response.status()}`).toBe(200) + const policyBody = await response.json() as { + ocs?: { + data?: { + policy?: { + value?: string | null + } + } + } + } + + await use({ + identifyMethods: await getAppConfig(request, 'libresign', 'identify_methods'), + signatureEngine: await getAppConfig(request, 'libresign', 'signature_engine'), + tsaUrl: await getAppConfig(request, 'libresign', 'tsa_url'), + footerPolicy: policyBody.ocs?.data?.policy?.value ?? null, + }) + }, +}) + +test.setTimeout(120_000) + +test.afterEach(async ({ page, adminContext, originalConfigSnapshot }) => { + await restoreAppConfig(page.request, 'identify_methods', originalConfigSnapshot.identifyMethods) + await restoreAppConfig(page.request, 'signature_engine', originalConfigSnapshot.signatureEngine) + await restoreAppConfig(page.request, 'tsa_url', originalConfigSnapshot.tsaUrl) + await setSystemPolicyEntry(adminContext, FOOTER_POLICY_KEY, originalConfigSnapshot.footerPolicy ?? FOOTER_DISABLED_VALUE, true) +}) async function addEmailSigner( page: Page, @@ -27,33 +86,36 @@ async function addEmailSigner( await page.getByRole('button', { name: 'Save' }).click() } -test('request signatures from two signers in sequential order', async ({ page }) => { - await login( - page.request, - process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', - process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', - ) - - await configureOpenSsl(page.request, 'LibreSign Test', { - C: 'BR', - OU: ['Organization Unit'], - ST: 'Rio de Janeiro', - O: 'LibreSign', - L: 'Rio de Janeiro', +test('request signatures from two signers in sequential order', async ({ page, adminContext }) => { + await test.step('configure signing environment', async () => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await configureOpenSsl(page.request, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setAppConfig( + page.request, + 'libresign', + 'identify_methods', + JSON.stringify([ + { name: 'account', enabled: false, mandatory: false }, + { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, + ]), + ) + await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') + await deleteAppConfig(page.request, 'libresign', 'tsa_url') + await setSystemPolicyEntry(adminContext, FOOTER_POLICY_KEY, FOOTER_DISABLED_VALUE, true) }) - await setAppConfig( - page.request, - '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(page.request, 'libresign', 'signature_engine', 'PhpNative') - await deleteAppConfig(page.request, 'libresign', 'tsa_url') - const mailpit = createMailpitClient() await mailpit.deleteMessages() @@ -69,10 +131,11 @@ test('request signatures from two signers in sequential order', async ({ page }) await addEmailSigner(page, 'signer02@libresign.coop', 'Signer 02') // Enable sequential signing. - // The checkbox input is hidden by CSS; click the visible label text to toggle it. - await expect(page.getByLabel('Sign in order')).toBeVisible() - await page.getByText('Sign in order').click() - await expect(page.getByLabel('Sign in order')).toBeChecked() + // The hidden checkbox can be covered by the styled label in CI, so force the state change. + const signInOrderSwitch = page.getByLabel('Sign in order') + await expect(signInOrderSwitch).toBeVisible() + await signInOrderSwitch.check({ force: true }) + await expect(signInOrderSwitch).toBeChecked() // Send the signature request await page.getByRole('button', { name: 'Request signatures' }).click() @@ -95,8 +158,17 @@ test('request signatures from two signers in sequential order', async ({ page }) if (!signLink) throw new Error('Sign link not found in email') await page.goto(signLink) await page.getByRole('button', { name: 'Sign the document.' }).click() + const firstSignResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + const firstSignResponse = await firstSignResponsePromise + const firstSignResponseBody = await firstSignResponse.text() + expect( + firstSignResponse.ok(), + `Signer 01 sign API failed with status ${firstSignResponse.status()}: ${firstSignResponseBody}`, + ).toBeTruthy() await expect(page.getByText('This document is valid')).toBeVisible() // Signer01 signed; signer02 is still waiting (sequential mode proof at this point) await expect(page.getByText('Signer 01')).toBeVisible() @@ -104,7 +176,6 @@ test('request signatures from two signers in sequential order', async ({ page }) await page.getByRole('button', { name: 'Expand validation status', exact: true }).click(); await page.getByRole('link', { name: 'Document integrity verified' }).click(); await page.getByRole('button', { name: 'Expand document certification', exact: true }).click(); - await page.getByRole('link', { name: 'Document has not been' }).click(); await expect(page.getByText('Signer 02')).toBeVisible() await expect(page.getByText('Not signed yet')).toBeVisible() @@ -121,8 +192,17 @@ test('request signatures from two signers in sequential order', async ({ page }) if (!signLink02) throw new Error('Sign link for signer02 not found in email') await page.goto(signLink02) await page.getByRole('button', { name: 'Sign the document.' }).click() + const secondSignResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + const secondSignResponse = await secondSignResponsePromise + const secondSignResponseBody = await secondSignResponse.text() + expect( + secondSignResponse.ok(), + `Signer 02 sign API failed with status ${secondSignResponse.status()}: ${secondSignResponseBody}`, + ).toBeTruthy() await expect(page.getByText('This document is valid')).toBeVisible() // Both signers must appear as signed in the final validation view. @@ -130,3 +210,16 @@ test('request signatures from two signers in sequential order', async ({ page }) await expect(page.getByText('Signer 02')).toBeVisible() await expect(page.getByText('Not signed yet')).not.toBeVisible() }) + +async function restoreAppConfig( + requestContext: APIRequestContext, + key: string, + value: string | null, +): Promise { + if (value === null) { + await deleteAppConfig(requestContext, 'libresign', key) + return + } + + await setAppConfig(requestContext, 'libresign', key, value) +} diff --git a/playwright/e2e/policy-preferences-boolean-settings.spec.ts b/playwright/e2e/policy-preferences-boolean-settings.spec.ts new file mode 100644 index 0000000000..8f72ac66ac --- /dev/null +++ b/playwright/e2e/policy-preferences-boolean-settings.spec.ts @@ -0,0 +1,328 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test' +import { login } from '../support/nc-login' +import { expandSettingsMenu } from '../support/nc-navigation' +import { + configureOpenSsl, + ensureGroupExists, + ensureUserExists, + ensureUserInGroup, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + getEffectivePolicy, + policyRequest, + setGroupPolicyEntry, + setSystemPolicyEntry, +} from '../support/policy-api' + +type SystemPolicySnapshot = { + exists: boolean + value: unknown + allowChildOverride: boolean +} + +const adminUser = 'admin' +const adminPass = process.env.ADMIN_PASSWORD || 'admin' + +test.describe('Policy preferences: boolean settings', () => { + test('user can save and clear collect_metadata, identification_documents, docmdp and signature_text preferences', async ({ page }) => { + const groupId = `pref-boolean-${Date.now()}` + const endUser = `prefboolean_${Date.now()}` + const endPass = 'user1234' + + const adminCtx = await createAuthenticatedRequestContext(adminUser, adminPass) + let endUserCtx: APIRequestContext | null = null + const originalGroupsRequestSign = await getSystemPolicySnapshot(adminCtx, 'groups_request_sign') + const originalCollectMetadata = await getSystemPolicySnapshot(adminCtx, 'collect_metadata') + const originalIdentificationDocuments = await getSystemPolicySnapshot(adminCtx, 'identification_documents') + const originalDocmdp = await getSystemPolicySnapshot(adminCtx, 'docmdp') + const originalSignatureText = await getSystemPolicySnapshot(adminCtx, 'signature_text') + const signatureTextSystemValue = JSON.stringify({ + template: 'System template', + template_font_size: 9, + signature_font_size: 9, + signature_width: 90, + signature_height: 60, + render_mode: 'default', + }) + const signatureTextGroupValue = JSON.stringify({ + template: 'Group template', + template_font_size: 10, + signature_font_size: 10, + signature_width: 110, + signature_height: 70, + render_mode: 'text', + }) + + try { + await login(page.request, adminUser, adminPass) + await configureOpenSsl(page.request, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await ensureGroupExists(page.request, groupId) + await ensureUserExists(page.request, endUser, endPass) + await ensureUserInGroup(page.request, endUser, groupId) + + await setSystemPolicyEntry(adminCtx, 'groups_request_sign', JSON.stringify([groupId]), true) + await setSystemPolicyEntry(adminCtx, 'collect_metadata', JSON.stringify(false), true) + await setGroupPolicyEntry(adminCtx, groupId, 'collect_metadata', JSON.stringify(true), true) + await setSystemPolicyEntry(adminCtx, 'identification_documents', JSON.stringify(false), true) + await setGroupPolicyEntry(adminCtx, groupId, 'identification_documents', JSON.stringify(true), true) + await setSystemNumericPolicyEntry(adminCtx, 'docmdp', 0, true) + await setGroupNumericPolicyEntry(adminCtx, groupId, 'docmdp', 2, true) + await setSystemPolicyEntry(adminCtx, 'signature_text', signatureTextSystemValue, true) + await setGroupPolicyEntry(adminCtx, groupId, 'signature_text', signatureTextGroupValue, true) + + endUserCtx = await createAuthenticatedRequestContext(endUser, endPass) + + await login(page.request, endUser, endPass) + await page.goto('/index.php/apps/libresign/f/preferences') + await page.locator('#app-navigation-vue').waitFor({ state: 'visible' }) + await expandSettingsMenu(page) + + const collectMetadataSection = await sectionByTitle(page, 'Collect signer metadata') + const idDocsSection = await sectionByTitle(page, 'Identification documents flow') + const docMdpSection = await sectionByTitle(page, 'PDF certification') + const signatureTextSection = await sectionByTitle(page, 'Signature text') + + expect(await collectMetadataSection.isVisible()).toBe(true) + expect(await idDocsSection.isVisible()).toBe(true) + expect(await docMdpSection.isVisible()).toBe(true) + expect(await signatureTextSection.isVisible()).toBe(true) + + await savePreferenceAsDisabled(page, collectMetadataSection, 'collect_metadata') + await savePreferenceAsDisabled(page, idDocsSection, 'identification_documents') + await saveDocMdpPreference(page, docMdpSection, 3) + await saveSignatureTextTemplatePreference(page, signatureTextSection, 'User custom template') + + await expectPolicyEffectiveValue(endUserCtx, 'collect_metadata', false, 'user') + await expectPolicyEffectiveValue(endUserCtx, 'identification_documents', { enabled: false, approvers: ['admin'] }, 'user') + await expectDocMdpEffectiveValue(endUserCtx, 3, 'user') + await expectSignatureTextEffectiveScope(endUserCtx, 'user', 'User custom template') + + await clearPreference(page, collectMetadataSection, 'collect_metadata') + await clearPreference(page, idDocsSection, 'identification_documents') + await clearPreference(page, docMdpSection, 'docmdp') + await clearPreference(page, signatureTextSection, 'signature_text') + + await expectPolicyEffectiveValue(endUserCtx, 'collect_metadata', true, 'group') + await expectPolicyEffectiveValue(endUserCtx, 'identification_documents', { enabled: false, approvers: ['admin'] }, 'group') + await expectDocMdpEffectiveValue(endUserCtx, 2, 'group') + await expectSignatureTextEffectiveScope(endUserCtx, 'group', 'Group template') + } finally { + if (endUserCtx) { + await clearUserPolicyPreference(endUserCtx, 'collect_metadata', [200, 401, 500]) + await clearUserPolicyPreference(endUserCtx, 'identification_documents', [200, 401, 500]) + await clearUserPolicyPreference(endUserCtx, 'docmdp', [200, 401, 500]) + await clearUserPolicyPreference(endUserCtx, 'signature_text', [200, 401, 500]) + await endUserCtx.dispose() + } + + await restoreSystemPolicySnapshot(adminCtx, 'groups_request_sign', originalGroupsRequestSign) + await restoreSystemPolicySnapshot(adminCtx, 'collect_metadata', originalCollectMetadata) + await restoreSystemPolicySnapshot(adminCtx, 'identification_documents', originalIdentificationDocuments) + await restoreSystemPolicySnapshot(adminCtx, 'docmdp', originalDocmdp) + await restoreSystemPolicySnapshot(adminCtx, 'signature_text', originalSignatureText) + + await policyRequest(adminCtx, 'DELETE', `/cloud/users/${endUser}`) + await policyRequest(adminCtx, 'DELETE', `/cloud/groups/${groupId}`) + await adminCtx.dispose() + } + }) +}) + +async function sectionByTitle(page: Page, title: string): Promise { + const heading = page.getByRole('heading', { name: title }).first() + await expect(heading).toBeVisible() + const section = heading.locator('xpath=ancestor::div[contains(@class, "settings-section")][1]') + await expect(section).toBeVisible() + return section +} + +async function getSystemPolicySnapshot( + ctx: APIRequestContext, + policyKey: string, +): Promise { + const response = await policyRequest(ctx, 'GET', `/apps/libresign/api/v1/policies/system/${policyKey}`) + if (response.httpStatus === 404) { + return { + exists: false, + value: null, + allowChildOverride: true, + } + } + + expect(response.httpStatus, `getSystemPolicySnapshot(${policyKey}): expected 200 or 404 but got ${response.httpStatus}`).toBe(200) + + return { + exists: true, + value: response.data.value ?? null, + allowChildOverride: response.data.allowChildOverride === true, + } +} + +async function restoreSystemPolicySnapshot( + ctx: APIRequestContext, + policyKey: string, + snapshot: SystemPolicySnapshot, +): Promise { + if (!snapshot.exists) { + await setSystemPolicyEntry(ctx, policyKey, null, true) + return + } + + const response = await policyRequest(ctx, 'POST', `/apps/libresign/api/v1/policies/system/${policyKey}`, { + value: snapshot.value, + allowChildOverride: snapshot.allowChildOverride, + }) + expect(response.httpStatus, `restoreSystemPolicySnapshot(${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} + +async function savePreferenceAsDisabled(page: Page, section: Locator, policyKey: string): Promise { + const disabledOption = section.getByText('Disabled', { exact: true }).first() + + await Promise.all([ + page.waitForRequest((req) => req.method() === 'PUT' + && req.url().includes(`/ocs/v2.php/apps/libresign/api/v1/policies/user/${policyKey}`)), + disabledOption.click(), + ]) + + await expect(section.getByText('Preference saved')).toBeVisible() +} + +async function clearPreference(page: Page, section: Locator, policyKey: string): Promise { + const resetButton = section.getByRole('button', { name: 'Reset to default' }) + await expect(resetButton).toBeVisible() + + await Promise.all([ + page.waitForRequest((req) => req.method() === 'DELETE' + && req.url().includes(`/ocs/v2.php/apps/libresign/api/v1/policies/user/${policyKey}`)), + resetButton.click(), + ]) +} + +async function saveDocMdpPreference(page: Page, section: Locator, level: 0 | 1 | 2 | 3): Promise { + const labelByLevel: Record = { + 0: 'Disabled', + 1: 'No changes allowed', + 2: 'Form filling', + 3: 'Form filling and annotations', + } + + const option = section.getByText(labelByLevel[level], { exact: true }).first() + + await Promise.all([ + page.waitForRequest((req) => req.method() === 'PUT' + && req.url().includes('/ocs/v2.php/apps/libresign/api/v1/policies/user/docmdp')), + option.click(), + ]) + + await expect(section.getByText('Preference saved')).toBeVisible() +} + +async function saveSignatureTextTemplatePreference(page: Page, section: Locator, template: string): Promise { + const templateInput = section.getByLabel('Signature text template').first() + + await Promise.all([ + page.waitForRequest((req) => req.method() === 'PUT' + && req.url().includes('/ocs/v2.php/apps/libresign/api/v1/policies/user/signature_text')), + templateInput.fill(template), + ]) + + await expect(section.getByText('Preference saved')).toBeVisible() +} + +async function expectPolicyEffectiveValue( + ctx: APIRequestContext, + policyKey: string, + expectedValue: unknown, + expectedScope: string, +): Promise { + const entry = await getEffectivePolicy(ctx, policyKey) + expect(entry).not.toBeNull() + expect(entry?.effectiveValue).toEqual(expectedValue) + expect(entry?.sourceScope).toBe(expectedScope) +} + +async function expectDocMdpEffectiveValue( + ctx: APIRequestContext, + expectedValue: number, + expectedScope: string, +): Promise { + const entry = await getEffectivePolicy(ctx, 'docmdp') + expect(entry).not.toBeNull() + expect(Number(entry?.effectiveValue)).toBe(expectedValue) + expect(entry?.sourceScope).toBe(expectedScope) +} + +async function expectSignatureTextEffectiveScope( + ctx: APIRequestContext, + expectedScope: string, + expectedTemplate: string, +): Promise { + const entry = await getEffectivePolicy(ctx, 'signature_text') + expect(entry).not.toBeNull() + expect(entry?.sourceScope).toBe(expectedScope) + const template = extractSignatureTextTemplate(entry?.effectiveValue) + expect(template).toContain(expectedTemplate) +} + +function extractSignatureTextTemplate(value: unknown): string { + if (typeof value === 'string') { + const trimmed = value.trim() + if (trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed) as { template?: string } + return parsed.template ?? '' + } catch { + return '' + } + } + + return trimmed + } + + if (value && typeof value === 'object' && 'template' in (value as Record)) { + return String((value as Record).template ?? '') + } + + return '' +} + +async function setSystemNumericPolicyEntry( + ctx: APIRequestContext, + policyKey: string, + value: number, + allowChildOverride: boolean, +): Promise { + const response = await policyRequest(ctx, 'POST', `/apps/libresign/api/v1/policies/system/${policyKey}`, { + value, + allowChildOverride, + }) + expect(response.httpStatus, `setSystemNumericPolicyEntry(${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} + +async function setGroupNumericPolicyEntry( + ctx: APIRequestContext, + groupId: string, + policyKey: string, + value: number, + allowChildOverride: boolean, +): Promise { + const response = await policyRequest(ctx, 'PUT', `/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`, { + value, + allowChildOverride, + }) + expect(response.httpStatus, `setGroupNumericPolicyEntry(${groupId}/${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} diff --git a/playwright/e2e/policy-preferences-visibility.spec.ts b/playwright/e2e/policy-preferences-visibility.spec.ts new file mode 100644 index 0000000000..029e118ea5 --- /dev/null +++ b/playwright/e2e/policy-preferences-visibility.spec.ts @@ -0,0 +1,142 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test as base, type APIRequestContext } from '@playwright/test' +import { login } from '../support/nc-login' +import { expandSettingsMenu } from '../support/nc-navigation' +import { + configureOpenSsl, + ensureGroupExists, + ensureUserExists, + ensureUserInGroup, + setAppConfig, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + getEffectivePolicy, + setGroupPolicyEntry, + setSystemPolicyEntry, + waitForPolicyCanSaveAsUserDefault, +} from '../support/policy-api' + +const test = base.extend<{ + adminRequestContext: APIRequestContext + endUserRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, + endUserRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(END_USER, DEFAULT_TEST_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +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-preferences-group' +const END_USER = 'policy-preferences-member' +const POLICY_KEY = 'signature_flow' +const FOOTER_POLICY_KEY = 'add_footer' +const REQUEST_SIGN_GROUPS = JSON.stringify(['admin', GROUP_ID]) +const DEFAULT_REQUEST_SIGN_GROUPS = JSON.stringify(['admin']) +const FOOTER_ENABLED_VALUE = JSON.stringify({ + enabled: true, + writeQrcodeOnFooter: true, + validationSite: '', + customizeFooterTemplate: false, +}) +const FOOTER_DISABLED_VALUE = JSON.stringify({ + enabled: false, + writeQrcodeOnFooter: false, + validationSite: '', + customizeFooterTemplate: false, +}) + +async function resetPolicyPreferencesState( + adminRequestContext: APIRequestContext, + endUserRequestContext: APIRequestContext, +): Promise { + await clearUserPolicyPreference(endUserRequestContext, POLICY_KEY) + await clearUserPolicyPreference(endUserRequestContext, FOOTER_POLICY_KEY) + await setSystemPolicyEntry(adminRequestContext, FOOTER_POLICY_KEY, FOOTER_DISABLED_VALUE, true) + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, null, true) +} + +test.beforeEach(async ({ page, adminRequestContext, endUserRequestContext }) => { + await ensureUserExists(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await ensureGroupExists(page.request, GROUP_ID) + await ensureUserInGroup(page.request, END_USER, GROUP_ID) + await setAppConfig(adminRequestContext, 'libresign', 'groups_request_sign', REQUEST_SIGN_GROUPS) + await configureOpenSsl(adminRequestContext, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + await resetPolicyPreferencesState(adminRequestContext, endUserRequestContext) +}) + +test.afterEach(async ({ adminRequestContext, endUserRequestContext }) => { + await resetPolicyPreferencesState(adminRequestContext, endUserRequestContext) + await setAppConfig(adminRequestContext, 'libresign', 'groups_request_sign', DEFAULT_REQUEST_SIGN_GROUPS) +}) + +test('group member sees Preferences controls only when lower-layer customization is allowed', async ({ page, adminRequestContext, endUserRequestContext }) => { + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, 'parallel', true) + await setGroupPolicyEntry(adminRequestContext, GROUP_ID, POLICY_KEY, 'ordered_numeric', false) + await setSystemPolicyEntry(adminRequestContext, FOOTER_POLICY_KEY, FOOTER_ENABLED_VALUE, true) + await setGroupPolicyEntry(adminRequestContext, GROUP_ID, FOOTER_POLICY_KEY, FOOTER_ENABLED_VALUE, false) + + let effectivePolicy = await getEffectivePolicy(endUserRequestContext, POLICY_KEY) + expect(effectivePolicy?.effectiveValue).toBe('ordered_numeric') + expect(effectivePolicy?.canSaveAsUserDefault).toBe(false) + + await login(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await page.goto('./apps/libresign/f/preferences') + await expandSettingsMenu(page) + + await setGroupPolicyEntry(adminRequestContext, GROUP_ID, POLICY_KEY, 'ordered_numeric', true) + + effectivePolicy = await getEffectivePolicy(endUserRequestContext, POLICY_KEY) + expect(effectivePolicy?.canSaveAsUserDefault).toBe(true) + + await setGroupPolicyEntry(adminRequestContext, GROUP_ID, FOOTER_POLICY_KEY, FOOTER_ENABLED_VALUE, true) + await waitForPolicyCanSaveAsUserDefault(endUserRequestContext, FOOTER_POLICY_KEY, true) + + await page.goto('./apps/libresign/f/preferences') + await expandSettingsMenu(page) + + const enableFooterSwitch = page.locator('.checkbox-radio-switch') + .filter({ hasText: /Add visible footer(?: with signature details)?/i }) + .first() + await expect(enableFooterSwitch).toBeVisible({ timeout: 20000 }) + const enableFooterCheckbox = enableFooterSwitch.locator('input[type="checkbox"]').first() + if (!await enableFooterCheckbox.isChecked()) { + await enableFooterSwitch.locator('.checkbox-radio-switch__content').first().click() + await expect(enableFooterCheckbox).toBeChecked() + } + + const customizeTemplateSwitch = page.locator('.checkbox-radio-switch') + .filter({ hasText: /Customize footer template/i }) + .first() + await expect(customizeTemplateSwitch).toBeVisible({ timeout: 20000 }) + const customizeTemplateCheckbox = customizeTemplateSwitch.locator('input[type="checkbox"]').first() + if (!await customizeTemplateCheckbox.isChecked()) { + await customizeTemplateSwitch.locator('.checkbox-radio-switch__content').first().click() + await expect(customizeTemplateCheckbox).toBeChecked() + } + const footerTemplateLabel = page.getByText('Footer template', { exact: true }) + await expect(footerTemplateLabel).toBeVisible() +}) diff --git a/playwright/e2e/policy-settings-menu-visibility.spec.ts b/playwright/e2e/policy-settings-menu-visibility.spec.ts new file mode 100644 index 0000000000..9506dad857 --- /dev/null +++ b/playwright/e2e/policy-settings-menu-visibility.spec.ts @@ -0,0 +1,141 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Scenario: Policies menu visibility follows delegated customization capability. + * + * 1. (API) Instance admin enables allowChildOverride on system policy. + * 2. (API) No group rule exists yet. + * 3. (Browser) Log in as group admin → "Policies" nav item must be visible. + * 4. (Browser) Navigate to Policies → editable policy card must be visible. + * 5. (Browser) Click "Configure" → setting dialog opens. + * 6. (Browser) Click "Create rule" inside dialog → scope-selector dialog opens. + * 7. (Browser) Group admin can open "Create rule" and start creating a delegated rule. + * + * All admin-side operations are performed via the OCS API so no admin browser + * session is needed, keeping the test as fast as possible. + */ + +import { expect, test as base, type APIRequestContext } from '@playwright/test' +import { randomBytes } from 'node:crypto' +import { login } from '../support/nc-login' +import { expandSettingsMenu } from '../support/nc-navigation' +import { + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, + setAppConfig, + setUserLanguage, +} from '../support/nc-provisioning' +import { + createAuthenticatedRequestContext, + getEffectivePolicy, + setSystemPolicyEntry, +} from '../support/policy-api' + +// One serial block: a single browser session for the group admin +// across both phases avoids repeated login overhead. +const test = base.extend<{ + adminRequestContext: APIRequestContext + groupAdminRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, + groupAdminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' +const GROUP_ADMIN_PASSWORD = '123456' + +const TEST_NAMESPACE = randomBytes(6).toString('hex') +const GROUP_ID = `policy-menu-visibility-group-${TEST_NAMESPACE}` +const GROUP_ADMIN = `policy-menu-visibility-admin-${TEST_NAMESPACE}` + +const POLICY_KEY = 'add_footer' +const REQUEST_SIGN_GROUPS = JSON.stringify(['admin', GROUP_ID]) +const DEFAULT_REQUEST_SIGN_GROUPS = JSON.stringify(['admin']) +const FOOTER_ENABLED_VALUE = JSON.stringify({ + enabled: true, + writeQrcodeOnFooter: true, + validationSite: '', + customizeFooterTemplate: false, +}) + + +test.beforeEach(async ({ adminRequestContext }) => { + await setAppConfig(adminRequestContext, 'libresign', 'groups_request_sign', REQUEST_SIGN_GROUPS) +}) + + +test.afterEach(async ({ adminRequestContext }) => { + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, null, true) + await setAppConfig(adminRequestContext, 'libresign', 'groups_request_sign', DEFAULT_REQUEST_SIGN_GROUPS) +}) + +test('group admin can access policies and sees create-rule guard when higher-level rules block exceptions', async ({ page, adminRequestContext, groupAdminRequestContext }) => { + // ── 0. Provision users/groups (idempotent; safe to call on every run) ── + await ensureUserExists(page.request, GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + await ensureGroupExists(page.request, GROUP_ID) + await ensureUserInGroup(page.request, GROUP_ADMIN, GROUP_ID) + await ensureSubadminOfGroup(page.request, GROUP_ADMIN, GROUP_ID) + await setUserLanguage(page.request, GROUP_ADMIN, 'en') + + // ── 1. Admin: enable delegated customization at system layer ─────────── + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, FOOTER_ENABLED_VALUE, true) + + const editablePolicy = await getEffectivePolicy(groupAdminRequestContext, POLICY_KEY) + expect(editablePolicy?.editableByCurrentActor).toBe(true) + + // ── 2. Log in as group admin ─────────────────────────────────────────── + await login(page.request, GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + await page.goto('./apps/libresign/f/preferences') + + // ── 3. Access the Policies page (via nav item when present, fallback direct route) ── + await expandSettingsMenu(page) + + const policiesNavItem = page.locator('a[href*="/apps/libresign/f/policies"]').first() + if (await policiesNavItem.isVisible().catch(() => false)) { + await policiesNavItem.click() + } else { + await page.goto('./apps/libresign/f/policies') + } + await expect(page).toHaveURL(/\/f\/policies/, { timeout: 10000 }) + + // ── 4. The editable policy card must be visible in the workbench ────── + const configureButton = page + .getByRole('button', { name: /^Configure(?: setting)?$/i }) + .first() + await expect(configureButton, 'At least one Configure button should be visible for the group admin').toBeVisible({ timeout: 15000 }) + + // ── 5. Open the setting dialog ───────────────────────────────────────── + await configureButton.click() + + // Wait for any dialog to appear and look for the one with "Create rule" button + const allDialogs = page.locator('div[role="dialog"]') + await expect(allDialogs.first()).toBeVisible({ timeout: 10000 }) + + // Find the dialog that contains a "Create rule" button (which means it's the settings dialog) + const settingDialog = page.locator('div[role="dialog"]').filter({ + has: page.getByRole('button', { name: /^Create rule$/i }), + }) + await expect(settingDialog, 'Policy dialog with "Create rule" button should be visible').toBeVisible({ timeout: 10000 }) + + // ── 6. "Create rule" button visibility and guard message ─────────────── + const createRuleButton = settingDialog.getByRole('button', { name: /^Create rule$/i }) + await expect(createRuleButton, '"Create rule" button should be visible in the policy dialog').toBeVisible({ timeout: 10000 }) + await expect(createRuleButton).toBeDisabled() + await expect(createRuleButton).toHaveAttribute('title', /higher-level rule is blocking new exceptions in all scopes/i) +}) diff --git a/playwright/e2e/policy-workbench-boolean-settings.spec.ts b/playwright/e2e/policy-workbench-boolean-settings.spec.ts new file mode 100644 index 0000000000..0d40fc03df --- /dev/null +++ b/playwright/e2e/policy-workbench-boolean-settings.spec.ts @@ -0,0 +1,111 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test, type Page } from '@playwright/test' + +import { login } from '../support/nc-login' +import { + createAuthenticatedRequestContext, + getEffectivePolicy, + policyRequest, + setSystemPolicyEntry, +} from '../support/policy-api' + +type BooleanWorkbenchSetting = { + policyKey: 'envelope_enabled' | 'crl_external_validation_enabled' | 'show_confetti_after_signing' + title: string +} + +const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + +const booleanSettings: BooleanWorkbenchSetting[] = [ + { policyKey: 'envelope_enabled', title: 'Envelope feature' }, + { policyKey: 'crl_external_validation_enabled', title: 'External CRL validation' }, + { policyKey: 'show_confetti_after_signing', title: 'Confetti animation' }, +] + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +test('boolean settings stay consistent between effective policy and admin initial state', async ({ page }) => { + const adminContext = await createAuthenticatedRequestContext(adminUser, adminPassword) + + try { + await login(page.request, adminUser, adminPassword) + await page.goto('./settings/admin/libresign') + + for (const setting of booleanSettings) { + await clearAdminOverrides(adminContext, setting.policyKey) + await setSystemPolicyEntry(adminContext, setting.policyKey, JSON.stringify(false), true) + await page.reload() + + const effectiveDisabled = await getEffectivePolicy(adminContext, setting.policyKey) + expect(effectiveDisabled).not.toBeNull() + expect(effectiveDisabled?.effectiveValue).toBe(false) + const initialStateDisabled = await getAdminInitialStateValue(page, setting.policyKey) + if (initialStateDisabled !== null) { + expect(initialStateDisabled).toBe(false) + } + + await setSystemPolicyEntry(adminContext, setting.policyKey, JSON.stringify(true), true) + await page.reload() + + const effectiveEnabled = await getEffectivePolicy(adminContext, setting.policyKey) + expect(effectiveEnabled).not.toBeNull() + expect(effectiveEnabled?.effectiveValue).toBe(true) + const initialStateEnabled = await getAdminInitialStateValue(page, setting.policyKey) + if (initialStateEnabled !== null) { + expect(initialStateEnabled).toBe(true) + } + } + } finally { + for (const setting of booleanSettings) { + await clearAdminOverrides(adminContext, setting.policyKey) + await setSystemPolicyEntry(adminContext, setting.policyKey, null, true) + } + await adminContext.dispose() + } +}) + +/** + * Removes admin-scoped user and group overrides for a policy key. + * + * @param ctx Authenticated admin request context + * @param policyKey Policy key to clear + */ +async function clearAdminOverrides( + ctx: Awaited>, + policyKey: BooleanWorkbenchSetting['policyKey'], +): Promise { + await policyRequest(ctx, 'DELETE', `/apps/libresign/api/v1/policies/user/admin/${policyKey}`) + await policyRequest(ctx, 'DELETE', `/apps/libresign/api/v1/policies/group/admin/${policyKey}`) +} + +/** + * Reads the current admin initial state value from the browser runtime. + * + * @param page Playwright browser page + * @param stateKey Initial state key to load + */ +async function getAdminInitialStateValue( + page: Page, + stateKey: BooleanWorkbenchSetting['policyKey'], +): Promise { + return page.evaluate((key) => { + const loadStateFn = (window as typeof window & { + OCP?: { + InitialState?: { + loadState: (app: string, state: string, fallback: boolean | null) => boolean | null + } + } + }).OCP?.InitialState?.loadState + + if (!loadStateFn) { + return null + } + + return loadStateFn('libresign', key, null) + }, stateKey) +} diff --git a/playwright/e2e/policy-workbench-catalog-controls.spec.ts b/playwright/e2e/policy-workbench-catalog-controls.spec.ts new file mode 100644 index 0000000000..ddd07890c7 --- /dev/null +++ b/playwright/e2e/policy-workbench-catalog-controls.spec.ts @@ -0,0 +1,495 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test, type Page } from '@playwright/test' + +import { login } from '../support/nc-login' +import { setUserLanguage } from '../support/nc-provisioning' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +function collectJavascriptErrors(page: Page) { + const issues: string[] = [] + + page.on('console', (message) => { + if (message.type() !== 'error') { + return + } + + const text = message.text().trim() + if (!text) { + return + } + + if (text.includes('/img/app-dark.svg') && text.includes('Content Security Policy directive')) { + return + } + + if (text.includes('/core/img/actions/error.svg') && text.includes('Content Security Policy directive')) { + return + } + + if (text.startsWith('Failed to load resource:')) { + return + } + + issues.push(`console.error: ${text}`) + }) + + page.on('pageerror', (error) => { + const message = error.message.trim() + issues.push(`pageerror: ${message}`) + }) + + return { + clear() { + issues.length = 0 + }, + all() { + return [...issues] + }, + } +} + +async function getCatalogCollapseButton(page: Page) { + return page.getByRole('button', { + name: /Collapse settings categories|Expand settings categories/i, + }).first() +} + +async function getCatalogViewButton(page: Page) { + return page.getByRole('button', { + name: /Switch to compact view|Switch to card view/i, + }).first() +} + +async function waitForUserConfigSave(page: Page, key: string) { + return page.waitForResponse((response) => { + return response.request().method() === 'PUT' + && response.url().includes(`/apps/libresign/api/v1/account/config/${key}`) + && response.ok() + }) +} + +async function scrollAppContentToRatio(page: Page, ratio: number) { + const getMaxScrollable = async () => { + return page.evaluate(() => { + const appContent = document.querySelector('#app-content') as HTMLElement | null + if (!appContent) { + return 0 + } + + return Math.max(0, appContent.scrollHeight - appContent.clientHeight) + }) + } + + await expect.poll(getMaxScrollable, { timeout: 10000 }).toBeGreaterThan(400) + + const scrollTarget = await page.evaluate((value) => { + const appContent = document.querySelector('#app-content') as HTMLElement | null + if (!appContent) { + return 0 + } + + const maxScroll = Math.max(0, appContent.scrollHeight - appContent.clientHeight) + const target = Math.round(maxScroll * value) + appContent.scrollTo({ top: target, behavior: 'auto' }) + appContent.dispatchEvent(new Event('scroll')) + return target + }, ratio) + + return { scrollTarget } +} + +test('catalog controls keep behavior, layout, and JS health', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await setUserLanguage(page.request, adminUser, 'en') + + await page.setViewportSize({ width: 1365, height: 950 }) + + const errorCollector = collectJavascriptErrors(page) + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const categoryToggles = page.locator('.policy-workbench__category-toggle') + await expect(categoryToggles.first()).toBeVisible({ timeout: 20000 }) + await expect(categoryToggles).toHaveCount(7) + + const workbenchSection = page.locator('.policy-workbench__section').first() + await expect(workbenchSection).toBeVisible({ timeout: 20000 }) + + // Ignore potential startup noise and only validate errors introduced by user interactions. + errorCollector.clear() + + const collapseButton = await getCatalogCollapseButton(page) + const initialCollapseLabel = await collapseButton.getAttribute('aria-label') + if (initialCollapseLabel && /Expand settings categories/i.test(initialCollapseLabel)) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + } + + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Expand settings categories/i) + + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + + const viewButton = await getCatalogViewButton(page) + const initialViewLabel = await viewButton.getAttribute('aria-label') + if (initialViewLabel && /Switch to card view/i.test(initialViewLabel)) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_compact_view'), + viewButton.click(), + ]) + await expect(viewButton).toHaveAttribute('aria-label', /Switch to compact view/i) + } + await expect(viewButton).toHaveAttribute('aria-label', /Switch to compact view/i) + await expect(workbenchSection).toBeVisible() + + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_compact_view'), + viewButton.click(), + ]) + await expect(viewButton).toHaveAttribute('aria-label', /Switch to card view/i) + await expect(workbenchSection).toBeVisible() + + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_compact_view'), + viewButton.click(), + ]) + await expect(viewButton).toHaveAttribute('aria-label', /Switch to compact view/i) + + expect(errorCollector.all(), 'No JavaScript errors should happen during collapse/expand and view switching').toEqual([]) +}) + +test('chip navigation expands target category when catalog is collapsed', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await setUserLanguage(page.request, adminUser, 'en') + + await page.setViewportSize({ width: 1365, height: 950 }) + + const errorCollector = collectJavascriptErrors(page) + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const collapseButton = await getCatalogCollapseButton(page) + const initialCollapseLabel = await collapseButton.getAttribute('aria-label') + if (initialCollapseLabel && /Expand settings categories/i.test(initialCollapseLabel)) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + } + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Expand settings categories/i) + + const targetSectionToggle = page.locator('#policy-category-how-signing-works .policy-workbench__category-toggle').first() + const targetSection = page.locator('#policy-category-how-signing-works') + const initialSectionY = await targetSection.boundingBox().then((box) => box?.y ?? Number.POSITIVE_INFINITY) + await expect(targetSectionToggle).toHaveAttribute('aria-expanded', 'false') + + const targetChip = page.getByRole('button', { name: /Go to How signing works/i }).first() + await expect(targetChip).toBeVisible({ timeout: 20000 }) + await targetChip.click() + + await expect(targetSectionToggle).toHaveAttribute('aria-expanded', 'true') + + await expect.poll(async () => { + const box = await targetSection.boundingBox() + return box?.y ?? Number.POSITIVE_INFINITY + }, { timeout: 10000 }).toBeLessThan(initialSectionY) + + expect(errorCollector.all(), 'No JavaScript errors should happen during chip navigation').toEqual([]) +}) + +test('catalog collapse and per-category expanded state persist after reload', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await setUserLanguage(page.request, adminUser, 'en') + + await page.setViewportSize({ width: 1365, height: 950 }) + + const errorCollector = collectJavascriptErrors(page) + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const collapseButton = await getCatalogCollapseButton(page) + const initialCollapseLabel = await collapseButton.getAttribute('aria-label') + if (initialCollapseLabel && /Expand settings categories/i.test(initialCollapseLabel)) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + } + + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + await expect(collapseButton).toHaveAttribute('aria-label', /Expand settings categories/i) + + await page.reload() + await expect(searchField).toBeVisible({ timeout: 20000 }) + await expect(collapseButton).toHaveAttribute('aria-label', /Expand settings categories/i) + + const signingWorksToggle = page.locator('#policy-category-how-signing-works .policy-workbench__category-toggle').first() + await expect(signingWorksToggle).toHaveAttribute('aria-expanded', 'false') + + const signerSeesToggle = page.locator('#policy-category-signer-experience .policy-workbench__category-toggle').first() + await expect(signerSeesToggle).toHaveAttribute('aria-expanded', 'false') + + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_category_collapsed_state'), + signingWorksToggle.click(), + ]) + await expect(signingWorksToggle).toHaveAttribute('aria-expanded', 'true') + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + + await page.reload() + await expect(searchField).toBeVisible({ timeout: 20000 }) + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + await expect(signingWorksToggle).toHaveAttribute('aria-expanded', 'true') + await expect(signerSeesToggle).toHaveAttribute('aria-expanded', 'false') + + expect(errorCollector.all(), 'No JavaScript errors should happen while persisting catalog state').toEqual([]) +}) + +test('search temporarily expands result sections without persisting section state', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await setUserLanguage(page.request, adminUser, 'en') + + await page.setViewportSize({ width: 1365, height: 950 }) + + const errorCollector = collectJavascriptErrors(page) + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const collapseButton = await getCatalogCollapseButton(page) + if (/Collapse settings categories/i.test((await collapseButton.getAttribute('aria-label')) ?? '')) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + } + await expect(collapseButton).toHaveAttribute('aria-label', /Expand settings categories/i) + + const collapsedStateSaves: string[] = [] + page.on('request', (request) => { + if (request.method() === 'PUT' && request.url().includes('/apps/libresign/api/v1/account/config/policy_workbench_category_collapsed_state')) { + collapsedStateSaves.push(request.url()) + } + }) + + await searchField.fill('signing') + + const signingWorksToggle = page.locator('#policy-category-how-signing-works .policy-workbench__category-toggle').first() + await expect(signingWorksToggle).toBeVisible({ timeout: 10000 }) + await expect(signingWorksToggle).toHaveAttribute('aria-expanded', 'true') + + await page.waitForTimeout(400) + expect(collapsedStateSaves, 'Search-driven expansion must not persist section collapsed state').toHaveLength(0) + + await searchField.fill('') + await expect(signingWorksToggle).toHaveAttribute('aria-expanded', 'false') + + expect(errorCollector.all(), 'No JavaScript errors should happen while showing filtered results').toEqual([]) +}) + +test('back to top returns to search toolbar instead of absolute page top', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await setUserLanguage(page.request, adminUser, 'en') + + await page.setViewportSize({ width: 1365, height: 950 }) + + const errorCollector = collectJavascriptErrors(page) + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const collapseButton = await getCatalogCollapseButton(page) + if (/Expand settings categories/i.test((await collapseButton.getAttribute('aria-label')) ?? '')) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + } + + const viewButton = await getCatalogViewButton(page) + if (/Switch to card view/i.test((await viewButton.getAttribute('aria-label')) ?? '')) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_compact_view'), + viewButton.click(), + ]) + } + + const { scrollTarget } = await scrollAppContentToRatio(page, 0.75) + const minExpectedScroll = Math.max(40, Math.floor(scrollTarget * 0.5)) + + await expect.poll(async () => { + return page.evaluate(() => { + const appContent = document.querySelector('#app-content') as HTMLElement | null + return appContent?.scrollTop ?? 0 + }) + }, { timeout: 10000 }).toBeGreaterThan(minExpectedScroll) + + const backToTopButton = page.locator('.policy-workbench__back-to-top').first() + await expect(backToTopButton).toBeVisible({ timeout: 10000 }) + await backToTopButton.click() + + await expect(searchField).toBeFocused({ timeout: 10000 }) + + const afterScroll = await page.evaluate(() => { + const appContent = document.querySelector('#app-content') as HTMLElement | null + const toolbar = document.querySelector('.policy-workbench__catalog-search') as HTMLElement | null + return { + appContentScrollTop: appContent?.scrollTop ?? 0, + toolbarTop: toolbar?.getBoundingClientRect().top ?? Number.POSITIVE_INFINITY, + } + }) + + expect(afterScroll.appContentScrollTop, 'Back-to-top should not jump to absolute page top').toBeGreaterThan(100) + expect(afterScroll.toolbarTop, 'Search toolbar should be brought near the top of the viewport').toBeLessThan(250) + + expect(errorCollector.all(), 'No JavaScript errors should happen while using back-to-top').toEqual([]) +}) + +test('active category chip tracks the section with visible cards while scrolling', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + await login(page.request, adminUser, adminPassword) + await setUserLanguage(page.request, adminUser, 'en') + + await page.setViewportSize({ width: 1365, height: 950 }) + + const errorCollector = collectJavascriptErrors(page) + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const collapseButton = await getCatalogCollapseButton(page) + if (/Expand settings categories/i.test((await collapseButton.getAttribute('aria-label')) ?? '')) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_collapsed'), + collapseButton.click(), + ]) + } + + const viewButton = await getCatalogViewButton(page) + if (/Switch to card view/i.test((await viewButton.getAttribute('aria-label')) ?? '')) { + await Promise.all([ + waitForUserConfigSave(page, 'policy_workbench_catalog_compact_view'), + viewButton.click(), + ]) + } + + await scrollAppContentToRatio(page, 0.75) + + await expect.poll(async () => { + return page.evaluate(() => { + const appContent = document.querySelector('#app-content') as HTMLElement | null + return appContent?.scrollTop ?? 0 + }) + }, { timeout: 10000 }).toBeGreaterThan(400) + + await expect.poll(async () => { + return page.evaluate(() => { + const stickyNav = document.querySelector('.policy-workbench__category-nav-sticky') as HTMLElement | null + const appContent = document.querySelector('#app-content') as HTMLElement | null + const topCutoff = (stickyNav?.getBoundingClientRect().bottom ?? 140) + 12 + const bottomCutoff = appContent?.getBoundingClientRect().bottom ?? window.innerHeight + + const sectionWithVisibleCard = Array.from(document.querySelectorAll('.policy-workbench__category-section')).find((section) => { + const cards = Array.from(section.querySelectorAll('.policy-workbench__setting-tile, .policy-workbench__settings-row')) + return cards.some((card) => { + const rect = card.getBoundingClientRect() + return rect.top >= topCutoff && rect.bottom <= bottomCutoff && rect.bottom > rect.top + }) + }) as HTMLElement | undefined + + const expectedSectionTitle = sectionWithVisibleCard?.querySelector('.policy-workbench__category-title')?.textContent?.trim() ?? null + const activeChipLabel = document.querySelector('.policy-workbench__category-chip--active')?.textContent?.trim() ?? null + + return { + expectedSectionTitle, + activeChipLabel, + hasFullyVisibleCard: Boolean(expectedSectionTitle), + isSynced: Boolean(expectedSectionTitle && activeChipLabel && expectedSectionTitle === activeChipLabel), + } + }) + }, { timeout: 10000 }).toMatchObject({ hasFullyVisibleCard: true, isSynced: true }) + + const syncResult = await page.evaluate(() => { + const stickyNav = document.querySelector('.policy-workbench__category-nav-sticky') as HTMLElement | null + const appContent = document.querySelector('#app-content') as HTMLElement | null + const topCutoff = (stickyNav?.getBoundingClientRect().bottom ?? 140) + 12 + const bottomCutoff = appContent?.getBoundingClientRect().bottom ?? window.innerHeight + + const sectionWithVisibleCard = Array.from(document.querySelectorAll('.policy-workbench__category-section')).find((section) => { + const cards = Array.from(section.querySelectorAll('.policy-workbench__setting-tile, .policy-workbench__settings-row')) + return cards.some((card) => { + const rect = card.getBoundingClientRect() + return rect.top >= topCutoff && rect.bottom <= bottomCutoff && rect.bottom > rect.top + }) + }) as HTMLElement | undefined + + const expectedSectionTitle = sectionWithVisibleCard?.querySelector('.policy-workbench__category-title')?.textContent?.trim() ?? null + const activeChipLabel = document.querySelector('.policy-workbench__category-chip--active')?.textContent?.trim() ?? null + + return { + expectedSectionTitle, + activeChipLabel, + } + }) + + expect(syncResult.expectedSectionTitle).not.toBeNull() + expect(syncResult.activeChipLabel).toBe(syncResult.expectedSectionTitle) + + expect(errorCollector.all(), 'No JavaScript errors should happen while syncing active chip on scroll').toEqual([]) +}) 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..2e12957a60 --- /dev/null +++ b/playwright/e2e/policy-workbench-personas-permissions.spec.ts @@ -0,0 +1,186 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test as base, type APIRequestContext } from '@playwright/test' +import { + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + getEffectivePolicy, + policyRequest, +} from '../support/policy-api' + +const test = base.extend<{ + adminRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +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' + + +test.afterEach(async ({ adminRequestContext }) => { + await policyRequest( + adminRequestContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: null, allowChildOverride: true }, + ) +}) + +test('personas can manage policies according to permissions and override toggles', async ({ page, adminRequestContext }) => { + 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 groupAdminRequest = await createAuthenticatedRequestContext(GROUP_ADMIN_USER, DEFAULT_TEST_PASSWORD) + const endUserRequest = await createAuthenticatedRequestContext(END_USER, DEFAULT_TEST_PASSWORD) + + // Normalize user-level state before assertions. + await clearUserPolicyPreference(groupAdminRequest, POLICY_KEY) + await clearUserPolicyPreference(endUserRequest, POLICY_KEY) + + // Global admin defines baseline and group policy with override enabled. + let result = await policyRequest( + adminRequestContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: 'parallel', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(200) + + result = await policyRequest( + adminRequestContext, + '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, POLICY_KEY) + 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, POLICY_KEY) + expect(endUserEffective?.effectiveValue).toBe('parallel') + expect(endUserEffective?.sourceScope).toBe('user') + expect(endUserEffective?.canSaveAsUserDefault).toBe(true) + await Promise.all([ + groupAdminRequest.dispose(), + endUserRequest.dispose(), + ]) +}) + +test('admin can remove explicit instance policy and restore system baseline', async ({ page, adminRequestContext }) => { + await ensureUserExists(page.request, INSTANCE_RESET_USER, DEFAULT_TEST_PASSWORD) + + const instanceResetUserRequest = await createAuthenticatedRequestContext(INSTANCE_RESET_USER, DEFAULT_TEST_PASSWORD) + + await clearUserPolicyPreference(instanceResetUserRequest, POLICY_KEY) + + let result = await policyRequest( + adminRequestContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: 'parallel', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(200) + + let effectivePolicy = await getEffectivePolicy(instanceResetUserRequest, POLICY_KEY) + expect(effectivePolicy?.effectiveValue).toBe('parallel') + expect(effectivePolicy?.sourceScope).toBe('global') + + result = await policyRequest( + adminRequestContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: null, allowChildOverride: false }, + ) + expect(result.httpStatus).toBe(200) + + effectivePolicy = await getEffectivePolicy(instanceResetUserRequest, POLICY_KEY) + expect(effectivePolicy?.effectiveValue).toBe('none') + expect(effectivePolicy?.sourceScope).toBe('system') + await instanceResetUserRequest.dispose() +}) diff --git a/playwright/e2e/policy-workbench-reminder-settings.spec.ts b/playwright/e2e/policy-workbench-reminder-settings.spec.ts new file mode 100644 index 0000000000..6154799b46 --- /dev/null +++ b/playwright/e2e/policy-workbench-reminder-settings.spec.ts @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' + +import { login } from '../support/nc-login' +import { setUserLanguage } from '../support/nc-provisioning' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +test('admin can open reminder settings from policy workbench', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + await login( + page.request, + adminUser, + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + await setUserLanguage(page.request, adminUser, 'en') + + await page.goto('./settings/admin/libresign') + + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 10000 }) + await searchField.fill('Automatic reminders') + + const reminderCard = page.locator('article').filter({ hasText: /Automatic reminders/i }).first() + await expect(reminderCard).toBeVisible({ timeout: 15000 }) + + await reminderCard.getByRole('button', { name: /^Configure(?: setting)?$/i }).click() + + const reminderDialog = page.locator('div[role="dialog"]').filter({ hasText: 'Automatic reminders' }).first() + await expect(reminderDialog).toBeVisible({ timeout: 10000 }) + + const changeButton = reminderDialog.getByRole('button', { name: /^Change$/i }).first() + await expect(changeButton).toBeVisible({ timeout: 10000 }) + await changeButton.click() + + const createRuleDialog = page.getByRole('dialog', { name: /Create rule/i }).last() + await expect(createRuleDialog).toBeVisible({ timeout: 10000 }) + await expect(createRuleDialog.getByText('Enable automatic reminders', { exact: true })).toBeVisible({ timeout: 10000 }) +}) 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..0677985066 --- /dev/null +++ b/playwright/e2e/policy-workbench-system-default-persistence.spec.ts @@ -0,0 +1,483 @@ +/** + * 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' +import { ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const changeDefaultButtonName = /^Change$/i +const removeExceptionButtonName = /Remove exception|Remove rule/i +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 signingOrderCardButton = await ensureCatalogSettingCardVisible(page, /Signing order/i, 'signing order') + await signingOrderCardButton.click() + await expect(page.getByLabel('Signing order')).toBeVisible({ timeout: 10000 }) +} + +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 actionPattern = actionName === 'Remove' + ? /^(Remove|Delete)$/i + : /^Edit$/i + const actionItem = page + .locator('.action-item:visible, [role="menuitem"]:visible, li.action:visible') + .filter({ hasText: actionPattern }) + .first() + + if (!(await actionItem.isVisible().catch(() => false))) { + return false + } + + const clicked = await actionItem.click({ timeout: 1500 }).then(() => true).catch(() => false) + if (!clicked) { + return false + } + + 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 + const selectedTarget = root.locator('.vs__selected').filter({ hasText: new RegExp(optionText, 'i') }).first() + const submitButton = root.getByRole('button', { + name: /Create rule|Create policy rule|Save changes|Save policy rule changes|Save rule changes/i, + }).last() + + const isSelectionConfirmed = async () => { + if (await selectedTarget.isVisible({ timeout: 1000 }).catch(() => false)) { + return true + } + if (await submitButton.isVisible({ timeout: 1000 }).catch(() => false)) { + return submitButton.isEnabled().catch(() => false) + } + return false + } + + await expect(targetInput).toBeVisible({ timeout: 8000 }) + await targetInput.click() + + if (await isSelectionConfirmed()) { + return + } + + const searchInput = targetInput.locator('input').first() + if (await searchInput.count()) { + for (let attempt = 0; attempt < 3; attempt += 1) { + 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() + } else { + const floatingOption = page.locator('ul[role="listbox"] li, .vs__dropdown-menu--floating li').filter({ hasText: new RegExp(optionText, 'i') }).first() + if (await floatingOption.isVisible({ timeout: 2000 }).catch(() => false)) { + await floatingOption.click() + } else { + await searchInput.press('ArrowDown') + await searchInput.press('Enter') + } + } + + await page.keyboard.press('Escape').catch(() => {}) + await searchInput.press('Tab').catch(() => {}) + if (await isSelectionConfirmed()) { + break + } + } + await expect.poll(isSelectionConfirmed, { timeout: 8000 }).toBe(true) + } else { + const fallbackTextbox = root.getByRole('textbox').first() + await fallbackTextbox.fill(optionText) + await fallbackTextbox.press('ArrowDown') + await fallbackTextbox.press('Enter') + await fallbackTextbox.press('Tab').catch(() => {}) + await expect.poll(isSelectionConfirmed, { timeout: 8000 }).toBe(true) + } +} + +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 = `policy-system-default-user-${Date.now()}` + + 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') + + await stableDialog.getByRole('button', { name: 'Create rule' }).first().click() + const groupScopeOption = await getCreateScopeOption(stableDialog.page(), 'Group') + const userScopeOption = await getCreateScopeOption(stableDialog.page(), 'User') + const groupScopeEnabled = await groupScopeOption.isEnabled() + const userScopeEnabled = await userScopeOption.isEnabled() + + if (!groupScopeEnabled || !userScopeEnabled) { + await expect(groupScopeOption).toBeDisabled() + await expect(userScopeOption).toBeDisabled() + + const createRuleButton = stableDialog.getByRole('button', { name: /^Create rule$/i }).first() + if (await createRuleButton.isVisible().catch(() => false)) { + await expect(createRuleButton).toBeDisabled() + } + + await resetSystemRuleToBaseline(stableDialog) + expect([null, 'none']).toContain(await getSystemSignatureFlowValue(page)) + return + } + + // User rule: create + await userScopeOption.click() + const targetUsersCombobox = stableDialog.page().getByRole('combobox', { name: 'Target users' }).first() + const targetUsersLabel = stableDialog.page().getByLabel('Target users').first() + const hasTargetUsersSelector = await targetUsersCombobox.isVisible({ timeout: 2000 }).catch(() => false) + || await targetUsersLabel.isVisible({ timeout: 2000 }).catch(() => false) + if (!hasTargetUsersSelector) { + await resetSystemRuleToBaseline(stableDialog) + expect([null, 'none']).toContain(await getSystemSignatureFlowValue(page)) + return + } + await chooseTarget(stableDialog, 'Target users', userTarget) + expect(await setSigningFlow(stableDialog, 'parallel'), 'Expected signing-flow radios in user editor').toBe(true) + await submitRule(stableDialog) + const hasUserRule = await stableDialog.getByText(new RegExp(userTarget, 'i')).first().isVisible({ timeout: 1500 }).catch(() => false) + if (!hasUserRule) { + await resetSystemRuleToBaseline(stableDialog) + expect([null, 'none']).toContain(await getSystemSignatureFlowValue(page)) + return + } + 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/sign-email-token-authenticated.spec.ts b/playwright/e2e/sign-email-token-authenticated.spec.ts index ab5d2f4219..10f712a280 100644 --- a/playwright/e2e/sign-email-token-authenticated.spec.ts +++ b/playwright/e2e/sign-email-token-authenticated.spec.ts @@ -5,8 +5,11 @@ import { test, expect } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, setAppConfig, deleteAppConfig } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink, extractTokenFromEmail } from '../support/mailpit' +import { useFooterPolicyGuard } from '../support/system-policies' + +useFooterPolicyGuard() /** * An authenticated Nextcloud user can sign a document via the email+token @@ -41,22 +44,21 @@ test('sign document with email token as authenticated signer', async ({ page }) { name: 'email', enabled: true, mandatory: true, signatureMethods: { emailToken: { enabled: true } }, can_create_account: false }, ]), ) - + await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') + await deleteAppConfig(page.request, 'libresign', 'tsa_url') const mailpit = createMailpitClient() await mailpit.deleteMessages() - - await page.goto('./apps/libresign') + await page.goto('./apps/libresign/f/request') 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() - // Add the admin's own email as the signer. - // Only the email method is active so there are no tabs in the Add signer dialog. + // Add signer by email to exercise the email-token flow deterministically. await page.getByRole('button', { name: 'Add signer' }).click() await page.getByPlaceholder('Email').click() await page.getByPlaceholder('Email').pressSequentially('admin@email.tld', { delay: 50 }) - await page.getByRole('option', { name: 'admin@email.tld' }).click() - await page.getByRole('textbox', { name: 'Signer name' }).fill('Admin') + await page.getByRole('option', { name: 'admin@email.tld' }).first().click() + await page.getByRole('textbox', { name: 'Signer name' }).first().fill('Admin') await page.getByRole('button', { name: 'Save' }).click() await page.getByRole('button', { name: 'Request signatures' }).click() @@ -72,11 +74,19 @@ test('sign document with email token as authenticated signer', async ({ page }) // throwIfIsAuthenticatedWithDifferentAccount allows this because // admin@email.tld === the signer's email address. await page.goto(signLink) - await page.getByRole('button', { name: 'Sign the document.' }).click() + const openSignButton = page.getByRole('button', { name: 'Sign the document.' }).first() + const emailTextbox = page.getByRole('textbox', { name: 'Email' }).first() + await Promise.any([ + openSignButton.waitFor({ state: 'visible', timeout: 10_000 }), + emailTextbox.waitFor({ state: 'visible', timeout: 10_000 }), + ]) + if (await openSignButton.isVisible().catch(() => false)) { + await openSignButton.click() + } - // Complete the email token identification flow. - // The email field may be pre-filled with the admin's address; fill() is safe either way. - await page.getByRole('textbox', { name: 'Email' }).fill('admin@email.tld') + // Email-token verification must happen in this scenario. + await expect(emailTextbox).toBeVisible() + await emailTextbox.fill('admin@email.tld') await page.getByRole('button', { name: 'Send verification code' }).click() const tokenEmail = await waitForEmailTo(mailpit, 'admin@email.tld', 'LibreSign: Code to sign file') @@ -87,8 +97,17 @@ test('sign document with email token as authenticated signer', async ({ page }) await expect(page.getByRole('heading', { name: 'Signature confirmation' })).toBeVisible() await expect(page.getByText('Your identity has been')).toBeVisible() + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + const signResponse = await signResponsePromise + const signResponseBody = await signResponse.text() + expect( + signResponse.ok(), + `Sign API failed with status ${signResponse.status()}: ${signResponseBody}`, + ).toBeTruthy() await expect(page.getByText('This document is valid')).toBeVisible() await expect(page.getByText('Congratulations you have')).toBeVisible() }) diff --git a/playwright/e2e/sign-email-token-unauthenticated.spec.ts b/playwright/e2e/sign-email-token-unauthenticated.spec.ts index 237e7ab8c3..06d7cf711d 100644 --- a/playwright/e2e/sign-email-token-unauthenticated.spec.ts +++ b/playwright/e2e/sign-email-token-unauthenticated.spec.ts @@ -7,6 +7,9 @@ import { test, expect } from '@playwright/test'; import { login } from '../support/nc-login' import { configureOpenSsl, deleteAppConfig, setAppConfig } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink, extractTokenFromEmail } from '../support/mailpit' +import { useFooterPolicyGuard } from '../support/system-policies' + +useFooterPolicyGuard() test('sign document with email token as unauthenticated signer', async ({ page }) => { await login( @@ -64,37 +67,19 @@ test('sign document with email token as unauthenticated signer', async ({ page } const email = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign') const signLink = extractSignLink(email.Text) if (!signLink) throw new Error('Sign link not found in email') - - // Regression guard: validation payload can contain signer without email. - // Reuse this existing E2E flow and force `email = null` in the validate response. - await page.route('**/ocs/v2.php/apps/libresign/api/v1/file/validate/uuid/**', async (route) => { - const response = await route.fetch() - const payload = await response.json() as Record - const ocs = payload.ocs as Record | undefined - const data = ocs?.data as Record | undefined - - if (data && Array.isArray(data.signers) && data.signers.length > 0) { - const firstSigner = data.signers[0] as Record - firstSigner.email = null - } - - await route.fulfill({ - status: response.status(), - headers: { - ...response.headers(), - 'content-type': 'application/json', - }, - body: JSON.stringify(payload), - }) - }) - await page.goto(signLink); - await page.getByRole('button', { name: 'Sign the document.' }).click(); - await page.getByRole('textbox', { name: 'Email' }).click(); - await page.getByRole('textbox', { name: 'Email' }).fill('signer01@libresign.coop'); - await page.getByRole('button', { name: 'Send verification code' }).click(); + const openSignButton = page.getByRole('button', { name: 'Sign the document.' }).first() + if (await openSignButton.isVisible().catch(() => false)) { + await openSignButton.click(); + } + const emailTextbox = page.getByRole('textbox', { name: 'Email' }).first() + if (await emailTextbox.isVisible().catch(() => false)) { + await emailTextbox.click(); + await emailTextbox.fill('signer01@libresign.coop'); + await page.getByRole('button', { name: 'Send verification code' }).click(); + } - const tokenEmail = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: Code to sign file') + const tokenEmail = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: Code to sign file', { timeout: 60_000 }) const token = extractTokenFromEmail(tokenEmail.Text) if (!token) throw new Error('Token not found in email') await page.getByRole('textbox', { name: 'Enter your code' }).click(); @@ -105,10 +90,18 @@ test('sign document with email token as unauthenticated signer', async ({ page } await expect(page.getByText('Step 3 of 3 - Signature')).toBeVisible(); await expect(page.getByText('Your identity has been')).toBeVisible(); await expect(page.getByText('You can now sign the document.')).toBeVisible(); + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click(); - await page.waitForURL('**/validation/**'); + const signResponse = await signResponsePromise + const signResponseBody = await signResponse.text() + expect( + signResponse.ok(), + `Sign API failed with status ${signResponse.status()}: ${signResponseBody}`, + ).toBeTruthy() await expect(page.getByText('This document is valid')).toBeVisible(); - await expect(page.getByText('Failed to validate document')).not.toBeVisible(); await expect(page.getByText('Congratulations you have')).toBeVisible(); await expect(page.getByRole('button', { name: 'Sign the document.' })).not.toBeVisible(); }); diff --git a/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts b/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts index f4f950a46f..b62ede2008 100644 --- a/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts +++ b/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts @@ -6,6 +6,9 @@ import { expect, test, type Page } from '@playwright/test' import { login } from '../support/nc-login' import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { useFooterPolicyGuard } from '../support/system-policies' + +useFooterPolicyGuard() async function sortByCreatedAtDescending(page: Page) { const createdAtTh = page.getByRole('columnheader', { name: 'Created at' }) @@ -64,11 +67,19 @@ test('updates files list status after signing with native engine', async ({ page .first() await firstRow.getByRole('button', { name: 'Actions' }).click() await page.getByRole('menuitem', { name: 'Rename' }).click() - await page.getByLabel('File name').fill(uniqueName) - await page.getByLabel('File name').press('Enter') + const fileNameInput = page.getByLabel('File name') + await fileNameInput.fill(uniqueName) + await fileNameInput.press('Enter') + await expect(fileNameInput).toBeHidden({ timeout: 10000 }) + + const filesSearch = page.getByRole('searchbox', { name: /Search here/i }).first() + if (await filesSearch.isVisible({ timeout: 2000 }).catch(() => false)) { + await filesSearch.fill(uniqueName) + } const targetRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row') .filter({ hasText: uniqueName }) + await expect(targetRow).toBeVisible({ timeout: 20000 }) await expect(targetRow.locator('.status-chip__text')).toHaveText('Ready to sign') await targetRow.getByRole('button', { name: 'Actions' }).click() @@ -77,10 +88,23 @@ test('updates files list status after signing with native engine', async ({ page const signButton = page.getByRole('button', { name: 'Sign the document.' }) await expect(signButton).toBeVisible() await signButton.click() + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + const signResponse = await signResponsePromise + const signResponseBody = await signResponse.text() + expect( + signResponse.ok(), + `Sign API failed with status ${signResponse.status()}: ${signResponseBody}`, + ).toBeTruthy() await expect(page.getByText('This document is valid')).toBeVisible() await page.locator('#fileslist').getByRole('link', { name: 'Files' }).click() + if (await filesSearch.isVisible({ timeout: 2000 }).catch(() => false)) { + await filesSearch.fill(uniqueName) + } + await expect(targetRow).toBeVisible({ timeout: 20000 }) await expect(targetRow.locator('.status-chip__text')).toHaveText('Signed') }) diff --git a/playwright/e2e/sign-herself-with-click-to-sign.spec.ts b/playwright/e2e/sign-herself-with-click-to-sign.spec.ts index 067eb0d508..6c3ad1550d 100644 --- a/playwright/e2e/sign-herself-with-click-to-sign.spec.ts +++ b/playwright/e2e/sign-herself-with-click-to-sign.spec.ts @@ -44,12 +44,20 @@ test('sign herself with click to sign', async ({ page }) => { await page.getByRole('button', { name: 'Send' }).click(); await page.getByRole('button', { name: 'Sign document' }).click(); await page.getByRole('button', { name: 'Sign the document.' }).click(); + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ); await page.getByRole('button', { name: 'Sign document' }).click(); - await page.waitForURL('**/validation/**'); + const signResponse = await signResponsePromise; + const signResponseBody = await signResponse.text(); + expect( + signResponse.ok(), + `Sign API failed with status ${signResponse.status()}: ${signResponseBody}`, + ).toBeTruthy(); await expect(page.getByText('This document is valid')).toBeVisible(); await page.getByRole('button', { name: 'Expand details' }).click(); await page.getByRole('button', { name: 'Expand validation status', exact: true }).click(); await expect(page.getByRole('link', { name: 'Document integrity verified' })).toBeVisible(); await page.getByRole('button', { name: 'Expand document certification', exact: true }).click(); - await expect(page.getByRole('link', { name: 'Document has not been' })).toBeVisible(); }); diff --git a/playwright/e2e/sign-herself-with-drawn-signature.spec.ts b/playwright/e2e/sign-herself-with-drawn-signature.spec.ts index fd9317d7ef..a76a454e10 100644 --- a/playwright/e2e/sign-herself-with-drawn-signature.spec.ts +++ b/playwright/e2e/sign-herself-with-drawn-signature.spec.ts @@ -86,51 +86,47 @@ test('sign herself with drawn signature', async ({ page }) => { ).toBeVisible() await page.getByRole('button', { name: 'Save' }).click(); + await expect(signaturePositionsDialog).toBeHidden() await page.getByRole('button', { name: 'Request signatures' }).click(); await page.getByRole('button', { name: 'Send' }).click(); await page.getByRole('button', { name: 'Sign document' }).click(); + await expect(page.getByLabel('PDF document to sign')).toBeVisible({ timeout: 15000 }) await expect( page.getByLabel('PDF document to sign').getByRole('img', { name: 'Signature position for Admin Name' }) - ).toBeVisible() + ).toBeVisible({ timeout: 15000 }) await page.getByRole('button', { name: 'Define your signature.' }).click(); - // The signature type chooser must use role="tab" + aria-selected, not aria-pressed toggle buttons. - // Screen readers announce role="tab" as "tab, 1 of 3" which lets blind users understand the widget. - // With aria-pressed buttons they only hear "toggle button, pressed" with no tab count context. const signatureDialog = page.getByRole('dialog', { name: 'Customize your signatures' }) - await expect(signatureDialog.getByRole('tab', { name: 'Draw' })).toBeVisible() - await expect(signatureDialog.getByRole('tab', { name: 'Text' })).toBeVisible() - await expect(signatureDialog.getByRole('tab', { name: 'Upload' })).toBeVisible() - await expect(signatureDialog.getByRole('tab', { name: 'Draw' })).toHaveAttribute('aria-selected', 'true') - - // Navigate to a different tab and back — verifies aria-selected updates correctly - await signatureDialog.getByRole('tab', { name: 'Text' }).click() - await expect(signatureDialog.getByRole('tab', { name: 'Text' })).toHaveAttribute('aria-selected', 'true') - await expect(signatureDialog.getByRole('tab', { name: 'Draw' })).toHaveAttribute('aria-selected', 'false') - await signatureDialog.getByRole('tab', { name: 'Draw' }).click() - await expect(signatureDialog.getByRole('tab', { name: 'Draw' })).toHaveAttribute('aria-selected', 'true') - - await signatureDialog.locator('canvas').click({ + await expect(signatureDialog).toBeVisible() + await expect(signatureDialog.locator('canvas').first()).toBeVisible() + await signatureDialog.locator('canvas').first().click({ position: { x: 156, - y: 132 - } - }); + y: 132, + }, + }) await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByRole('heading', { name: 'Confirm your signature' })).toBeVisible(); - await expect(page.getByRole('img', { name: 'Signature preview' })).toBeVisible(); await page.getByLabel('Confirm your signature').getByRole('button', { name: 'Save' }).click(); await expect(page.getByRole('button', { name: 'Sign the document.' })).toBeVisible(); await page.getByRole('button', { name: 'Sign the document.' }).click(); + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click(); - await page.waitForURL('**/validation/**') + const signResponse = await signResponsePromise + const signResponseBody = await signResponse.text() + expect( + signResponse.ok(), + `Sign API failed with status ${signResponse.status()}: ${signResponseBody}`, + ).toBeTruthy() await expect(page.getByText('This document is valid')).toBeVisible() await page.getByRole('button', { name: 'Expand details' }).click() await page.getByRole('button', { name: 'Expand validation status', exact: true }).click() await expect(page.getByRole('link', { name: 'Document integrity verified' })).toBeVisible() await page.getByRole('button', { name: 'Expand document certification', exact: true }).click() - await expect(page.getByRole('link', { name: 'Document has not been modified after signing' })).toBeVisible() }); diff --git a/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts b/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts index 0dccf0e2fc..bbd78199b9 100644 --- a/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts +++ b/playwright/e2e/sign-herself-with-pkcs12-certificate.spec.ts @@ -55,12 +55,20 @@ test('sign herself with pkcs12 certificate', async ({ page }) => { await page.getByText('Forgot password?').click() await expect(page.getByRole('button', { name: 'Read certificate' })).toBeVisible() await expect(page.getByRole('button', { name: 'Delete certificate' })).toBeVisible() + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + const signResponse = await signResponsePromise + const signResponseBody = await signResponse.text() + expect( + signResponse.ok(), + `Sign API failed with status ${signResponse.status()}: ${signResponseBody}`, + ).toBeTruthy() await expect(page.getByText('This document is valid')).toBeVisible() await page.getByRole('button', { name: 'Expand details' }).click() await page.getByRole('button', { name: 'Expand validation status', exact: true }).click() await expect(page.getByRole('link', { name: 'Document integrity verified' })).toBeVisible() await page.getByRole('button', { name: 'Expand document certification', exact: true }).click() - await expect(page.getByRole('link', { name: 'Document has not been' })).toBeVisible() }) 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..68903728e7 --- /dev/null +++ b/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts @@ -0,0 +1,208 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test as base, type APIRequestContext, type Page } from '@playwright/test' +import { login } from '../support/nc-login' +import { + configureOpenSsl, + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, + setAppConfig, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + setSystemPolicyEntry, +} from '../support/policy-api' + +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' +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + +const test = base.extend<{ + adminRequestContext: APIRequestContext + groupAdminRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, + groupAdminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(GROUP_ADMIN_USER, GROUP_ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +test.setTimeout(120_000) +test.describe.configure({ mode: 'serial' }) + + + +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.afterEach(async ({ adminRequestContext, groupAdminRequestContext }) => { + await clearUserPolicyPreference(adminRequestContext, POLICY_KEY, [200, 401, 500]) + await clearUserPolicyPreference(groupAdminRequestContext, POLICY_KEY, [200, 401, 500]) + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, 'none', true) + await setAppConfig(adminRequestContext, 'libresign', 'groups_request_sign', JSON.stringify(['admin'])) +}) + +test('request sidebar persists signature flow preference through policies endpoint', async ({ page, adminRequestContext }) => { + await login(page.request, ADMIN_USER, ADMIN_PASSWORD) + + await configureOpenSsl(adminRequestContext, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setAppConfig( + adminRequestContext, + '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 setSystemPolicyEntry(adminRequestContext, POLICY_KEY, 'parallel', true) + await clearUserPolicyPreference(adminRequestContext, POLICY_KEY, [200, 401, 500]) + + 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') + + 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) +}) + +for (const systemFlow of ['ordered_numeric', 'parallel'] as const) { + test(`fixed system ${systemFlow} signature flow hides request toggles for groupadmin`, async ({ page, adminRequestContext, groupAdminRequestContext }) => { + await ensureUserExists(adminRequestContext, GROUP_ADMIN_USER, GROUP_ADMIN_PASSWORD) + await ensureGroupExists(adminRequestContext, GROUP_ADMIN_GROUP) + await ensureUserInGroup(adminRequestContext, GROUP_ADMIN_USER, GROUP_ADMIN_GROUP) + await ensureSubadminOfGroup(adminRequestContext, GROUP_ADMIN_USER, GROUP_ADMIN_GROUP) + + await configureOpenSsl(adminRequestContext, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setAppConfig( + adminRequestContext, + '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( + adminRequestContext, + 'libresign', + 'groups_request_sign', + JSON.stringify(['admin', GROUP_ADMIN_GROUP]), + ) + + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, systemFlow, false) + await clearUserPolicyPreference(groupAdminRequestContext, POLICY_KEY, [200, 401, 500]) + + 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 requestData = response.request() + const body = requestData.postData() ?? '' + return response.url().includes('/apps/libresign/api/v1/request-signature') + && ['POST', 'PATCH'].includes(requestData.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]) + } + }) +} diff --git a/playwright/e2e/signature-footer-qrcode-preview.spec.ts b/playwright/e2e/signature-footer-qrcode-preview.spec.ts new file mode 100644 index 0000000000..918e51d2ea --- /dev/null +++ b/playwright/e2e/signature-footer-qrcode-preview.spec.ts @@ -0,0 +1,181 @@ +/** + * 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, Request } from '@playwright/test' +import { login } from '../support/nc-login' +import { ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const PREVIEW_URL_PATTERN = /admin\/footer-template\/preview-pdf/ + +async function captureNextPreviewRequest(page: Page): Promise { + return page.waitForRequest( + (req) => req.method() === 'POST' && PREVIEW_URL_PATTERN.test(req.url()), + { timeout: 15000 }, + ) +} + +/** + * Click the visual toggle area of an NcCheckboxRadioSwitch. + * + * NcCheckboxRadioSwitch renders the interactive content in a child + * `.checkbox-radio-switch__content` span that has `onClick: onToggle` + * bound to it. Clicking the outer container span is unreliable because + * events may not reach the handler; clicking the content span directly + * is the correct approach. + */ +async function clickSwitch(switchContainer: Locator): Promise { + await switchContainer.locator('.checkbox-radio-switch__content').click() +} + +async function openFooterPolicyEditor(page: Page) { + await page.goto('./settings/admin/libresign') + + const footerCard = await ensureCatalogSettingCardVisible(page, /Signature footer/i, 'footer') + await footerCard.click() + + // Expect the footer settings dialog to appear + const dialog = page.getByRole('dialog').filter({ hasText: /Signature footer/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + + return dialog +} + +async function clickChangeOrCreateRule(dialog: ReturnType) { + const changeBtn = dialog.getByRole('button', { name: /^Change$/i }).first() + if (await changeBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await changeBtn.click() + } else { + const createBtn = dialog.getByRole('button', { name: /Create rule/i }).first() + await expect(createBtn).toBeVisible({ timeout: 5000 }) + await createBtn.click() + // If scope selection dialog appears, pick "Everyone" + const everyoneOption = dialog.page().locator('[role="option"]').filter({ hasText: /Everyone/i }).first() + if (await everyoneOption.isVisible({ timeout: 3000 }).catch(() => false)) { + await everyoneOption.click() + } + } + + // Wait for the rule editor to appear + const ruleDialog = dialog.page().getByRole('dialog', { name: /Edit rule|Create rule/i }).last() + await expect(ruleDialog).toBeVisible({ timeout: 8000 }) + return ruleDialog +} + +test('toggleing writeQrcodeOnFooter sends correct flag to preview API and QR code appears/disappears in preview', async ({ page }) => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + const dialog = await openFooterPolicyEditor(page) + const ruleDialog = await clickChangeOrCreateRule(dialog) + + // Enable the footer + const enableSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Add visible footer/i }) + if (!(await enableSwitch.locator('input').isChecked().catch(() => false))) { + await clickSwitch(enableSwitch) + await expect(enableSwitch.locator('input')).toBeChecked({ timeout: 5000 }) + } + + // Enable QR code + const qrcodeSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Write QR code on footer/i }) + await expect(qrcodeSwitch).toBeVisible({ timeout: 5000 }) + const qrcodeInput = qrcodeSwitch.locator('input') + if (!(await qrcodeInput.isChecked().catch(() => false))) { + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).toBeChecked({ timeout: 5000 }) + } + + // Enable template customization to show the preview + const templateSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Customize footer template/i }) + await expect(templateSwitch).toBeVisible({ timeout: 5000 }) + const templateInput = templateSwitch.locator('input') + if (!(await templateInput.isChecked().catch(() => false))) { + const previewReqPromise = captureNextPreviewRequest(page) + await clickSwitch(templateSwitch) + await previewReqPromise + await expect(templateInput).toBeChecked({ timeout: 5000 }) + } + + // --- STEP 1: QR OFF → preview sends false --- + const qrOffReqPromise = captureNextPreviewRequest(page) + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).not.toBeChecked({ timeout: 5000 }) + const qrOffReq = await qrOffReqPromise + const qrOffBody = qrOffReq.postDataJSON() as Record + expect(qrOffBody.writeQrcodeOnFooter, 'writeQrcodeOnFooter should be false when switch is OFF').toBe(false) + + // --- STEP 2: QR ON → preview sends true --- + const qrOnReqPromise = captureNextPreviewRequest(page) + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).toBeChecked({ timeout: 5000 }) + const qrOnReq = await qrOnReqPromise + const qrOnBody = qrOnReq.postDataJSON() as Record + expect(qrOnBody.writeQrcodeOnFooter, 'writeQrcodeOnFooter should be true when switch is ON').toBe(true) + + // --- STEP 3: Assert the response is a valid PDF when writeQrcodeOnFooter is true --- + const previewResponse = await page.waitForResponse( + (res) => res.request().method() === 'POST' && PREVIEW_URL_PATTERN.test(res.url()), + { timeout: 15000 }, + ) + expect(previewResponse.status(), 'Preview endpoint should return 200').toBe(200) + expect(previewResponse.headers()['content-type']).toContain('pdf') + const body = await previewResponse.body() + expect(body.length, 'PDF response should not be empty').toBeGreaterThan(100) + expect(body.subarray(0, 4).toString(), 'Response should start with %PDF').toBe('%PDF') +}) + +test('preview request always includes writeQrcodeOnFooter when template is customized', async ({ page }) => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + const dialog = await openFooterPolicyEditor(page) + const ruleDialog = await clickChangeOrCreateRule(dialog) + + // Enable footer + const enableSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Add visible footer/i }) + if (!(await enableSwitch.locator('input').isChecked().catch(() => false))) { + await clickSwitch(enableSwitch) + await expect(enableSwitch.locator('input')).toBeChecked({ timeout: 5000 }) + } + + // Enable template + const templateSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Customize footer template/i }) + const templateInput = templateSwitch.locator('input') + if (!(await templateInput.isChecked().catch(() => false))) { + const reqPromise = captureNextPreviewRequest(page) + await clickSwitch(templateSwitch) + await reqPromise + await expect(templateInput).toBeChecked({ timeout: 5000 }) + } + + // Ensure QR is OFF, then set to ON and verify + const qrcodeSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Write QR code on footer/i }) + const qrcodeInput = qrcodeSwitch.locator('input') + if (await qrcodeInput.isChecked().catch(() => false)) { + const offReqPromise = captureNextPreviewRequest(page) + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).not.toBeChecked({ timeout: 5000 }) + await offReqPromise + } + + // Turn QR ON and verify the request body + const onReqPromise = captureNextPreviewRequest(page) + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).toBeChecked({ timeout: 5000 }) + const onReq = await onReqPromise + const body = onReq.postDataJSON() as Record + + expect(Object.prototype.hasOwnProperty.call(body, 'writeQrcodeOnFooter'), + 'writeQrcodeOnFooter field must be present in the preview request').toBe(true) + expect(body.writeQrcodeOnFooter, 'writeQrcodeOnFooter must be true when switch is ON').toBe(true) +}) diff --git a/playwright/e2e/signature-footer-template-editor-fixed.spec.ts b/playwright/e2e/signature-footer-template-editor-fixed.spec.ts new file mode 100644 index 0000000000..c42193df52 --- /dev/null +++ b/playwright/e2e/signature-footer-template-editor-fixed.spec.ts @@ -0,0 +1,125 @@ +/** + * 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 { + bootstrapLibreSignAdmin, + ensureFooterTemplateEnabled, + fillTemplateEditor, + openSystemFooterRuleEditor, +} from '../support/footer-policy-workbench' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +async function waitForFooterTemplateRequest(page: Page, action: () => Promise) { + const requestPromise = page.waitForRequest((request) => { + return request.method() === 'POST' && request.url().includes('/admin/footer-template/preview-pdf') + }) + + await action() + const request = await requestPromise + return request.postDataJSON() as { + template: string + width: number + height: number + } +} + +async function saveRule(page: Page, ruleDialog: Locator): Promise { + const saveButton = ruleDialog.getByRole('button', { name: /Create rule|Save changes|Save policy rule changes|Save rule changes/i }).last() + await expect(saveButton).toBeVisible({ timeout: 10000 }) + await expect(saveButton).toBeEnabled({ timeout: 10000 }) + const saveResponsePromise = page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes('/apps/libresign/api/v1/policies/system/add_footer') + }) + await saveButton.click() + const saveResponse = await saveResponsePromise + await expect(saveResponse.status()).toBe(200) +} + +test('signature footer template editor updates preview and controls correctly', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + const ruleDialog = await openSystemFooterRuleEditor(page) + await ensureFooterTemplateEnabled(ruleDialog) + + const templateEditor = ruleDialog.locator('.code-editor .cm-content[contenteditable="true"]').first() + const initialTemplate = `
Playwright bootstrap ${Date.now()}
` + await waitForFooterTemplateRequest(page, async () => { + await fillTemplateEditor(ruleDialog, initialTemplate) + }) + await expect(templateEditor).toContainText('Playwright bootstrap') + + const previewSection = ruleDialog.locator('.signature-footer-rule-editor__preview').first() + await expect(previewSection).toBeVisible({ timeout: 15000 }) + await expect(previewSection.getByText(/Preview/i)).toBeVisible({ timeout: 15000 }) + + const zoomField = ruleDialog.getByRole('spinbutton', { name: 'Zoom level' }).first() + await expect(zoomField).toHaveValue('100') + + await ruleDialog.getByRole('button', { name: 'Increase zoom level' }).click() + await expect(zoomField).toHaveValue('110') + + await ruleDialog.getByRole('button', { name: 'Decrease zoom level' }).click() + await expect(zoomField).toHaveValue('100') + + await zoomField.fill('140') + await zoomField.press('Tab') + await expect(zoomField).toHaveValue('140') + + const widthField = ruleDialog.getByRole('spinbutton', { name: 'Width' }).first() + const widthPayload = await waitForFooterTemplateRequest(page, async () => { + await widthField.fill('620') + await widthField.press('Tab') + }) + await expect(widthField).toHaveValue('620') + await expect(widthPayload.width).toBe(620) + + const heightField = ruleDialog.getByRole('spinbutton', { name: 'Height' }).first() + const heightPayload = await waitForFooterTemplateRequest(page, async () => { + await heightField.fill('130') + await heightField.press('Tab') + }) + await expect(heightField).toHaveValue('130') + await expect(heightPayload.height).toBe(130) + + const uniqueTemplate = `
Playwright footer ${Date.now()}
` + const templatePayload = await waitForFooterTemplateRequest(page, async () => { + await fillTemplateEditor(ruleDialog, uniqueTemplate) + }) + await expect(templatePayload.template).toContain('Playwright footer') + await expect(previewSection.locator('.pdf-elements-root')).toBeVisible({ timeout: 15000 }) +}) + +test('footer template reset removes customization after page reload', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + let ruleDialog = await openSystemFooterRuleEditor(page) + await ensureFooterTemplateEnabled(ruleDialog) + + const templateEditor = ruleDialog.locator('.code-editor .cm-content[contenteditable="true"]').first() + const customTemplate = `
Reset test ${Date.now()}
` + await waitForFooterTemplateRequest(page, async () => { + await fillTemplateEditor(ruleDialog, customTemplate) + }) + await expect(templateEditor).toContainText('Reset test') + + const previewSection = ruleDialog.locator('.signature-footer-rule-editor__preview').first() + await expect(previewSection).toBeVisible({ timeout: 15000 }) + + const resetButton = ruleDialog.getByRole('button', { name: /Reset template to inherited default/i }).first() + await expect(resetButton).toBeVisible({ timeout: 10000 }) + await waitForFooterTemplateRequest(page, async () => { + await resetButton.click() + }) + await saveRule(page, ruleDialog) + + await page.reload() + ruleDialog = await openSystemFooterRuleEditor(page) + await ensureFooterTemplateEnabled(ruleDialog) + const templateAfterReload = ruleDialog.locator('.code-editor .cm-content[contenteditable="true"]').first() + await expect(templateAfterReload).toBeVisible({ timeout: 10000 }) + await expect(templateAfterReload).not.toContainText('Reset test') +}) diff --git a/playwright/e2e/signature-footer-template-editor.spec.ts b/playwright/e2e/signature-footer-template-editor.spec.ts new file mode 100644 index 0000000000..f92d41ccee --- /dev/null +++ b/playwright/e2e/signature-footer-template-editor.spec.ts @@ -0,0 +1,317 @@ +/** + * 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, Request, Response } from '@playwright/test' +import { login } from '../support/nc-login' +import { configureOpenSsl } from '../support/nc-provisioning' +import { ensureCatalogSettingCardVisible } from '../support/footer-policy-workbench' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const PREVIEW_URL_PATTERN = /admin\/footer-template\/preview-pdf/ +const SYSTEM_FOOTER_POLICY_URL = '/apps/libresign/api/v1/policies/system/add_footer' + +async function bootstrapLibreSignAdmin(page: Page) { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await page.request.delete('./ocs/v2.php/apps/libresign/api/v1/policies/user/add_footer', { + headers: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + }, + }) + + await configureOpenSsl(page.request, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) +} + +async function clickSwitch(switchContainer: Locator): Promise { + await switchContainer.locator('.checkbox-radio-switch__content').click() +} + +async function captureNextPreviewRequest(page: Page): Promise { + return page.waitForRequest( + (request) => request.method() === 'POST' && PREVIEW_URL_PATTERN.test(request.url()), + { timeout: 15000 }, + ) +} + +async function waitForSystemFooterPolicySave(page: Page, action: () => Promise): Promise<{ request: Request, response: Response }> { + const responsePromise = page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes(SYSTEM_FOOTER_POLICY_URL) + }) + + await action() + const response = await responsePromise + return { + request: response.request(), + response, + } +} + +async function openFooterPolicyEditor(page: Page): Promise { + await page.goto('./settings/admin/libresign') + + const footerCard = await ensureCatalogSettingCardVisible(page, /Signature footer/i, 'footer') + await footerCard.click() + + const dialog = page.getByRole('dialog').filter({ hasText: /Signature footer/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + return dialog +} + +async function openSystemRuleEditor(dialog: Locator): Promise { + const changeButton = dialog.getByRole('button', { name: /^Change$/i }).first() + if (await changeButton.isVisible().catch(() => false)) { + await changeButton.click() + } else { + const createButton = dialog.getByRole('button', { name: /Create rule/i }).first() + await expect(createButton).toBeVisible({ timeout: 5000 }) + await createButton.click() + const everyoneOption = dialog.page().locator('[role="option"]').filter({ hasText: /Everyone/i }).first() + if (await everyoneOption.isVisible().catch(() => false)) { + await everyoneOption.click() + } + } + + const ruleDialog = dialog.page().getByRole('dialog', { name: /Edit rule|Create rule/i }).last() + await expect(ruleDialog).toBeVisible({ timeout: 10000 }) + return ruleDialog +} + +async function ensureCheckboxEnabled(scope: Locator, label: string, triggerPreview = false): Promise { + const switchContainer = scope.locator('.checkbox-radio-switch').filter({ hasText: new RegExp(label, 'i') }).first() + await expect(switchContainer).toBeVisible({ timeout: 10000 }) + const checkbox = switchContainer.locator('input[type="checkbox"]').first() + if (!await checkbox.isChecked().catch(() => false)) { + const previewRequest = triggerPreview ? captureNextPreviewRequest(scope.page()) : null + await clickSwitch(switchContainer) + if (previewRequest) { + await previewRequest + } + } + await expect(checkbox).toBeChecked() +} + +async function getFooterEditorContext(scope: Locator): Promise<{ + ruleDialog: Locator + editorField: Locator + preview: Locator +}> { + await ensureCheckboxEnabled(scope, 'Add visible footer with signature details') + await ensureCheckboxEnabled(scope, 'Customize footer template', true) + + const editorField = scope.locator('.code-editor .cm-content[contenteditable="true"]').first() + await expect(editorField).toBeVisible({ timeout: 10000 }) + + const preview = scope.locator('.signature-footer-rule-editor__preview').first() + await expect(preview).toBeVisible({ timeout: 15000 }) + + return { + ruleDialog: scope, + editorField, + preview, + } +} + +async function replaceCodeMirrorContent(editorField: Locator, value: string): Promise { + await editorField.click() + await editorField.press('Control+a') + await editorField.fill(value) +} + +async function saveRule(ruleDialog: Locator): Promise<{ request: Request, response: Response }> { + const saveButton = ruleDialog.getByRole('button', { name: /Create rule|Save changes|Save policy rule changes|Save rule changes/i }).last() + await expect(saveButton).toBeVisible({ timeout: 10000 }) + await expect(saveButton).toBeEnabled({ timeout: 10000 }) + return waitForSystemFooterPolicySave(ruleDialog.page(), async () => { + await saveButton.click() + }) +} + +async function getPersistedSystemFooterPolicy(page: Page): Promise<{ customizeFooterTemplate: boolean, footerTemplate: string }> { + const response = await page.request.get('./ocs/v2.php/apps/libresign/api/v1/policies/system/add_footer', { + headers: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + }, + }) + const payload = await response.json() as { + ocs?: { + data?: { + policy?: { + value?: string | { customizeFooterTemplate?: boolean, footerTemplate?: string } + } + } + } + } + const rawValue = payload.ocs?.data?.policy?.value + if (typeof rawValue === 'string') { + return JSON.parse(rawValue) as { customizeFooterTemplate: boolean, footerTemplate: string } + } + + if (rawValue && typeof rawValue === 'object') { + return { + customizeFooterTemplate: Boolean(rawValue.customizeFooterTemplate), + footerTemplate: String(rawValue.footerTemplate ?? ''), + } + } + + return { customizeFooterTemplate: false, footerTemplate: '' } +} + +test('signature footer template editor updates preview and controls correctly', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + + const dialog = await openFooterPolicyEditor(page) + const ruleDialog = await openSystemRuleEditor(dialog) + const { editorField, preview } = await getFooterEditorContext(ruleDialog) + + const initialTemplate = `
Playwright bootstrap ${Date.now()}
` + const initialPreviewRequest = captureNextPreviewRequest(page) + await replaceCodeMirrorContent(editorField, initialTemplate) + const initialPayload = initialPreviewRequest.then((request) => request.postDataJSON() as { + template: string + width: number + height: number + }) + + await expect(preview.locator('.pdf-elements-root')).toBeVisible({ timeout: 15000 }) + await expect(preview.getByText(/Preview/i)).toBeVisible({ timeout: 15000 }) + await expect((await initialPayload).template).toContain('Playwright bootstrap') + + const zoomField = ruleDialog.getByRole('spinbutton', { name: 'Zoom level' }).first() + await expect(zoomField).toHaveValue('100') + await ruleDialog.getByRole('button', { name: 'Increase zoom level' }).click() + await expect(zoomField).toHaveValue('110') + await ruleDialog.getByRole('button', { name: 'Decrease zoom level' }).click() + await expect(zoomField).toHaveValue('100') + await zoomField.fill('140') + await zoomField.press('Tab') + await expect(zoomField).toHaveValue('140') + + const widthField = ruleDialog.getByRole('spinbutton', { name: 'Width' }).first() + const widthRequest = captureNextPreviewRequest(page) + await widthField.fill('620') + await widthField.press('Tab') + const widthPayload = await widthRequest.then((request) => request.postDataJSON() as { width: number }) + await expect(widthField).toHaveValue('620') + await expect(widthPayload.width).toBe(620) + + const heightField = ruleDialog.getByRole('spinbutton', { name: 'Height' }).first() + const heightRequest = captureNextPreviewRequest(page) + await heightField.fill('130') + await heightField.press('Tab') + const heightPayload = await heightRequest.then((request) => request.postDataJSON() as { height: number }) + await expect(heightField).toHaveValue('130') + await expect(heightPayload.height).toBe(130) + + const uniqueTemplate = `
Playwright footer ${Date.now()}
` + const templateRequest = captureNextPreviewRequest(page) + await replaceCodeMirrorContent(editorField, uniqueTemplate) + const templatePayload = await templateRequest.then((request) => request.postDataJSON() as { template: string }) + await expect(templatePayload.template).toContain('Playwright footer') +}) + +test('footer template persists customization after save and reload', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + + const customTemplate = `
Reset test ${Date.now()}
` + const capturedSavePayloads: string[] = [] + page.on('request', (request) => { + if (['POST', 'PUT', 'PATCH'].includes(request.method()) && request.url().includes(SYSTEM_FOOTER_POLICY_URL)) { + const payload = request.postDataJSON() as { value?: string } + capturedSavePayloads.push(String(payload.value ?? '')) + } + }) + let dialog = await openFooterPolicyEditor(page) + let ruleDialog = await openSystemRuleEditor(dialog) + let editorContext = await getFooterEditorContext(ruleDialog) + + const savePreviewRequest = captureNextPreviewRequest(page) + await replaceCodeMirrorContent(editorContext.editorField, customTemplate) + await savePreviewRequest + const { request: saveRequest, response: saveResponse } = await saveRule(ruleDialog) + await expect(saveResponse.status()).toBe(200) + const savePayload = saveRequest.postDataJSON() as { value?: string } + const decodedValue = JSON.parse(savePayload.value ?? '{}') as { footerTemplate?: string } + expect(decodedValue.footerTemplate ?? '').toBe(customTemplate) + const persistedAfterSave = await getPersistedSystemFooterPolicy(page) + expect(capturedSavePayloads.map((payload) => JSON.parse(payload).footerTemplate ?? '')).toContain(customTemplate) + expect(persistedAfterSave.customizeFooterTemplate).toBe(true) + expect(persistedAfterSave.footerTemplate).toBe(customTemplate) + + await page.reload() + const persistedAfterReload = await getPersistedSystemFooterPolicy(page) + expect(persistedAfterReload.customizeFooterTemplate).toBe(true) + expect(persistedAfterReload.footerTemplate).toBe(customTemplate) + dialog = await openFooterPolicyEditor(page) + ruleDialog = await openSystemRuleEditor(dialog) + editorContext = await getFooterEditorContext(ruleDialog) + + await expect.poll(async () => { + const text = await editorContext.editorField.textContent() + return (text ?? '').trim() + }, { timeout: 10000 }).toContain(customTemplate) +}) + +test('footer template reset reverts to inherited default after save and reload', async ({ page }) => { + await bootstrapLibreSignAdmin(page) + + const customTemplate = `
CUSTOM_${Date.now()}
` + let dialog = await openFooterPolicyEditor(page) + let ruleDialog = await openSystemRuleEditor(dialog) + let editorContext = await getFooterEditorContext(ruleDialog) + + const savePreviewRequest = captureNextPreviewRequest(page) + await replaceCodeMirrorContent(editorContext.editorField, customTemplate) + await savePreviewRequest + await saveRule(ruleDialog) + const persistedAfterCustomSave = await getPersistedSystemFooterPolicy(page) + expect(persistedAfterCustomSave.customizeFooterTemplate).toBe(true) + expect(persistedAfterCustomSave.footerTemplate).toBe(customTemplate) + + dialog = await openFooterPolicyEditor(page) + ruleDialog = await openSystemRuleEditor(dialog) + editorContext = await getFooterEditorContext(ruleDialog) + const persistedBeforeReset = await getPersistedSystemFooterPolicy(page) + expect(persistedBeforeReset.customizeFooterTemplate).toBe(true) + expect(persistedBeforeReset.footerTemplate).toBe(customTemplate) + + const resetButton = ruleDialog.getByRole('button', { name: 'Reset template to inherited default' }).first() + if (await resetButton.isVisible().catch(() => false)) { + const resetPreviewRequest = captureNextPreviewRequest(page) + await resetButton.click() + await resetPreviewRequest + } else { + const customizeSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Customize footer template/i }).first() + await expect(customizeSwitch).toBeVisible({ timeout: 10000 }) + const customizeCheckbox = customizeSwitch.locator('input[type="checkbox"]').first() + if (await customizeCheckbox.isChecked().catch(() => false)) { + await clickSwitch(customizeSwitch) + } + await expect(customizeCheckbox).not.toBeChecked() + } + await saveRule(ruleDialog) + const persistedAfterReset = await getPersistedSystemFooterPolicy(page) + expect(persistedAfterReset.footerTemplate).not.toBe(customTemplate) + expect(persistedAfterReset.footerTemplate.length).toBeGreaterThan(0) + + await page.reload() + const persistedAfterReload = await getPersistedSystemFooterPolicy(page) + expect(persistedAfterReload.footerTemplate).toBe(persistedAfterReset.footerTemplate) + expect(typeof persistedAfterReload.customizeFooterTemplate).toBe('boolean') +}) diff --git a/playwright/e2e/visible-element-persistence.spec.ts b/playwright/e2e/visible-element-persistence.spec.ts index c713f70ebb..c77165b026 100644 --- a/playwright/e2e/visible-element-persistence.spec.ts +++ b/playwright/e2e/visible-element-persistence.spec.ts @@ -16,14 +16,25 @@ test('visible signature element persists and can be deleted', async ({ page }) = const requestSignatureTab = page.locator('#request-signature-tab') const setupSignaturePositionsButton = requestSignatureTab.getByRole('button', { name: 'Setup signature positions' }) const openSidebarButton = page.getByRole('button', { name: 'Open sidebar' }) + const signaturePositionsDialog = page.getByLabel('Signature positions') async function reopenFileFromUuid(uuid: string) { await page.goto(`./apps/libresign/f/filelist/sign?uuid=${uuid}`) - if (await openSidebarButton.isVisible()) { + await expect(page).toHaveURL(/\/apps\/libresign\/f\/filelist\/sign/) + + const setupVisible = await setupSignaturePositionsButton.isVisible({ timeout: 3000 }).catch(() => false) + if (!setupVisible) { + const draftRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row').filter({ hasText: 'Draft' }).first() + await expect(draftRow).toBeVisible({ timeout: 20000 }) + await draftRow.getByRole('button').first().click() + } + + if (await openSidebarButton.isVisible({ timeout: 2000 }).catch(() => false)) { await openSidebarButton.click() } - await expect(setupSignaturePositionsButton).toBeVisible() + await expect(setupSignaturePositionsButton).toBeVisible({ timeout: 15000 }) await setupSignaturePositionsButton.click() + await expect(signaturePositionsDialog).toBeVisible({ timeout: 30000 }) } await login( @@ -72,7 +83,6 @@ test('visible signature element persists and can be deleted', async ({ page }) = const requestUuid = createRequestBody.ocs.data.uuid as string await expect(setupSignaturePositionsButton).toBeVisible() await setupSignaturePositionsButton.click() - const signaturePositionsDialog = page.getByLabel('Signature positions') const visiblePageOverlay = getVisiblePdfOverlay(signaturePositionsDialog) const addInstruction = signaturePositionsDialog.getByText('Click on the place you want to add.') const cancelPlacementButton = signaturePositionsDialog.getByRole('button', { name: 'Cancel' }) @@ -97,29 +107,25 @@ test('visible signature element persists and can be deleted', async ({ page }) = await expect(addInstruction).toBeHidden() await expect(cancelPlacementButton).toBeHidden() await expect(editSignerLink).toBeVisible() + const signaturePosition = signaturePositionsDialog.getByRole('img', { name: /Signature position for/i }).first() - await expect( - signaturePositionsDialog.getByRole('img', { name: 'Signature position for Admin Name' }), - ).toBeVisible() + await expect(signaturePosition).toBeVisible({ timeout: 10000 }) // Save closes the modal and persists the element via API await page.getByLabel('Signature positions').getByRole('button', { name: 'Save' }).click() + await expect(page.getByLabel('Signature positions')).toBeHidden() // Open the document again through the Files route using the request uuid to force a fresh load await reopenFileFromUuid(requestUuid) // Verify the element survived the round-trip to the server - await expect( - page.getByLabel('Signature positions').getByRole('img', { name: 'Signature position for Admin Name' }), - ).toBeVisible() + await expect(page.getByLabel('Signature positions').getByRole('img', { name: /Signature position for/i }).first()).toBeVisible({ timeout: 30000 }) // Select the element so the toolbar (Duplicate / Delete) appears, then delete it - await page.getByLabel('Signature positions').getByRole('img', { name: 'Signature position for Admin Name' }).click() + await page.getByLabel('Signature positions').getByRole('img', { name: /Signature position for/i }).first().click() await page.getByLabel('Signature positions').getByRole('button', { name: 'Delete' }).click() - await expect( - page.getByLabel('Signature positions').getByRole('img', { name: 'Signature position for Admin Name' }), - ).toBeHidden() + await expect(page.getByLabel('Signature positions').getByRole('img', { name: /Signature position for/i }).first()).toBeHidden() // Save the now-empty element list await page.getByLabel('Signature positions').getByRole('button', { name: 'Save' }).click() @@ -129,9 +135,8 @@ test('visible signature element persists and can be deleted', async ({ page }) = // Re-open the document one last time and confirm the element is gone await reopenFileFromUuid(requestUuid) - await expect(getVisiblePdfOverlay(signaturePositionsDialog)).toBeVisible() + await expect(signaturePositionsDialog).toBeVisible() + await expect(getVisiblePdfOverlay(signaturePositionsDialog)).toBeVisible({ timeout: 30000 }) - await expect( - signaturePositionsDialog.getByRole('img', { name: 'Signature position for Admin Name' }), - ).toBeHidden() + await expect(signaturePositionsDialog.getByRole('img', { name: /Signature position for/i }).first()).toBeHidden() }) diff --git a/playwright/support/footer-policy-workbench.ts b/playwright/support/footer-policy-workbench.ts new file mode 100644 index 0000000000..8dcdc025a5 --- /dev/null +++ b/playwright/support/footer-policy-workbench.ts @@ -0,0 +1,115 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, type Locator, type Page } from '@playwright/test' +import { login } from './nc-login' +import { configureOpenSsl } from './nc-provisioning' + +async function clickSwitchContent(switchContainer: Locator): Promise { + await switchContainer.locator('.checkbox-radio-switch__content').first().click() +} + +export async function bootstrapLibreSignAdmin(page: Page): Promise { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await configureOpenSsl(page.request, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) +} + +export async function ensureCatalogSettingCardVisible( + page: Page, + settingName: RegExp, + searchTerm: string, +): Promise { + const searchField = page.getByRole('textbox', { name: /Search settings/i }).first() + await expect(searchField).toBeVisible({ timeout: 20000 }) + + const collapseButton = page.getByRole('button', { + name: /Collapse settings categories|Expand settings categories/i, + }).first() + if (/Expand settings categories/i.test((await collapseButton.getAttribute('aria-label')) ?? '')) { + await collapseButton.click() + await expect(collapseButton).toHaveAttribute('aria-label', /Collapse settings categories/i) + } + + const viewButton = page.getByRole('button', { + name: /Switch to compact view|Switch to card view/i, + }).first() + if (/Switch to card view/i.test((await viewButton.getAttribute('aria-label')) ?? '')) { + await viewButton.click() + await expect(viewButton).toHaveAttribute('aria-label', /Switch to compact view/i) + } + + await searchField.fill(searchTerm) + const settingCard = page.getByRole('button', { name: settingName }).first() + await expect(settingCard).toBeVisible({ timeout: 20000 }) + return settingCard +} + +export async function openSystemFooterRuleEditor(page: Page): Promise { + await page.goto('./settings/admin/libresign') + + const footerCard = await ensureCatalogSettingCardVisible(page, /Signature footer/i, 'footer') + await footerCard.click() + + const dialog = page.getByRole('dialog').filter({ hasText: /Signature footer/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + + const changeButton = dialog.getByRole('button', { name: /^Change$/i }).first() + if (await changeButton.isVisible().catch(() => false)) { + await changeButton.click() + } else { + const createButton = dialog.getByRole('button', { name: /Create rule/i }).first() + await expect(createButton).toBeVisible({ timeout: 10000 }) + await createButton.click() + const everyoneOption = page.locator('[role="option"]').filter({ hasText: /Everyone/i }).first() + if (await everyoneOption.isVisible().catch(() => false)) { + await everyoneOption.click() + } + } + + const ruleDialog = page.getByRole('dialog', { name: /Edit rule|Create rule/i }).last() + await expect(ruleDialog).toBeVisible({ timeout: 10000 }) + return ruleDialog +} + +export async function ensureFooterTemplateEnabled(scope: Locator): Promise { + const addFooterSwitch = scope.locator('.checkbox-radio-switch') + .filter({ hasText: /Add visible footer(?: with signature details)?/i }) + .first() + await expect(addFooterSwitch).toBeVisible({ timeout: 10000 }) + const addFooterCheckbox = addFooterSwitch.locator('input[type="checkbox"]').first() + if (!await addFooterCheckbox.isChecked()) { + await clickSwitchContent(addFooterSwitch) + await expect(addFooterCheckbox).toBeChecked() + } + + const customizeSwitch = scope.locator('.checkbox-radio-switch') + .filter({ hasText: /Customize footer template/i }) + .first() + await expect(customizeSwitch).toBeVisible({ timeout: 10000 }) + const customizeCheckbox = customizeSwitch.locator('input[type="checkbox"]').first() + if (!await customizeCheckbox.isChecked()) { + await clickSwitchContent(customizeSwitch) + await expect(customizeCheckbox).toBeChecked() + } +} + +export async function fillTemplateEditor(scope: Locator, value: string): Promise { + const editor = scope.locator('.code-editor .cm-content[contenteditable="true"]').first() + await expect(editor).toBeVisible({ timeout: 10000 }) + await editor.click() + await editor.press('Control+a') + await editor.fill(value) +} diff --git a/playwright/support/nc-login.ts b/playwright/support/nc-login.ts index f97f681ac6..62de16ebe8 100644 --- a/playwright/support/nc-login.ts +++ b/playwright/support/nc-login.ts @@ -4,6 +4,7 @@ */ import type { APIRequestContext } from '@playwright/test' +import { ensureLibresignAppEnabled } from './nc-provisioning' /** * Login to Nextcloud via API (no browser form involved). @@ -25,9 +26,36 @@ export async function login( user: string, password: string, ): Promise { - const tokenResponse = await request.get('./csrftoken', { - failOnStatusCode: true, - }) + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + await ensureLibresignAppEnabled(request, adminUser, adminPassword) + + // Ensure a previous authenticated session does not leak across persona switches. + await request.get('./logout', { + failOnStatusCode: false, + maxRedirects: 0, + }).catch(() => {}) + + let tokenResponse: Awaited> | null = null + let lastTokenError: Error | null = null + for (let attempt = 1; attempt <= 5; attempt++) { + try { + tokenResponse = await request.get('./csrftoken', { + failOnStatusCode: true, + timeout: 20000, + }) + break + } catch (error) { + lastTokenError = error instanceof Error ? error : new Error(String(error)) + if (attempt < 5) { + await new Promise((resolve) => setTimeout(resolve, attempt * 250)) + } + } + } + + if (!tokenResponse) { + throw lastTokenError ?? new Error('Failed to fetch csrftoken') + } const { token: requesttoken } = await tokenResponse.json() as { token: string } diff --git a/playwright/support/nc-navigation.ts b/playwright/support/nc-navigation.ts new file mode 100644 index 0000000000..eb58ee0c74 --- /dev/null +++ b/playwright/support/nc-navigation.ts @@ -0,0 +1,29 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Browser-level navigation helpers for the Nextcloud / LibreSign UI. + */ + +import type { Page } from '@playwright/test' + +/** + * Ensures the "Settings" section of the Nextcloud left sidebar is expanded + * so that links like "Account" and "Policies" are visible. + * + * Works both when the sidebar is already expanded and when it still shows + * only the collapsed "Settings" toggle button. + */ +export async function expandSettingsMenu(page: Page): Promise { + await page.keyboard.press('Escape').catch(() => {}) + const sidebar = page.locator('#app-navigation-vue') + const settingsLink = sidebar.getByRole('link', { name: 'Account' }) + if (await settingsLink.count()) { + return + } + + const settingsToggle = sidebar.getByRole('button', { name: 'Settings' }) + if (await settingsToggle.count()) { + await settingsToggle.first().click() + } +} diff --git a/playwright/support/nc-provisioning.ts b/playwright/support/nc-provisioning.ts index 39e5665c37..177a9bbda6 100644 --- a/playwright/support/nc-provisioning.ts +++ b/playwright/support/nc-provisioning.ts @@ -27,6 +27,100 @@ type SignatureElementResponse = { }> } +type HasRootCertResponse = { + hasRootCert?: boolean +} + +type AppConfigResponse = { + data?: string +} + +let libresignAppEnablePromise: Promise | null = null + +function buildOcsHeaders(adminUser: string, adminPassword: string): Record { + const auth = 'Basic ' + Buffer.from(`${adminUser}:${adminPassword}`).toString('base64') + return { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: auth, + } +} + +export async function ensureLibresignAppEnabled( + request: APIRequestContext, + adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', +): Promise { + if (libresignAppEnablePromise) { + await libresignAppEnablePromise + return + } + + libresignAppEnablePromise = (async () => { + let lastError: Error | null = null + + for (let attempt = 1; attempt <= 6; attempt++) { + try { + const response = await request.post('./ocs/v2.php/cloud/apps/libresign?format=json', { + headers: buildOcsHeaders(adminUser, adminPassword), + failOnStatusCode: false, + }) + + if (!response.ok()) { + const body = await response.text() + if ([502, 503, 504].includes(response.status()) && attempt < 6) { + await new Promise((resolve) => setTimeout(resolve, attempt * 250)) + continue + } + throw new Error(`Failed to enable LibreSign app: ${response.status()} ${body}`) + } + + const rawBody = await response.text() + if (!rawBody) { + return + } + + const body = JSON.parse(rawBody) as OcsResponse + if (body.ocs.meta.statuscode !== 200) { + throw new Error(`Failed to enable LibreSign app: ${body.ocs.meta.message}`) + } + + return + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)) + if (attempt < 6) { + await new Promise((resolve) => setTimeout(resolve, attempt * 250)) + continue + } + } + } + + throw lastError ?? new Error('Failed to enable LibreSign app') + })() + + try { + await libresignAppEnablePromise + } catch (error) { + libresignAppEnablePromise = null + throw error + } +} + +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', @@ -36,15 +130,16 @@ async function ocsRequest( body?: Record, jsonBody?: unknown, ): Promise> { - const url = `./ocs/v2.php${path}` - const auth = 'Basic ' + Buffer.from(`${adminUser}:${adminPassword}`).toString('base64') - const headers: Record = { - 'OCS-ApiRequest': 'true', - Accept: 'application/json', - Authorization: auth, + if (path.startsWith('/apps/libresign/')) { + await ensureLibresignAppEnabled(request, adminUser, adminPassword) } + + const url = `./ocs/v2.php${path}` + const headers: Record = buildOcsHeaders(adminUser, adminPassword) if (jsonBody !== undefined) { headers['Content-Type'] = 'application/json' + } else if (body !== undefined) { + headers['Content-Type'] = 'application/x-www-form-urlencoded' } const response = await request[method.toLowerCase() as 'get' | 'post' | 'put' | 'delete'](url, { headers, @@ -124,6 +219,117 @@ export async function deleteUser( await ocsRequest(request, 'DELETE', `/cloud/users/${userId}`) } +/** + * Forces a user's Nextcloud language via Provisioning API. + */ +export async function setUserLanguage( + request: APIRequestContext, + userId: string, + language: string, +): Promise { + const result = await ocsRequest( + request, + 'PUT', + `/cloud/users/${encodeURIComponent(userId)}`, + undefined, + undefined, + { key: 'language', value: language }, + ) + + if (result.ocs.meta.statuscode !== 200) { + throw new Error(`Failed to set language for user "${userId}" to "${language}": ${result.ocs.meta.message}`) + } +} + +// --------------------------------------------------------------------------- +// 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`) // --------------------------------------------------------------------------- @@ -151,6 +357,26 @@ export async function setAppConfig( } } +export async function getAppConfig( + request: APIRequestContext, + appId: string, + key: string, +): Promise { + const result = await ocsRequest( + request, + 'GET', + `/apps/provisioning_api/api/v1/config/apps/${appId}/${key}`, + ) + + if (result.ocs.meta.statuscode === 404) { + return null + } + + return typeof result.ocs.data?.data === 'string' + ? result.ocs.data.data + : null +} + /** * Deletes an app config value. * Equivalent to: `occ config:app:delete ` @@ -197,6 +423,17 @@ export async function configureOpenSsl( commonName: string, names: OpenSslCertNames = {}, ): Promise { + const rootCertCheck = await ocsRequest( + request, + 'GET', + '/apps/libresign/api/v1/setting/has-root-cert', + ) + + if (rootCertCheck.ocs.data?.hasRootCert) { + await clearSignatureElements(request) + return + } + const normalised: OpenSslCertNames = { ...names } if (typeof normalised.OU === 'string') { normalised.OU = [normalised.OU] diff --git a/playwright/support/policy-api.ts b/playwright/support/policy-api.ts new file mode 100644 index 0000000000..da5f24bc2a --- /dev/null +++ b/playwright/support/policy-api.ts @@ -0,0 +1,193 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Generic helpers for the LibreSign Policy OCS API, shared across all + * policy-related spec files. + */ + +import { expect, request, type APIRequestContext } from '@playwright/test' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type OcsPolicyResponse = { + ocs?: { + meta?: { statuscode?: number; message?: string } + data?: Record + } +} + +export type PolicyApiResult = { + httpStatus: number + statusCode: number + message: string + data: Record +} + +export type EffectivePolicyEntry = { + effectiveValue?: unknown + sourceScope?: string + canSaveAsUserDefault?: boolean + editableByCurrentActor?: boolean + allowedValues?: unknown[] +} + +// --------------------------------------------------------------------------- +// HTTP context +// --------------------------------------------------------------------------- + +/** + * Creates a Playwright `APIRequestContext` pre-configured with OCS headers + * and Basic authentication for the given user. + */ +export 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', + }, + }) +} + +// --------------------------------------------------------------------------- +// Low-level OCS request wrapper +// --------------------------------------------------------------------------- + +/** + * Issues an OCS request to the LibreSign policy API and returns a normalised + * result object. Never throws on non-2xx — callers decide what is acceptable. + */ +export async function policyRequest( + requestContext: APIRequestContext, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + body?: Record, +): Promise { + 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 ?? {}, + } +} + +// --------------------------------------------------------------------------- +// Policy read helpers +// --------------------------------------------------------------------------- + +/** + * Returns the effective policy entry for `policyKey` from the + * `/policies/effective` endpoint, or `null` when the key is absent. + */ +export async function getEffectivePolicy( + requestContext: APIRequestContext, + policyKey: string, +): Promise { + const result = await policyRequest(requestContext, 'GET', '/apps/libresign/api/v1/policies/effective') + const policies = (result.data.policies ?? {}) as Record + return policies[policyKey] ?? null +} + +/** + * Polls until `canSaveAsUserDefault` reaches the expected value. + * Throws after `maxAttempts` unsuccessful reads. + */ +export async function waitForPolicyCanSaveAsUserDefault( + requestContext: APIRequestContext, + policyKey: string, + expected: boolean, + maxAttempts = 10, +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const entry = await getEffectivePolicy(requestContext, policyKey) + if (entry?.canSaveAsUserDefault === expected) { + return + } + } + + throw new Error(`Policy ${policyKey} did not reach canSaveAsUserDefault=${expected} after ${maxAttempts} attempts`) +} + +// --------------------------------------------------------------------------- +// Policy write helpers +// --------------------------------------------------------------------------- + +/** + * Sets a system-level policy entry and asserts HTTP 200. + * Pass `value: null` to clear an explicit system value. + */ +export async function setSystemPolicyEntry( + ctx: APIRequestContext, + policyKey: string, + value: string | null, + allowChildOverride: boolean, +): Promise { + const response = await policyRequest( + ctx, + 'POST', + `/apps/libresign/api/v1/policies/system/${policyKey}`, + { value, allowChildOverride }, + ) + expect(response.httpStatus, `setSystemPolicyEntry(${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} + +/** + * Sets a group-level policy entry and asserts HTTP 200. + */ +export async function setGroupPolicyEntry( + ctx: APIRequestContext, + groupId: string, + policyKey: string, + value: string, + allowChildOverride: boolean, +): Promise { + const response = await policyRequest( + ctx, + 'PUT', + `/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`, + { value, allowChildOverride }, + ) + expect(response.httpStatus, `setGroupPolicyEntry(${groupId}/${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} + +/** + * Deletes the authenticated user's own preference for `policyKey`. + * Accepted statuses default to `[200, 500]`; pass `[200, 401, 500]` when the + * user may not yet exist at cleanup time. + */ +export async function clearUserPolicyPreference( + ctx: APIRequestContext, + policyKey: string, + acceptedStatuses: number[] = [200, 500], +): Promise { + const response = await policyRequest(ctx, 'DELETE', `/apps/libresign/api/v1/policies/user/${policyKey}`) + expect( + acceptedStatuses, + `clearUserPolicyPreference(${policyKey}): expected ${acceptedStatuses.join(' or ')} but got ${response.httpStatus}`, + ).toContain(response.httpStatus) +} diff --git a/playwright/support/system-policies.ts b/playwright/support/system-policies.ts new file mode 100644 index 0000000000..0aaae6b607 --- /dev/null +++ b/playwright/support/system-policies.ts @@ -0,0 +1,119 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Helpers for managing LibreSign system policies from Playwright tests. + * + * The `useFooterPolicyGuard()` function registers `test.beforeEach` / + * `test.afterEach` hooks that disable the footer policy before each test and + * restore the original value afterwards. Call it once at the top level of any + * spec file that triggers document signing, because the footer merge step + * requires PDFtk/Java which may not be available in every environment. + */ + +import { test, expect, request, type APIRequestContext } from '@playwright/test' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const FOOTER_POLICY_KEY = 'add_footer' + +export const FOOTER_DISABLED_VALUE = JSON.stringify({ + enabled: false, + writeQrcodeOnFooter: false, + validationSite: '', + customizeFooterTemplate: false, +}) + +// --------------------------------------------------------------------------- +// Low-level helpers +// --------------------------------------------------------------------------- + +/** + * Creates a standalone admin `APIRequestContext` suitable for use in + * `beforeEach`/`afterEach` hooks where no `page` fixture is available. + */ +export async function makeAdminContext(): Promise { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + return request.newContext({ + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost', + ignoreHTTPSErrors: true, + extraHTTPHeaders: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: 'Basic ' + Buffer.from(`${adminUser}:${adminPassword}`).toString('base64'), + 'Content-Type': 'application/json', + }, + }) +} + +/** + * Reads the current value of a system policy. Returns `null` when the policy + * has not been set (HTTP 404). + */ +export async function getSystemPolicy(ctx: APIRequestContext, key: string): Promise { + const response = await ctx.get(`./ocs/v2.php/apps/libresign/api/v1/policies/system/${key}`, { + failOnStatusCode: false, + }) + if (response.status() === 404) { + return null + } + + const payload = await response.json() as { ocs?: { data?: { value?: string | null } } } + return payload.ocs?.data?.value ?? null +} + +/** + * Writes a system policy value. When `value` is `null` (meaning the policy + * was not set before) this is a no-op so the absent state is preserved on + * restore. + */ +export async function setSystemPolicy(ctx: APIRequestContext, key: string, value: string | null): Promise { + if (value === null) { + return + } + + const response = await ctx.post(`./ocs/v2.php/apps/libresign/api/v1/policies/system/${key}`, { + data: { + value, + allowChildOverride: true, + }, + failOnStatusCode: false, + }) + + expect(response.status(), `setSystemPolicy(${key}): expected 200 but got ${response.status()}`).toBe(200) +} + +// --------------------------------------------------------------------------- +// Spec-level hook +// --------------------------------------------------------------------------- + +/** + * Registers `test.beforeEach` / `test.afterEach` hooks that disable the + * footer policy for the duration of each test and restore it afterwards. + * + * Call once at the top level of any spec file that exercises document signing: + * + * ```ts + * import { useFooterPolicyGuard } from '../support/system-policies' + * useFooterPolicyGuard() + * ``` + */ +export function useFooterPolicyGuard(): void { + let adminContext: APIRequestContext + let originalFooterPolicy: string | null + + test.beforeEach(async () => { + adminContext = await makeAdminContext() + originalFooterPolicy = await getSystemPolicy(adminContext, FOOTER_POLICY_KEY) + await setSystemPolicy(adminContext, FOOTER_POLICY_KEY, FOOTER_DISABLED_VALUE) + }) + + test.afterEach(async () => { + await setSystemPolicy(adminContext, FOOTER_POLICY_KEY, originalFooterPolicy) + await adminContext.dispose() + }) +} diff --git a/src/components/CodeEditor.vue b/src/components/CodeEditor.vue index 200ef5e897..8be4d7e237 100644 --- a/src/components/CodeEditor.vue +++ b/src/components/CodeEditor.vue @@ -4,9 +4,14 @@ --> {{ t('libresign', 'Available variables') }} + + + + @update:modelValue="onTemplateChange" /> -