diff --git a/application/controllers/ContactsController.php b/application/controllers/ContactsController.php index df0b1adf..c2e1e29e 100644 --- a/application/controllers/ContactsController.php +++ b/application/controllers/ContactsController.php @@ -42,14 +42,8 @@ public function init() public function indexAction() { - $contacts = Contact::on($this->db); - - $contacts->withColumns( - [ - 'has_email', - 'has_rc' - ] - ); + $contacts = Contact::on($this->db) + ->withColumns('has_email'); $limitControl = $this->createLimitControl(); $paginationControl = $this->createPaginationControl($contacts); diff --git a/application/forms/ChannelForm.php b/application/forms/ChannelForm.php index a8a31dec..b1388b95 100644 --- a/application/forms/ChannelForm.php +++ b/application/forms/ChannelForm.php @@ -5,11 +5,18 @@ namespace Icinga\Module\Notifications\Forms; use Icinga\Module\Notifications\Model\Channel; +use Icinga\Module\Notifications\Model\AvailableChannelType; use Icinga\Web\Session; use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\FormElement\BaseFormElement; +use ipl\Html\FormElement\FieldsetElement; +use ipl\I18n\GettextTranslator; +use ipl\I18n\StaticTranslator; use ipl\Sql\Connection; +use ipl\Validator\EmailAddressValidator; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; +use stdClass; class ChannelForm extends CompatForm { @@ -41,115 +48,41 @@ protected function assemble() ] ); - $type = [ - '' => sprintf(' - %s - ', $this->translate('Please choose')), - 'email' => $this->translate('Email'), - 'rocketchat' => 'Rocket.Chat' - ]; + $query = AvailableChannelType::on($this->db)->columns(['type', 'name', 'config_attrs']); + + /** @var string[] $typesConfig */ + $typesConfig = []; + + /** @var string[] $typeNamePair */ + $typeNamePair = []; + + $defaultType = null; + /** @var Channel $channel */ + foreach ($query as $channel) { + if ($defaultType === null) { + $defaultType = $channel->type; + } + + $typesConfig[$channel->type] = $channel->config_attrs; + $typeNamePair[$channel->type] = $channel->name; + } $this->addElement( 'select', 'type', [ - 'label' => $this->translate('Type'), - 'class' => 'autosubmit', - 'required' => true, - 'disable' => [''], - 'options' => $type + 'label' => $this->translate('Type'), + 'class' => 'autosubmit', + 'required' => true, + 'disabledOptions' => [''], + 'value' => $defaultType, + 'options' => $typeNamePair ] ); + /** @var string $selectedType */ $selectedType = $this->getValue('type'); - - if ($selectedType === 'email') { - $this->addElement( - 'text', - 'host', - [ - 'label' => $this->translate('SMTP Host'), - 'autocomplete' => 'off', - 'placeholder' => 'localhost' - ] - )->addElement( - 'select', - 'port', - [ - 'label' => $this->translate('SMTP Port'), - 'options' => [ - 25 => 25, - 465 => 465, - 587 => 587, - 2525 => 2525 - ] - ] - )->addElement( - 'text', - 'from', - [ - 'label' => $this->translate('From'), - 'autocomplete' => 'off', - 'placeholder' => 'notifications@icinga' - ] - )->addElement( - 'password', - 'password', - [ - 'label' => $this->translate('Password'), - 'autocomplete' => 'off', - ] - ); - - $this->addElement( - 'checkbox', - 'tls', - [ - 'label' => 'TLS / SSL', - 'class' => 'autosubmit', - 'checkedValue' => '1', - 'uncheckedValue' => '0', - 'value' => '1' - ] - ); - - if ($this->getElement('tls')->getValue() === '1') { - $this->addElement( - 'checkbox', - 'tls_certcheck', - [ - 'label' => $this->translate('Certificate Check'), - 'class' => 'autosubmit', - 'checkedValue' => '1', - 'uncheckedValue' => '0', - 'value' => '0' - ] - ); - } - } elseif ($selectedType === 'rocketchat') { - $this->addElement( - 'text', - 'url', - [ - 'label' => $this->translate('Rocket.Chat URL'), - 'required' => true - ] - )->addElement( - 'text', - 'user_id', - [ - 'autocomplete' => 'off', - 'label' => $this->translate('User ID'), - 'required' => true - ] - )->addElement( - 'password', - 'token', - [ - 'autocomplete' => 'off', - 'label' => $this->translate('Personal Access Token'), - 'required' => true - ] - ); - } + $this->createConfigElements($selectedType, $typesConfig[$selectedType]); $this->addElement( 'submit', @@ -207,13 +140,11 @@ public function hasBeenSubmitted() public function populate($values) { if ($values instanceof Channel) { - $values = array_merge( - [ - 'name' => $values->name, - 'type' => $values->type - ], - json_decode($values->config, JSON_OBJECT_AS_ARRAY) ?? [] - ); + $values = [ + 'name' => $values->name, + 'type' => $values->type, + 'config' => json_decode($values->config, true) ?? [] + ]; } parent::populate($values); @@ -229,30 +160,7 @@ protected function onSuccess() return; } - $channel = [ - 'name' => $this->getValue('name'), - 'type' => $this->getValue('type') - ]; - - if ($this->getValue('type') === 'email') { - $channel['config'] = [ - 'host' => $this->getValue('host'), - 'port' => $this->getValue('port'), - 'from' => $this->getValue('from'), - 'password' => $this->getValue('password') - ]; - if ($this->getElement('tls')->isChecked()) { - $channel['config']['tls'] = true; - $channel['config']['tls_certcheck'] = $this->getValue('tls_certcheck'); - } - } else { - $channel['config'] = [ - 'url' => $this->getValue('url'), - 'user_id' => $this->getValue('user_id'), - 'token' => $this->getValue('token') - ]; - } - + $channel = $this->getValues(); $channel['config'] = json_encode($channel['config']); if ($this->channelId === null) { $this->db->insert('channel', $channel); @@ -260,4 +168,144 @@ protected function onSuccess() $this->db->update('channel', $channel, ['id = ?' => $this->channelId]); } } + + /** + * Create config elements for the given channel type + * + * @param string $type The channel type + * @param string $config The channel type config + */ + protected function createConfigElements(string $type, string $config): void + { + /** @var array $elementsConfig */ + $elementsConfig = json_decode($config, false); + + if (empty($elementsConfig)) { + return; + } + + $configFieldset = new FieldsetElement('config'); + $this->addElement($configFieldset); + + foreach ($elementsConfig as $elementConfig) { + /** @var BaseFormElement $elem */ + $elem = $this->createElement( + $this->getElementType($elementConfig), + $elementConfig->name, + $this->getElementOptions($elementConfig) + ); + + if ($type === "email" && $elem->getName() === "sender_mail") { + $elem->getValidators()->add(new EmailAddressValidator()); + } + + $configFieldset->addElement($elem); + } + } + + /** + * Get the element type from given element config + * + * @param stdClass $elementConfig The config object of an element + * + * @return string + */ + protected function getElementType(stdClass $elementConfig): string + { + switch ($elementConfig->type) { + case 'string': + $elementType = 'text'; + break; + case 'number': + $elementType = 'number'; + break; + case 'text': + $elementType = 'textarea'; + break; + case 'bool': + $elementType = 'checkbox'; + break; + case 'option': + case 'options': + $elementType = 'select'; + break; + case 'secret': + $elementType = 'password'; + break; + default: + $elementType = 'text'; + } + + return $elementType; + } + + /** + * Get the element options from the given element config + * + * @param stdClass $elementConfig + * + * @return string[] + */ + protected function getElementOptions(stdClass $elementConfig): array + { + $options = [ + 'label' => $this->fromCurrentLocale($elementConfig->label) + ]; + + if (isset($elementConfig->help)) { + $options['description'] = $this->fromCurrentLocale($elementConfig->help); + } + + if (isset($elementConfig->required)) { + $options['required'] = $elementConfig->required; + } + + $isSelectElement = isset($elementConfig->options) + && ($elementConfig->type === 'option' || $elementConfig->type === 'options'); + if ($isSelectElement) { + $options['options'] = (array) $elementConfig->options; + if ($elementConfig->type === 'options') { + $options['multiple'] = true; + } + } + + if (isset($elementConfig->default)) { + if ($isSelectElement || $elementConfig->type === 'bool') { + $options['value'] = $elementConfig->default; + } else { + $options['placeholder'] = $elementConfig->default; + } + } + + if ($elementConfig->type === "number") { + if (isset($elementConfig->min)) { + $options['min'] = $elementConfig->min; + } + + if (isset($elementConfig->max)) { + $options['max'] = $elementConfig->max; + } + } + + return $options; + } + + /** + * Get the current locale based string from given locale map + * + * Fallback to locale `en_US` if the current locale isn't provided + * + * @param stdClass $localeMap + * + * @return ?string Only returns null if the fallback locale is also not specified + */ + protected function fromCurrentLocale(stdClass $localeMap): ?string + { + /** @var GettextTranslator $translator */ + $translator = StaticTranslator::$instance; + $default = $translator->getDefaultLocale(); + $locale = $translator->getLocale(); + + return $localeMap->$locale ?? $localeMap->$default ?? null; + } } diff --git a/application/forms/EscalationRecipientForm.php b/application/forms/EscalationRecipientForm.php index 48baf434..006d71a8 100644 --- a/application/forms/EscalationRecipientForm.php +++ b/application/forms/EscalationRecipientForm.php @@ -71,7 +71,7 @@ protected function assembleElements(): void $this->registerElement($col); $options = ['' => sprintf(' - %s - ', $this->translate('Please choose'))]; - $options += Channel::fetchChannelTypes(Database::get()); + $options += Channel::fetchChannelNames(Database::get()); $val = $this->createElement( 'select', diff --git a/library/Notifications/Model/AvailableChannelType.php b/library/Notifications/Model/AvailableChannelType.php new file mode 100644 index 00000000..84fa77df --- /dev/null +++ b/library/Notifications/Model/AvailableChannelType.php @@ -0,0 +1,37 @@ +hasMany('channel', Channel::class) + ->setForeignKey('type'); + } +} diff --git a/library/Notifications/Model/Behavior/HasAddress.php b/library/Notifications/Model/Behavior/HasAddress.php index 78584075..2e7db3fc 100644 --- a/library/Notifications/Model/Behavior/HasAddress.php +++ b/library/Notifications/Model/Behavior/HasAddress.php @@ -30,7 +30,7 @@ public function setQuery(Query $query) public function rewriteColumn($column, ?string $relation = null) { if ($this->isSelectableColumn($column)) { - $type = $column === 'has_email' ? 'email' : 'rocketchat'; + $type = 'email'; $subQueryRelation = $relation !== null ? $relation . '.contact.contact_address' : 'contact.contact_address'; @@ -53,7 +53,7 @@ public function rewriteColumn($column, ?string $relation = null) public function isSelectableColumn(string $name): bool { - return $name === 'has_email' || $name === 'has_rc'; + return $name === 'has_email'; } public function rewriteColumnDefinition(ColumnDefinition $def, string $relation): void @@ -61,9 +61,7 @@ public function rewriteColumnDefinition(ColumnDefinition $def, string $relation) $name = $def->getName(); if ($this->isSelectableColumn($name)) { - $label = $name === 'has_email' ? t('Has Email Address') : t('Has Rocket.Chat Username'); - - $def->setLabel($label); + $def->setLabel(t('Has Email Address')); } } @@ -72,7 +70,7 @@ public function rewriteCondition(Filter\Condition $condition, $relation = null) $column = substr($condition->getColumn(), strlen($relation)); if ($this->isSelectableColumn($column)) { - $type = $column === 'has_email' ? 'email' : 'rocketchat'; + $type = 'email'; $subQuery = $this->query->createSubQuery(new ContactAddress(), $relation) ->limit(1) diff --git a/library/Notifications/Model/Channel.php b/library/Notifications/Model/Channel.php index 5e879446..e857e277 100644 --- a/library/Notifications/Model/Channel.php +++ b/library/Notifications/Model/Channel.php @@ -7,6 +7,7 @@ use ipl\Orm\Model; use ipl\Orm\Relations; use ipl\Sql\Connection; +use ipl\Web\Widget\Icon; class Channel extends Model { @@ -55,30 +56,45 @@ public function createRelations(Relations $relations) $relations->hasMany('contact', Contact::class) ->setJoinType('LEFT') ->setForeignKey('default_channel_id'); + $relations->belongsTo('available_channel_type', AvailableChannelType::class) + ->setCandidateKey('type'); } /** - * Fetch and map all the configured channel types to a key => value array + * Get the channel icon + * + * @return Icon + */ + public function getIcon(): Icon + { + switch ($this->type) { + case 'rocketchat': + $icon = new Icon('comment-dots'); + break; + case 'email': + $icon = new Icon('at'); + break; + default: + $icon = new Icon('envelope'); + } + + return $icon; + } + + /** + * Fetch and map all the configured channel names to a key => value array * * @param Connection $conn * - * @return array All the channel types mapped as id => type + * @return string[] All the channel names mapped as id => name */ - public static function fetchChannelTypes(Connection $conn): array + public static function fetchChannelNames(Connection $conn): array { $channels = []; + /** @var Channel $channel */ foreach (Channel::on($conn) as $channel) { - switch ($channel->type) { - case 'rocketchat': - $name = 'Rocket.Chat'; - break; - case 'email': - $name = t('E-Mail'); - break; - default: - $name = $channel->type; - } - + /** @var string $name */ + $name = $channel->name; $channels[$channel->id] = $name; } diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index 1d59b42d..5835ded3 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -5,6 +5,7 @@ namespace Icinga\Module\Notifications\Web\Form; use Icinga\Module\Notifications\Common\Database; +use Icinga\Module\Notifications\Model\AvailableChannelType; use Icinga\Module\Notifications\Model\Channel; use Icinga\Module\Notifications\Model\Contact; use Icinga\Module\Notifications\Model\ContactAddress; @@ -80,7 +81,7 @@ protected function assemble() $this->addElement($contact); $channelOptions = ['' => sprintf(' - %s - ', $this->translate('Please choose'))]; - $channelOptions += Channel::fetchChannelTypes(Database::get()); + $channelOptions += Channel::fetchChannelNames(Database::get()); $contact->addElement( 'text', @@ -120,37 +121,14 @@ protected function assemble() 'select', 'default_channel_id', [ - 'label' => $this->translate('Default Channel'), - 'required' => true, - 'disable' => [''], - 'options' => $channelOptions + 'label' => $this->translate('Default Channel'), + 'required' => true, + 'disabledOptions' => [''], + 'options' => $channelOptions ] ); - // Fieldset for addresses - $address = (new FieldsetElement( - 'contact_address', - [ - 'label' => $this->translate('Addresses'), - ] - )); - - $this->addElement($address); - - $address->addElement( - 'text', - 'email', - [ - 'label' => $this->translate('Email Address'), - 'validators' => [new EmailAddressValidator()] - ] - )->addElement( - 'text', - 'rocketchat', - [ - 'label' => $this->translate('Rocket.Chat Username'), - ] - ); + $this->addAddressElements(); $this->addElement( 'submit', @@ -227,11 +205,7 @@ public function addOrUpdateContact() $this->db->beginTransaction(); - $addressFromDb = [ - 'email' => null, - 'rocketchat' => null - ]; - + $addressFromDb = []; if ($this->contactId === null) { $this->db->insert('contact', $contact); @@ -244,17 +218,12 @@ public function addOrUpdateContact() $addressObjects->filter(Filter::equal('contact_id', $this->contactId)); foreach ($addressObjects as $addressRow) { - if ($addressRow->type === 'email') { - $addressFromDb['email'] = [$addressRow->id, $addressRow->address]; - } - - if ($addressRow->type === 'rocketchat') { - $addressFromDb['rocketchat'] = [$addressRow->id, $addressRow->address]; - } + $addressFromDb[$addressRow->type] = [$addressRow->id, $addressRow->address]; } } - foreach ($addressFromDb as $type => $value) { + $addr = ! empty($addressFromDb) ? $addressFromDb : $addressFromForm; + foreach ($addr as $type => $value) { $this->insertOrUpdateAddress($type, $addressFromForm, $addressFromDb); } @@ -281,7 +250,7 @@ public function removeContact() private function insertOrUpdateAddress(string $type, array $addressFromForm, array $addressFromDb): void { if ($addressFromForm[$type] !== null) { - if ($addressFromDb[$type] === null) { + if (! isset($addressFromDb[$type])) { $address = [ 'contact_id' => $this->contactId, 'type' => $type, @@ -299,8 +268,38 @@ private function insertOrUpdateAddress(string $type, array $addressFromForm, arr ] ); } - } elseif ($addressFromDb[$type] !== null) { + } elseif (isset($addressFromDb[$type])) { $this->db->delete('contact_address', ['id = ?' => $addressFromDb[$type][0]]); } } + + /** + * Add address elements for all existing channel plugins + * + * @return void + */ + private function addAddressElements(): void + { + $plugins = $this->db->fetchPairs( + AvailableChannelType::on($this->db) + ->columns(['type', 'name']) + ->assembleSelect() + ); + + if (empty($plugins)) { + return; + } + + $address = new FieldsetElement('contact_address', ['label' => $this->translate('Addresses')]); + $this->addElement($address); + + foreach ($plugins as $type => $label) { + $element = $this->createElement('text', $type, ['label' => $label]); + if ($type === 'email') { + $element->addAttributes(['validators' => [new EmailAddressValidator()]]); + } + + $address->addElement($element); + } + } } diff --git a/library/Notifications/Widget/ItemList/ChannelListItem.php b/library/Notifications/Widget/ItemList/ChannelListItem.php index 0c43308b..47b8b766 100644 --- a/library/Notifications/Widget/ItemList/ChannelListItem.php +++ b/library/Notifications/Widget/ItemList/ChannelListItem.php @@ -30,11 +30,7 @@ protected function init(): void protected function assembleVisual(BaseHtmlElement $visual): void { - if ($this->item->type === 'email') { - $visual->addHtml(new Icon('envelope')); - } elseif ($this->item->type === 'rocketchat') { - $visual->addHtml(new Icon('comment-dots')); - } + $visual->addHtml($this->item->getIcon()); } protected function assembleTitle(BaseHtmlElement $title): void diff --git a/library/Notifications/Widget/ItemList/ContactListItem.php b/library/Notifications/Widget/ItemList/ContactListItem.php index a6917856..b73cbab1 100644 --- a/library/Notifications/Widget/ItemList/ContactListItem.php +++ b/library/Notifications/Widget/ItemList/ContactListItem.php @@ -44,11 +44,7 @@ protected function assembleFooter(BaseHtmlElement $footer): void { $contactIcons = new HtmlElement('div', Attributes::create(['class' => 'contact-icons'])); if ($this->item->has_email) { - $contactIcons->addHtml(new Icon('envelope')); - } - - if ($this->item->has_rc) { - $contactIcons->addHtml(new Icon('comment-dots')); + $contactIcons->addHtml(new Icon('at')); } $footer->addHtml($contactIcons); diff --git a/phpstan-baseline-7x.neon b/phpstan-baseline-7x.neon index 79c2cba4..5be0f381 100644 --- a/phpstan-baseline-7x.neon +++ b/phpstan-baseline-7x.neon @@ -1,15 +1,5 @@ parameters: ignoreErrors: - - - message: "#^Parameter \\#2 \\$assoc of function json_decode expects bool\\|null, int given\\.$#" - count: 1 - path: application/forms/ChannelForm.php - - - - message: "#^Parameter \\#2 \\.\\.\\.\\$args of function array_merge expects array, mixed given\\.$#" - count: 1 - path: application/forms/ChannelForm.php - - message: "#^Parameter \\#2 \\$str of function explode expects string, mixed given\\.$#" count: 2 diff --git a/phpstan-baseline-8x.neon b/phpstan-baseline-8x.neon index 55ee9a38..99ed68cf 100644 --- a/phpstan-baseline-8x.neon +++ b/phpstan-baseline-8x.neon @@ -1,15 +1,5 @@ parameters: ignoreErrors: - - - message: "#^Parameter \\#2 \\$associative of function json_decode expects bool\\|null, int given\\.$#" - count: 1 - path: application/forms/ChannelForm.php - - - - message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, mixed given\\.$#" - count: 1 - path: application/forms/ChannelForm.php - - message: "#^Parameter \\#2 \\$string of function explode expects string, mixed given\\.$#" count: 2 diff --git a/phpstan-baseline-standard.neon b/phpstan-baseline-standard.neon index 3cf5dfa3..863e0252 100644 --- a/phpstan-baseline-standard.neon +++ b/phpstan-baseline-standard.neon @@ -295,11 +295,6 @@ parameters: count: 1 path: application/forms/BaseEscalationForm.php - - - message: "#^Call to an undefined method ipl\\\\Html\\\\Contract\\\\FormElement\\:\\:isChecked\\(\\)\\.$#" - count: 1 - path: application/forms/ChannelForm.php - - message: "#^Cannot call method getName\\(\\) on ipl\\\\Html\\\\Contract\\\\FormSubmitElement\\|null\\.$#" count: 2 @@ -715,26 +710,11 @@ parameters: count: 1 path: library/Notifications/Model/Behavior/HasAddress.php - - - message: "#^Cannot access property \\$id on mixed\\.$#" - count: 1 - path: library/Notifications/Model/Channel.php - - - - message: "#^Cannot access property \\$type on mixed\\.$#" - count: 2 - path: library/Notifications/Model/Channel.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Model\\\\Channel\\:\\:createRelations\\(\\) has no return type specified\\.$#" count: 1 path: library/Notifications/Model/Channel.php - - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Model\\\\Channel\\:\\:fetchChannelTypes\\(\\) should return array\\ but returns array\\\\.$#" - count: 1 - path: library/Notifications/Model/Channel.php - - message: "#^Method Icinga\\\\Module\\\\Notifications\\\\Model\\\\Channel\\:\\:getColumnDefinitions\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 @@ -1067,17 +1047,17 @@ parameters: - message: "#^Cannot access property \\$address on mixed\\.$#" - count: 4 + count: 3 path: library/Notifications/Web/Form/ContactForm.php - message: "#^Cannot access property \\$id on mixed\\.$#" - count: 2 + count: 1 path: library/Notifications/Web/Form/ContactForm.php - message: "#^Cannot access property \\$type on mixed\\.$#" - count: 4 + count: 3 path: library/Notifications/Web/Form/ContactForm.php -