diff --git a/CHANGELOG.md b/CHANGELOG.md index 1492e49fd..e30ab1e6b 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +* [PR-564](https://github.com/itk-dev/deltag.aarhus.dk/pull/564) + Added hoeringsportal_anonymous_edit module * [PR-557](https://github.com/itk-dev/deltag.aarhus.dk/pull/557) * Change dialogue proposal backend * Add seperate view for dialogue proposal comments diff --git a/Taskfile.yml b/Taskfile.yml index de1aa03ba..22b38cec1 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -284,6 +284,8 @@ tasks: # We need the pretix service to load public meeting fixtures (cf. https://docs.docker.com/compose/how-tos/environment-variables/envvars/#compose_profiles) - COMPOSE_PROFILES=pretix COMPOSE_UP_WAIT=true task compose-up - task drush -- --yes pm:enable hoeringsportal_base_fixtures $(find web/modules/custom -type f -name 'hoeringsportal_*_fixtures.info.yml' -exec basename -s .info.yml {} \;) + - task drush -- sql:query "TRUNCATE hoeringsportal_anonymous_edit_content" + - task drush -- sql:query "TRUNCATE hoeringsportal_anonymous_edit_owners" - task drush -- --yes content-fixtures:load - task drush -- --yes pm:uninstall content_fixtures # Update states on public meetings. diff --git a/config/sync/core.entity_form_display.comment.early_inclusion_comment.default.yml b/config/sync/core.entity_form_display.comment.early_inclusion_comment.default.yml index 3bdf32daf..f20883296 100644 --- a/config/sync/core.entity_form_display.comment.early_inclusion_comment.default.yml +++ b/config/sync/core.entity_form_display.comment.early_inclusion_comment.default.yml @@ -10,6 +10,11 @@ targetEntityType: comment bundle: early_inclusion_comment mode: default content: + author: + weight: 1 + region: content + settings: { } + third_party_settings: { } field_comment: type: string_textarea weight: 0 @@ -19,6 +24,5 @@ content: placeholder: '' third_party_settings: { } hidden: - author: true langcode: true subject: true diff --git a/config/sync/core.entity_form_display.node.dialogue_proposal.default.yml b/config/sync/core.entity_form_display.node.dialogue_proposal.default.yml index ad9f5534f..e0dd95d16 100644 --- a/config/sync/core.entity_form_display.node.dialogue_proposal.default.yml +++ b/config/sync/core.entity_form_display.node.dialogue_proposal.default.yml @@ -9,6 +9,8 @@ dependencies: - field.field.node.dialogue_proposal.field_dialogue_proposal_descr - field.field.node.dialogue_proposal.field_image_upload - field.field.node.dialogue_proposal.field_location + - field.field.node.dialogue_proposal.field_owner_email + - field.field.node.dialogue_proposal.field_owner_name - image.style.thumbnail - node.type.dialogue_proposal module: @@ -61,6 +63,22 @@ content: localplanids: 0 localplanids_node: 0 third_party_settings: { } + field_owner_email: + type: email_default + weight: 27 + region: content + settings: + placeholder: '' + size: 60 + third_party_settings: { } + field_owner_name: + type: string_textfield + weight: 26 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } title: type: string_textfield weight: 0 diff --git a/config/sync/core.entity_view_display.node.dialogue_proposal.default.yml b/config/sync/core.entity_view_display.node.dialogue_proposal.default.yml index 28e62aa4f..3cc05a66d 100644 --- a/config/sync/core.entity_view_display.node.dialogue_proposal.default.yml +++ b/config/sync/core.entity_view_display.node.dialogue_proposal.default.yml @@ -10,6 +10,8 @@ dependencies: - field.field.node.dialogue_proposal.field_dialogue_proposal_descr - field.field.node.dialogue_proposal.field_image_upload - field.field.node.dialogue_proposal.field_location + - field.field.node.dialogue_proposal.field_owner_email + - field.field.node.dialogue_proposal.field_owner_name - node.type.dialogue_proposal module: - comment @@ -75,6 +77,21 @@ content: third_party_settings: { } weight: 4 region: content + field_owner_email: + type: basic_string + label: hidden + settings: { } + third_party_settings: { } + weight: 8 + region: content + field_owner_name: + type: string + label: hidden + settings: + link_to_entity: false + third_party_settings: { } + weight: 7 + region: content flag_support_proposal: settings: { } third_party_settings: { } diff --git a/config/sync/core.entity_view_display.node.dialogue_proposal.list_display.yml b/config/sync/core.entity_view_display.node.dialogue_proposal.list_display.yml index b035a341c..acaa26bce 100644 --- a/config/sync/core.entity_view_display.node.dialogue_proposal.list_display.yml +++ b/config/sync/core.entity_view_display.node.dialogue_proposal.list_display.yml @@ -10,6 +10,8 @@ dependencies: - field.field.node.dialogue_proposal.field_dialogue_proposal_descr - field.field.node.dialogue_proposal.field_image_upload - field.field.node.dialogue_proposal.field_location + - field.field.node.dialogue_proposal.field_owner_email + - field.field.node.dialogue_proposal.field_owner_name - node.type.dialogue_proposal module: - user @@ -43,6 +45,8 @@ hidden: field_dialogue: true field_image_upload: true field_location: true + field_owner_email: true + field_owner_name: true langcode: true links: true published_at: true diff --git a/config/sync/core.entity_view_display.node.dialogue_proposal.search_result.yml b/config/sync/core.entity_view_display.node.dialogue_proposal.search_result.yml index a381e88a5..6ba97eb5c 100644 --- a/config/sync/core.entity_view_display.node.dialogue_proposal.search_result.yml +++ b/config/sync/core.entity_view_display.node.dialogue_proposal.search_result.yml @@ -10,6 +10,8 @@ dependencies: - field.field.node.dialogue_proposal.field_dialogue_proposal_descr - field.field.node.dialogue_proposal.field_image_upload - field.field.node.dialogue_proposal.field_location + - field.field.node.dialogue_proposal.field_owner_email + - field.field.node.dialogue_proposal.field_owner_name - node.type.dialogue_proposal module: - user @@ -30,6 +32,8 @@ hidden: field_dialogue_proposal_descr: true field_image_upload: true field_location: true + field_owner_email: true + field_owner_name: true langcode: true links: true published_at: true diff --git a/config/sync/core.entity_view_display.node.dialogue_proposal.teaser.yml b/config/sync/core.entity_view_display.node.dialogue_proposal.teaser.yml index e0474bc67..590fc5c82 100644 --- a/config/sync/core.entity_view_display.node.dialogue_proposal.teaser.yml +++ b/config/sync/core.entity_view_display.node.dialogue_proposal.teaser.yml @@ -10,6 +10,8 @@ dependencies: - field.field.node.dialogue_proposal.field_dialogue_proposal_descr - field.field.node.dialogue_proposal.field_image_upload - field.field.node.dialogue_proposal.field_location + - field.field.node.dialogue_proposal.field_owner_email + - field.field.node.dialogue_proposal.field_owner_name - node.type.dialogue_proposal module: - user @@ -40,6 +42,8 @@ hidden: field_dialogue_proposal_descr: true field_image_upload: true field_location: true + field_owner_email: true + field_owner_name: true langcode: true published_at: true search_api_excerpt: true diff --git a/config/sync/core.extension.yml b/config/sync/core.extension.yml index 4287ac84c..819420829 100644 --- a/config/sync/core.extension.yml +++ b/config/sync/core.extension.yml @@ -44,6 +44,7 @@ module: file_resup: 0 filter: 0 flag: 0 + hoeringsportal_anonymous_edit: 0 hoeringsportal_audit_log: 0 hoeringsportal_citizen_proposal: 0 hoeringsportal_citizen_proposal_archiving: 0 diff --git a/config/sync/field.field.node.dialogue_proposal.field_comments.yml b/config/sync/field.field.node.dialogue_proposal.field_comments.yml index 1a9da2d7c..d7b5f05d3 100644 --- a/config/sync/field.field.node.dialogue_proposal.field_comments.yml +++ b/config/sync/field.field.node.dialogue_proposal.field_comments.yml @@ -27,7 +27,7 @@ default_value_callback: '' settings: default_mode: 1 per_page: 50 - anonymous: 0 + anonymous: 1 form_location: true preview: 0 field_type: comment diff --git a/config/sync/field.field.node.dialogue_proposal.field_owner_email.yml b/config/sync/field.field.node.dialogue_proposal.field_owner_email.yml new file mode 100644 index 000000000..919f57f65 --- /dev/null +++ b/config/sync/field.field.node.dialogue_proposal.field_owner_email.yml @@ -0,0 +1,19 @@ +uuid: 35add208-8c2f-4144-b903-93fed0eb95b6 +langcode: da +status: true +dependencies: + config: + - field.storage.node.field_owner_email + - node.type.dialogue_proposal +id: node.dialogue_proposal.field_owner_email +field_name: field_owner_email +entity_type: node +bundle: dialogue_proposal +label: Email +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: email diff --git a/config/sync/field.field.node.dialogue_proposal.field_owner_name.yml b/config/sync/field.field.node.dialogue_proposal.field_owner_name.yml new file mode 100644 index 000000000..19937f20f --- /dev/null +++ b/config/sync/field.field.node.dialogue_proposal.field_owner_name.yml @@ -0,0 +1,19 @@ +uuid: 83eab418-a994-48a6-a9af-650b0e49fa99 +langcode: da +status: true +dependencies: + config: + - field.storage.node.field_owner_name + - node.type.dialogue_proposal +id: node.dialogue_proposal.field_owner_name +field_name: field_owner_name +entity_type: node +bundle: dialogue_proposal +label: Name +description: '' +required: false +translatable: false +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/config/sync/field.storage.node.field_dialogue_proposal_config.yml b/config/sync/field.storage.node.field_dialogue_proposal_config.yml index 2ddd187ea..093f35ac5 100644 --- a/config/sync/field.storage.node.field_dialogue_proposal_config.yml +++ b/config/sync/field.storage.node.field_dialogue_proposal_config.yml @@ -20,6 +20,18 @@ settings: - value: use_image_on_proposals label: 'Anvend billede på dialogforslag' + - + value: use_email_on_proposals + label: 'Use email on proposals' + - + value: use_name_on_proposals + label: 'Use name on proposals' + - + value: use_email_on_proposal_comments + label: 'Use email on proposal comments' + - + value: use_name_on_proposal_comments + label: 'Use name on proposal comments' allowed_values_function: '' module: options locked: false diff --git a/config/sync/field.storage.node.field_owner_email.yml b/config/sync/field.storage.node.field_owner_email.yml new file mode 100644 index 000000000..edc184182 --- /dev/null +++ b/config/sync/field.storage.node.field_owner_email.yml @@ -0,0 +1,18 @@ +uuid: a71e22bc-742a-48a5-b517-67bfbfac116c +langcode: da +status: true +dependencies: + module: + - node +id: node.field_owner_email +field_name: field_owner_email +entity_type: node +type: email +settings: { } +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/sync/field.storage.node.field_owner_name.yml b/config/sync/field.storage.node.field_owner_name.yml new file mode 100644 index 000000000..196badb22 --- /dev/null +++ b/config/sync/field.storage.node.field_owner_name.yml @@ -0,0 +1,21 @@ +uuid: 9af78cfb-064e-46cc-9a39-9b10d6c19519 +langcode: da +status: true +dependencies: + module: + - node +id: node.field_owner_name +field_name: field_owner_name +entity_type: node +type: string +settings: + max_length: 255 + case_sensitive: false + is_ascii: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/config/sync/symfony_mailer.mailer_policy.hoeringsportal_anonymous_edit.content_recover.yml b/config/sync/symfony_mailer.mailer_policy.hoeringsportal_anonymous_edit.content_recover.yml new file mode 100644 index 000000000..45af7e702 --- /dev/null +++ b/config/sync/symfony_mailer.mailer_policy.hoeringsportal_anonymous_edit.content_recover.yml @@ -0,0 +1,14 @@ +uuid: 16550f09-0b5f-4b26-9763-f22c0b980ff4 +langcode: da +status: true +dependencies: + module: + - hoeringsportal_anonymous_edit +id: hoeringsportal_anonymous_edit.content_recover +configuration: + email_body: + content: + value: "Gå til {{ recover_url }} for at finde dit eget indhold på deltag.aarhus.dk.\r\n" + format: email_html + email_subject: + value: 'Dit indhold på deltag.aarhus.dk' diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/README.md b/web/modules/custom/hoeringsportal_anonymous_edit/README.md new file mode 100644 index 000000000..1016d954b --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/README.md @@ -0,0 +1,100 @@ +# Anonymous edit + +This module keeps track of anonymous user's content and allows them to update +and delete it. + +The edit access is based on a long-lived cookie, +`hoeringsportal_anonymous_edit_token`, in a browser. If the user deletes the +cookie (or uses another browser), the cookie can be restored (or recovered?) via +a URL sent to the user's email address (see [Recovering the edit +token](#recovering-the-edit-token)). + +When content, i.e. an instance of +[`EntityInterface`](https://api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21Entity%21EntityInterface.php/interface/EntityInterface/11.x), +is created by an anonymous user, the module dispatches an +`HoeringsportalAnonymousEditEvent` event to let other modules tell if the +content supports editing by anonymous users. It supported, the module ensures +that an edit token (cookie) is set in the users browser and the token is +attached to the content and any future content created by the user. + +In addition to telling if the content is supported, the event subscriber can +also set an email address to be associated with the content. This email can +later be used to recover the edit token if need be. + +See +[AnonymousEditSubscriber.php](../hoeringsportal_dialogue/src/EventSubscriber/AnonymousEditSubscriber.php) +for an example event subscriber implementation. + +The module implements +[`hook_entity_access`](https://api.drupal.org/api/drupal/core%21lib%21Drupal%21Core%21Entity%21entity.api.php/function/hook_entity_access/11.x) +to allow anonymous users to update and delete content matching the current edit +token. + +## Configuration + +Anonymous user names (!) are generated on demand using a string format pattern: + +``` php +# settings.local.php +$settings['hoeringsportal_anonymous_edit']['owner_name_pattern'] = 'Bruger %1$d'; // The default value +``` + +## Content list + +An anonymous user with a valid edtit token can find a list of its content on +`/hoeringsportal-anonymous-edit/content`. The content is grouped by content type +and bundle. + +The content list uses the +[`hoeringsportal-anonymous-edit-content-index.html.twig`](templates/hoeringsportal-anonymous-edit-content-index.html.twig) +template file which can — and probably should be — overridden in the theme. + +## Recovering the edit token + +If the user looses its edit token, the token can be recovered if an email has +been attached to a piece of content created by the user. + +The `hoeringsportal_anonymous_edit.content_request` route +(`/hoeringsportal-anonymous-edit/content/request`) lets the user enter an email +address and request an email with a link to recover the edit token. + +The `hoeringsportal_anonymous_edit.content_recover` route +(`/hoeringsportal-anonymous-edit/content/recover/{token}`) asks the user to +confirm the email address, and if the email matches the token, the edit token is +restored and set as en edit token. + +The recover email subject and body is managed on +`/admin/config/system/mailer/policy/hoeringsportal_anonymous_edit.content_recover`. +Twig can be used in both fields and the following variable is available (cf. +[src/Plugin/EmailBuilder/EmailBuilder.php](src/Plugin/EmailBuilder/EmailBuilder.php)): + +| Name | Description | +|-------------|--------------------------| +| recover_url | The absolute recover URL | + +## Development + +By default only errors (and higher levels) are logged. During development the +module's log level can be set lower, e.g. + +``` php +# settings.local.php +$settings['hoeringsportal_anonymous_edit']['log_level'] = \Drupal\Core\Logger\RfcLogLevel::DEBUG; +``` + +Show log messages with + +``` shell +drush watchdog:show --type=hoeringsportal_anonymous_edit --extended +``` + +Peek in the database table: + +``` shell +drush sql:query --extra=--table "SELECT * FROM hoeringsportal_anonymous_edit" +``` + +--- + +* [ ] What happens if the same email is attached to multiple tokens? +* [ ] What happens if a user has used multiple tokens? diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.info.yml b/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.info.yml new file mode 100644 index 000000000..ab38c8673 --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.info.yml @@ -0,0 +1,8 @@ +name: "hoeringsportal_anonymous_edit" +type: module +description: "Allow anonymous users to edit their content" +package: Custom +core_version_requirement: ^10 || ^11 + +dependencies: + - drupal:symfony_mailer diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.install b/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.install new file mode 100644 index 000000000..40553ffa3 --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.install @@ -0,0 +1,98 @@ + 'Anonymous content, i.e. a mapping from an entity to an owner token.', + 'fields' => [ + 'entity_type' => [ + 'description' => 'The entity type.', + 'type' => 'varchar_ascii', + 'length' => DRUPAL_EXTENSION_NAME_MAX_LENGTH, + 'not null' => TRUE, + 'default' => '', + ], + 'entity_bundle' => [ + 'description' => 'The entity bundle.', + 'type' => 'varchar_ascii', + 'length' => DRUPAL_EXTENSION_NAME_MAX_LENGTH, + 'not null' => TRUE, + 'default' => '', + ], + 'entity_uuid' => [ + 'description' => 'The entity UUID.', + 'type' => 'varchar_ascii', + 'length' => 36, + 'not null' => TRUE, + 'default' => '', + ], + 'owner_token' => [ + 'description' => 'The owner token (UUID).', + 'type' => 'varchar_ascii', + 'length' => 36, + 'not null' => TRUE, + 'default' => '', + ], + ], + 'primary key' => [ + 'entity_type', + 'entity_bundle', + 'entity_uuid', + ], + 'indexes' => [ + 'owner' => [ + 'owner_token', + ], + ], + ]; + + $schema['hoeringsportal_anonymous_edit_owners'] = [ + 'description' => 'Information on owners of anonymous content.', + 'fields' => [ + 'owner_token' => [ + 'description' => 'The owner token (UUID).', + 'type' => 'varchar_ascii', + 'length' => 36, + 'not null' => TRUE, + 'default' => '', + ], + 'email' => [ + 'description' => 'The email.', + 'type' => 'varchar', + 'length' => 255, + 'default' => '', + ], + 'name' => [ + 'description' => 'The name.', + 'type' => 'varchar', + 'length' => 255, + 'default' => '', + ], + ], + 'primary key' => [ + 'owner_token', + ], + 'indexes' => [ + 'owner' => [ + 'email', + ], + ], + 'foreign keys' => [ + 'hoeringsportal_anonymous_edit_content' => [ + 'table' => 'hoeringsportal_anonymous_edit_content', + 'columns' => [ + 'owner_token' => 'owner_token', + ], + ], + ], + ]; + + return $schema; +} diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.module b/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.module new file mode 100644 index 000000000..b9253fa99 --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.module @@ -0,0 +1,52 @@ +entityInsert($entity); +} + +/** + * Implements hook_entity_delete(). + */ +function hoeringsportal_anonymous_edit_entity_delete(EntityInterface $entity): void { + _hoeringsportal_anonymous_edit_get_helper()->entityDelete($entity); +} + +/** + * Implements hook_entity_access(). + */ +function hoeringsportal_anonymous_edit_entity_access(EntityInterface $entity, $operation, AccountInterface $account): AccessResultInterface { + return _hoeringsportal_anonymous_edit_get_helper()->entityAccess($entity, $operation, $account); +} + +/** + * Implements hook_theme(). + */ +function hoeringsportal_anonymous_edit_theme($existing, $type, $theme, $path) : array { + return [ + 'hoeringsportal_anonymous_edit_content_index' => [ + 'variables' => [ + 'entities' => NULL, + ], + ], + ]; +} + +/** + * Get the hoeringsportal_anonymous_edit helper. + */ +function _hoeringsportal_anonymous_edit_get_helper(): Helper { + return \Drupal::service(Helper::class); +} diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.routing.yml b/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.routing.yml new file mode 100644 index 000000000..099043976 --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.routing.yml @@ -0,0 +1,35 @@ +hoeringsportal_anonymous_edit: + path: "/hoeringsportal-anonymous-edit" + defaults: + _title: "Main controller" + _controller: '\Drupal\hoeringsportal_anonymous_edit\Controller\HoeringsportalAnonymousEditController' + options: + no_cache: "TRUE" + requirements: + _user_is_logged_in: "FALSE" + +hoeringsportal_anonymous_edit.content: + path: "/hoeringsportal-anonymous-edit/content" + defaults: + _title: "Content" + _controller: '\Drupal\hoeringsportal_anonymous_edit\Controller\HoeringsportalAnonymousEditContentController' + options: + no_cache: "TRUE" + requirements: + _user_is_logged_in: "FALSE" + +hoeringsportal_anonymous_edit.content_request: + path: "/hoeringsportal-anonymous-edit/content/request" + defaults: + _title: "Request" + _form: 'Drupal\hoeringsportal_anonymous_edit\Form\RequestForm' + requirements: + _user_is_logged_in: "FALSE" + +hoeringsportal_anonymous_edit.content_recover: + path: "/hoeringsportal-anonymous-edit/content/recover/{token}" + defaults: + _title: "Recover" + _form: 'Drupal\hoeringsportal_anonymous_edit\Form\RecoverForm' + requirements: + _user_is_logged_in: "FALSE" diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.services.yml b/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.services.yml new file mode 100644 index 000000000..1dff9ae9c --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/hoeringsportal_anonymous_edit.services.yml @@ -0,0 +1,15 @@ +services: + _defaults: + autowire: true + + hoeringsportal_anonymous_edit.logger: + parent: logger.channel_base + arguments: ["hoeringsportal_anonymous_edit"] + + Drupal\hoeringsportal_anonymous_edit\Helper\StorageHelper: + + Drupal\hoeringsportal_anonymous_edit\Helper\MailHelper: + + Drupal\hoeringsportal_anonymous_edit\Helper\Helper: + tags: + - { name: event_subscriber } diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/src/Controller/HoeringsportalAnonymousEditContentController.php b/web/modules/custom/hoeringsportal_anonymous_edit/src/Controller/HoeringsportalAnonymousEditContentController.php new file mode 100644 index 000000000..75d853fdc --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/src/Controller/HoeringsportalAnonymousEditContentController.php @@ -0,0 +1,34 @@ +helper->getContent(); + + $build['content'] = [ + '#type' => 'theme', + '#theme' => 'hoeringsportal_anonymous_edit_content_index', + '#entities' => $entities, + ]; + + return $build; + } + +} diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/src/Controller/HoeringsportalAnonymousEditController.php b/web/modules/custom/hoeringsportal_anonymous_edit/src/Controller/HoeringsportalAnonymousEditController.php new file mode 100644 index 000000000..7678f9649 --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/src/Controller/HoeringsportalAnonymousEditController.php @@ -0,0 +1,22 @@ +redirect('hoeringsportal_anonymous_edit.content'); + } + +} diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/src/Event/HoeringsportalAnonymousEditEvent.php b/web/modules/custom/hoeringsportal_anonymous_edit/src/Event/HoeringsportalAnonymousEditEvent.php new file mode 100644 index 000000000..3ee39df27 --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/src/Event/HoeringsportalAnonymousEditEvent.php @@ -0,0 +1,57 @@ +entity; + } + + /** + * Is supported? + */ + public function isSupported(): bool { + return $this->isSupported; + } + + /** + * Set is supported. + */ + public function setIsSupported(bool $isSupported = TRUE): self { + $this->isSupported = $isSupported; + + return $this; + } + + /** + * Get owner data. + */ + public function getOwner(): Owner { + return $this->owner; + } + +} diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/src/Exception/Exception.php b/web/modules/custom/hoeringsportal_anonymous_edit/src/Exception/Exception.php new file mode 100644 index 000000000..87f93cdc4 --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/src/Exception/Exception.php @@ -0,0 +1,10 @@ + 'email', + '#title' => $this->t('Confirm your email address', options: ['context' => 'hoeringsportal_anonymous_edit']), + '#required' => TRUE, + ]; + + $form['actions'] = [ + '#type' => 'actions', + 'submit' => [ + '#type' => 'submit', + '#value' => $this->t('Find content', options: ['context' => 'hoeringsportal_anonymous_edit']), + ], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state): void { + $email = $form_state->getValue('email'); + $token = $this->getRouteMatch()->getParameter('token'); + if (!$this->helper->isValidTokenEmail($email, $token)) { + $form_state->setErrorByName('email', $this->t('Invalid email address')); + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + $email = $form_state->getValue('email'); + $token = $this->getRouteMatch()->getParameter('token'); + try { + $this->helper->setTokenByEmail($email, $token); + $this->messenger()->addMessage('Your edit token has been recovered'); + } + catch (\Exception $exception) { + $this->helper->logException($exception); + $this->messenger()->addError('Error recovering your edit token'); + } + $form_state->setRedirect('hoeringsportal_anonymous_edit.content'); + } + +} diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/src/Form/RequestForm.php b/web/modules/custom/hoeringsportal_anonymous_edit/src/Form/RequestForm.php new file mode 100644 index 000000000..374767c08 --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/src/Form/RequestForm.php @@ -0,0 +1,69 @@ + 'email', + '#title' => $this->t('Your email address', options: ['context' => 'hoeringsportal_anonymous_edit']), + '#required' => TRUE, + ]; + + $form['actions'] = [ + '#type' => 'actions', + 'submit' => [ + '#type' => 'submit', + '#value' => $this->t('Find content', options: ['context' => 'hoeringsportal_anonymous_edit']), + ], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + $email = $form_state->getValue('email'); + + try { + $this->helper->sendRecoverUrl($email); + + $this->messenger()->addMessage($this->t('Email sent to @email', ['@email' => $email])); + // @todo Redirect to where? + $form_state->setRedirect(''); + } + catch (\Exception $exception) { + $this->helper->logException($exception); + $this->messenger()->addError($this->t('Error sending email @email', ['@email' => $email])); + } + } + +} diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/src/Helper/Helper.php b/web/modules/custom/hoeringsportal_anonymous_edit/src/Helper/Helper.php new file mode 100644 index 000000000..85d2a3b6a --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/src/Helper/Helper.php @@ -0,0 +1,316 @@ +setLogger($logger); + } + + /** + * Get owner of a piece of content. + */ + public function getContentOwner(EntityInterface $entity): ?Owner { + if ($item = $this->storageHelper->fetchItemByEntity($entity)) { + if ($owner = $this->storageHelper->fetchOwner($item->owner_token)) { + $owner->isCurrent = $owner->owner_token === $this->getToken(); + return $owner; + } + } + + return NULL; + } + + /** + * Get content for the current token if any. + */ + public function getContent(): ?array { + $token = $this->getToken(); + if (NULL === $token) { + return NULL; + } + + $items = $this->storageHelper->fetchItemsByToken($token); + + $groups = []; + foreach ($items as $row) { + $groups[$row->entity_type][$row->entity_bundle][] = $row->entity_uuid; + } + + $entities = []; + foreach ($groups as $type => $items) { + $storage = $this->entityTypeManager->getStorage($type); + $entityType = $storage->getEntityType(); + $entity = [ + 'type' => $entityType, + 'bundles' => [], + ]; + foreach ($items as $bundle => $ids) { + $ids = $storage->getQuery() + ->accessCheck(FALSE) + ->condition($entityType->getKey('bundle'), $bundle) + ->condition($entityType->getKey('uuid'), $ids, 'IN') + ->sort('created') + ->execute(); + $content = $storage->loadMultiple($ids); + $entity['bundles'][] = [ + // @todo Get full bundle information (id, label, fields, …) + 'type' => $bundle, + 'entities' => array_map(fn (EntityInterface $entity) => [ + 'instance' => $entity, + 'edit_query' => [ + self::QUERY_PARAM_NAME => $token, + ], + ], $content), + ]; + } + $entities[] = $entity; + } + + return $entities; + } + + /** + * Get unique token. + * + * If a token does not exists in a cookie, a new one is created and set if + * requested. + */ + private function getToken(bool $create = FALSE): ?string { + if (!$this->account->isAnonymous()) { + return NULL; + } + + $request = $this->requestStack->getCurrentRequest(); + $token = $request->cookies->get(self::COOKIE_NAME); + if (NULL === $token && $create) { + $token = $this->uuid->generate(); + $this->setToken($token); + } + + return $token; + } + + /** + * Set token. + * + * The token is set in the attributes of the request and then + * self::onKernelResponse will set it in the response cookies. + * + * @see self::onKernelResponse() + */ + private function setToken(string $token): void { + if (!Uuid::isValid($token)) { + throw new InvalidTokenException($token); + } + $request = $this->requestStack->getCurrentRequest(); + $request->attributes->set(self::COOKIE_NAME, $token); + } + + /** + * Decide if email address matches a token. + */ + public function isValidTokenEmail(string $email, string $token): bool { + return NULL !== $this->fetchOwnerByEmail($email, $token); + } + + /** + * Set token by email. + */ + public function setTokenByEmail(string $email, string $token): void { + $item = $this->fetchOwnerByEmail($email, $token); + if (!$item) { + throw new InvalidTokenException($email); + } + + $this->setToken($item->owner_token); + } + + /** + * Fetch item by email. + */ + private function fetchOwnerByEmail(string $email, string $token): ?Owner { + return $this->storageHelper->fetchOwnerByEmail($email, $token); + } + + /** + * Get recover URL. + */ + public function getRecoverUrl(string $email): ?string { + $owner = $this->storageHelper->fetchOwnerByEmail($email); + if (NULL === $owner) { + return NULL; + } + + return Url::fromRoute('hoeringsportal_anonymous_edit.content_recover', ['token' => $owner->owner_token]) + ->setAbsolute() + ->toString(TRUE)->getGeneratedUrl(); + } + + /** + * Send recover URL. + */ + public function sendRecoverUrl(string $email): void { + $url = $this->getRecoverUrl($email); + if (NULL !== $url) { + $this->mailHelper->sendRecoverMail($email, $url); + } + } + + /** + * Set ant token cookie from the request. + */ + public function onKernelResponse(ResponseEvent $event): void { + $request = $event->getRequest(); + if ($token = $request->attributes->get(self::COOKIE_NAME)) { + $response = $event->getResponse(); + $response->headers->setCookie(new Cookie( + self::COOKIE_NAME, + $token, + )); + } + } + + /** + * Implements hook_entity_insert(). + */ + public function entityInsert(EntityInterface $entity): void { + $token = $this->getToken(create: TRUE); + if (NULL === $token) { + return; + } + $owner = $this->storageHelper->fetchOwner($token); + if (NULL === $owner) { + $owner = new Owner(); + $owner->owner_token = $token; + $format = $this->getSetting('owner_name_pattern', 'Bruger %1$d'); + $owner->name = $this->storageHelper->computeName($format); + } + $event = new HoeringsportalAnonymousEditEvent($entity, $owner); + $this->eventDispatcher->dispatch($event); + if ($event->isSupported()) { + $ownerData = $event->getOwner(); + $item = $this->storageHelper->createItem($entity, $token, $ownerData); + $this->debug('Item
@item
created.', ['@item' => json_encode($item, JSON_PRETTY_PRINT)]); + } + } + + /** + * Implements hook_entity_delete(). + */ + public function entityDelete(EntityInterface $entity): void { + $this->storageHelper->deleteItem($entity); + } + + /** + * Implements hook_entity_access(). + */ + public function entityAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResultInterface { + if (in_array($operation, ['update', 'delete'])) { + if ($token = $this->getToken()) { + if ($item = $this->storageHelper->fetchItemByEntity($entity)) { + if ($item->owner_token === $token) { + return AccessResult::allowed(); + } + } + } + } + + return AccessResult::neutral(); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + KernelEvents::RESPONSE => ['onKernelResponse'], + ]; + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function log($level, \Stringable|string $message, array $context = []): void { + // Lifted from LoggerChannel. + $levelTranslation = [ + LogLevel::EMERGENCY => RfcLogLevel::EMERGENCY, + LogLevel::ALERT => RfcLogLevel::ALERT, + LogLevel::CRITICAL => RfcLogLevel::CRITICAL, + LogLevel::ERROR => RfcLogLevel::ERROR, + LogLevel::WARNING => RfcLogLevel::WARNING, + LogLevel::NOTICE => RfcLogLevel::NOTICE, + LogLevel::INFO => RfcLogLevel::INFO, + LogLevel::DEBUG => RfcLogLevel::DEBUG, + ]; + $rfcLogLevel = $levelTranslation[$level] ?? RfcLogLevel::ERROR; + $logLevel = $this->getSetting('log_level', RfcLogLevel::ERROR); + if ($logLevel >= $rfcLogLevel) { + $this->logger->log($level, $message, $context); + } + } + + /** + * Log an exception. + */ + public function logException(\Exception $exception) { + $this->error('Exception: @message', ['@message' => $exception->getMessage(), 'exception' => $exception]); + } + + /** + * Get a setting. + */ + private function getSetting(string $key, mixed $default): mixed { + return Settings::get('hoeringsportal_anonymous_edit')[$key] ?? $default; + } + +} diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/src/Helper/MailHelper.php b/web/modules/custom/hoeringsportal_anonymous_edit/src/Helper/MailHelper.php new file mode 100644 index 000000000..ea5bf3f3d --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/src/Helper/MailHelper.php @@ -0,0 +1,36 @@ +emailFactory->sendTypedEmail(self::MAILER_TYPE, + self::MAILER_SUBTYPE_CONTENT_RECOVER, $email, $url); + } + +} diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/src/Helper/StorageHelper.php b/web/modules/custom/hoeringsportal_anonymous_edit/src/Helper/StorageHelper.php new file mode 100644 index 000000000..46fdb91b4 --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/src/Helper/StorageHelper.php @@ -0,0 +1,186 @@ +database + ->insert(self::TABLE_NAME_CONTENT) + ->fields([ + 'entity_type' => $entity->getEntityTypeId(), + 'entity_bundle' => $entity->bundle(), + 'entity_uuid' => $entity->uuid(), + 'owner_token' => $token, + ]) + ->execute(); + + // Create or update owner. + $this->database + ->upsert(self::TABLE_NAME_OWNERS) + ->key(self::OWNER_TOKEN) + ->fields([ + self::OWNER_TOKEN, + self::OWNER_EMAIL, + self::OWNER_NAME, + ]) + ->values([ + self::OWNER_TOKEN => $token, + self::OWNER_EMAIL => $owner->email ?? '', + self::OWNER_NAME => $owner->name ?? '', + ]) + ->execute(); + + return $this->fetchItemByEntity($entity, refresh: TRUE); + } + + /** + * Delete item. + */ + public function deleteItem(EntityInterface $entity): void { + $this->database + ->delete(self::TABLE_NAME_CONTENT) + ->condition('entity_type', $entity->getEntityTypeId()) + ->condition('entity_bundle', $entity->bundle()) + ->condition('entity_uuid', $entity->uuid()) + ->execute(); + } + + /** + * Fetch items bo token. + */ + public function fetchItemsByToken(string $token): array { + return $this->database + ->select(self::TABLE_NAME_CONTENT, 't') + ->fields('t') + ->condition('t.owner_token', $token) + // Order the result to make the grouping a little easier. + ->orderBy('t.entity_type') + ->orderBy('t.entity_bundle') + ->execute() + ->fetchAll(\PDO::FETCH_CLASS, Content::class); + } + + /** + * Fetch items by email. + */ + public function fetchOwnerByEmail(string $email, ?string $token = NULL): ?Owner { + $query = $this->database + ->select(self::TABLE_NAME_OWNERS, 't') + ->fields('t') + ->condition('t.email', $email); + if (NULL !== $token) { + $query->condition('t.owner_token', $token); + } + + return $query + ->execute() + ->fetchObject(Owner::class) ?: NULL; + } + + /** + * Fetch item by entity. + */ + public function fetchItemByEntity(EntityInterface $entity, bool $refresh = FALSE): ?Content { + $items = &drupal_static(__FUNCTION__); + + $type = $entity->getEntityTypeId(); + $bundle = $entity->bundle(); + if (!isset($items[$type][$bundle]) || $refresh) { + // @todo Optimize this to load all content in one go and index by (entity_type, entity_bundle). + $result = $this->database + ->select(self::TABLE_NAME_CONTENT, 't') + ->fields('t') + ->condition('t.entity_type', $entity->getEntityTypeId()) + ->condition('t.entity_bundle', $entity->bundle()) + ->execute() + ->fetchAll(\PDO::FETCH_CLASS, Content::class); + + // Index by UUID. + $items[$type][$bundle] = array_combine(array_column($result, 'entity_uuid'), $result); + } + + return $items[$type][$bundle][$entity->uuid()] ?? NULL; + } + + /** + * Fetch owner data by token. + */ + public function fetchOwner(string $token): ?Owner { + $owners = &drupal_static(__FUNCTION__); + + if (!isset($owners)) { + $owners = $this->database + ->select(self::TABLE_NAME_OWNERS, 't') + ->fields('t') + ->execute() + ->fetchAll(\PDO::FETCH_CLASS, Owner::class); + + // Index by token. + $owners = array_combine(array_column($owners, self::OWNER_TOKEN), $owners); + } + + return $owners[$token] ?? NULL; + } + + /** + * Compute a unique user name. + */ + public function computeName(string $format) { + $transaction = $this->database->startTransaction(); + try { + $id = $this->database + ->select(self::TABLE_NAME_OWNERS, 't') + ->countQuery() + ->execute() + ->fetchField(); + $limit = 100; + while ($limit-- > 0) { + $id++; + $name = sprintf($format, $id); + $result = $this->database->select(self::TABLE_NAME_OWNERS, 't') + ->fields('t') + ->condition('t.name', $name) + ->execute() + ->fetchAll(); + if (empty($result)) { + return $name; + } + } + } + catch (\Exception) { + $transaction->rollback(); + } + finally { + unset($transaction); + } + + throw new RuntimeException('Cannot compute unique name'); + } + +} diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/src/Model/Content.php b/web/modules/custom/hoeringsportal_anonymous_edit/src/Model/Content.php new file mode 100644 index 000000000..72e4d413e --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/src/Model/Content.php @@ -0,0 +1,18 @@ +setParam('to', $to); + $email->setParam('recover_url', $url); + } + + /** + * {@inheritdoc} + */ + public function fromArray(EmailFactoryInterface $factory, array $message) { + return $factory->newTypedEmail($message['module'], $message['key'], $message['params']['to'], $message['params']['recover_url']); + } + + /** + * {@inheritdoc} + */ + public function build(EmailInterface $email) { + $to = $email->getParam('to'); + $url = $email->getParam('recover_url'); + $email->setTo($to); + $email->setVariable('recover_url', $url); + parent::build($email); + } + +} diff --git a/web/modules/custom/hoeringsportal_anonymous_edit/templates/hoeringsportal-anonymous-edit-content-index.html.twig b/web/modules/custom/hoeringsportal_anonymous_edit/templates/hoeringsportal-anonymous-edit-content-index.html.twig new file mode 100644 index 000000000..838513762 --- /dev/null +++ b/web/modules/custom/hoeringsportal_anonymous_edit/templates/hoeringsportal-anonymous-edit-content-index.html.twig @@ -0,0 +1,79 @@ +{# +/** + * @file + * Content list template. + * + * Available variables: + * - entities: a list of content grouped by bundle and type, e.g. + * [ + * { + * type: an instance of ContentEntityType, + * bundles: [ + * { + * type: the bundle type, e.g. "early_inclusion_comment", + * entities: [ + * { + * instance: an instance of EntityInterface + * edit_query: a URL query to add when generating an edit URL, e.g. `{"edit_token": "17f4139a-8248-422b-b985-d1bb00ba266c"}` + * }, + * { + * instance: …, + * edit_query: … + * }, + * … + * ] + * }, + * { + * type: …, + * entities: … + * }, + * … + * ] + * }, + * { + * type: …, + * bundles: … + * }, + * … + * ] + */ +#} +
+{% if entities is empty %} +
{{ "You don't have any content."|trans }}
+ + {# If entities is null, the user does not have an edit token #} + {% if entities is same as(null) %} + {{ 'Find my content'|trans }} + {% endif %} +{% else %} + {% for entity_type in entities %} +
+ {{ entity_type.type.label }} + {% for bundle in entity_type.bundles %} +
+ {{ bundle.type }} + +
    + {% for entity in bundle.entities %} +
  • + {# @todo Clean this up #} + {% set instance = entity.instance %} + {% if instance.comment_type is defined and ('node' == instance.entity_type.string|default(false)) and instance.entity_id is defined %} + {% set comment = instance %} + {% set commentedEntity = comment.getCommentedEntity() %} + {% set url = commentedEntity.toUrl(options: {fragment: 'comment-' ~ comment.id}) %} + {{ 'Comment #@comment on %title'|t({'@comment': comment.id, '%title': commentedEntity.label()}) }} + {% else %} + {{ entity.instance.toLink('Edit'|trans, 'edit-form', {query: entity.edit_query}) }} + {% endif %} +
  • + {% endfor %} +
+
+ {% endfor %} +
+ {% endfor %} + +{% endif %} +
diff --git a/web/modules/custom/hoeringsportal_dialogue/hoeringsportal_dialogue.info.yml b/web/modules/custom/hoeringsportal_dialogue/hoeringsportal_dialogue.info.yml index 0dc5f1fa4..faabbe128 100755 --- a/web/modules/custom/hoeringsportal_dialogue/hoeringsportal_dialogue.info.yml +++ b/web/modules/custom/hoeringsportal_dialogue/hoeringsportal_dialogue.info.yml @@ -5,3 +5,6 @@ package: Hoeringsportal core_version_requirement: ^10 || ^11 interface translation project: hoeringsportal_dialogue interface translation server pattern: modules/custom/hoeringsportal_dialogue/translations/hoeringsportal_dialogue.%language.po + +dependencies: + - hoeringsportal_anonymous_edit:hoeringsportal_anonymous_edit diff --git a/web/modules/custom/hoeringsportal_dialogue/hoeringsportal_dialogue.module b/web/modules/custom/hoeringsportal_dialogue/hoeringsportal_dialogue.module index ec93095be..3261a5f41 100644 --- a/web/modules/custom/hoeringsportal_dialogue/hoeringsportal_dialogue.module +++ b/web/modules/custom/hoeringsportal_dialogue/hoeringsportal_dialogue.module @@ -59,3 +59,10 @@ function hoeringsportal_dialogue_form_node_dialogue_proposal_form_alter(array &$ function hoeringsportal_dialogue_form_node_dialogue_proposal_edit_form_alter(array &$form, FormStateInterface $form_state) { Drupal::service(DialogueHelper::class)->dialogueProposalFormAlter($form, $form_state); } + +/** + * Implements hook_form_FORMID_alter(). + */ +function hoeringsportal_dialogue_form_comment_early_inclusion_comment_form_alter(array &$form, FormStateInterface $form_state) { + Drupal::service(DialogueHelper::class)->commentEarlyInclusionCommentFormAlter($form, $form_state); +} diff --git a/web/modules/custom/hoeringsportal_dialogue/hoeringsportal_dialogue.services.yml b/web/modules/custom/hoeringsportal_dialogue/hoeringsportal_dialogue.services.yml index 87bc9bc9f..d71c90a31 100644 --- a/web/modules/custom/hoeringsportal_dialogue/hoeringsportal_dialogue.services.yml +++ b/web/modules/custom/hoeringsportal_dialogue/hoeringsportal_dialogue.services.yml @@ -7,3 +7,7 @@ services: Drupal\hoeringsportal_dialogue\Theme\ThemeDialogueNegotiator: tags: - { name: theme_negotiator, priority: 10 } + + Drupal\hoeringsportal_dialogue\EventSubscriber\AnonymousEditSubscriber: + tags: + - { name: event_subscriber } diff --git a/web/modules/custom/hoeringsportal_dialogue/modules/hoeringsportal_dialogue_fixtures/src/Fixture/CommentFixture.php b/web/modules/custom/hoeringsportal_dialogue/modules/hoeringsportal_dialogue_fixtures/src/Fixture/CommentFixture.php index beed2a813..a0abdda07 100644 --- a/web/modules/custom/hoeringsportal_dialogue/modules/hoeringsportal_dialogue_fixtures/src/Fixture/CommentFixture.php +++ b/web/modules/custom/hoeringsportal_dialogue/modules/hoeringsportal_dialogue_fixtures/src/Fixture/CommentFixture.php @@ -130,6 +130,22 @@ public function load() { $comment->save(); $this->addReference('comment:early_inclusion_comment:8', $comment); + + $comment = Comment::create([ + 'comment_type' => 'early_inclusion_comment', + 'field_name' => 'field_comments', + ]) + ->set('subject', '(No subject)') + ->set('entity_type', 'node') + ->set('entity_id', $this->getReference('node:dialogue_proposal:Test Dialogue proposal with name and email')) + ->set('uid', 0) + ->set('status', 1) + ->set('field_comment', 'Jeg er enig!') + ->set('name', 'Anders And') + ->set('mail', 'aand@andeby.dk'); + + $comment->save(); + $this->addReference('comment:early_inclusion_comment:9', $comment); } /** diff --git a/web/modules/custom/hoeringsportal_dialogue/modules/hoeringsportal_dialogue_fixtures/src/Fixture/DialogueFixture.php b/web/modules/custom/hoeringsportal_dialogue/modules/hoeringsportal_dialogue_fixtures/src/Fixture/DialogueFixture.php index a162ac2bd..c238053ae 100644 --- a/web/modules/custom/hoeringsportal_dialogue/modules/hoeringsportal_dialogue_fixtures/src/Fixture/DialogueFixture.php +++ b/web/modules/custom/hoeringsportal_dialogue/modules/hoeringsportal_dialogue_fixtures/src/Fixture/DialogueFixture.php @@ -114,6 +114,50 @@ public function load() { $node->save(); $this->addReference('node:dialogue:Test Dialogue - proposals simple, private', $node); + + $node = Node::create([ + 'type' => 'dialogue', + 'title' => 'Test Dialogue - name and email', + 'status' => TRUE, + 'field_teaser' => 'Test teaser', + 'field_area' => [ + $this->getReference('area:Hele kommunen'), + ], + 'field_top_images' => [ + $this->getReference('media:Large1'), + $this->getReference('media:Large2'), + $this->getReference('media:Large3'), + ], + 'field_type' => [ + $this->getReference('type:Klima'), + ], + 'field_dialogue_proposal_category' => [ + $this->getReference('dialogue_proposal_categories:Grønne pladser'), + $this->getReference('dialogue_proposal_categories:Biodiversitet Initiativer'), + $this->getReference('dialogue_proposal_categories:Cykelstier'), + $this->getReference('dialogue_proposal_categories:Regnvandsopsamling'), + $this->getReference('dialogue_proposal_categories:Parkeringspladser for elbiler'), + $this->getReference('dialogue_proposal_categories:Bæredygtig Belysning'), + $this->getReference('dialogue_proposal_categories:Energi Effektivisering'), + $this->getReference('dialogue_proposal_categories:Grønne Materialer'), + $this->getReference('dialogue_proposal_categories:Affaldshåndtering'), + $this->getReference('dialogue_proposal_categories:Vedvarende Energi'), + ], + 'field_dialogue_proposal_config' => [ + ['value' => 'public_proposals'], + ['value' => 'use_email_on_proposals'], + ['value' => 'use_name_on_proposals'], + ['value' => 'use_email_on_proposal_comments'], + ['value' => 'use_name_on_proposal_comments'], + ], + 'field_content_sections' => [ + 'target_id' => $paragraph->id(), + 'target_revision_id' => $paragraph->getRevisionId(), + ], + ]); + + $node->save(); + $this->addReference('node:dialogue:Test Dialogue - name and email', $node); } /** diff --git a/web/modules/custom/hoeringsportal_dialogue/modules/hoeringsportal_dialogue_fixtures/src/Fixture/DialogueProposalFixture.php b/web/modules/custom/hoeringsportal_dialogue/modules/hoeringsportal_dialogue_fixtures/src/Fixture/DialogueProposalFixture.php index 68c60d3eb..6ee5b4223 100644 --- a/web/modules/custom/hoeringsportal_dialogue/modules/hoeringsportal_dialogue_fixtures/src/Fixture/DialogueProposalFixture.php +++ b/web/modules/custom/hoeringsportal_dialogue/modules/hoeringsportal_dialogue_fixtures/src/Fixture/DialogueProposalFixture.php @@ -137,6 +137,22 @@ public function load() { $node->save(); $this->addReference('node:dialogue_proposal:Test Dialogue proposal simple', $node); + + $node = Node::create([ + 'type' => 'dialogue_proposal', + 'title' => 'Test Dialogue proposal with name and email', + 'status' => TRUE, + 'field_dialogue_proposal_descr' => 'Jeg vil gerne stå ved mit forslag.', + 'field_dialogue_proposal_category' => [ + $this->getReference('dialogue_proposal_categories:Grønne pladser'), + ], + 'field_dialogue' => $this->getReference('node:dialogue:Test Dialogue - name and email'), + 'field_owner_name' => 'J. von And', + 'field_owner_email' => 'jva@pengeby.dk', + ]); + + $node->save(); + $this->addReference('node:dialogue_proposal:Test Dialogue proposal with name and email', $node); } /** diff --git a/web/modules/custom/hoeringsportal_dialogue/src/EventSubscriber/AnonymousEditSubscriber.php b/web/modules/custom/hoeringsportal_dialogue/src/EventSubscriber/AnonymousEditSubscriber.php new file mode 100644 index 000000000..75b3a6f35 --- /dev/null +++ b/web/modules/custom/hoeringsportal_dialogue/src/EventSubscriber/AnonymousEditSubscriber.php @@ -0,0 +1,52 @@ +getEntity(); + if ($entity instanceof CommentInterface && DialogueHelper::DIALOGUE_PROPOSAL_COMMENT_TYPE === $entity->bundle()) { + $event->setIsSupported(); + if ($email = $entity->getAuthorEmail()) { + $event->getOwner()->email = $email; + } + if ($name = $entity->getAuthorName()) { + $event->getOwner()->name = $name; + } + } + elseif ($entity instanceof NodeInterface && DialogueHelper::DIALOGUE_PROPOSAL_TYPE === $entity->bundle()) { + $event->setIsSupported(); + if ($email = $entity->get('field_owner_email')->getString()) { + $event->getOwner()->email = $email; + } + if ($name = $entity->get('field_owner_name')->getString()) { + $event->getOwner()->name = $name; + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + HoeringsportalAnonymousEditEvent::class => ['onHoeringsportalAnonymousEdit'], + ]; + } + +} diff --git a/web/modules/custom/hoeringsportal_dialogue/src/Helper/DialogueHelper.php b/web/modules/custom/hoeringsportal_dialogue/src/Helper/DialogueHelper.php index 27306921e..610f0a67a 100644 --- a/web/modules/custom/hoeringsportal_dialogue/src/Helper/DialogueHelper.php +++ b/web/modules/custom/hoeringsportal_dialogue/src/Helper/DialogueHelper.php @@ -8,9 +8,11 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Messenger\MessengerInterface; +use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\node\Entity\Node; +use Drupal\node\NodeInterface; use Symfony\Component\HttpFoundation\RequestStack; /** @@ -18,6 +20,7 @@ */ class DialogueHelper { + public const DIALOGUE_TYPE = 'dialogue'; public const DIALOGUE_PROPOSAL_TYPE = 'dialogue_proposal'; public const DIALOGUE_PROPOSAL_COMMENT_TYPE = 'early_inclusion_comment'; @@ -28,6 +31,8 @@ class DialogueHelper { * * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack * The request stack. + * @param \Drupal\Core\Routing\RouteMatchInterface $routeMatch + * The route match. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager * The enity type manager. * @param \Drupal\Core\Session\AccountInterface $account @@ -37,6 +42,7 @@ class DialogueHelper { */ public function __construct( protected RequestStack $requestStack, + protected RouteMatchInterface $routeMatch, protected EntityTypeManagerInterface $entityTypeManager, protected AccountInterface $account, protected MessengerInterface $messenger, @@ -193,6 +199,14 @@ public function dialogueProposalFormAlter(array &$form, FormStateInterface $form $form['field_location']['#access'] = FALSE; } + if (!in_array('use_name_on_proposals', $config)) { + $form['field_owner_name']['#access'] = FALSE; + } + + if (!in_array('use_email_on_proposals', $config)) { + $form['field_owner_email']['#access'] = FALSE; + } + $parentLocationSelection = $parent->get('field_dialogue_proposal_location')->getValue(); $parentPoint = json_decode($parentLocationSelection[0]['point'] ?? ''); @@ -237,6 +251,39 @@ public function formAlterSubmit(array &$form, FormStateInterface $form_state): v } } + /** + * Implements hook_form_FORMID_alter(). + */ + public function commentEarlyInclusionCommentFormAlter(array &$form, FormStateInterface $form_state) { + if (!isset($form['author'])) { + return; + } + + $config = []; + // The comment form is shown on routes `entity.node.canonical` and the POST + // request (when creating a comment) is sent to `comment.reply`. + $node = $this->routeMatch->getParameter('node') + ?? $this->routeMatch->getParameter('entity'); + if ($node instanceof NodeInterface && self::DIALOGUE_PROPOSAL_TYPE === $node->getType()) { + $parent = $node->get('field_dialogue')->referencedEntities()[0] ?? NULL; + if ($parent instanceof NodeInterface && self::DIALOGUE_TYPE === $parent->getType()) { + $config = $this->getProposalConfig($parent); + } + } + + // Disable author fields apart from the ones explicitly enabled. + foreach ($form['author'] as $key => &$formPart) { + $disable = match ($key) { + 'name' => !in_array('use_name_on_proposal_comments', $config), + 'mail' => !in_array('use_email_on_proposal_comments', $config), + default => TRUE, + }; + if ($disable) { + $formPart['#access'] = FALSE; + } + } + } + /** * Get parent node. * @@ -382,7 +429,7 @@ private function getDialogueCommentChildren(Comment $comment, array &$children): private function getDialogueIdFromFormState(FormStateInterface $form_state): ?int { $userInput = $form_state->getUserInput(); - if ($userInput['dialogue_options']) { + if (isset($userInput['dialogue_options'])) { $dialogueOptions = unserialize($userInput['dialogue_options']); $originalUrlObject = \Drupal::service('path.validator')->getUrlIfValid($dialogueOptions['originalPath']); diff --git a/web/sites/default/settings.php b/web/sites/default/settings.php index 70c15886e..bd3e6ca8e 100644 --- a/web/sites/default/settings.php +++ b/web/sites/default/settings.php @@ -69,6 +69,9 @@ // Additions // Allow calling `entity.toUrl` 'toUrl', + // and `entity.toLink` + 'toLink', + 'access', ]; // Local settings. These come last so that they can override anything. diff --git a/web/themes/custom/hoeringsportal/hoeringsportal.theme b/web/themes/custom/hoeringsportal/hoeringsportal.theme index a7a2a6a5c..5961c16dd 100755 --- a/web/themes/custom/hoeringsportal/hoeringsportal.theme +++ b/web/themes/custom/hoeringsportal/hoeringsportal.theme @@ -10,6 +10,7 @@ use Drupal\hoeringsportal_dialogue\Helper\DialogueHelper; use Drupal\node\NodeInterface; use Drupal\taxonomy\Entity\Term; use Drupal\hoeringsportal_citizen_proposal\Helper\Helper as CitizenProposalHelper; +use Drupal\hoeringsportal_anonymous_edit\Helper\Helper as AnonymousEditHelper; /** * Implements hook_preprocess(). @@ -19,6 +20,7 @@ function hoeringsportal_preprocess(&$vars) { $vars['public_meeting_helper'] = \Drupal::service('hoeringsportal_public_meeting.public_meeting_helper'); $vars['citizen_proposal_helper'] = \Drupal::service(CitizenProposalHelper::class); $vars['dialogue_helper'] = \Drupal::service(DialogueHelper::class); + $vars['anonymous_edit_helper'] = \Drupal::service(AnonymousEditHelper::class); } /** diff --git a/web/themes/custom/hoeringsportal/templates/comment/comment.html.twig b/web/themes/custom/hoeringsportal/templates/comment/comment.html.twig index b6d6870e6..ba2b9759a 100644 --- a/web/themes/custom/hoeringsportal/templates/comment/comment.html.twig +++ b/web/themes/custom/hoeringsportal/templates/comment/comment.html.twig @@ -81,6 +81,16 @@ {% if submitted and status is same as('published') %}
+ {# @todo #} + {% set owner = anonymous_edit_helper.getContentOwner(comment) %} + {% if owner and owner.name|default(false) %} +
{{ owner.isCurrent ? 'You'|t : owner.name }}
+ {# @todo #} + {# + {% if comment.access('update') %}[EDIT COMMENT]{% endif %} + {% if comment.access('delete') %}[DELETE COMMENT]{% endif %} + #} + {% endif %}
{{ comment.created.value|time_diff }}
{{ content|without('flag_support_comment') }}