diff --git a/library/Icinga/Web/Form/ConfigForm.php b/library/Icinga/Web/Form/ConfigForm.php
new file mode 100644
index 0000000000..d76d7e633a
--- /dev/null
+++ b/library/Icinga/Web/Form/ConfigForm.php
@@ -0,0 +1,194 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Web\Form;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Widget\ShowConfiguration;
+use ipl\Web\Compat\CompatForm;
+
+/**
+ * Form base-class providing standard functionality for configuration forms
+ */
+class ConfigForm extends CompatForm
+{
+ /** @var string Name of the submit button element */
+ protected const SUBMIT_BUTTON_NAME = 'store';
+
+ /**
+ * A list of elements that should not be saved to the configuration
+ *
+ * @var string[]
+ */
+ protected array $ignoredElements = [self::SUBMIT_BUTTON_NAME];
+
+ /**
+ * The configuration to work with
+ *
+ * @var Config|null
+ */
+ protected ?Config $config = null;
+
+ /**
+ * Set the configuration to use when populating and saving
+ *
+ * @param Config $config The configuration to use
+ *
+ * @return $this
+ */
+ public function setConfig(Config $config): static
+ {
+ $this->config = $config;
+
+ return $this;
+ }
+
+ public function ensureAssembled(): static
+ {
+ if (! $this->hasBeenAssembled) {
+ parent::ensureAssembled();
+ $this->populateFromConfig();
+ }
+
+ return $this;
+ }
+
+ /**
+ * Populate the form elements from the configuration
+ *
+ * @return void
+ *
+ * @throws ProgrammingError
+ */
+ protected function populateFromConfig(): void
+ {
+ if (! $this->config) {
+ throw new ProgrammingError("A config object must be set before populating the form.");
+ }
+
+ $populate = [];
+ foreach ($this->getElements() as $element) {
+ [$section, $key] = $this->getIniKeyFromName($element->getName());
+ if ($section === null || $key === null) {
+ continue;
+ }
+ $value = $this->getPopulatedValue($element->getName()) ?? $this->config->get($section, $key);
+ if ($value === null) {
+ continue;
+ }
+ $populate[$element->getName()] = $value;
+ }
+ $this->populate($populate);
+ }
+
+ /**
+ * Get the section and key from the element name
+ *
+ * @param string $name The element name
+ *
+ * @return string[]|null
+ */
+ protected function getIniKeyFromName(string $name): ?array
+ {
+ $parts = explode('__', $name, 2);
+
+ if (count($parts) !== 2) {
+ return [null, null];
+ }
+
+ return $parts;
+ }
+
+ /**
+ * Get the value of a configuration key from an element name
+ *
+ * @param string $name The element name
+ * @param mixed $default The default value to return if the config entry does not exist
+ *
+ * @return mixed The value of the configuration key or the default value
+ */
+ public function getConfigValue(string $name, mixed $default = null): mixed
+ {
+ [$section, $key] = $this->getIniKeyFromName($name);
+ if ($section === null || $key === null) {
+ return $default;
+ }
+
+ return $this->config->get($section, $key, $default);
+ }
+
+ /**
+ * Persist the current configuration to disk
+ *
+ * If an error occurs, the form will be re-rendered with the error message
+ * and the raw INI configuration.
+ *
+ * @throws ProgrammingError
+ */
+ protected function save(): void
+ {
+ if (! $this->config) {
+ throw new ProgrammingError("A config object must be set before saving the configuration.");
+ }
+
+ foreach ($this->getElements() as $element) {
+ if (in_array($element->getName(), $this->ignoredElements)) {
+ continue;
+ }
+ [$section, $key] = $this->getIniKeyFromName($element->getName());
+ if ($section === null || $key === null) {
+ continue;
+ }
+
+ $value = $this->getPopulatedValue($element->getName());
+ $configSection = $this->config->getSection($section);
+ if ((string) $value === '') {
+ unset($configSection[$key]);
+ } else {
+ $configSection->$key = $value;
+ }
+
+ if ($configSection->isEmpty()) {
+ $this->config->removeSection($section);
+ } else {
+ $this->config->setSection($section, $configSection);
+ }
+ }
+
+ $this->config->saveIni();
+ }
+
+ protected function onSuccess(): void
+ {
+ try {
+ $this->save();
+ } catch (Exception $e) {
+ $content = $this->getContent();
+ array_unshift(
+ $content,
+ new ShowConfiguration(
+ $e,
+ $this->config,
+ )
+ );
+ $this->setContent($content);
+ throw $e;
+ }
+ }
+
+ /**
+ * Add the store button to the form
+ *
+ * @return void
+ */
+ protected function addButtonElements(): void
+ {
+ $this->addElement('submit', static::SUBMIT_BUTTON_NAME, [
+ 'label' => $this->translate('Store'),
+ ]);
+ }
+}
diff --git a/library/Icinga/Web/Form/ConfigSectionForm.php b/library/Icinga/Web/Form/ConfigSectionForm.php
new file mode 100644
index 0000000000..ea4e9ddb56
--- /dev/null
+++ b/library/Icinga/Web/Form/ConfigSectionForm.php
@@ -0,0 +1,393 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Web\Form;
+
+use Exception;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Web\Widget\ShowConfiguration;
+use ipl\Html\Contract\FormSubmitElement;
+use ipl\Validator\CallbackValidator;
+
+class ConfigSectionForm extends ConfigForm
+{
+ /** @var string Name of the delete button element */
+ protected const DELETE_BUTTON_NAME = 'delete';
+
+ /** @var string Name of the element containing the section name */
+ protected const NAME_ELEMENT_NAME = 'name';
+
+ /** @var string Event emitted when the form has successfully deleted a configuration section */
+ public const ON_DELETE = 'delete';
+
+ /** @var string Event emitted when the form has successfully renamed a configuration section */
+ public const ON_RENAME = 'rename';
+
+ /**
+ * A list of elements that should not be saved to the configuration
+ *
+ * @var string[]
+ */
+ protected array $ignoredElements = [self::SUBMIT_BUTTON_NAME, self::DELETE_BUTTON_NAME, self::NAME_ELEMENT_NAME];
+
+ /**
+ * The section to work with.
+ * If not set, the section is determined from the element name.
+ *
+ * @var string|null
+ */
+ protected ?string $section = null;
+
+ /**
+ * Whether the form is used for creating a new configuration section
+ *
+ * @var bool
+ */
+ protected bool $isCreateForm = false;
+
+ /**
+ * Whether the form allows deletion of the configuration section
+ *
+ * @var bool
+ */
+ protected bool $allowDeletion = true;
+
+ /**
+ * Whether the form allows renaming of the configuration section
+ *
+ * @var bool
+ */
+ protected bool $allowRename = true;
+
+ public function __construct()
+ {
+ $this->on(static::ON_SENT, function () {
+ if ($this->shouldDelete()) {
+ $this->handleDelete();
+ $this->emit(static::ON_DELETE, [$this]);
+ }
+ });
+ }
+
+ protected function populateFromConfig(): void
+ {
+ if ($this->allowRename()) {
+ $this->populate([
+ static::NAME_ELEMENT_NAME => $this->getPopulatedValue(static::NAME_ELEMENT_NAME, $this->section),
+ ]);
+ }
+
+ parent::populateFromConfig();
+ }
+
+ public function isValidEvent($event): bool
+ {
+ if ($event === static::ON_DELETE || $event === static::ON_RENAME) {
+ return true;
+ }
+
+ return parent::isValidEvent($event);
+ }
+
+ /**
+ * Set the section to use when populating and saving
+ *
+ * @param string $section The section to use
+ *
+ * @return $this
+ */
+ public function setSection(string $section): static
+ {
+ $this->section = $section;
+
+ return $this;
+ }
+
+ /**
+ * Set whether the form is used for creating a new configuration section, with a name that can be chosen by the user
+ *
+ * @param bool $create
+ *
+ * @return static
+ */
+ public function setIsCreateForm(bool $create = true): static
+ {
+ $this->isCreateForm = $create;
+
+ return $this;
+ }
+
+ /**
+ * Is the form used for creating a new configuration section
+ *
+ * @return bool
+ */
+ public function isCreateForm(): bool
+ {
+ return $this->isCreateForm;
+ }
+
+ /**
+ * Set whether the form allows deletion of the configuration section
+ *
+ * @param bool $allowDeletion
+ *
+ * @return static
+ */
+ public function setAllowDeletion(bool $allowDeletion = true): static
+ {
+ $this->allowDeletion = $allowDeletion;
+
+ return $this;
+ }
+
+ /**
+ * Whether the form is allowed to delete the configuration section
+ *
+ * Note: Creation forms are never allowed to be deleted.
+ *
+ * @return bool
+ */
+ public function allowDeletion(): bool
+ {
+ if ($this->isCreateForm()) {
+ return false;
+ }
+
+ return $this->allowDeletion;
+ }
+
+ /**
+ * Set the ability to rename the configuration section
+ *
+ * @param bool $allowRename Whether the form is allowed to rename the configuration section
+ *
+ * @return $this
+ */
+ public function setAllowRename(bool $allowRename = true): static
+ {
+ $this->allowRename = $allowRename;
+
+ return $this;
+ }
+
+ /**
+ * Whether the form is allowed to rename the configuration section
+ *
+ * Note: Creation forms are never allowed to be rename forms.
+ *
+ * @return bool
+ */
+ public function allowRename(): bool
+ {
+ if ($this->isCreateForm()) {
+ return false;
+ }
+
+ return $this->allowRename;
+ }
+
+ /**
+ * Handle the deletion of the configuration section
+ *
+ * This method is called when the delete button is pressed.
+ * It deletes the underlying section regardless of whether form validation passed.
+ * This is done to allow for deletion of sections that contain invalid configuration.
+ *
+ * @return void
+ *
+ * @throws ProgrammingError
+ */
+ protected function handleDelete(): void
+ {
+ if ($this->section === null) {
+ throw new ProgrammingError('Section must be set before deleting a configuration section.');
+ }
+
+ try {
+ $this->config->removeSection($this->section);
+ $this->config->saveIni();
+ } catch (Exception $e) {
+ $content = $this->getContent();
+ array_unshift(
+ $content,
+ new ShowConfiguration(
+ $e,
+ $this->config,
+ )
+ );
+ $this->setContent($content);
+ throw $e;
+ }
+ }
+
+ /**
+ * Handle the renaming of the configuration section
+ *
+ * This method is called when the rename button is pressed.
+ * It renames the underlying section and updates the section name in the form.
+ *
+ * @return void
+ *
+ * @throws ProgrammingError
+ */
+ protected function handleRename(): void
+ {
+ if (! $this->config) {
+ throw new ProgrammingError('Config must be set before renaming a configuration section.');
+ }
+
+ if ($this->section === null) {
+ throw new ProgrammingError('Section must be set before renaming a configuration section.');
+ }
+
+ $newName = $this->getPopulatedValue(static::NAME_ELEMENT_NAME);
+ $section = $this->config->getSection($this->section);
+ $this->config->removeSection($this->section);
+ $this->config->setSection($newName, $section);
+ $this->section = $newName;
+ }
+
+ /**
+ * Check if the delete button has been pressed and the section should be deleted
+ *
+ * @return bool
+ */
+ public function shouldDelete(): bool
+ {
+ if (! $this->hasDeleteButton()) {
+ return false;
+ }
+
+ $deleteButton = $this->getElement(static::DELETE_BUTTON_NAME);
+ if (! ($deleteButton instanceof FormSubmitElement)) {
+ return false;
+ }
+
+ return $deleteButton->hasBeenPressed();
+ }
+
+ public function hasDeleteButton(): bool
+ {
+ return $this->hasElement(static::DELETE_BUTTON_NAME);
+ }
+
+ /**
+ * Add the section name element to the form
+ *
+ * This element is used to create a new configuration section with the given
+ * name. The added element automatically validates that the name is unique
+ * within the configuration.
+ *
+ * @param array $params Additional parameters to pass to the element constructor
+ *
+ * @return void
+ */
+ protected function addSectionNameElement(array $params = []): void
+ {
+ if (! $this->isCreateForm() && ! $this->allowRename()) {
+ return;
+ }
+
+ $params['required'] = true;
+
+ if (! isset($params['label'])) {
+ $params['label'] = $this->translate('Name');
+ }
+
+ $uniqueValidator = new CallbackValidator(function ($value, CallbackValidator $validator) {
+ if (empty($value)) {
+ return true;
+ }
+
+ if ($value == $this->section) {
+ return true;
+ }
+
+ if ($this->config->hasSection($value)) {
+ $validator->addMessage(t('An entry with this name already exists.'));
+ return false;
+ }
+
+ return true;
+ });
+
+ if (! isset($params['validators'])) {
+ $params['validators'] = [];
+ }
+ $params['validators'][] = $uniqueValidator;
+
+ $this->addElement('text', static::NAME_ELEMENT_NAME, $params);
+ }
+
+ protected function getIniKeyFromName(string $name): ?array
+ {
+ return [$this->section, $name];
+ }
+
+ protected function onSuccess(): void
+ {
+ if ($this->isCreateForm()) {
+ $this->section = $this->getValue(static::NAME_ELEMENT_NAME);
+
+ if (empty($this->section)) {
+ throw new ProgrammingError('Section must be set before saving a new configuration section.');
+ }
+ }
+
+ $oldSection = $this->section;
+ $isRename = $this->shouldRename();
+
+ if ($isRename) {
+ $this->handleRename();
+ }
+
+ parent::onSuccess();
+
+ if ($isRename) {
+ $this->emit(static::ON_RENAME, [
+ $this,
+ $oldSection,
+ $this->section,
+ ]);
+ }
+ }
+
+ protected function addButtonElements(): void
+ {
+ parent::addButtonElements();
+
+ if (! $this->allowDeletion()) {
+ return;
+ }
+
+ $deleteButton = $this->createElement(
+ 'submit',
+ static::DELETE_BUTTON_NAME,
+ [
+ 'label' => $this->translate('Delete'),
+ 'formnovalidate' => true,
+ ],
+ );
+ $this->registerElement($deleteButton);
+ $this->getElement(static::SUBMIT_BUTTON_NAME)
+ ->getWrapper()
+ ->prepend($deleteButton);
+ }
+
+ /**
+ * Check if the form should rename the section for this request
+ *
+ * @return bool
+ */
+ private function shouldRename(): bool
+ {
+ if (! $this->allowRename() || ! $this->hasBeenSubmitted() || ! $this->isValid()) {
+ return false;
+ }
+
+ return $this->section !== $this->getPopulatedValue(static::NAME_ELEMENT_NAME);
+ }
+}
diff --git a/library/Icinga/Web/Widget/ShowConfiguration.php b/library/Icinga/Web/Widget/ShowConfiguration.php
new file mode 100644
index 0000000000..261906dd80
--- /dev/null
+++ b/library/Icinga/Web/Widget/ShowConfiguration.php
@@ -0,0 +1,98 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Web\Widget;
+
+use Exception;
+use Icinga\Application\Config;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\HtmlElement;
+use ipl\Html\HtmlString;
+use ipl\I18n\Translation;
+use ipl\Web\Widget\CopyToClipboard;
+
+/**
+ * Widget to show the configuration that couldn't be saved.
+ */
+class ShowConfiguration extends BaseHtmlElement
+{
+ use Translation;
+
+ protected $tag = 'div';
+
+ /**
+ * Create a new ShowConfiguration widget
+ * @param Exception $exception The exception that was thrown
+ * @param Config $config The configuration instance
+ */
+ public function __construct(
+ protected Exception $exception,
+ protected Config $config,
+ ) {
+ }
+
+ protected function assemble(): void
+ {
+ $this->addHtml(HtmlElement::create(
+ 'h4',
+ null,
+ t('Saving Configuration Failed!'),
+ ));
+
+ $this->addHtml(HtmlElement::create(
+ 'p',
+ null,
+ [
+ sprintf(
+ t("The file %s couldn't be stored. (Error: '%s')"),
+ $this->config->getConfigFile(),
+ $this->exception->getMessage(),
+ ),
+ HtmlString::create('
'),
+ t('This could have one or more of the following reasons:'),
+ ],
+ ));
+
+ $this->addHtml(HtmlElement::create(
+ 'ul',
+ null,
+ [
+ HtmlElement::create('li', null, t("You don't have file-system permissions to write to the file")),
+ HtmlElement::create('li', null, t('Something went wrong while writing the file')),
+ HtmlElement::create(
+ 'li',
+ null,
+ t("There's an application error preventing you from persisting the configuration"),
+ ),
+ ],
+ ));
+
+ $this->addHtml(HtmlElement::create(
+ 'p',
+ null,
+ [
+ t(
+ 'Details can be found in the application log. ' .
+ "(If you don't have access to this log, call your administrator in this case)",
+ ),
+ HtmlString::create('
'),
+ t('In case you can access the file by yourself, you can open it and insert the config manually:'),
+ ],
+ ));
+
+ $code = HtmlElement::create('code', null, (string) $this->config);
+ CopyToClipboard::attachTo($code);
+
+ $this->addHtml(HtmlElement::create(
+ 'p',
+ null,
+ HtmlElement::create(
+ 'pre',
+ null,
+ $code,
+ ),
+ ));
+ }
+}