Browse Source

feat: Add declarative settings

Signed-off-by: jld3103 <jld3103yt@gmail.com>
Signed-off-by: Julien Veyssier <julien-nc@posteo.net>
Signed-off-by: Andrey Borysenko <andrey18106x@gmail.com>
jld3103 4 months ago
parent
commit
4ac2375ca2
41 changed files with 3212 additions and 52 deletions
  1. 5 1
      apps/settings/appinfo/routes.php
  2. 2 0
      apps/settings/composer/composer/autoload_classmap.php
  3. 2 0
      apps/settings/composer/composer/autoload_static.php
  4. 2 2
      apps/settings/composer/composer/installed.php
  5. 9 2
      apps/settings/lib/Controller/AdminSettingsController.php
  6. 46 17
      apps/settings/lib/Controller/CommonSettingsTrait.php
  7. 105 0
      apps/settings/lib/Controller/DeclarativeSettingsController.php
  8. 7 1
      apps/settings/lib/Controller/PersonalSettingsController.php
  9. 56 0
      apps/settings/lib/ResponseDefinitions.php
  10. 65 0
      apps/settings/openapi-administration.json
  11. 433 0
      apps/settings/openapi-full.json
  12. 344 14
      apps/settings/openapi.json
  13. 268 0
      apps/settings/src/components/DeclarativeSettings/DeclarativeSection.vue
  14. 50 0
      apps/settings/src/main-declarative-settings-forms.ts
  15. 16 1
      apps/settings/tests/Controller/AdminSettingsControllerTest.php
  16. 4 0
      apps/testing/composer/composer/autoload_classmap.php
  17. 4 0
      apps/testing/composer/composer/autoload_static.php
  18. 2 2
      apps/testing/composer/composer/installed.php
  19. 12 0
      apps/testing/lib/AppInfo/Application.php
  20. 32 0
      apps/testing/lib/Listener/GetDeclarativeSettingsValueListener.php
  21. 68 0
      apps/testing/lib/Listener/RegisterDeclarativeSettingsListener.php
  22. 32 0
      apps/testing/lib/Listener/SetDeclarativeSettingsValueListener.php
  23. 172 0
      apps/testing/lib/Settings/DeclarativeSettingsForm.php
  24. 0 0
      dist/settings-declarative-settings-forms.js
  25. 0 0
      dist/settings-declarative-settings-forms.js.map
  26. 0 2
      lib/composer/composer/LICENSE
  27. 7 0
      lib/composer/composer/autoload_classmap.php
  28. 7 0
      lib/composer/composer/autoload_static.php
  29. 2 2
      lib/composer/composer/installed.php
  30. 21 8
      lib/private/AppFramework/Bootstrap/RegistrationContext.php
  31. 4 0
      lib/private/Server.php
  32. 402 0
      lib/private/Settings/DeclarativeManager.php
  33. 11 0
      lib/public/AppFramework/Bootstrap/IRegistrationContext.php
  34. 145 0
      lib/public/Settings/DeclarativeSettingsTypes.php
  35. 81 0
      lib/public/Settings/Events/DeclarativeSettingsGetValueEvent.php
  36. 29 0
      lib/public/Settings/Events/DeclarativeSettingsRegisterFormEvent.php
  37. 63 0
      lib/public/Settings/Events/DeclarativeSettingsSetValueEvent.php
  38. 89 0
      lib/public/Settings/IDeclarativeManager.php
  39. 78 0
      lib/public/Settings/IDeclarativeSettingsForm.php
  40. 536 0
      tests/lib/Settings/DeclarativeManagerTest.php
  41. 1 0
      webpack.modules.js

+ 5 - 1
apps/settings/appinfo/routes.php

@@ -81,5 +81,9 @@ return [
 		['name' => 'WebAuthn#deleteRegistration', 'url' => '/settings/api/personal/webauthn/registration/{id}', 'verb' => 'DELETE' , 'root' => ''],
 
 		['name' => 'Reasons#getPdf', 'url' => '/settings/download/reasons', 'verb' => 'GET', 'root' => ''],
-	]
+	],
+	'ocs' => [
+		['name' => 'DeclarativeSettings#setValue', 'url' => '/settings/api/declarative/value', 'verb' => 'POST', 'root' => ''],
+		['name' => 'DeclarativeSettings#getForms', 'url' => '/settings/api/declarative/forms', 'verb' => 'GET', 'root' => ''],
+	],
 ];

+ 2 - 0
apps/settings/composer/composer/autoload_classmap.php

@@ -27,6 +27,7 @@ return array(
     'OCA\\Settings\\Controller\\ChangePasswordController' => $baseDir . '/../lib/Controller/ChangePasswordController.php',
     'OCA\\Settings\\Controller\\CheckSetupController' => $baseDir . '/../lib/Controller/CheckSetupController.php',
     'OCA\\Settings\\Controller\\CommonSettingsTrait' => $baseDir . '/../lib/Controller/CommonSettingsTrait.php',
+    'OCA\\Settings\\Controller\\DeclarativeSettingsController' => $baseDir . '/../lib/Controller/DeclarativeSettingsController.php',
     'OCA\\Settings\\Controller\\HelpController' => $baseDir . '/../lib/Controller/HelpController.php',
     'OCA\\Settings\\Controller\\LogSettingsController' => $baseDir . '/../lib/Controller/LogSettingsController.php',
     'OCA\\Settings\\Controller\\MailSettingsController' => $baseDir . '/../lib/Controller/MailSettingsController.php',
@@ -43,6 +44,7 @@ return array(
     'OCA\\Settings\\Listener\\UserRemovedFromGroupActivityListener' => $baseDir . '/../lib/Listener/UserRemovedFromGroupActivityListener.php',
     'OCA\\Settings\\Mailer\\NewUserMailHelper' => $baseDir . '/../lib/Mailer/NewUserMailHelper.php',
     'OCA\\Settings\\Middleware\\SubadminMiddleware' => $baseDir . '/../lib/Middleware/SubadminMiddleware.php',
+    'OCA\\Settings\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
     'OCA\\Settings\\Search\\AppSearch' => $baseDir . '/../lib/Search/AppSearch.php',
     'OCA\\Settings\\Search\\SectionSearch' => $baseDir . '/../lib/Search/SectionSearch.php',
     'OCA\\Settings\\Search\\UserSearch' => $baseDir . '/../lib/Search/UserSearch.php',

+ 2 - 0
apps/settings/composer/composer/autoload_static.php

@@ -42,6 +42,7 @@ class ComposerStaticInitSettings
         'OCA\\Settings\\Controller\\ChangePasswordController' => __DIR__ . '/..' . '/../lib/Controller/ChangePasswordController.php',
         'OCA\\Settings\\Controller\\CheckSetupController' => __DIR__ . '/..' . '/../lib/Controller/CheckSetupController.php',
         'OCA\\Settings\\Controller\\CommonSettingsTrait' => __DIR__ . '/..' . '/../lib/Controller/CommonSettingsTrait.php',
+        'OCA\\Settings\\Controller\\DeclarativeSettingsController' => __DIR__ . '/..' . '/../lib/Controller/DeclarativeSettingsController.php',
         'OCA\\Settings\\Controller\\HelpController' => __DIR__ . '/..' . '/../lib/Controller/HelpController.php',
         'OCA\\Settings\\Controller\\LogSettingsController' => __DIR__ . '/..' . '/../lib/Controller/LogSettingsController.php',
         'OCA\\Settings\\Controller\\MailSettingsController' => __DIR__ . '/..' . '/../lib/Controller/MailSettingsController.php',
@@ -58,6 +59,7 @@ class ComposerStaticInitSettings
         'OCA\\Settings\\Listener\\UserRemovedFromGroupActivityListener' => __DIR__ . '/..' . '/../lib/Listener/UserRemovedFromGroupActivityListener.php',
         'OCA\\Settings\\Mailer\\NewUserMailHelper' => __DIR__ . '/..' . '/../lib/Mailer/NewUserMailHelper.php',
         'OCA\\Settings\\Middleware\\SubadminMiddleware' => __DIR__ . '/..' . '/../lib/Middleware/SubadminMiddleware.php',
+        'OCA\\Settings\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
         'OCA\\Settings\\Search\\AppSearch' => __DIR__ . '/..' . '/../lib/Search/AppSearch.php',
         'OCA\\Settings\\Search\\SectionSearch' => __DIR__ . '/..' . '/../lib/Search/SectionSearch.php',
         'OCA\\Settings\\Search\\UserSearch' => __DIR__ . '/..' . '/../lib/Search/UserSearch.php',

+ 2 - 2
apps/settings/composer/composer/installed.php

@@ -3,7 +3,7 @@
         'name' => '__root__',
         'pretty_version' => 'dev-master',
         'version' => 'dev-master',
-        'reference' => 'b1797842784b250fb01ed5e3bf130705eb94751b',
+        'reference' => '4ff660ca2e0baa02440ba07296ed7e75fa544c0e',
         'type' => 'library',
         'install_path' => __DIR__ . '/../',
         'aliases' => array(),
@@ -13,7 +13,7 @@
         '__root__' => array(
             'pretty_version' => 'dev-master',
             'version' => 'dev-master',
-            'reference' => 'b1797842784b250fb01ed5e3bf130705eb94751b',
+            'reference' => '4ff660ca2e0baa02440ba07296ed7e75fa544c0e',
             'type' => 'library',
             'install_path' => __DIR__ . '/../',
             'aliases' => array(),

+ 9 - 2
apps/settings/lib/Controller/AdminSettingsController.php

@@ -30,12 +30,14 @@ use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException;
 use OCP\AppFramework\Controller;
 use OCP\AppFramework\Http\Attribute\OpenAPI;
 use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
 use OCP\Group\ISubAdmin;
 use OCP\IGroupManager;
 use OCP\INavigationManager;
 use OCP\IRequest;
 use OCP\IUser;
 use OCP\IUserSession;
+use OCP\Settings\IDeclarativeManager;
 use OCP\Settings\IManager as ISettingsManager;
 use OCP\Template;
 
@@ -50,7 +52,9 @@ class AdminSettingsController extends Controller {
 		ISettingsManager $settingsManager,
 		IUserSession $userSession,
 		IGroupManager $groupManager,
-		ISubAdmin $subAdmin
+		ISubAdmin $subAdmin,
+		IDeclarativeManager $declarativeSettingsManager,
+		IInitialState $initialState,
 	) {
 		parent::__construct($appName, $request);
 		$this->navigationManager = $navigationManager;
@@ -58,6 +62,8 @@ class AdminSettingsController extends Controller {
 		$this->userSession = $userSession;
 		$this->groupManager = $groupManager;
 		$this->subAdmin = $subAdmin;
+		$this->declarativeSettingsManager = $declarativeSettingsManager;
+		$this->initialState = $initialState;
 	}
 
 	/**
@@ -80,7 +86,8 @@ class AdminSettingsController extends Controller {
 		$user = $this->userSession->getUser();
 		$isSubAdmin = !$this->groupManager->isAdmin($user->getUID()) && $this->subAdmin->isSubAdmin($user);
 		$settings = $this->settingsManager->getAllowedAdminSettings($section, $user);
-		if (empty($settings)) {
+		$declarativeFormIDs = $this->declarativeSettingsManager->getFormIDs($user, 'admin', $section);
+		if (empty($settings) && empty($declarativeFormIDs)) {
 			throw new NotAdminException("Logged in user doesn't have permission to access these settings.");
 		}
 		$formatted = $this->formatSettings($settings);

+ 46 - 17
apps/settings/lib/Controller/CommonSettingsTrait.php

@@ -26,17 +26,26 @@
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  *
  */
+
 namespace OCA\Settings\Controller;
 
+use OCA\Settings\AppInfo\Application;
 use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
 use OCP\Group\ISubAdmin;
 use OCP\IGroupManager;
 use OCP\INavigationManager;
 use OCP\IUserSession;
+use OCP\Settings\IDeclarativeManager;
+use OCP\Settings\IDeclarativeSettingsForm;
 use OCP\Settings\IIconSection;
 use OCP\Settings\IManager as ISettingsManager;
 use OCP\Settings\ISettings;
+use OCP\Util;
 
+/**
+ * @psalm-import-type DeclarativeSettingsFormField from IDeclarativeSettingsForm
+ */
 trait CommonSettingsTrait {
 
 	/** @var ISettingsManager */
@@ -54,28 +63,26 @@ trait CommonSettingsTrait {
 	/** @var ISubAdmin */
 	private $subAdmin;
 
+	private IDeclarativeManager $declarativeSettingsManager;
+
+	/** @var IInitialState */
+	private $initialState;
+
 	/**
 	 * @return array{forms: array{personal: array, admin: array}}
 	 */
 	private function getNavigationParameters(string $currentType, string $currentSection): array {
-		$templateParameters = [
-			'personal' => $this->formatPersonalSections($currentType, $currentSection),
-			'admin' => []
-		];
-
-		$templateParameters['admin'] = $this->formatAdminSections(
-			$currentType,
-			$currentSection
-		);
-
 		return [
-			'forms' => $templateParameters
+			'forms' => [
+				'personal' => $this->formatPersonalSections($currentType, $currentSection),
+				'admin' => $this->formatAdminSections($currentType, $currentSection),
+			],
 		];
 	}
 
 	/**
 	 * @param IIconSection[][] $sections
-	 * @psam-param 'admin'|'personal' $type
+	 * @psalm-param 'admin'|'personal' $type
 	 * @return list<array{anchor: string, section-name: string, active: bool, icon: string}>
 	 */
 	protected function formatSections(array $sections, string $currentSection, string $type, string $currentType): array {
@@ -87,7 +94,11 @@ trait CommonSettingsTrait {
 				} elseif ($type === 'personal') {
 					$settings = $this->settingsManager->getPersonalSettings($section->getID());
 				}
-				if (empty($settings) && !($section->getID() === 'additional' && count(\OC_App::getForms('admin')) > 0)) {
+
+				/** @psalm-suppress PossiblyNullArgument */
+				$declarativeFormIDs = $this->declarativeSettingsManager->getFormIDs($this->userSession->getUser(), $type, $section->getID());
+
+				if (empty($settings) && empty($declarativeFormIDs) && !($section->getID() === 'additional' && count(\OC_App::getForms('admin')) > 0)) {
 					continue;
 				}
 
@@ -107,14 +118,14 @@ trait CommonSettingsTrait {
 		return $templateParameters;
 	}
 
-	protected function formatPersonalSections(string $currentType, string $currentSections): array {
+	protected function formatPersonalSections(string $currentType, string $currentSection): array {
 		$sections = $this->settingsManager->getPersonalSections();
-		return $this->formatSections($sections, $currentSections, 'personal', $currentType);
+		return $this->formatSections($sections, $currentSection, 'personal', $currentType);
 	}
 
-	protected function formatAdminSections(string $currentType, string $currentSections): array {
+	protected function formatAdminSections(string $currentType, string $currentSection): array {
 		$sections = $this->settingsManager->getAdminSections();
-		return $this->formatSections($sections, $currentSections, 'admin', $currentType);
+		return $this->formatSections($sections, $currentSection, 'admin', $currentType);
 	}
 
 	/**
@@ -133,6 +144,9 @@ trait CommonSettingsTrait {
 		return ['content' => $html];
 	}
 
+	/**
+	 * @psalm-param 'admin'|'personal' $type
+	 */
 	private function getIndexResponse(string $type, string $section): TemplateResponse {
 		if ($type === 'personal') {
 			if ($section === 'theming') {
@@ -144,9 +158,24 @@ trait CommonSettingsTrait {
 			$this->navigationManager->setActiveEntry('admin_settings');
 		}
 
+		$this->declarativeSettingsManager->loadSchemas();
+
 		$templateParams = [];
 		$templateParams = array_merge($templateParams, $this->getNavigationParameters($type, $section));
 		$templateParams = array_merge($templateParams, $this->getSettings($section));
+
+		/** @psalm-suppress PossiblyNullArgument */
+		$declarativeFormIDs = $this->declarativeSettingsManager->getFormIDs($this->userSession->getUser(), $type, $section);
+		if (!empty($declarativeFormIDs)) {
+			foreach ($declarativeFormIDs as $app => $ids) {
+				/** @psalm-suppress PossiblyUndefinedArrayOffset */
+				$templateParams['content'] .= join(array_map(fn (string $id) => '<div id="' . $app . '_' . $id . '"></div>', $ids));
+			}
+			Util::addScript(Application::APP_ID, 'declarative-settings-forms');
+			/** @psalm-suppress PossiblyNullArgument */
+			$this->initialState->provideInitialState('declarative-settings-forms', $this->declarativeSettingsManager->getFormsWithValues($this->userSession->getUser(), $type, $section));
+		}
+
 		$activeSection = $this->settingsManager->getSection($type, $section);
 		if ($activeSection) {
 			$templateParams['pageTitle'] = $activeSection->getName();

+ 105 - 0
apps/settings/lib/Controller/DeclarativeSettingsController.php

@@ -0,0 +1,105 @@
+<?php
+/**
+ * @copyright Copyright (c) 2023 Kate Döen <kate.doeen@nextcloud.com>
+ *
+ * @author Kate Döen <kate.doeen@nextcloud.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Settings\Controller;
+
+use Exception;
+use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException;
+use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException;
+use OCA\Settings\ResponseDefinitions;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCS\OCSBadRequestException;
+use OCP\AppFramework\OCSController;
+use OCP\IRequest;
+use OCP\IUserSession;
+use OCP\Settings\IDeclarativeManager;
+use Psr\Log\LoggerInterface;
+
+/**
+ * @psalm-import-type SettingsDeclarativeForm from ResponseDefinitions
+ */
+class DeclarativeSettingsController extends OCSController {
+	public function __construct(
+		string $appName,
+		IRequest $request,
+		private IUserSession $userSession,
+		private IDeclarativeManager $declarativeManager,
+		private LoggerInterface $logger,
+	) {
+		parent::__construct($appName, $request);
+	}
+
+	/**
+	 * Sets a declarative settings value
+	 *
+	 * @param string $app ID of the app
+	 * @param string $formId ID of the form
+	 * @param string $fieldId ID of the field
+	 * @param mixed $value Value to be saved
+	 * @return DataResponse<Http::STATUS_OK, null, array{}>
+	 * @throws NotLoggedInException Not logged in or not an admin user
+	 * @throws NotAdminException Not logged in or not an admin user
+	 * @throws OCSBadRequestException Invalid arguments to save value
+	 *
+	 * 200: Value set successfully
+	 */
+	#[NoAdminRequired]
+	public function setValue(string $app, string $formId, string $fieldId, mixed $value): DataResponse {
+		$user = $this->userSession->getUser();
+		if ($user === null) {
+			throw new NotLoggedInException();
+		}
+
+		try {
+			$this->declarativeManager->loadSchemas();
+			$this->declarativeManager->setValue($user, $app, $formId, $fieldId, $value);
+			return new DataResponse(null);
+		} catch (NotAdminException $e) {
+			throw $e;
+		} catch (Exception $e) {
+			$this->logger->error('Failed to set declarative settings value: ' . $e->getMessage());
+			throw new OCSBadRequestException();
+		}
+	}
+
+	/**
+	 * Gets all declarative forms with the values prefilled.
+	 *
+	 * @return DataResponse<Http::STATUS_OK, list<SettingsDeclarativeForm>, array{}>
+	 * @throws NotLoggedInException
+	 * @NoSubAdminRequired
+	 *
+	 * 200: Forms returned
+	 */
+	#[NoAdminRequired]
+	public function getForms(): DataResponse {
+		$user = $this->userSession->getUser();
+		if ($user === null) {
+			throw new NotLoggedInException();
+		}
+		$this->declarativeManager->loadSchemas();
+		return new DataResponse($this->declarativeManager->getFormsWithValues($user, null, null));
+	}
+}

+ 7 - 1
apps/settings/lib/Controller/PersonalSettingsController.php

@@ -29,11 +29,13 @@ namespace OCA\Settings\Controller;
 use OCP\AppFramework\Controller;
 use OCP\AppFramework\Http\Attribute\OpenAPI;
 use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
 use OCP\Group\ISubAdmin;
 use OCP\IGroupManager;
 use OCP\INavigationManager;
 use OCP\IRequest;
 use OCP\IUserSession;
+use OCP\Settings\IDeclarativeManager;
 use OCP\Settings\IManager as ISettingsManager;
 use OCP\Template;
 
@@ -48,7 +50,9 @@ class PersonalSettingsController extends Controller {
 		ISettingsManager $settingsManager,
 		IUserSession $userSession,
 		IGroupManager $groupManager,
-		ISubAdmin $subAdmin
+		ISubAdmin $subAdmin,
+		IDeclarativeManager $declarativeSettingsManager,
+		IInitialState $initialState,
 	) {
 		parent::__construct($appName, $request);
 		$this->navigationManager = $navigationManager;
@@ -56,6 +60,8 @@ class PersonalSettingsController extends Controller {
 		$this->userSession = $userSession;
 		$this->subAdmin = $subAdmin;
 		$this->groupManager = $groupManager;
+		$this->declarativeSettingsManager = $declarativeSettingsManager;
+		$this->initialState = $initialState;
 	}
 
 	/**

+ 56 - 0
apps/settings/lib/ResponseDefinitions.php

@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2024 Kate Döen <kate.doeen@nextcloud.com>
+ *
+ * @author Kate Döen <kate.doeen@nextcloud.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Settings;
+
+/**
+ * @psalm-type SettingsDeclarativeFormField = array{
+ *   id: string,
+ *   title: string,
+ *   description?: string,
+ *   type: 'text'|'password'|'email'|'tel'|'url'|'number'|'checkbox'|'multi-checkbox'|'radio'|'select'|'multi-select',
+ *   placeholder?: string,
+ *   label?: string,
+ *   default: mixed,
+ *   options?: list<string|array{name: string, value: mixed}>,
+ *   value: string|int|float|bool|list<string>,
+ * }
+ *
+ * @psalm-type SettingsDeclarativeForm = array{
+ *   id: string,
+ *   priority: int,
+ *   section_type: 'admin'|'personal',
+ *   section_id: string,
+ *   storage_type: 'internal'|'external',
+ *   title: string,
+ *   description?: string,
+ *   doc_url?: string,
+ *   app: string,
+ *   fields: list<SettingsDeclarativeFormField>,
+ * }
+ */
+class ResponseDefinitions {
+}

+ 65 - 0
apps/settings/openapi-administration.json

@@ -0,0 +1,65 @@
+{
+    "openapi": "3.0.3",
+    "info": {
+        "title": "settings-administration",
+        "version": "0.0.1",
+        "description": "Nextcloud settings",
+        "license": {
+            "name": "agpl"
+        }
+    },
+    "components": {
+        "securitySchemes": {
+            "basic_auth": {
+                "type": "http",
+                "scheme": "basic"
+            },
+            "bearer_auth": {
+                "type": "http",
+                "scheme": "bearer"
+            }
+        },
+        "schemas": {}
+    },
+    "paths": {
+        "/index.php/settings/admin/log/download": {
+            "get": {
+                "operationId": "log_settings-download",
+                "summary": "download logfile",
+                "description": "This endpoint requires admin access",
+                "tags": [
+                    "log_settings"
+                ],
+                "security": [
+                    {
+                        "bearer_auth": []
+                    },
+                    {
+                        "basic_auth": []
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "Logfile returned",
+                        "headers": {
+                            "Content-Disposition": {
+                                "schema": {
+                                    "type": "string"
+                                }
+                            }
+                        },
+                        "content": {
+                            "application/octet-stream": {
+                                "schema": {
+                                    "type": "string",
+                                    "format": "binary"
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    },
+    "tags": []
+}

+ 433 - 0
apps/settings/openapi-full.json

@@ -0,0 +1,433 @@
+{
+    "openapi": "3.0.3",
+    "info": {
+        "title": "settings-full",
+        "version": "0.0.1",
+        "description": "Nextcloud settings",
+        "license": {
+            "name": "agpl"
+        }
+    },
+    "components": {
+        "securitySchemes": {
+            "basic_auth": {
+                "type": "http",
+                "scheme": "basic"
+            },
+            "bearer_auth": {
+                "type": "http",
+                "scheme": "bearer"
+            }
+        },
+        "schemas": {
+            "DeclarativeForm": {
+                "type": "object",
+                "required": [
+                    "id",
+                    "priority",
+                    "section_type",
+                    "section_id",
+                    "storage_type",
+                    "title",
+                    "app",
+                    "fields"
+                ],
+                "properties": {
+                    "id": {
+                        "type": "string"
+                    },
+                    "priority": {
+                        "type": "integer",
+                        "format": "int64"
+                    },
+                    "section_type": {
+                        "type": "string",
+                        "enum": [
+                            "admin",
+                            "personal"
+                        ]
+                    },
+                    "section_id": {
+                        "type": "string"
+                    },
+                    "storage_type": {
+                        "type": "string",
+                        "enum": [
+                            "internal",
+                            "external"
+                        ]
+                    },
+                    "title": {
+                        "type": "string"
+                    },
+                    "description": {
+                        "type": "string"
+                    },
+                    "doc_url": {
+                        "type": "string"
+                    },
+                    "app": {
+                        "type": "string"
+                    },
+                    "fields": {
+                        "type": "array",
+                        "items": {
+                            "$ref": "#/components/schemas/DeclarativeFormField"
+                        }
+                    }
+                }
+            },
+            "DeclarativeFormField": {
+                "type": "object",
+                "required": [
+                    "id",
+                    "title",
+                    "type",
+                    "default",
+                    "value"
+                ],
+                "properties": {
+                    "id": {
+                        "type": "string"
+                    },
+                    "title": {
+                        "type": "string"
+                    },
+                    "description": {
+                        "type": "string"
+                    },
+                    "type": {
+                        "type": "string",
+                        "enum": [
+                            "text",
+                            "password",
+                            "email",
+                            "tel",
+                            "url",
+                            "number",
+                            "checkbox",
+                            "multi-checkbox",
+                            "radio",
+                            "select",
+                            "multi-select"
+                        ]
+                    },
+                    "placeholder": {
+                        "type": "string"
+                    },
+                    "label": {
+                        "type": "string"
+                    },
+                    "default": {
+                        "type": "object"
+                    },
+                    "options": {
+                        "type": "array",
+                        "items": {
+                            "oneOf": [
+                                {
+                                    "type": "string"
+                                },
+                                {
+                                    "type": "object",
+                                    "required": [
+                                        "name",
+                                        "value"
+                                    ],
+                                    "properties": {
+                                        "name": {
+                                            "type": "string"
+                                        },
+                                        "value": {
+                                            "type": "object"
+                                        }
+                                    }
+                                }
+                            ]
+                        }
+                    },
+                    "value": {
+                        "oneOf": [
+                            {
+                                "type": "string"
+                            },
+                            {
+                                "type": "integer",
+                                "format": "int64"
+                            },
+                            {
+                                "type": "number",
+                                "format": "float"
+                            },
+                            {
+                                "type": "boolean"
+                            },
+                            {
+                                "type": "array",
+                                "items": {
+                                    "type": "string"
+                                }
+                            }
+                        ]
+                    }
+                }
+            },
+            "OCSMeta": {
+                "type": "object",
+                "required": [
+                    "status",
+                    "statuscode"
+                ],
+                "properties": {
+                    "status": {
+                        "type": "string"
+                    },
+                    "statuscode": {
+                        "type": "integer"
+                    },
+                    "message": {
+                        "type": "string"
+                    },
+                    "totalitems": {
+                        "type": "string"
+                    },
+                    "itemsperpage": {
+                        "type": "string"
+                    }
+                }
+            }
+        }
+    },
+    "paths": {
+        "/index.php/settings/admin/log/download": {
+            "get": {
+                "operationId": "log_settings-download",
+                "summary": "download logfile",
+                "description": "This endpoint requires admin access",
+                "tags": [
+                    "log_settings"
+                ],
+                "security": [
+                    {
+                        "bearer_auth": []
+                    },
+                    {
+                        "basic_auth": []
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "Logfile returned",
+                        "headers": {
+                            "Content-Disposition": {
+                                "schema": {
+                                    "type": "string"
+                                }
+                            }
+                        },
+                        "content": {
+                            "application/octet-stream": {
+                                "schema": {
+                                    "type": "string",
+                                    "format": "binary"
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        },
+        "/ocs/v2.php/settings/api/declarative/value": {
+            "post": {
+                "operationId": "declarative_settings-set-value",
+                "summary": "Sets a declarative settings value",
+                "tags": [
+                    "declarative_settings"
+                ],
+                "security": [
+                    {
+                        "bearer_auth": []
+                    },
+                    {
+                        "basic_auth": []
+                    }
+                ],
+                "parameters": [
+                    {
+                        "name": "app",
+                        "in": "query",
+                        "description": "ID of the app",
+                        "required": true,
+                        "schema": {
+                            "type": "string"
+                        }
+                    },
+                    {
+                        "name": "formId",
+                        "in": "query",
+                        "description": "ID of the form",
+                        "required": true,
+                        "schema": {
+                            "type": "string"
+                        }
+                    },
+                    {
+                        "name": "fieldId",
+                        "in": "query",
+                        "description": "ID of the field",
+                        "required": true,
+                        "schema": {
+                            "type": "string"
+                        }
+                    },
+                    {
+                        "name": "value",
+                        "in": "query",
+                        "description": "Value to be saved",
+                        "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": "Value set successfully",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "nullable": true
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    },
+                    "500": {
+                        "description": "Not logged in or not an admin user",
+                        "content": {
+                            "text/plain": {
+                                "schema": {
+                                    "type": "string"
+                                }
+                            }
+                        }
+                    },
+                    "400": {
+                        "description": "Invalid arguments to save value",
+                        "content": {
+                            "text/plain": {
+                                "schema": {
+                                    "type": "string"
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        },
+        "/ocs/v2.php/settings/api/declarative/forms": {
+            "get": {
+                "operationId": "declarative_settings-get-forms",
+                "summary": "Gets all declarative forms with the values prefilled.",
+                "tags": [
+                    "declarative_settings"
+                ],
+                "security": [
+                    {
+                        "bearer_auth": []
+                    },
+                    {
+                        "basic_auth": []
+                    }
+                ],
+                "parameters": [
+                    {
+                        "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": "Forms returned",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "type": "array",
+                                                    "items": {
+                                                        "$ref": "#/components/schemas/DeclarativeForm"
+                                                    }
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    },
+                    "500": {
+                        "description": "",
+                        "content": {
+                            "text/plain": {
+                                "schema": {
+                                    "type": "string"
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    },
+    "tags": []
+}

+ 344 - 14
apps/settings/openapi.json

@@ -19,16 +19,192 @@
                 "scheme": "bearer"
             }
         },
-        "schemas": {}
+        "schemas": {
+            "DeclarativeForm": {
+                "type": "object",
+                "required": [
+                    "id",
+                    "priority",
+                    "section_type",
+                    "section_id",
+                    "storage_type",
+                    "title",
+                    "app",
+                    "fields"
+                ],
+                "properties": {
+                    "id": {
+                        "type": "string"
+                    },
+                    "priority": {
+                        "type": "integer",
+                        "format": "int64"
+                    },
+                    "section_type": {
+                        "type": "string",
+                        "enum": [
+                            "admin",
+                            "personal"
+                        ]
+                    },
+                    "section_id": {
+                        "type": "string"
+                    },
+                    "storage_type": {
+                        "type": "string",
+                        "enum": [
+                            "internal",
+                            "external"
+                        ]
+                    },
+                    "title": {
+                        "type": "string"
+                    },
+                    "description": {
+                        "type": "string"
+                    },
+                    "doc_url": {
+                        "type": "string"
+                    },
+                    "app": {
+                        "type": "string"
+                    },
+                    "fields": {
+                        "type": "array",
+                        "items": {
+                            "$ref": "#/components/schemas/DeclarativeFormField"
+                        }
+                    }
+                }
+            },
+            "DeclarativeFormField": {
+                "type": "object",
+                "required": [
+                    "id",
+                    "title",
+                    "type",
+                    "default",
+                    "value"
+                ],
+                "properties": {
+                    "id": {
+                        "type": "string"
+                    },
+                    "title": {
+                        "type": "string"
+                    },
+                    "description": {
+                        "type": "string"
+                    },
+                    "type": {
+                        "type": "string",
+                        "enum": [
+                            "text",
+                            "password",
+                            "email",
+                            "tel",
+                            "url",
+                            "number",
+                            "checkbox",
+                            "multi-checkbox",
+                            "radio",
+                            "select",
+                            "multi-select"
+                        ]
+                    },
+                    "placeholder": {
+                        "type": "string"
+                    },
+                    "label": {
+                        "type": "string"
+                    },
+                    "default": {
+                        "type": "object"
+                    },
+                    "options": {
+                        "type": "array",
+                        "items": {
+                            "oneOf": [
+                                {
+                                    "type": "string"
+                                },
+                                {
+                                    "type": "object",
+                                    "required": [
+                                        "name",
+                                        "value"
+                                    ],
+                                    "properties": {
+                                        "name": {
+                                            "type": "string"
+                                        },
+                                        "value": {
+                                            "type": "object"
+                                        }
+                                    }
+                                }
+                            ]
+                        }
+                    },
+                    "value": {
+                        "oneOf": [
+                            {
+                                "type": "string"
+                            },
+                            {
+                                "type": "integer",
+                                "format": "int64"
+                            },
+                            {
+                                "type": "number",
+                                "format": "float"
+                            },
+                            {
+                                "type": "boolean"
+                            },
+                            {
+                                "type": "array",
+                                "items": {
+                                    "type": "string"
+                                }
+                            }
+                        ]
+                    }
+                }
+            },
+            "OCSMeta": {
+                "type": "object",
+                "required": [
+                    "status",
+                    "statuscode"
+                ],
+                "properties": {
+                    "status": {
+                        "type": "string"
+                    },
+                    "statuscode": {
+                        "type": "integer"
+                    },
+                    "message": {
+                        "type": "string"
+                    },
+                    "totalitems": {
+                        "type": "string"
+                    },
+                    "itemsperpage": {
+                        "type": "string"
+                    }
+                }
+            }
+        }
     },
     "paths": {
-        "/index.php/settings/admin/log/download": {
-            "get": {
-                "operationId": "log_settings-download",
-                "summary": "download logfile",
-                "description": "This endpoint requires admin access",
+        "/ocs/v2.php/settings/api/declarative/value": {
+            "post": {
+                "operationId": "declarative_settings-set-value",
+                "summary": "Sets a declarative settings value",
                 "tags": [
-                    "log_settings"
+                    "declarative_settings"
                 ],
                 "security": [
                     {
@@ -38,21 +214,175 @@
                         "basic_auth": []
                     }
                 ],
+                "parameters": [
+                    {
+                        "name": "app",
+                        "in": "query",
+                        "description": "ID of the app",
+                        "required": true,
+                        "schema": {
+                            "type": "string"
+                        }
+                    },
+                    {
+                        "name": "formId",
+                        "in": "query",
+                        "description": "ID of the form",
+                        "required": true,
+                        "schema": {
+                            "type": "string"
+                        }
+                    },
+                    {
+                        "name": "fieldId",
+                        "in": "query",
+                        "description": "ID of the field",
+                        "required": true,
+                        "schema": {
+                            "type": "string"
+                        }
+                    },
+                    {
+                        "name": "value",
+                        "in": "query",
+                        "description": "Value to be saved",
+                        "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": "Logfile returned",
-                        "headers": {
-                            "Content-Disposition": {
+                        "description": "Value set successfully",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "nullable": true
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    },
+                    "500": {
+                        "description": "Not logged in or not an admin user",
+                        "content": {
+                            "text/plain": {
+                                "schema": {
+                                    "type": "string"
+                                }
+                            }
+                        }
+                    },
+                    "400": {
+                        "description": "Invalid arguments to save value",
+                        "content": {
+                            "text/plain": {
                                 "schema": {
                                     "type": "string"
                                 }
                             }
-                        },
+                        }
+                    }
+                }
+            }
+        },
+        "/ocs/v2.php/settings/api/declarative/forms": {
+            "get": {
+                "operationId": "declarative_settings-get-forms",
+                "summary": "Gets all declarative forms with the values prefilled.",
+                "tags": [
+                    "declarative_settings"
+                ],
+                "security": [
+                    {
+                        "bearer_auth": []
+                    },
+                    {
+                        "basic_auth": []
+                    }
+                ],
+                "parameters": [
+                    {
+                        "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": "Forms returned",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "type": "object",
+                                    "required": [
+                                        "ocs"
+                                    ],
+                                    "properties": {
+                                        "ocs": {
+                                            "type": "object",
+                                            "required": [
+                                                "meta",
+                                                "data"
+                                            ],
+                                            "properties": {
+                                                "meta": {
+                                                    "$ref": "#/components/schemas/OCSMeta"
+                                                },
+                                                "data": {
+                                                    "type": "array",
+                                                    "items": {
+                                                        "$ref": "#/components/schemas/DeclarativeForm"
+                                                    }
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    },
+                    "500": {
+                        "description": "",
                         "content": {
-                            "application/octet-stream": {
+                            "text/plain": {
                                 "schema": {
-                                    "type": "string",
-                                    "format": "binary"
+                                    "type": "string"
                                 }
                             }
                         }

+ 268 - 0
apps/settings/src/components/DeclarativeSettings/DeclarativeSection.vue

@@ -0,0 +1,268 @@
+<template>
+	<NcSettingsSection
+		class="declarative-settings-section"
+		:name="t(formApp, form.title)"
+		:description="t(formApp, form.description)"
+		:doc-url="form.doc_url || ''">
+		<div v-for="formField in formFields"
+			 :key="formField.id"
+			 class="declarative-form-field"
+			:aria-label="t('settings', '{app}\'s declarative setting field: {name}', { app: formApp, name: t(formApp, formField.title) })"
+			:class="{
+				'declarative-form-field-text': isTextFormField(formField),
+				'declarative-form-field-select': formField.type === 'select',
+				'declarative-form-field-multi-select': formField.type === 'multi-select',
+				'declarative-form-field-checkbox': formField.type === 'checkbox',
+				'declarative-form-field-multi_checkbox': formField.type === 'multi-checkbox',
+				'declarative-form-field-radio': formField.type === 'radio'
+			}">
+
+			<template v-if="isTextFormField(formField)">
+				<div class="input-wrapper">
+					<NcInputField
+						:type="formField.type"
+						:label="t(formApp, formField.title)"
+						:value.sync="formFieldsData[formField.id].value"
+						:placeholder="t(formApp, formField.placeholder)"
+						@update:value="onChangeDebounced(formField)"
+						@submit="updateDeclarativeSettingsValue(formField)"/>
+				</div>
+				<span class="hint">{{ t(formApp, formField.description) }}</span>
+			</template>
+
+			<template v-if="formField.type === 'select'">
+				<label :for="formField.id + '_field'">{{ t(formApp, formField.title) }}</label>
+				<div class="input-wrapper">
+					<NcSelect
+						:id="formField.id + '_field'"
+						:options="formField.options"
+						:placeholder="t(formApp, formField.placeholder)"
+						:label-outside="true"
+						:value="formFieldsData[formField.id].value"
+						@input="(value) => updateFormFieldDataValue(value, formField, true)"/>
+				</div>
+				<span class="hint">{{ t(formApp, formField.description) }}</span>
+			</template>
+
+			<template v-if="formField.type === 'multi-select'">
+				<label :for="formField.id + '_field'">{{ t(formApp, formField.title) }}</label>
+				<div class="input-wrapper">
+					<NcSelect
+						:id="formField.id + '_field'"
+						:options="formField.options"
+						:placeholder="t(formApp, formField.placeholder)"
+						:multiple="true"
+						:label-outside="true"
+						:value="formFieldsData[formField.id].value"
+						@input="(value) => {
+							formFieldsData[formField.id].value = value
+							updateDeclarativeSettingsValue(formField, JSON.stringify(formFieldsData[formField.id].value))
+						}
+				"/>
+				</div>
+				<span class="hint">{{ t(formApp, formField.description) }}</span>
+			</template>
+
+			<template v-if="formField.type === 'checkbox'">
+				<label :for="formField.id + '_field'">{{ t(formApp, formField.title) }}</label>
+				<NcCheckboxRadioSwitch
+					:id="formField.id + '_field'"
+					:checked="Boolean(formFieldsData[formField.id].value)"
+					@update:checked="(value) => {
+						formField.value = value
+						updateFormFieldDataValue(+value, formField, true)
+					}
+				">
+					{{ t(formApp, formField.label) }}
+				</NcCheckboxRadioSwitch>
+				<span class="hint">{{ t(formApp, formField.description) }}</span>
+			</template>
+
+			<template v-if="formField.type === 'multi-checkbox'">
+				<label :for="formField.id + '_field'">{{ t(formApp, formField.title) }}</label>
+				<NcCheckboxRadioSwitch
+					v-for="option in formField.options"
+					:id="formField.id + '_field_' + option.value"
+					:key="option.value"
+					:checked="formFieldsData[formField.id].value[option.value]"
+					@update:checked="(value) => {
+						formFieldsData[formField.id].value[option.value] = value
+						// Update without re-generating initial formFieldsData.value object as the link to components are lost
+						updateDeclarativeSettingsValue(formField, JSON.stringify(formFieldsData[formField.id].value))
+					}
+				">
+					{{ t(formApp, option.name) }}
+				</NcCheckboxRadioSwitch>
+				<span class="hint">{{ t(formApp, formField.description) }}</span>
+			</template>
+
+			<template v-if="formField.type === 'radio'">
+				<label :for="formField.id + '_field'">{{ t(formApp, formField.title) }}</label>
+				<NcCheckboxRadioSwitch
+					v-for="option in formField.options"
+					:key="option.value"
+					:value="option.value"
+					type="radio"
+					:checked="formFieldsData[formField.id].value"
+					@update:checked="(value) => updateFormFieldDataValue(value, formField, true)">
+					{{ t(formApp, option.name) }}
+				</NcCheckboxRadioSwitch>
+				<span class="hint">{{ t(formApp, formField.description) }}</span>
+			</template>
+		</div>
+	</NcSettingsSection>
+</template>
+
+<script>
+import axios from '@nextcloud/axios'
+import { generateOcsUrl } from '@nextcloud/router'
+import { showError } from '@nextcloud/dialogs'
+import debounce from 'debounce'
+import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
+import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
+import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
+
+export default {
+	name: 'DeclarativeSection',
+	components: {
+		NcSettingsSection,
+		NcInputField,
+		NcSelect,
+		NcCheckboxRadioSwitch,
+	},
+	props: {
+		form: {
+			type: Object,
+			required: true,
+		},
+	},
+	data() {
+		return {
+			formFieldsData: {},
+		}
+	},
+	beforeMount() {
+		this.initFormFieldsData()
+	},
+	computed: {
+		formApp() {
+			return this.form.app || ''
+		},
+		formFields() {
+			return this.form.fields || []
+		},
+	},
+	methods: {
+		initFormFieldsData() {
+			this.form.fields.forEach((formField) => {
+				if (formField.type === 'checkbox') {
+					// convert bool to number using unary plus (+) operator
+					this.$set(formField, 'value', +formField.value)
+				}
+				if (formField.type === 'multi-checkbox') {
+					if (formField.value === '') {
+						// Init formFieldsData from options
+						this.$set(formField, 'value', {})
+						formField.options.forEach(option => {
+							this.$set(formField.value, option.value, false)
+						})
+					} else {
+						this.$set(formField, 'value', JSON.parse(formField.value))
+						// Merge possible new options
+						formField.options.forEach(option => {
+							if (!formField.value.hasOwnProperty(option.value)) {
+								this.$set(formField.value, option.value, false)
+							}
+						})
+						// Remove options that are not in the form anymore
+						Object.keys(formField.value).forEach(key => {
+							if (!formField.options.find(option => option.value === key)) {
+								delete formField.value[key]
+							}
+						})
+					}
+				}
+				if (formField.type === 'multi-select') {
+					if (formField.value === '') {
+						// Init empty array for multi-select
+						this.$set(formField, 'value', [])
+					} else {
+						// JSON decode an array of multiple values set
+						this.$set(formField, 'value', JSON.parse(formField.value))
+					}
+				}
+				this.$set(this.formFieldsData, formField.id, {
+					value: formField.value,
+				})
+			})
+		},
+
+		updateFormFieldDataValue(value, formField, update = false) {
+			this.formFieldsData[formField.id].value = value
+			if (update) {
+				this.updateDeclarativeSettingsValue(formField)
+			}
+		},
+
+		updateDeclarativeSettingsValue(formField, value = null) {
+			try {
+				return axios.post(generateOcsUrl('settings/api/declarative/value'), {
+					app: this.formApp,
+					formId: this.form.id.replace(this.formApp + '_', ''), // Remove app prefix to send clean form id
+					fieldId: formField.id,
+					value: value === null ? this.formFieldsData[formField.id].value : value,
+				});
+			} catch (err) {
+				console.debug(err)
+				showError(t('settings', 'Failed to save setting'))
+			}
+		},
+
+		onChangeDebounced: debounce(function(formField) {
+			this.updateDeclarativeSettingsValue(formField)
+		}, 1000),
+
+		isTextFormField(formField) {
+			return ['text', 'password', 'email', 'tel', 'url', 'number'].includes(formField.type)
+		},
+	},
+}
+</script>
+
+<style lang="scss" scoped>
+.declarative-form-field {
+	margin: 20px 0;
+	padding: 10px 0;
+
+	.input-wrapper {
+		width: 100%;
+		max-width: 400px;
+	}
+
+	&:last-child {
+		border-bottom: none;
+	}
+
+	.hint {
+		display: inline-block;
+		color: var(--color-text-maxcontrast);
+		margin-left: 8px;
+		padding-top: 5px;
+	}
+
+	&-radio, &-multi_checkbox {
+		max-height: 250px;
+		overflow-y: auto;
+	}
+
+	&-multi-select, &-select {
+		display: flex;
+		flex-direction: column;
+
+		label {
+			margin-bottom: 5px;
+		}
+	}
+}
+</style>

+ 50 - 0
apps/settings/src/main-declarative-settings-forms.ts

@@ -0,0 +1,50 @@
+import Vue from 'vue';
+import { loadState } from '@nextcloud/initial-state';
+import { translate as t, translatePlural as n } from '@nextcloud/l10n';
+import DeclarativeSection from './components/DeclarativeSettings/DeclarativeSection.vue';
+
+interface DeclarativeFormField {
+	id: string,
+	title: string,
+	description: string,
+	type: string,
+	placeholder: string,
+	label: string,
+	options: Array<any>|null,
+	value: any,
+	default: any,
+}
+
+interface DeclarativeForm {
+	id: number,
+	priority: number,
+	section_type: string,
+	section_id: string,
+	storage_type: string,
+	title: string,
+	description: string,
+	doc_url: string,
+	app: string,
+	fields: Array<DeclarativeFormField>,
+}
+
+const forms = loadState('settings', 'declarative-settings-forms', []) as Array<DeclarativeForm>;
+console.debug('Loaded declarative forms:', forms);
+
+function renderDeclarativeSettingsSections(forms: Array<DeclarativeForm>): void {
+	Vue.mixin({ methods: { t, n } })
+	const DeclarativeSettingsSection = Vue.extend(<any>DeclarativeSection);
+	for (const form of forms) {
+		const el = `#${form.app}_${form.id}`
+		new DeclarativeSettingsSection({
+			el: el,
+			propsData: {
+				form,
+			},
+		})
+	}
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+	renderDeclarativeSettingsSections(forms);
+});

+ 16 - 1
apps/settings/tests/Controller/AdminSettingsControllerTest.php

@@ -29,12 +29,14 @@ namespace OCA\Settings\Tests\Controller;
 use OCA\Settings\Controller\AdminSettingsController;
 use OCA\Settings\Settings\Personal\ServerDevNotice;
 use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
 use OCP\Group\ISubAdmin;
 use OCP\IGroupManager;
 use OCP\INavigationManager;
 use OCP\IRequest;
 use OCP\IUser;
 use OCP\IUserSession;
+use OCP\Settings\IDeclarativeManager;
 use OCP\Settings\IManager;
 use PHPUnit\Framework\MockObject\MockObject;
 use Test\TestCase;
@@ -62,6 +64,10 @@ class AdminSettingsControllerTest extends TestCase {
 	private $groupManager;
 	/** @var ISubAdmin|MockObject */
 	private $subAdmin;
+	/** @var IDeclarativeManager|MockObject */
+	private $declarativeSettingsManager;
+	/** @var IInitialState|MockObject */
+	private $initialState;
 	/** @var string */
 	private $adminUid = 'lololo';
 
@@ -74,6 +80,8 @@ class AdminSettingsControllerTest extends TestCase {
 		$this->userSession = $this->createMock(IUserSession::class);
 		$this->groupManager = $this->createMock(IGroupManager::class);
 		$this->subAdmin = $this->createMock(ISubAdmin::class);
+		$this->declarativeSettingsManager = $this->createMock(IDeclarativeManager::class);
+		$this->initialState = $this->createMock(IInitialState::class);
 
 		$this->adminSettingsController = new AdminSettingsController(
 			'settings',
@@ -82,7 +90,9 @@ class AdminSettingsControllerTest extends TestCase {
 			$this->settingsManager,
 			$this->userSession,
 			$this->groupManager,
-			$this->subAdmin
+			$this->subAdmin,
+			$this->declarativeSettingsManager,
+			$this->initialState,
 		);
 
 		$user = \OC::$server->getUserManager()->createUser($this->adminUid, 'mylongrandompassword');
@@ -123,6 +133,11 @@ class AdminSettingsControllerTest extends TestCase {
 			->method('getAllowedAdminSettings')
 			->with('test')
 			->willReturn([5 => $this->createMock(ServerDevNotice::class)]);
+		$this->declarativeSettingsManager
+			->expects($this->any())
+			->method('getFormIDs')
+			->with($user, 'admin', 'test')
+			->willReturn([]);
 
 		$idx = $this->adminSettingsController->index('test');
 

+ 4 - 0
apps/testing/composer/composer/autoload_classmap.php

@@ -12,9 +12,13 @@ return array(
     'OCA\\Testing\\Controller\\ConfigController' => $baseDir . '/../lib/Controller/ConfigController.php',
     'OCA\\Testing\\Controller\\LockingController' => $baseDir . '/../lib/Controller/LockingController.php',
     'OCA\\Testing\\Controller\\RateLimitTestController' => $baseDir . '/../lib/Controller/RateLimitTestController.php',
+    'OCA\\Testing\\Listener\\GetDeclarativeSettingsValueListener' => $baseDir . '/../lib/Listener/GetDeclarativeSettingsValueListener.php',
+    'OCA\\Testing\\Listener\\RegisterDeclarativeSettingsListener' => $baseDir . '/../lib/Listener/RegisterDeclarativeSettingsListener.php',
+    'OCA\\Testing\\Listener\\SetDeclarativeSettingsValueListener' => $baseDir . '/../lib/Listener/SetDeclarativeSettingsValueListener.php',
     'OCA\\Testing\\Locking\\FakeDBLockingProvider' => $baseDir . '/../lib/Locking/FakeDBLockingProvider.php',
     'OCA\\Testing\\Provider\\FakeText2ImageProvider' => $baseDir . '/../lib/Provider/FakeText2ImageProvider.php',
     'OCA\\Testing\\Provider\\FakeTextProcessingProvider' => $baseDir . '/../lib/Provider/FakeTextProcessingProvider.php',
     'OCA\\Testing\\Provider\\FakeTextProcessingProviderSync' => $baseDir . '/../lib/Provider/FakeTextProcessingProviderSync.php',
     'OCA\\Testing\\Provider\\FakeTranslationProvider' => $baseDir . '/../lib/Provider/FakeTranslationProvider.php',
+    'OCA\\Testing\\Settings\\DeclarativeSettingsForm' => $baseDir . '/../lib/Settings/DeclarativeSettingsForm.php',
 );

+ 4 - 0
apps/testing/composer/composer/autoload_static.php

@@ -27,11 +27,15 @@ class ComposerStaticInitTesting
         'OCA\\Testing\\Controller\\ConfigController' => __DIR__ . '/..' . '/../lib/Controller/ConfigController.php',
         'OCA\\Testing\\Controller\\LockingController' => __DIR__ . '/..' . '/../lib/Controller/LockingController.php',
         'OCA\\Testing\\Controller\\RateLimitTestController' => __DIR__ . '/..' . '/../lib/Controller/RateLimitTestController.php',
+        'OCA\\Testing\\Listener\\GetDeclarativeSettingsValueListener' => __DIR__ . '/..' . '/../lib/Listener/GetDeclarativeSettingsValueListener.php',
+        'OCA\\Testing\\Listener\\RegisterDeclarativeSettingsListener' => __DIR__ . '/..' . '/../lib/Listener/RegisterDeclarativeSettingsListener.php',
+        'OCA\\Testing\\Listener\\SetDeclarativeSettingsValueListener' => __DIR__ . '/..' . '/../lib/Listener/SetDeclarativeSettingsValueListener.php',
         'OCA\\Testing\\Locking\\FakeDBLockingProvider' => __DIR__ . '/..' . '/../lib/Locking/FakeDBLockingProvider.php',
         'OCA\\Testing\\Provider\\FakeText2ImageProvider' => __DIR__ . '/..' . '/../lib/Provider/FakeText2ImageProvider.php',
         'OCA\\Testing\\Provider\\FakeTextProcessingProvider' => __DIR__ . '/..' . '/../lib/Provider/FakeTextProcessingProvider.php',
         'OCA\\Testing\\Provider\\FakeTextProcessingProviderSync' => __DIR__ . '/..' . '/../lib/Provider/FakeTextProcessingProviderSync.php',
         'OCA\\Testing\\Provider\\FakeTranslationProvider' => __DIR__ . '/..' . '/../lib/Provider/FakeTranslationProvider.php',
+        'OCA\\Testing\\Settings\\DeclarativeSettingsForm' => __DIR__ . '/..' . '/../lib/Settings/DeclarativeSettingsForm.php',
     );
 
     public static function getInitializer(ClassLoader $loader)

+ 2 - 2
apps/testing/composer/composer/installed.php

@@ -3,7 +3,7 @@
         'name' => '__root__',
         'pretty_version' => 'dev-master',
         'version' => 'dev-master',
-        'reference' => 'b1797842784b250fb01ed5e3bf130705eb94751b',
+        'reference' => '4ff660ca2e0baa02440ba07296ed7e75fa544c0e',
         'type' => 'library',
         'install_path' => __DIR__ . '/../',
         'aliases' => array(),
@@ -13,7 +13,7 @@
         '__root__' => array(
             'pretty_version' => 'dev-master',
             'version' => 'dev-master',
-            'reference' => 'b1797842784b250fb01ed5e3bf130705eb94751b',
+            'reference' => '4ff660ca2e0baa02440ba07296ed7e75fa544c0e',
             'type' => 'library',
             'install_path' => __DIR__ . '/../',
             'aliases' => array(),

+ 12 - 0
apps/testing/lib/AppInfo/Application.php

@@ -25,14 +25,21 @@
 namespace OCA\Testing\AppInfo;
 
 use OCA\Testing\AlternativeHomeUserBackend;
+use OCA\Testing\Listener\GetDeclarativeSettingsValueListener;
+use OCA\Testing\Listener\RegisterDeclarativeSettingsListener;
+use OCA\Testing\Listener\SetDeclarativeSettingsValueListener;
 use OCA\Testing\Provider\FakeText2ImageProvider;
 use OCA\Testing\Provider\FakeTextProcessingProvider;
 use OCA\Testing\Provider\FakeTextProcessingProviderSync;
 use OCA\Testing\Provider\FakeTranslationProvider;
+use OCA\Testing\Settings\DeclarativeSettingsForm;
 use OCP\AppFramework\App;
 use OCP\AppFramework\Bootstrap\IBootContext;
 use OCP\AppFramework\Bootstrap\IBootstrap;
 use OCP\AppFramework\Bootstrap\IRegistrationContext;
+use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
+use OCP\Settings\Events\DeclarativeSettingsRegisterFormEvent;
+use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
 
 class Application extends App implements IBootstrap {
 	public function __construct(array $urlParams = []) {
@@ -44,6 +51,11 @@ class Application extends App implements IBootstrap {
 		$context->registerTextProcessingProvider(FakeTextProcessingProvider::class);
 		$context->registerTextProcessingProvider(FakeTextProcessingProviderSync::class);
 		$context->registerTextToImageProvider(FakeText2ImageProvider::class);
+
+		$context->registerDeclarativeSettings(DeclarativeSettingsForm::class);
+		$context->registerEventListener(DeclarativeSettingsRegisterFormEvent::class, RegisterDeclarativeSettingsListener::class);
+		$context->registerEventListener(DeclarativeSettingsGetValueEvent::class, GetDeclarativeSettingsValueListener::class);
+		$context->registerEventListener(DeclarativeSettingsSetValueEvent::class, SetDeclarativeSettingsValueListener::class);
 	}
 
 	public function boot(IBootContext $context): void {

+ 32 - 0
apps/testing/lib/Listener/GetDeclarativeSettingsValueListener.php

@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OCA\Testing\Listener;
+
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\IConfig;
+use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
+
+/**
+ * @template-implements IEventListener<DeclarativeSettingsGetValueEvent>
+ */
+class GetDeclarativeSettingsValueListener implements IEventListener {
+
+	public function __construct(private IConfig $config) {
+	}
+
+	public function handle(Event $event): void {
+		if (!$event instanceof DeclarativeSettingsGetValueEvent) {
+			return;
+		}
+
+		if ($event->getApp() !== 'testing') {
+			return;
+		}
+
+		$value = $this->config->getUserValue($event->getUser()->getUID(), $event->getApp(), $event->getFieldId());
+		$event->setValue($value);
+	}
+}

+ 68 - 0
apps/testing/lib/Listener/RegisterDeclarativeSettingsListener.php

@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OCA\Testing\Listener;
+
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\Settings\DeclarativeSettingsTypes;
+use OCP\Settings\Events\DeclarativeSettingsRegisterFormEvent;
+
+/**
+ * @template-implements IEventListener<DeclarativeSettingsRegisterFormEvent>
+ */
+class RegisterDeclarativeSettingsListener implements IEventListener {
+
+	public function __construct() {
+	}
+
+	public function handle(Event $event): void {
+		if (!($event instanceof DeclarativeSettingsRegisterFormEvent)) {
+			// Unrelated
+			return;
+		}
+
+		$event->registerSchema('testing', [
+			'id' => 'test_declarative_form_event',
+			'priority' => 20,
+			'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN,
+			'section_id' => 'additional',
+			'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL,
+			'title' => 'Test declarative settings event', // NcSettingsSection name
+			'description' => 'This form is registered via the RegisterDeclarativeSettingsFormEvent', // NcSettingsSection description
+			'fields' => [
+				[
+					'id' => 'event_field_1',
+					'title' => 'Why is 42 this answer to all questions?',
+					'description' => 'Hint: It\'s not',
+					'type' => DeclarativeSettingsTypes::TEXT,
+					'placeholder' => 'Enter your answer',
+					'default' => 'Because it is',
+				],
+				[
+					'id' => 'feature_rating',
+					'title' => 'How would you rate this feature?',
+					'description' => 'Your vote is not anonymous',
+					'type' => DeclarativeSettingsTypes::RADIO, // radio, radio-button (NcCheckboxRadioSwitch button-variant)
+					'label' => 'Select single toggle',
+					'default' => '3',
+					'options' => [
+						[
+							'name' => 'Awesome', // NcCheckboxRadioSwitch display name
+							'value' => '1' // NcCheckboxRadioSwitch value
+						],
+						[
+							'name' => 'Very awesome',
+							'value' => '2'
+						],
+						[
+							'name' => 'Super awesome',
+							'value' => '3'
+						],
+					],
+				],
+			],
+		]);
+	}
+}

+ 32 - 0
apps/testing/lib/Listener/SetDeclarativeSettingsValueListener.php

@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OCA\Testing\Listener;
+
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\IConfig;
+use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
+
+/**
+ * @template-implements IEventListener<DeclarativeSettingsSetValueEvent>
+ */
+class SetDeclarativeSettingsValueListener implements IEventListener {
+
+	public function __construct(private IConfig $config) {
+	}
+
+	public function handle(Event $event): void {
+		if (!$event instanceof DeclarativeSettingsSetValueEvent) {
+			return;
+		}
+
+		if ($event->getApp() !== 'testing') {
+			return;
+		}
+
+		error_log('Testing app wants to store ' . $event->getValue() . ' for field ' . $event->getFieldId() . ' for user ' . $event->getUser()->getUID());
+		$this->config->setUserValue($event->getUser()->getUID(), $event->getApp(), $event->getFieldId(), $event->getValue());
+	}
+}

+ 172 - 0
apps/testing/lib/Settings/DeclarativeSettingsForm.php

@@ -0,0 +1,172 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OCA\Testing\Settings;
+
+use OCP\Settings\DeclarativeSettingsTypes;
+use OCP\Settings\IDeclarativeSettingsForm;
+
+class DeclarativeSettingsForm implements IDeclarativeSettingsForm {
+	public function getSchema(): array {
+		return [
+			'id' => 'test_declarative_form',
+			'priority' => 10,
+			'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN, // admin, personal
+			'section_id' => 'additional',
+			'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL, // external, internal (handled by core to store in appconfig and preferences)
+			'title' => 'Test declarative settings class', // NcSettingsSection name
+			'description' => 'This form is registered with a DeclarativeSettingsForm class', // NcSettingsSection description
+			'doc_url' => '', // NcSettingsSection doc_url for documentation or help page, empty string if not needed
+			'fields' => [
+				[
+					'id' => 'test_ex_app_field_7', // configkey
+					'title' => 'Multi-selection', // name or label
+					'description' => 'Select some option setting', // hint
+					'type' => DeclarativeSettingsTypes::MULTI_SELECT, // select, radio, multi-select
+					'options' => ['foo', 'bar', 'baz'], // simple options for select, radio, multi-select
+					'placeholder' => 'Select some multiple options', // input placeholder
+					'default' => ['foo', 'bar'],
+				],
+				[
+					'id' => 'some_real_setting',
+					'title' => 'Choose init status check background job interval',
+					'description' => 'How often AppAPI should check for initialization status',
+					'type' => DeclarativeSettingsTypes::RADIO, // radio (NcCheckboxRadioSwitch type radio)
+					'placeholder' => 'Choose init status check background job interval',
+					'default' => '40m',
+					'options' => [
+						[
+							'name' => 'Each 40 minutes', // NcCheckboxRadioSwitch display name
+							'value' => '40m' // NcCheckboxRadioSwitch value
+						],
+						[
+							'name' => 'Each 60 minutes',
+							'value' => '60m'
+						],
+						[
+							'name' => 'Each 120 minutes',
+							'value' => '120m'
+						],
+						[
+							'name' => 'Each day',
+							'value' => 60 * 24 . 'm'
+						],
+					],
+				],
+				[
+					'id' => 'test_ex_app_field_1', // configkey
+					'title' => 'Default text field', // label
+					'description' => 'Set some simple text setting', // hint
+					'type' => DeclarativeSettingsTypes::TEXT, // text, password, email, tel, url, number
+					'placeholder' => 'Enter text setting', // placeholder
+					'default' => 'foo',
+				],
+				[
+					'id' => 'test_ex_app_field_1_1',
+					'title' => 'Email field',
+					'description' => 'Set email config',
+					'type' => DeclarativeSettingsTypes::EMAIL,
+					'placeholder' => 'Enter email',
+					'default' => '',
+				],
+				[
+					'id' => 'test_ex_app_field_1_2',
+					'title' => 'Tel field',
+					'description' => 'Set tel config',
+					'type' => DeclarativeSettingsTypes::TEL,
+					'placeholder' => 'Enter your tel',
+					'default' => '',
+				],
+				[
+					'id' => 'test_ex_app_field_1_3',
+					'title' => 'Url (website) field',
+					'description' => 'Set url config',
+					'type' => 'url',
+					'placeholder' => 'Enter url',
+					'default' => '',
+				],
+				[
+					'id' => 'test_ex_app_field_1_4',
+					'title' => 'Number field',
+					'description' => 'Set number config',
+					'type' => DeclarativeSettingsTypes::NUMBER,
+					'placeholder' => 'Enter number value',
+					'default' => 0,
+				],
+				[
+					'id' => 'test_ex_app_field_2',
+					'title' => 'Password',
+					'description' => 'Set some secure value setting',
+					'type' => 'password',
+					'placeholder' => 'Set secure value',
+					'default' => '',
+				],
+				[
+					'id' => 'test_ex_app_field_3',
+					'title' => 'Selection',
+					'description' => 'Select some option setting',
+					'type' => DeclarativeSettingsTypes::SELECT, // select, radio, multi-select
+					'options' => ['foo', 'bar', 'baz'],
+					'placeholder' => 'Select some option setting',
+					'default' => 'foo',
+				],
+				[
+					'id' => 'test_ex_app_field_4',
+					'title' => 'Toggle something',
+					'description' => 'Select checkbox option setting',
+					'type' => DeclarativeSettingsTypes::CHECKBOX, // checkbox, multiple-checkbox
+					'label' => 'Verify something if enabled',
+					'default' => false,
+				],
+				[
+					'id' => 'test_ex_app_field_5',
+					'title' => 'Multiple checkbox toggles, describing one setting, checked options are saved as an JSON object {foo: true, bar: false}',
+					'description' => 'Select checkbox option setting',
+					'type' => DeclarativeSettingsTypes::MULTI_CHECKBOX, // checkbox, multi-checkbox
+					'default' => ['foo' => true, 'bar' => true, 'baz' => true],
+					'options' => [
+						[
+							'name' => 'Foo',
+							'value' => 'foo', // multiple-checkbox configkey
+						],
+						[
+							'name' => 'Bar',
+							'value' => 'bar',
+						],
+						[
+							'name' => 'Baz',
+							'value' => 'baz',
+						],
+						[
+							'name' => 'Qux',
+							'value' => 'qux',
+						],
+					],
+				],
+				[
+					'id' => 'test_ex_app_field_6',
+					'title' => 'Radio toggles, describing one setting like single select',
+					'description' => 'Select radio option setting',
+					'type' => DeclarativeSettingsTypes::RADIO, // radio (NcCheckboxRadioSwitch type radio)
+					'label' => 'Select single toggle',
+					'default' => 'foo',
+					'options' => [
+						[
+							'name' => 'First radio', // NcCheckboxRadioSwitch display name
+							'value' => 'foo' // NcCheckboxRadioSwitch value
+						],
+						[
+							'name' => 'Second radio',
+							'value' => 'bar'
+						],
+						[
+							'name' => 'Third radio',
+							'value' => 'baz'
+						],
+					],
+				],
+			],
+		];
+	}
+}

File diff suppressed because it is too large
+ 0 - 0
dist/settings-declarative-settings-forms.js


File diff suppressed because it is too large
+ 0 - 0
dist/settings-declarative-settings-forms.js.map


+ 0 - 2
lib/composer/composer/LICENSE

@@ -1,4 +1,3 @@
-
 Copyright (c) Nils Adermann, Jordi Boggiano
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -18,4 +17,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 THE SOFTWARE.
-

+ 7 - 0
lib/composer/composer/autoload_classmap.php

@@ -631,6 +631,12 @@ return array(
     'OCP\\Security\\VerificationToken\\InvalidTokenException' => $baseDir . '/lib/public/Security/VerificationToken/InvalidTokenException.php',
     'OCP\\Server' => $baseDir . '/lib/public/Server.php',
     'OCP\\Session\\Exceptions\\SessionNotAvailableException' => $baseDir . '/lib/public/Session/Exceptions/SessionNotAvailableException.php',
+    'OCP\\Settings\\DeclarativeSettingsTypes' => $baseDir . '/lib/public/Settings/DeclarativeSettingsTypes.php',
+    'OCP\\Settings\\Events\\DeclarativeSettingsGetValueEvent' => $baseDir . '/lib/public/Settings/Events/DeclarativeSettingsGetValueEvent.php',
+    'OCP\\Settings\\Events\\DeclarativeSettingsRegisterFormEvent' => $baseDir . '/lib/public/Settings/Events/DeclarativeSettingsRegisterFormEvent.php',
+    'OCP\\Settings\\Events\\DeclarativeSettingsSetValueEvent' => $baseDir . '/lib/public/Settings/Events/DeclarativeSettingsSetValueEvent.php',
+    'OCP\\Settings\\IDeclarativeManager' => $baseDir . '/lib/public/Settings/IDeclarativeManager.php',
+    'OCP\\Settings\\IDeclarativeSettingsForm' => $baseDir . '/lib/public/Settings/IDeclarativeSettingsForm.php',
     'OCP\\Settings\\IDelegatedSettings' => $baseDir . '/lib/public/Settings/IDelegatedSettings.php',
     'OCP\\Settings\\IIconSection' => $baseDir . '/lib/public/Settings/IIconSection.php',
     'OCP\\Settings\\IManager' => $baseDir . '/lib/public/Settings/IManager.php',
@@ -1755,6 +1761,7 @@ return array(
     'OC\\Session\\Session' => $baseDir . '/lib/private/Session/Session.php',
     'OC\\Settings\\AuthorizedGroup' => $baseDir . '/lib/private/Settings/AuthorizedGroup.php',
     'OC\\Settings\\AuthorizedGroupMapper' => $baseDir . '/lib/private/Settings/AuthorizedGroupMapper.php',
+    'OC\\Settings\\DeclarativeManager' => $baseDir . '/lib/private/Settings/DeclarativeManager.php',
     'OC\\Settings\\Manager' => $baseDir . '/lib/private/Settings/Manager.php',
     'OC\\Settings\\Section' => $baseDir . '/lib/private/Settings/Section.php',
     'OC\\Setup' => $baseDir . '/lib/private/Setup.php',

+ 7 - 0
lib/composer/composer/autoload_static.php

@@ -672,6 +672,12 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
         'OCP\\Security\\VerificationToken\\InvalidTokenException' => __DIR__ . '/../../..' . '/lib/public/Security/VerificationToken/InvalidTokenException.php',
         'OCP\\Server' => __DIR__ . '/../../..' . '/lib/public/Server.php',
         'OCP\\Session\\Exceptions\\SessionNotAvailableException' => __DIR__ . '/../../..' . '/lib/public/Session/Exceptions/SessionNotAvailableException.php',
+        'OCP\\Settings\\DeclarativeSettingsTypes' => __DIR__ . '/../../..' . '/lib/public/Settings/DeclarativeSettingsTypes.php',
+        'OCP\\Settings\\Events\\DeclarativeSettingsGetValueEvent' => __DIR__ . '/../../..' . '/lib/public/Settings/Events/DeclarativeSettingsGetValueEvent.php',
+        'OCP\\Settings\\Events\\DeclarativeSettingsRegisterFormEvent' => __DIR__ . '/../../..' . '/lib/public/Settings/Events/DeclarativeSettingsRegisterFormEvent.php',
+        'OCP\\Settings\\Events\\DeclarativeSettingsSetValueEvent' => __DIR__ . '/../../..' . '/lib/public/Settings/Events/DeclarativeSettingsSetValueEvent.php',
+        'OCP\\Settings\\IDeclarativeManager' => __DIR__ . '/../../..' . '/lib/public/Settings/IDeclarativeManager.php',
+        'OCP\\Settings\\IDeclarativeSettingsForm' => __DIR__ . '/../../..' . '/lib/public/Settings/IDeclarativeSettingsForm.php',
         'OCP\\Settings\\IDelegatedSettings' => __DIR__ . '/../../..' . '/lib/public/Settings/IDelegatedSettings.php',
         'OCP\\Settings\\IIconSection' => __DIR__ . '/../../..' . '/lib/public/Settings/IIconSection.php',
         'OCP\\Settings\\IManager' => __DIR__ . '/../../..' . '/lib/public/Settings/IManager.php',
@@ -1796,6 +1802,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
         'OC\\Session\\Session' => __DIR__ . '/../../..' . '/lib/private/Session/Session.php',
         'OC\\Settings\\AuthorizedGroup' => __DIR__ . '/../../..' . '/lib/private/Settings/AuthorizedGroup.php',
         'OC\\Settings\\AuthorizedGroupMapper' => __DIR__ . '/../../..' . '/lib/private/Settings/AuthorizedGroupMapper.php',
+        'OC\\Settings\\DeclarativeManager' => __DIR__ . '/../../..' . '/lib/private/Settings/DeclarativeManager.php',
         'OC\\Settings\\Manager' => __DIR__ . '/../../..' . '/lib/private/Settings/Manager.php',
         'OC\\Settings\\Section' => __DIR__ . '/../../..' . '/lib/private/Settings/Section.php',
         'OC\\Setup' => __DIR__ . '/../../..' . '/lib/private/Setup.php',

+ 2 - 2
lib/composer/composer/installed.php

@@ -3,7 +3,7 @@
         'name' => '__root__',
         'pretty_version' => 'dev-master',
         'version' => 'dev-master',
-        'reference' => 'b6abfc4cba2d1ef4fdd8f2c22bbff46796b9485e',
+        'reference' => '4ff660ca2e0baa02440ba07296ed7e75fa544c0e',
         'type' => 'library',
         'install_path' => __DIR__ . '/../../../',
         'aliases' => array(),
@@ -13,7 +13,7 @@
         '__root__' => array(
             'pretty_version' => 'dev-master',
             'version' => 'dev-master',
-            'reference' => 'b6abfc4cba2d1ef4fdd8f2c22bbff46796b9485e',
+            'reference' => '4ff660ca2e0baa02440ba07296ed7e75fa544c0e',
             'type' => 'library',
             'install_path' => __DIR__ . '/../../../',
             'aliases' => array(),

+ 21 - 8
lib/private/AppFramework/Bootstrap/RegistrationContext.php

@@ -49,6 +49,7 @@ use OCP\Http\WellKnown\IHandler;
 use OCP\Notification\INotifier;
 use OCP\Profile\ILinkAction;
 use OCP\Search\IProvider;
+use OCP\Settings\IDeclarativeSettingsForm;
 use OCP\SetupCheck\ISetupCheck;
 use OCP\Share\IPublicShareTemplateProvider;
 use OCP\SpeechToText\ISpeechToTextProvider;
@@ -142,9 +143,6 @@ class RegistrationContext {
 	/** @var ServiceRegistration<\OCP\TextToImage\IProvider>[] */
 	private $textToImageProviders = [];
 
-
-
-
 	/** @var ParameterRegistration[] */
 	private $sensitiveMethods = [];
 
@@ -159,6 +157,9 @@ class RegistrationContext {
 	/** @var PreviewProviderRegistration[] */
 	private array $previewProviders = [];
 
+	/** @var ServiceRegistration<IDeclarativeSettingsForm>[] */
+	private array $declarativeSettings = [];
+
 	/** @var ServiceRegistration<ITeamResourceProvider>[] */
 	private array $teamResourceProviders = [];
 
@@ -403,6 +404,13 @@ class RegistrationContext {
 					$setupCheckClass
 				);
 			}
+
+			public function registerDeclarativeSettings(string $declarativeSettingsClass): void {
+				$this->context->registerDeclarativeSettings(
+					$this->appId,
+					$declarativeSettingsClass
+				);
+			}
 		};
 	}
 
@@ -542,7 +550,6 @@ class RegistrationContext {
 		);
 	}
 
-
 	/**
 	 * @psalm-param class-string<ITeamResourceProvider> $class
 	 */
@@ -576,6 +583,13 @@ class RegistrationContext {
 		$this->setupChecks[] = new ServiceRegistration($appId, $setupCheckClass);
 	}
 
+	/**
+	 * @psalm-param class-string<IDeclarativeSettingsForm> $declarativeSettingsClass
+	 */
+	public function registerDeclarativeSettings(string $appId, string $declarativeSettingsClass): void {
+		$this->declarativeSettings[] = new ServiceRegistration($appId, $declarativeSettingsClass);
+	}
+
 	/**
 	 * @param App[] $apps
 	 */
@@ -893,11 +907,10 @@ class RegistrationContext {
 		return $this->setupChecks;
 	}
 
-
 	/**
-	 * @return ServiceRegistration<ITeamResourceProvider>[]
+	 * @return ServiceRegistration<IDeclarativeSettingsForm>[]
 	 */
-	public function getTeamResourceProviders(): array {
-		return $this->teamResourceProviders;
+	public function getDeclarativeSettings(): array {
+		return $this->declarativeSettings;
 	}
 }

+ 4 - 0
lib/private/Server.php

@@ -150,6 +150,7 @@ use OC\Security\SecureRandom;
 use OC\Security\TrustedDomainHelper;
 use OC\Security\VerificationToken\VerificationToken;
 use OC\Session\CryptoWrapper;
+use OC\Settings\DeclarativeManager;
 use OC\SetupCheck\SetupCheckManager;
 use OC\Share20\ProviderFactory;
 use OC\Share20\ShareHelper;
@@ -259,6 +260,7 @@ use OCP\Security\ISecureRandom;
 use OCP\Security\ITrustedDomainHelper;
 use OCP\Security\RateLimiting\ILimiter;
 use OCP\Security\VerificationToken\IVerificationToken;
+use OCP\Settings\IDeclarativeManager;
 use OCP\SetupCheck\ISetupCheckManager;
 use OCP\Share\IProviderFactory;
 use OCP\Share\IShareHelper;
@@ -1430,6 +1432,8 @@ class Server extends ServerContainer implements IServerContainer {
 
 		$this->registerAlias(IAvailabilityCoordinator::class, AvailabilityCoordinator::class);
 
+		$this->registerAlias(IDeclarativeManager::class, DeclarativeManager::class);
+
 		$this->connectDispatcher();
 	}
 

+ 402 - 0
lib/private/Settings/DeclarativeManager.php

@@ -0,0 +1,402 @@
+<?php
+/**
+ * @copyright Copyright (c) 2023 Kate Döen <kate.doeen@nextcloud.com>
+ *
+ * @author Kate Döen <kate.doeen@nextcloud.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OC\Settings;
+
+use Exception;
+use OC\AppFramework\Bootstrap\Coordinator;
+use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IAppConfig;
+use OCP\IConfig;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\Server;
+use OCP\Settings\DeclarativeSettingsTypes;
+use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
+use OCP\Settings\Events\DeclarativeSettingsRegisterFormEvent;
+use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
+use OCP\Settings\IDeclarativeManager;
+use OCP\Settings\IDeclarativeSettingsForm;
+use Psr\Log\LoggerInterface;
+
+/**
+ * @psalm-import-type DeclarativeSettingsValueTypes from IDeclarativeSettingsForm
+ * @psalm-import-type DeclarativeSettingsStorageType from IDeclarativeSettingsForm
+ * @psalm-import-type DeclarativeSettingsSectionType from IDeclarativeSettingsForm
+ * @psalm-import-type DeclarativeSettingsFormSchemaWithValues from IDeclarativeSettingsForm
+ * @psalm-import-type DeclarativeSettingsFormSchemaWithoutValues from IDeclarativeSettingsForm
+ */
+class DeclarativeManager implements IDeclarativeManager {
+	public function __construct(
+		private IEventDispatcher $eventDispatcher,
+		private IGroupManager    $groupManager,
+		private Coordinator      $coordinator,
+		private IConfig          $config,
+		private IAppConfig       $appConfig,
+		private LoggerInterface  $logger,
+	) {
+	}
+
+	/**
+	 * @var array<string, list<DeclarativeSettingsFormSchemaWithoutValues>>
+	 */
+	private array $appSchemas = [];
+
+	/**
+	 * @inheritdoc
+	 */
+	public function registerSchema(string $app, array $schema): void {
+		$this->appSchemas[$app] ??= [];
+
+		if (!$this->validateSchema($app, $schema)) {
+			throw new Exception('Invalid schema. Please check the logs for more details.');
+		}
+
+		foreach ($this->appSchemas[$app] as $otherSchema) {
+			if ($otherSchema['id'] === $schema['id']) {
+				throw new Exception('Duplicate form IDs detected: ' . $schema['id']);
+			}
+		}
+
+		$fieldIDs = array_map(fn ($field) => $field['id'], $schema['fields']);
+		$otherFieldIDs = array_merge(...array_map(fn ($schema) => array_map(fn ($field) => $field['id'], $schema['fields']), $this->appSchemas[$app]));
+		$intersectionFieldIDs = array_intersect($fieldIDs, $otherFieldIDs);
+		if (count($intersectionFieldIDs) > 0) {
+			throw new Exception('Non unique field IDs detected: ' . join(', ', $intersectionFieldIDs));
+		}
+
+		$this->appSchemas[$app][] = $schema;
+	}
+
+	/**
+	 * @inheritdoc
+	 */
+	public function loadSchemas(): void {
+		$declarativeSettings = $this->coordinator->getRegistrationContext()->getDeclarativeSettings();
+		foreach ($declarativeSettings as $declarativeSetting) {
+			/** @var IDeclarativeSettingsForm $declarativeSettingObject */
+			$declarativeSettingObject = Server::get($declarativeSetting->getService());
+			$this->registerSchema($declarativeSetting->getAppId(), $declarativeSettingObject->getSchema());
+		}
+
+		$this->eventDispatcher->dispatchTyped(new DeclarativeSettingsRegisterFormEvent($this));
+	}
+
+	/**
+	 * @inheritdoc
+	 */
+	public function getFormIDs(IUser $user, string $type, string $section): array {
+		$isAdmin = $this->groupManager->isAdmin($user->getUID());
+		/** @var array<string, list<string>> $formIds */
+		$formIds = [];
+
+		foreach ($this->appSchemas as $app => $schemas) {
+			$ids = [];
+			usort($schemas, [$this, 'sortSchemasByPriorityCallback']);
+			foreach ($schemas as $schema) {
+				if ($schema['section_type'] === DeclarativeSettingsTypes::SECTION_TYPE_ADMIN && !$isAdmin) {
+					continue;
+				}
+				if ($schema['section_type'] === $type && $schema['section_id'] === $section) {
+					$ids[] = $schema['id'];
+				}
+			}
+
+			if (!empty($ids)) {
+				$formIds[$app] = array_merge($formIds[$app] ?? [], $ids);
+			}
+		}
+
+		return $formIds;
+	}
+
+	/**
+	 * @inheritdoc
+	 * @throws Exception
+	 */
+	public function getFormsWithValues(IUser $user, ?string $type, ?string $section): array {
+		$isAdmin = $this->groupManager->isAdmin($user->getUID());
+		$forms = [];
+
+		foreach ($this->appSchemas as $app => $schemas) {
+			foreach ($schemas as $schema) {
+				if ($type !== null && $schema['section_type'] !== $type) {
+					continue;
+				}
+				if ($section !== null && $schema['section_id'] !== $section) {
+					continue;
+				}
+				// If listing all fields skip the admin fields which a non-admin user has no access to
+				if ($type === null && $schema['section_type'] === 'admin' && !$isAdmin) {
+					continue;
+				}
+
+				$s = $schema;
+				$s['app'] = $app;
+
+				foreach ($s['fields'] as &$field) {
+					$field['value'] = $this->getValue($user, $app, $schema['id'], $field['id']);
+				}
+				unset($field);
+
+				/** @var DeclarativeSettingsFormSchemaWithValues $s */
+				$forms[] = $s;
+			}
+		}
+
+		usort($forms, [$this, 'sortSchemasByPriorityCallback']);
+
+		return $forms;
+	}
+
+	private function sortSchemasByPriorityCallback(mixed $a, mixed $b): int {
+		if ($a['priority'] === $b['priority']) {
+			return 0;
+		}
+		return $a['priority'] > $b['priority'] ? -1 : 1;
+	}
+
+	/**
+	 * @return DeclarativeSettingsStorageType
+	 */
+	private function getStorageType(string $app, string $fieldId): string {
+		if (array_key_exists($app, $this->appSchemas)) {
+			foreach ($this->appSchemas[$app] as $schema) {
+				foreach ($schema['fields'] as $field) {
+					if ($field['id'] == $fieldId) {
+						if (array_key_exists('storage_type', $field)) {
+							return $field['storage_type'];
+						}
+					}
+				}
+
+				if (array_key_exists('storage_type', $schema)) {
+					return $schema['storage_type'];
+				}
+			}
+		}
+
+		return DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL;
+	}
+
+	/**
+	 * @return DeclarativeSettingsSectionType
+	 * @throws Exception
+	 */
+	private function getSectionType(string $app, string $fieldId): string {
+		if (array_key_exists($app, $this->appSchemas)) {
+			foreach ($this->appSchemas[$app] as $schema) {
+				foreach ($schema['fields'] as $field) {
+					if ($field['id'] == $fieldId) {
+						return $schema['section_type'];
+					}
+				}
+			}
+		}
+
+		throw new Exception('Unknown fieldId "' . $fieldId . '"');
+	}
+
+	/**
+	 * @psalm-param DeclarativeSettingsSectionType $sectionType
+	 * @throws NotAdminException
+	 */
+	private function assertAuthorized(IUser $user, string $sectionType): void {
+		if ($sectionType === 'admin' && !$this->groupManager->isAdmin($user->getUID())) {
+			throw new NotAdminException('Logged in user does not have permission to access these settings.');
+		}
+	}
+
+	/**
+	 * @return DeclarativeSettingsValueTypes
+	 * @throws Exception
+	 * @throws NotAdminException
+	 */
+	private function getValue(IUser $user, string $app, string $formId, string $fieldId): mixed {
+		$sectionType = $this->getSectionType($app, $fieldId);
+		$this->assertAuthorized($user, $sectionType);
+
+		$storageType = $this->getStorageType($app, $fieldId);
+		switch ($storageType) {
+			case DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL:
+				$event = new DeclarativeSettingsGetValueEvent($user, $app, $formId, $fieldId);
+				$this->eventDispatcher->dispatchTyped($event);
+				return $event->getValue();
+			case DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL:
+				return $this->getInternalValue($user, $app, $formId, $fieldId);
+			default:
+				throw new Exception('Unknown storage type "' . $storageType . '"');
+		}
+	}
+
+	/**
+	 * @inheritdoc
+	 */
+	public function setValue(IUser $user, string $app, string $formId, string $fieldId, mixed $value): void {
+		$sectionType = $this->getSectionType($app, $fieldId);
+		$this->assertAuthorized($user, $sectionType);
+
+		$storageType = $this->getStorageType($app, $fieldId);
+		switch ($storageType) {
+			case DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL:
+				$this->eventDispatcher->dispatchTyped(new DeclarativeSettingsSetValueEvent($user, $app, $formId, $fieldId, $value));
+				break;
+			case DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL:
+				$this->saveInternalValue($user, $app, $fieldId, $value);
+				break;
+			default:
+				throw new Exception('Unknown storage type "' . $storageType . '"');
+		}
+	}
+
+	private function getInternalValue(IUser $user, string $app, string $formId, string $fieldId): mixed {
+		$sectionType = $this->getSectionType($app, $fieldId);
+		$defaultValue = $this->getDefaultValue($app, $formId, $fieldId);
+		switch ($sectionType) {
+			case DeclarativeSettingsTypes::SECTION_TYPE_ADMIN:
+				return $this->config->getAppValue($app, $fieldId, $defaultValue);
+			case DeclarativeSettingsTypes::SECTION_TYPE_PERSONAL:
+				return $this->config->getUserValue($user->getUID(), $app, $fieldId, $defaultValue);
+			default:
+				throw new Exception('Unknown section type "' . $sectionType . '"');
+		}
+	}
+
+	private function saveInternalValue(IUser $user, string $app, string $fieldId, mixed $value): void {
+		$sectionType = $this->getSectionType($app, $fieldId);
+		switch ($sectionType) {
+			case DeclarativeSettingsTypes::SECTION_TYPE_ADMIN:
+				$this->appConfig->setValueString($app, $fieldId, $value);
+				break;
+			case DeclarativeSettingsTypes::SECTION_TYPE_PERSONAL:
+				$this->config->setUserValue($user->getUID(), $app, $fieldId, $value);
+				break;
+			default:
+				throw new Exception('Unknown section type "' . $sectionType . '"');
+		}
+	}
+
+	private function getDefaultValue(string $app, string $formId, string $fieldId): mixed {
+		foreach ($this->appSchemas[$app] as $schema) {
+			if ($schema['id'] === $formId) {
+				foreach ($schema['fields'] as $field) {
+					if ($field['id'] === $fieldId) {
+						if (isset($field['default'])) {
+							if (is_array($field['default'])) {
+								return json_encode($field['default']);
+							}
+							return $field['default'];
+						}
+					}
+				}
+			}
+		}
+		return null;
+	}
+
+	private function validateSchema(string $appId, array $schema): bool {
+		if (!isset($schema['id'])) {
+			$this->logger->warning('Attempt to register a declarative settings schema with no id', ['app' => $appId]);
+			return false;
+		}
+		$formId = $schema['id'];
+		if (!isset($schema['section_type'])) {
+			$this->logger->warning('Declarative settings: missing section_type', ['app' => $appId, 'form_id' => $formId]);
+			return false;
+		}
+		if (!in_array($schema['section_type'], [DeclarativeSettingsTypes::SECTION_TYPE_ADMIN, DeclarativeSettingsTypes::SECTION_TYPE_PERSONAL])) {
+			$this->logger->warning('Declarative settings: invalid section_type', ['app' => $appId, 'form_id' => $formId, 'section_type' => $schema['section_type']]);
+			return false;
+		}
+		if (!isset($schema['section_id'])) {
+			$this->logger->warning('Declarative settings: missing section_id', ['app' => $appId, 'form_id' => $formId]);
+			return false;
+		}
+		if (!isset($schema['storage_type'])) {
+			$this->logger->warning('Declarative settings: missing storage_type', ['app' => $appId, 'form_id' => $formId]);
+			return false;
+		}
+		if (!in_array($schema['storage_type'], [DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL, DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL])) {
+			$this->logger->warning('Declarative settings: invalid storage_type', ['app' => $appId, 'form_id' => $formId, 'storage_type' => $schema['storage_type']]);
+			return false;
+		}
+		if (!isset($schema['title'])) {
+			$this->logger->warning('Declarative settings: missing title', ['app' => $appId, 'form_id' => $formId]);
+			return false;
+		}
+		if (!isset($schema['fields']) || !is_array($schema['fields'])) {
+			$this->logger->warning('Declarative settings: missing or invalid fields', ['app' => $appId, 'form_id' => $formId]);
+			return false;
+		}
+		foreach ($schema['fields'] as $field) {
+			if (!isset($field['id'])) {
+				$this->logger->warning('Declarative settings: missing field id', ['app' => $appId, 'form_id' => $formId, 'field' => $field]);
+				return false;
+			}
+			$fieldId = $field['id'];
+			if (!isset($field['title'])) {
+				$this->logger->warning('Declarative settings: missing field title', ['app' => $appId, 'form_id' => $formId, 'field_id' => $fieldId]);
+				return false;
+			}
+			if (!isset($field['type'])) {
+				$this->logger->warning('Declarative settings: missing field type', ['app' => $appId, 'form_id' => $formId, 'field_id' => $fieldId]);
+				return false;
+			}
+			if (!in_array($field['type'], [
+				DeclarativeSettingsTypes::MULTI_SELECT, DeclarativeSettingsTypes::MULTI_CHECKBOX, DeclarativeSettingsTypes::RADIO,
+				DeclarativeSettingsTypes::SELECT, DeclarativeSettingsTypes::CHECKBOX,
+				DeclarativeSettingsTypes::URL, DeclarativeSettingsTypes::EMAIL, DeclarativeSettingsTypes::NUMBER,
+				DeclarativeSettingsTypes::TEL, DeclarativeSettingsTypes::TEXT, DeclarativeSettingsTypes::PASSWORD,
+			])) {
+				$this->logger->warning('Declarative settings: invalid field type', [
+					'app' => $appId, 'form_id' => $formId, 'field_id' => $fieldId, 'type' => $field['type'],
+				]);
+				return false;
+			}
+			if (!$this->validateField($appId, $formId, $field)) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
+	private function validateField(string $appId, string $formId, array $field): bool {
+		$fieldId = $field['id'];
+		if (in_array($field['type'], [
+			DeclarativeSettingsTypes::MULTI_SELECT, DeclarativeSettingsTypes::MULTI_CHECKBOX, DeclarativeSettingsTypes::RADIO,
+			DeclarativeSettingsTypes::SELECT
+		])) {
+			if (!isset($field['options'])) {
+				$this->logger->warning('Declarative settings: missing field options', ['app' => $appId, 'form_id' => $formId, 'field_id' => $fieldId]);
+				return false;
+			}
+			if (!is_array($field['options'])) {
+				$this->logger->warning('Declarative settings: field options should be an array', ['app' => $appId, 'form_id' => $formId, 'field_id' => $fieldId]);
+				return false;
+			}
+		}
+		return true;
+	}
+}

+ 11 - 0
lib/public/AppFramework/Bootstrap/IRegistrationContext.php

@@ -399,4 +399,15 @@ interface IRegistrationContext {
 	 * @since 28.0.0
 	 */
 	public function registerSetupCheck(string $setupCheckClass): void;
+
+	/**
+	 * Register an implementation of \OCP\Settings\IDeclarativeSettings that
+	 * will handle the implementation of declarative settings
+	 *
+	 * @param string $declarativeSettingsClass
+	 * @psalm-param class-string<\OCP\Settings\IDeclarativeSettingsForm> $declarativeSettingsClass
+	 * @return void
+	 * @since 29.0.0
+	 */
+	public function registerDeclarativeSettings(string $declarativeSettingsClass): void;
 }

+ 145 - 0
lib/public/Settings/DeclarativeSettingsTypes.php

@@ -0,0 +1,145 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2023 Andrey Borysenko <andrey.borysenko@nextcloud.com>
+ *
+ * @author Andrey Borysenko <andrey.borysenko@nextcloud.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCP\Settings;
+
+/**
+ * Declarative settings types supported in the IDeclarativeSettingsForm forms
+ *
+ * @since 29.0.0
+ */
+final class DeclarativeSettingsTypes {
+	/**
+	 * IDeclarativeSettingsForm section_type which is determines where the form is displayed
+	 *
+	 * @since 29.0.0
+	 */
+	public const SECTION_TYPE_ADMIN = 'admin';
+
+	/**
+	 * IDeclarativeSettingsForm section_type which is determines where the form is displayed
+	 *
+	 * @since 29.0.0
+	 */
+	public const SECTION_TYPE_PERSONAL = 'personal';
+
+	/**
+	 * IDeclarativeSettingsForm storage_type which is determines where and how the config value is stored
+	 *
+	 *
+	 * For `external` storage_type the app implementing \OCP\Settings\SetDeclarativeSettingsValueEvent and \OCP\Settings\GetDeclarativeSettingsValueEvent events is responsible for storing and retrieving the config value.
+	 *
+	 * @since 29.0.0
+	 */
+	public const STORAGE_TYPE_EXTERNAL = 'external';
+
+	/**
+	 * IDeclarativeSettingsForm storage_type which is determines where and how the config value is stored
+	 *
+	 * For `internal` storage_type the config value is stored in default `appconfig` and `preferences` tables.
+	 * For `external` storage_type the app implementing \OCP\Settings\SetDeclarativeSettingsValueEvent and \OCP\Settings\GetDeclarativeSettingsValueEvent events is responsible for storing and retrieving the config value.
+	 *
+	 * @since 29.0.0
+	 */
+	public const STORAGE_TYPE_INTERNAL = 'internal';
+
+	/**
+	 * NcInputField type text
+	 *
+	 * @since 29.0.0
+	 */
+	public const TEXT = 'text';
+
+	/**
+	 * NcInputField type password
+	 *
+	 * @since 29.0.0
+	 */
+	public const PASSWORD = 'password';
+
+	/**
+	 * NcInputField type email
+	 *
+	 * @since 29.0.0
+	 */
+	public const EMAIL = 'email';
+
+	/**
+	 * NcInputField type tel
+	 *
+	 * @since 29.0.0
+	 */
+	public const TEL = 'tel';
+
+	/**
+	 * NcInputField type url
+	 *
+	 * @since 29.0.0
+	 */
+	public const URL = 'url';
+
+	/**
+	 * NcInputField type number
+	 *
+	 * @since 29.0.0
+	 */
+	public const NUMBER = 'number';
+
+	/**
+	 * NcCheckboxRadioSwitch type checkbox
+	 *
+	 * @since 29.0.0
+	 */
+	public const CHECKBOX = 'checkbox';
+
+	/**
+	 * Multiple NcCheckboxRadioSwitch type checkbox representing a one config value (saved as JSON object)
+	 *
+	 * @since 29.0.0
+	 */
+	public const MULTI_CHECKBOX = 'multi-checkbox';
+
+	/**
+	 * NcCheckboxRadioSwitch type radio
+	 *
+	 * @since 29.0.0
+	 */
+	public const RADIO = 'radio';
+
+	/**
+	 * NcSelect
+	 *
+	 * @since 29.0.0
+	 */
+	public const SELECT = 'select';
+
+	/**
+	 * Multiple NcSelect representing a one config value (saved as JSON array)
+	 *
+	 * @since 29.0.0
+	 */
+	public const MULTI_SELECT = 'multi-select';
+}

+ 81 - 0
lib/public/Settings/Events/DeclarativeSettingsGetValueEvent.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace OCP\Settings\Events;
+
+use Exception;
+use OCP\EventDispatcher\Event;
+use OCP\IUser;
+use OCP\Settings\IDeclarativeSettingsForm;
+
+/**
+ * @psalm-import-type DeclarativeSettingsValueTypes from IDeclarativeSettingsForm
+ *
+ * @since 29.0.0
+ */
+class DeclarativeSettingsGetValueEvent extends Event {
+	/**
+	 * @var ?DeclarativeSettingsValueTypes
+	 */
+	private mixed $value = null;
+
+	/**
+	 * @since 29.0.0
+	 */
+	public function __construct(
+		private IUser $user,
+		private string $app,
+		private string $formId,
+		private string $fieldId,
+	) {
+		parent::__construct();
+	}
+
+	/**
+	 * @since 29.0.0
+	 */
+	public function getUser(): IUser {
+		return $this->user;
+	}
+
+	/**
+	 * @since 29.0.0
+	 */
+	public function getApp(): string {
+		return $this->app;
+	}
+
+	/**
+	 * @since 29.0.0
+	 */
+	public function getFormId(): string {
+		return $this->formId;
+	}
+
+	/**
+	 * @since 29.0.0
+	 */
+	public function getFieldId(): string {
+		return $this->fieldId;
+	}
+
+	/**
+	 * @since 29.0.0
+	 */
+	public function setValue(mixed $value): void {
+		$this->value = $value;
+	}
+
+	/**
+	 * @return DeclarativeSettingsValueTypes
+	 * @throws Exception
+	 *
+	 * @since 29.0.0
+	 */
+	public function getValue(): mixed {
+		if ($this->value === null) {
+			throw new Exception('Value not set');
+		}
+
+		return $this->value;
+	}
+}

+ 29 - 0
lib/public/Settings/Events/DeclarativeSettingsRegisterFormEvent.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace OCP\Settings\Events;
+
+use OCP\EventDispatcher\Event;
+use OCP\Settings\IDeclarativeManager;
+use OCP\Settings\IDeclarativeSettingsForm;
+
+/**
+ * @psalm-import-type DeclarativeSettingsFormSchemaWithoutValues from IDeclarativeSettingsForm
+ *
+ * @since 29.0.0
+ */
+class DeclarativeSettingsRegisterFormEvent extends Event {
+	/**
+	 * @since 29.0.0
+	 */
+	public function __construct(private IDeclarativeManager $manager) {
+		parent::__construct();
+	}
+
+	/**
+	 * @param DeclarativeSettingsFormSchemaWithoutValues $schema
+	 * @since 29.0.0
+	 */
+	public function registerSchema(string $app, array $schema): void {
+		$this->manager->registerSchema($app, $schema);
+	}
+}

+ 63 - 0
lib/public/Settings/Events/DeclarativeSettingsSetValueEvent.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace OCP\Settings\Events;
+
+use OCP\EventDispatcher\Event;
+use OCP\IUser;
+use OCP\Settings\IDeclarativeSettingsForm;
+
+/**
+ * @psalm-import-type DeclarativeSettingsValueTypes from IDeclarativeSettingsForm
+ *
+ * @since 29.0.0
+ */
+class DeclarativeSettingsSetValueEvent extends Event {
+	/**
+	 * @param DeclarativeSettingsValueTypes $value
+	 * @since 29.0.0
+	 */
+	public function __construct(
+		private IUser $user,
+		private string $app,
+		private string $formId,
+		private string $fieldId,
+		private mixed $value,
+	) {
+		parent::__construct();
+	}
+
+	/**
+	 * @since 29.0.0
+	 */
+	public function getUser(): IUser {
+		return $this->user;
+	}
+
+	/**
+	 * @since 29.0.0
+	 */
+	public function getApp(): string {
+		return $this->app;
+	}
+
+	/**
+	 * @since 29.0.0
+	 */
+	public function getFormId(): string {
+		return $this->formId;
+	}
+
+	/**
+	 * @since 29.0.0
+	 */
+	public function getFieldId(): string {
+		return $this->fieldId;
+	}
+
+	/**
+	 * @since 29.0.0
+	 */
+	public function getValue(): mixed {
+		return $this->value;
+	}
+}

+ 89 - 0
lib/public/Settings/IDeclarativeManager.php

@@ -0,0 +1,89 @@
+<?php
+/**
+ * @copyright Copyright (c) 2023 Kate Döen <kate.doeen@nextcloud.com>
+ *
+ * @author Kate Döen <kate.doeen@nextcloud.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCP\Settings;
+
+use Exception;
+use OC\AppFramework\Middleware\Security\Exceptions\NotAdminException;
+use OCP\IUser;
+
+/**
+ * @since 29.0.0
+ *
+ * @psalm-import-type DeclarativeSettingsValueTypes from IDeclarativeSettingsForm
+ * @psalm-import-type DeclarativeSettingsSectionType from IDeclarativeSettingsForm
+ * @psalm-import-type DeclarativeSettingsFormSchemaWithValues from IDeclarativeSettingsForm
+ * @psalm-import-type DeclarativeSettingsFormSchemaWithoutValues from IDeclarativeSettingsForm
+ */
+interface IDeclarativeManager {
+	/**
+	 * Registers a new declarative settings schema.
+	 *
+	 * @param DeclarativeSettingsFormSchemaWithoutValues $schema
+	 * @since 29.0.0
+	 */
+	public function registerSchema(string $app, array $schema): void;
+
+	/**
+	 * Load all schemas from the registration context and events.
+	 *
+	 * @since 29.0.0
+	 */
+	public function loadSchemas(): void;
+
+	/**
+	 * Gets the IDs of the forms for the given type and section.
+	 *
+	 * @param DeclarativeSettingsSectionType $type
+	 * @param string $section
+	 * @return array<string, list<string>>
+	 *
+	 * @since 29.0.0
+	 */
+	public function getFormIDs(IUser $user, string $type, string $section): array;
+
+	/**
+	 * Gets the forms including the field values for the given type and section.
+	 *
+	 * @param IUser $user Used for reading values from the personal section or for authorization for the admin section.
+	 * @param ?DeclarativeSettingsSectionType $type If it is null the forms will not be filtered by type.
+	 * @param ?string $section If it is null the forms will not be filtered by section.
+	 * @return list<DeclarativeSettingsFormSchemaWithValues>
+	 *
+	 * @since 29.0.0
+	 */
+	public function getFormsWithValues(IUser $user, ?string $type, ?string $section): array;
+
+	/**
+	 * Sets a value for the given field ID.
+	 *
+	 * @param IUser $user Used for storing values in the personal section or for authorization for the admin section.
+	 * @param DeclarativeSettingsValueTypes $value
+	 *
+	 * @throws Exception
+	 * @throws NotAdminException
+	 *
+	 * @since 29.0.0
+	 */
+	public function setValue(IUser $user, string $app, string $formId, string $fieldId, mixed $value): void;
+}

+ 78 - 0
lib/public/Settings/IDeclarativeSettingsForm.php

@@ -0,0 +1,78 @@
+<?php
+/**
+ * @copyright Copyright (c) 2023 Kate Döen <kate.doeen@nextcloud.com>
+ *
+ * @author Kate Döen <kate.doeen@nextcloud.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCP\Settings;
+
+/**
+ * @since 29.0.0
+ *
+ * @psalm-type DeclarativeSettingsSectionType = 'admin'|'personal'
+ *
+ * @psalm-type DeclarativeSettingsStorageType = 'internal'|'external'
+ *
+ * @psalm-type DeclarativeSettingsValueTypes = string|int|float|bool|list<string>
+ *
+ * @psalm-type DeclarativeSettingsFormField = array{
+ *   id: string,
+ *   title: string,
+ *   description?: string,
+ *   type: 'text'|'password'|'email'|'tel'|'url'|'number'|'checkbox'|'multi-checkbox'|'radio'|'select'|'multi-select',
+ *   placeholder?: string,
+ *   label?: string,
+ *   default: mixed,
+ *   options?: list<string|array{name: string, value: mixed}>,
+ * }
+ *
+ * @psalm-type DeclarativeSettingsFormFieldWithValue = DeclarativeSettingsFormField&array{
+ *     value: DeclarativeSettingsValueTypes,
+ * }
+ *
+ * @psalm-type DeclarativeSettingsFormSchema = array{
+ *   id: string,
+ *   priority: int,
+ *   section_type: DeclarativeSettingsSectionType,
+ *   section_id: string,
+ *   storage_type: DeclarativeSettingsStorageType,
+ *   title: string,
+ *   description?: string,
+ *   doc_url?: string,
+ * }
+ *
+ * @psalm-type DeclarativeSettingsFormSchemaWithValues = DeclarativeSettingsFormSchema&array{
+ *   app: string,
+ *   fields: list<DeclarativeSettingsFormFieldWithValue>,
+ * }
+ *
+ * @psalm-type DeclarativeSettingsFormSchemaWithoutValues = DeclarativeSettingsFormSchema&array{
+ *   fields: list<DeclarativeSettingsFormField>,
+ * }
+ */
+interface IDeclarativeSettingsForm {
+	/**
+	 * Gets the schema that defines the declarative settings form
+	 *
+	 * @return DeclarativeSettingsFormSchemaWithoutValues
+	 * @since 29.0.0
+	 */
+	public function getSchema(): array;
+}

+ 536 - 0
tests/lib/Settings/DeclarativeManagerTest.php

@@ -0,0 +1,536 @@
+<?php
+
+/**
+ * @copyright Copyright (c) 2023 Andrey Borysenko <andrey.borysenko@nextcloud.com>
+ *
+ * @author Andrey Borysenko <andrey.borysenko@nextcloud.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace Test\Settings;
+
+use OC\AppFramework\Bootstrap\Coordinator;
+use OC\Settings\DeclarativeManager;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IAppConfig;
+use OCP\IConfig;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\Settings\DeclarativeSettingsTypes;
+use OCP\Settings\Events\DeclarativeSettingsSetValueEvent;
+use OCP\Settings\IDeclarativeManager;
+use PHPUnit\Framework\MockObject\MockObject;
+use Psr\Log\LoggerInterface;
+use Test\TestCase;
+
+class DeclarativeManagerTest extends TestCase {
+
+	/** @var IDeclarativeManager|MockObject */
+	private $declarativeManager;
+
+	/** @var IEventDispatcher|MockObject */
+	private $eventDispatcher;
+
+	/** @var IGroupManager|MockObject */
+	private $groupManager;
+
+	/** @var Coordinator|MockObject */
+	private $coordinator;
+
+	/** @var IConfig|MockObject */
+	private $config;
+
+	/** @var IAppConfig|MockObject */
+	private $appConfig;
+
+	/** @var LoggerInterface|MockObject */
+	private $logger;
+
+	/** @var IUser|MockObject */
+	private $user;
+
+	/** @var IUser|MockObject */
+	private $adminUser;
+
+	public const validSchemaAllFields = [
+		'id' => 'test_form_1',
+		'priority' => 10,
+		'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN, // admin, personal
+		'section_id' => 'additional',
+		'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL, // external, internal (handled by core to store in appconfig and preferences)
+		'title' => 'Test declarative settings', // NcSettingsSection name
+		'description' => 'These fields are rendered dynamically from declarative schema', // NcSettingsSection description
+		'doc_url' => '', // NcSettingsSection doc_url for documentation or help page, empty string if not needed
+		'fields' => [
+			[
+				'id' => 'test_field_7', // configkey
+				'title' => 'Multi-selection', // name or label
+				'description' => 'Select some option setting', // hint
+				'type' => DeclarativeSettingsTypes::MULTI_SELECT,
+				'options' => ['foo', 'bar', 'baz'], // simple options for select, radio, multi-select
+				'placeholder' => 'Select some multiple options', // input placeholder
+				'default' => ['foo', 'bar'],
+			],
+			[
+				'id' => 'some_real_setting',
+				'title' => 'Select single option',
+				'description' => 'Single option radio buttons',
+				'type' => DeclarativeSettingsTypes::RADIO, // radio (NcCheckboxRadioSwitch type radio)
+				'placeholder' => 'Select single option, test interval',
+				'default' => '40m',
+				'options' => [
+					[
+						'name' => 'Each 40 minutes', // NcCheckboxRadioSwitch display name
+						'value' => '40m' // NcCheckboxRadioSwitch value
+					],
+					[
+						'name' => 'Each 60 minutes',
+						'value' => '60m'
+					],
+					[
+						'name' => 'Each 120 minutes',
+						'value' => '120m'
+					],
+					[
+						'name' => 'Each day',
+						'value' => 60 * 24 . 'm'
+					],
+				],
+			],
+			[
+				'id' => 'test_field_1', // configkey
+				'title' => 'Default text field', // label
+				'description' => 'Set some simple text setting', // hint
+				'type' => DeclarativeSettingsTypes::TEXT,
+				'placeholder' => 'Enter text setting', // placeholder
+				'default' => 'foo',
+			],
+			[
+				'id' => 'test_field_1_1',
+				'title' => 'Email field',
+				'description' => 'Set email config',
+				'type' => DeclarativeSettingsTypes::EMAIL,
+				'placeholder' => 'Enter email',
+				'default' => '',
+			],
+			[
+				'id' => 'test_field_1_2',
+				'title' => 'Tel field',
+				'description' => 'Set tel config',
+				'type' => DeclarativeSettingsTypes::TEL,
+				'placeholder' => 'Enter your tel',
+				'default' => '',
+			],
+			[
+				'id' => 'test_field_1_3',
+				'title' => 'Url (website) field',
+				'description' => 'Set url config',
+				'type' => 'url',
+				'placeholder' => 'Enter url',
+				'default' => '',
+			],
+			[
+				'id' => 'test_field_1_4',
+				'title' => 'Number field',
+				'description' => 'Set number config',
+				'type' => DeclarativeSettingsTypes::NUMBER,
+				'placeholder' => 'Enter number value',
+				'default' => 0,
+			],
+			[
+				'id' => 'test_field_2',
+				'title' => 'Password',
+				'description' => 'Set some secure value setting',
+				'type' => 'password',
+				'placeholder' => 'Set secure value',
+				'default' => '',
+			],
+			[
+				'id' => 'test_field_3',
+				'title' => 'Selection',
+				'description' => 'Select some option setting',
+				'type' => DeclarativeSettingsTypes::SELECT,
+				'options' => ['foo', 'bar', 'baz'],
+				'placeholder' => 'Select some option setting',
+				'default' => 'foo',
+			],
+			[
+				'id' => 'test_field_4',
+				'title' => 'Toggle something',
+				'description' => 'Select checkbox option setting',
+				'type' => DeclarativeSettingsTypes::CHECKBOX,
+				'label' => 'Verify something if enabled',
+				'default' => false,
+			],
+			[
+				'id' => 'test_field_5',
+				'title' => 'Multiple checkbox toggles, describing one setting, checked options are saved as an JSON object {foo: true, bar: false}',
+				'description' => 'Select checkbox option setting',
+				'type' => DeclarativeSettingsTypes::MULTI_CHECKBOX,
+				'default' => ['foo' => true, 'bar' => true],
+				'options' => [
+					[
+						'name' => 'Foo',
+						'value' => 'foo', // multiple-checkbox configkey
+					],
+					[
+						'name' => 'Bar',
+						'value' => 'bar',
+					],
+					[
+						'name' => 'Baz',
+						'value' => 'baz',
+					],
+					[
+						'name' => 'Qux',
+						'value' => 'qux',
+					],
+				],
+			],
+			[
+				'id' => 'test_field_6',
+				'title' => 'Radio toggles, describing one setting like single select',
+				'description' => 'Select radio option setting',
+				'type' => DeclarativeSettingsTypes::RADIO, // radio (NcCheckboxRadioSwitch type radio)
+				'label' => 'Select single toggle',
+				'default' => 'foo',
+				'options' => [
+					[
+						'name' => 'First radio', // NcCheckboxRadioSwitch display name
+						'value' => 'foo' // NcCheckboxRadioSwitch value
+					],
+					[
+						'name' => 'Second radio',
+						'value' => 'bar'
+					],
+					[
+						'name' => 'Second radio',
+						'value' => 'baz'
+					],
+				],
+			],
+		],
+	];
+
+	public static bool $testSetInternalValueAfterChange = false;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
+		$this->groupManager = $this->createMock(IGroupManager::class);
+		$this->coordinator = $this->createMock(Coordinator::class);
+		$this->config = $this->createMock(IConfig::class);
+		$this->appConfig = $this->createMock(IAppConfig::class);
+		$this->logger = $this->createMock(LoggerInterface::class);
+
+		$this->declarativeManager = new DeclarativeManager(
+			$this->eventDispatcher,
+			$this->groupManager,
+			$this->coordinator,
+			$this->config,
+			$this->appConfig,
+			$this->logger
+		);
+
+		$this->user = $this->createMock(IUser::class);
+		$this->user->expects($this->any())
+			->method('getUID')
+			->willReturn('test_user');
+
+		$this->adminUser = $this->createMock(IUser::class);
+		$this->adminUser->expects($this->any())
+			->method('getUID')
+			->willReturn('admin_test_user');
+
+		$this->groupManager->expects($this->any())
+			->method('isAdmin')
+			->willReturnCallback(function ($userId) {
+				return $userId === 'admin_test_user';
+			});
+	}
+
+	public function testRegisterSchema(): void {
+		$app = 'testing';
+		$schema = self::validSchemaAllFields;
+		$this->declarativeManager->registerSchema($app, $schema);
+		$formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schema['section_type'], $schema['section_id']);
+		$this->assertTrue(isset($formIds[$app]) && in_array($schema['id'], $formIds[$app]));
+	}
+
+	/**
+	 * Simple test to verify that exception is thrown when trying to register schema with duplicate id
+	 */
+	public function testRegisterDuplicateSchema(): void {
+		$this->declarativeManager->registerSchema('testing', self::validSchemaAllFields);
+		$this->expectException(\Exception::class);
+		$this->declarativeManager->registerSchema('testing', self::validSchemaAllFields);
+	}
+
+	/**
+	 * It's not allowed to register schema with duplicate fields ids for the same app
+	 */
+	public function testRegisterSchemaWithDuplicateFields(): void {
+		// Register first valid schema
+		$this->declarativeManager->registerSchema('testing', self::validSchemaAllFields);
+		// Register second schema with duplicate fields, but different schema id
+		$this->expectException(\Exception::class);
+		$schema = self::validSchemaAllFields;
+		$schema['id'] = 'test_form_2';
+		$this->declarativeManager->registerSchema('testing', $schema);
+	}
+
+	public function testRegisterMultipleSchemasAndDuplicate(): void {
+		$app = 'testing';
+		$schema = self::validSchemaAllFields;
+		$this->declarativeManager->registerSchema($app, $schema);
+		$formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schema['section_type'], $schema['section_id']);
+		// 1. Check that form is registered for the app
+		$this->assertTrue(isset($formIds[$app]) && in_array($schema['id'], $formIds[$app]));
+		$app = 'testing2';
+		$this->declarativeManager->registerSchema($app, $schema);
+		$formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schema['section_type'], $schema['section_id']);
+		// 2. Check that form is registered for the second app
+		$this->assertTrue(isset($formIds[$app]) && in_array($schema['id'], $formIds[$app]));
+		$app = 'testing';
+		$this->expectException(\Exception::class); // expecting duplicate form id and duplicate fields ids exception
+		$this->declarativeManager->registerSchema($app, $schema);
+		$schemaDuplicateFields = self::validSchemaAllFields;
+		$schemaDuplicateFields['id'] = 'test_form_2'; // change form id to test duplicate fields
+		$this->declarativeManager->registerSchema($app, $schemaDuplicateFields);
+		// 3. Check that not valid form with duplicate fields is not registered
+		$formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schemaDuplicateFields['section_type'], $schemaDuplicateFields['section_id']);
+		$this->assertFalse(isset($formIds[$app]) && in_array($schemaDuplicateFields['id'], $formIds[$app]));
+	}
+
+	/**
+	 * @dataProvider dataValidateSchema
+	 */
+	public function testValidateSchema(bool $expected, bool $expectException, string $app, array $schema): void {
+		if ($expectException) {
+			$this->expectException(\Exception::class);
+		}
+		$this->declarativeManager->registerSchema($app, $schema);
+		$formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schema['section_type'], $schema['section_id']);
+		$this->assertEquals($expected, isset($formIds[$app]) && in_array($schema['id'], $formIds[$app]));
+	}
+
+	public static function dataValidateSchema(): array {
+		return [
+			'valid schema with all supported fields' => [
+				true,
+				false,
+				'testing',
+				self::validSchemaAllFields,
+			],
+			'invalid schema with missing id' => [
+				false,
+				true,
+				'testing',
+				[
+					'priority' => 10,
+					'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN,
+					'section_id' => 'additional',
+					'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL,
+					'title' => 'Test declarative settings',
+					'description' => 'These fields are rendered dynamically from declarative schema',
+					'doc_url' => '',
+					'fields' => [
+						[
+							'id' => 'test_field_7',
+							'title' => 'Multi-selection',
+							'description' => 'Select some option setting',
+							'type' => DeclarativeSettingsTypes::MULTI_SELECT,
+							'options' => ['foo', 'bar', 'baz'],
+							'placeholder' => 'Select some multiple options',
+							'default' => ['foo', 'bar'],
+						],
+					],
+				],
+			],
+			'invalid schema with invalid field' => [
+				false,
+				true,
+				'testing',
+				[
+					'id' => 'test_form_1',
+					'priority' => 10,
+					'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_ADMIN,
+					'section_id' => 'additional',
+					'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL,
+					'title' => 'Test declarative settings',
+					'description' => 'These fields are rendered dynamically from declarative schema',
+					'doc_url' => '',
+					'fields' => [
+						[
+							'id' => 'test_invalid_field',
+							'title' => 'Invalid field',
+							'description' => 'Some invalid setting description',
+							'type' => 'some_invalid_type',
+							'placeholder' => 'Some invalid field placeholder',
+							'default' => null,
+						],
+					],
+				],
+			],
+		];
+	}
+
+	public function testGetFormIDs(): void {
+		$app = 'testing';
+		$schema = self::validSchemaAllFields;
+		$this->declarativeManager->registerSchema($app, $schema);
+		$formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schema['section_type'], $schema['section_id']);
+		$this->assertTrue(isset($formIds[$app]) && in_array($schema['id'], $formIds[$app]));
+		$app = 'testing2';
+		$this->declarativeManager->registerSchema($app, $schema);
+		$formIds = $this->declarativeManager->getFormIDs($this->adminUser, $schema['section_type'], $schema['section_id']);
+		$this->assertTrue(isset($formIds[$app]) && in_array($schema['id'], $formIds[$app]));
+	}
+
+	/**
+	 * Check that form with default values is returned with internal storage_type
+	 */
+	public function testGetFormsWithDefaultValues(): void {
+		$app = 'testing';
+		$schema = self::validSchemaAllFields;
+		$this->declarativeManager->registerSchema($app, $schema);
+
+		$this->config->expects($this->any())
+			->method('getAppValue')
+			->willReturnCallback(fn ($app, $configkey, $default) => $default);
+
+		$forms = $this->declarativeManager->getFormsWithValues($this->adminUser, $schema['section_type'], $schema['section_id']);
+		$this->assertNotEmpty($forms);
+		$this->assertTrue(array_search($schema['id'], array_column($forms, 'id')) !== false);
+		// Check some_real_setting field default value
+		$someRealSettingField = array_values(array_filter(array_filter($forms, fn ($form) => $form['id'] === $schema['id'])[0]['fields'], fn ($field) => $field['id'] === 'some_real_setting'))[0];
+		$schemaSomeRealSettingField = array_values(array_filter($schema['fields'], fn ($field) => $field['id'] === 'some_real_setting'))[0];
+		$this->assertEquals($schemaSomeRealSettingField['default'], $someRealSettingField['default']);
+	}
+
+	/**
+	 * Check values in json format to ensure that they are properly encoded
+	 */
+	public function testGetFormsWithDefaultValuesJson(): void {
+		$app = 'testing';
+		$schema = [
+			'id' => 'test_form_1',
+			'priority' => 10,
+			'section_type' => DeclarativeSettingsTypes::SECTION_TYPE_PERSONAL,
+			'section_id' => 'additional',
+			'storage_type' => DeclarativeSettingsTypes::STORAGE_TYPE_INTERNAL,
+			'title' => 'Test declarative settings',
+			'description' => 'These fields are rendered dynamically from declarative schema',
+			'doc_url' => '',
+			'fields' => [
+				[
+					'id' => 'test_field_json',
+					'title' => 'Multi-selection',
+					'description' => 'Select some option setting',
+					'type' => DeclarativeSettingsTypes::MULTI_SELECT,
+					'options' => ['foo', 'bar', 'baz'],
+					'placeholder' => 'Select some multiple options',
+					'default' => ['foo', 'bar'],
+				],
+			],
+		];
+		$this->declarativeManager->registerSchema($app, $schema);
+
+		// config->getUserValue() should be called with json encoded default value
+		$this->config->expects($this->once())
+			->method('getUserValue')
+			->with($this->adminUser->getUID(), $app, 'test_field_json', json_encode($schema['fields'][0]['default']))
+			->willReturn(json_encode($schema['fields'][0]['default']));
+
+		$forms = $this->declarativeManager->getFormsWithValues($this->adminUser, $schema['section_type'], $schema['section_id']);
+		$this->assertNotEmpty($forms);
+		$this->assertTrue(array_search($schema['id'], array_column($forms, 'id')) !== false);
+		$testFieldJson = array_values(array_filter(array_filter($forms, fn ($form) => $form['id'] === $schema['id'])[0]['fields'], fn ($field) => $field['id'] === 'test_field_json'))[0];
+		$this->assertEquals(json_encode($schema['fields'][0]['default']), $testFieldJson['value']);
+	}
+
+	/**
+	 * Check that saving value for field with internal storage_type is handled by core
+	 */
+	public function testSetInternalValue(): void {
+		$app = 'testing';
+		$schema = self::validSchemaAllFields;
+		$this->declarativeManager->registerSchema($app, $schema);
+		self::$testSetInternalValueAfterChange = false;
+
+		$this->config->expects($this->any())
+			->method('getAppValue')
+			->willReturnCallback(function ($app, $configkey, $default) {
+				if ($configkey === 'some_real_setting' && self::$testSetInternalValueAfterChange) {
+					return '120m';
+				}
+				return $default;
+			});
+
+		$this->appConfig->expects($this->once())
+			->method('setValueString')
+			->with($app, 'some_real_setting', '120m');
+
+		$forms = $this->declarativeManager->getFormsWithValues($this->adminUser, $schema['section_type'], $schema['section_id']);
+		$someRealSettingField = array_values(array_filter(array_filter($forms, fn ($form) => $form['id'] === $schema['id'])[0]['fields'], fn ($field) => $field['id'] === 'some_real_setting'))[0];
+		$this->assertEquals('40m', $someRealSettingField['value']); // first check that default value (40m) is returned
+
+		// Set new value for some_real_setting field
+		$this->declarativeManager->setValue($this->adminUser, $app, $schema['id'], 'some_real_setting', '120m');
+		self::$testSetInternalValueAfterChange = true;
+
+		$forms = $this->declarativeManager->getFormsWithValues($this->adminUser, $schema['section_type'], $schema['section_id']);
+		$this->assertNotEmpty($forms);
+		$this->assertTrue(array_search($schema['id'], array_column($forms, 'id')) !== false);
+		// Check some_real_setting field default value
+		$someRealSettingField = array_values(array_filter(array_filter($forms, fn ($form) => $form['id'] === $schema['id'])[0]['fields'], fn ($field) => $field['id'] === 'some_real_setting'))[0];
+		$this->assertEquals('120m', $someRealSettingField['value']);
+	}
+
+	public function testSetExternalValue(): void {
+		$app = 'testing';
+		$schema = self::validSchemaAllFields;
+		// Change storage_type to external and section_type to personal
+		$schema['storage_type'] = DeclarativeSettingsTypes::STORAGE_TYPE_EXTERNAL;
+		$schema['section_type'] = DeclarativeSettingsTypes::SECTION_TYPE_PERSONAL;
+		$this->declarativeManager->registerSchema($app, $schema);
+
+		$setDeclarativeSettingsValueEvent = new DeclarativeSettingsSetValueEvent(
+			$this->adminUser,
+			$app,
+			$schema['id'],
+			'some_real_setting',
+			'120m'
+		);
+
+		$this->eventDispatcher->expects($this->once())
+			->method('dispatchTyped')
+			->with($setDeclarativeSettingsValueEvent);
+		$this->declarativeManager->setValue($this->adminUser, $app, $schema['id'], 'some_real_setting', '120m');
+	}
+
+	public function testAdminFormUserUnauthorized(): void {
+		$app = 'testing';
+		$schema = self::validSchemaAllFields;
+		$this->declarativeManager->registerSchema($app, $schema);
+
+		$this->expectException(\Exception::class);
+		$this->declarativeManager->getFormsWithValues($this->user, $schema['section_type'], $schema['section_id']);
+	}
+}

+ 1 - 0
webpack.modules.js

@@ -98,6 +98,7 @@ module.exports = {
 		'vue-settings-personal-password': path.join(__dirname, 'apps/settings/src', 'main-personal-password.js'),
 		'vue-settings-personal-security': path.join(__dirname, 'apps/settings/src', 'main-personal-security.js'),
 		'vue-settings-personal-webauthn': path.join(__dirname, 'apps/settings/src', 'main-personal-webauth.js'),
+		'declarative-settings-forms': path.join(__dirname, 'apps/settings/src', 'main-declarative-settings-forms.ts'),
 	},
 	sharebymail: {
 		'vue-settings-admin-sharebymail': path.join(__dirname, 'apps/sharebymail/src', 'main-admin.js'),

Some files were not shown because too many files changed in this diff