diff --git a/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php b/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php index bd8407232..0e5371d43 100644 --- a/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php +++ b/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php @@ -30,6 +30,8 @@ namespace FileConnector\Controller; use App\Controller\StandardPluginController; +use Cake\Event\EventInterface; +use Cake\Http\Response; class FileSourcesController extends StandardPluginController { public $paginate = [ @@ -37,4 +39,25 @@ class FileSourcesController extends StandardPluginController { 'FileSources.id' => 'asc' ] ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->FileSources->ExternalIdentitySources->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->FileSources->ExternalIdentitySources->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->FileSources->ExternalIdentitySources->getPrimaryKey()); + } + + return parent::beforeRender($event); + } } diff --git a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php index c51a21307..0e26f851a 100644 --- a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php +++ b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php @@ -44,7 +44,8 @@ class FileSourcesTable extends Table { use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - + use \App\Lib\Traits\TabTrait; + // Cache of the field configuration protected $fieldCfg = null; @@ -78,6 +79,23 @@ public function initialize(array $config): void { ] ]); + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['ExternalIdentitySources', 'FileConnector.FileSources', 'ExternalIdentitySources@action.search'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'ExternalIdentitySources' => ['edit', 'view', 'search'], + 'FileConnector.FileSources' => ['edit'], + 'ExternalIdentitySources@action.search' => [], + ], + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ diff --git a/app/composer.json b/app/composer.json index e50a631fd..dada08d3b 100644 --- a/app/composer.json +++ b/app/composer.json @@ -32,6 +32,7 @@ "App\\": "src/", "ApiConnector\\": "availableplugins/ApiConnector/src/", "CoreAssigner\\": "plugins/CoreAssigner/src/", + "CoreEnroller\\": "plugins/CoreEnroller/src/", "CoreServer\\": "plugins/CoreServer/src/", "FileConnector\\": "availableplugins/FileConnector/src/", "PipelineToolkit\\": "availableplugins/PipelineToolkit/src/", @@ -45,6 +46,7 @@ "ApiConnector\\Test\\": "availableplugins/ApiConnector/tests/", "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", "CoreAssigner\\Test\\": "plugins/CoreAssigner/tests/", + "CoreEnroller\\Test\\": "plugins/CoreEnroller/tests/", "CoreServer\\Test\\": "plugins/CoreServer/tests/", "FileConnector\\Test\\": "availableplugins/FileConnector/tests/", "PipelineToolkit\\Test\\": "availableplugins/PipelineToolkit/tests/", diff --git a/app/config/routes.php b/app/config/routes.php index 89664c075..2bf695e7b 100644 --- a/app/config/routes.php +++ b/app/config/routes.php @@ -168,6 +168,20 @@ function (RouteBuilder $builder) { * ...and connect the rest of 'Pages' controller's URLs. */ $builder->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']); + + /** + * Registry allows URLs of the form /coid/name to render as a Mostly Static Page. + * + * Note this will effectively route any URL of the form /registry/x, where x consists of + * digits, to the Pages controller. We need to filter on digits, or we'll end up taking + * over all controllers as well. (The implication is we can't have a controller whose + * name consists entirely of digits, but we probably shouldn't...) + */ + $builder->connect( + '/{coid}/{name}', + ['controller' => 'Pages', 'action' => 'show' ], + ['coid' => '\d+', 'pass' => ['coid', 'name']] + ); /* * Connect catchall routes for all controllers. diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index da2844120..11ce07bc1 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -9,12 +9,14 @@ "comment": "Columns with names matching those defined here will by default inherit these properties", "columns": { + "action": { "type": "string", "size": 4 }, "api_user_id": { "type": "integer", "foreignkey": { "table": "api_users", "column": "id" } }, "co_id": { "type": "integer", "foreignkey": { "table": "cos", "column": "id" }, "notnull": true }, "comment": { "type": "string", "size": 256 }, "context": { "type": "string", "size": 2 }, "cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } }, "description": { "type": "string", "size": 128 }, + "enrollment_flow_step_id": { "type": "integer", "foreignkey": { "table": "enrollment_flow_steps", "column": "id" } }, "external_identity_id": { "type": "integer", "foreignkey": { "table": "external_identities", "column": "id" } }, "external_identity_role_id": { "type": "integer", "foreignkey": { "table": "external_identity_roles", "column": "id" } }, "external_identity_source_id": { "type": "integer", "foreignkey": { "table": "external_identity_sources", "column": "id" } }, @@ -23,15 +25,19 @@ "id": { "type": "integer", "autoincrement": true, "primarykey": true }, "identifier_assignment_id": { "type": "integer", "foreignkey": { "table": "identifier_assignments", "column": "id" }, "notnull": true }, "language": { "type": "string", "size": 16 }, + "mail": { "type": "string", "size": 256 }, + "message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" } }, "name": { "type": "string", "size": 128, "notnull": true }, "ordr": { "type": "integer" }, "person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, "person_role_id": { "type": "integer", "foreignkey": { "table": "person_roles", "column": "id" } }, + "petition_id": { "type": "integer", "foreignkey": { "table": "petitions", "column": "id" } }, "plugin": { "type": "string", "size": 80 }, "provisioning_target_id": { "type": "integer", "foreignkey": { "table": "provisioning_targets", "column": "id" }, "notnull": true }, "reference_identifier": { "type": "string", "size": 40 }, "report_id": { "type": "integer", "foreignkey": { "table": "reports", "column": "id" }, "notnull": true }, "server_id": { "type": "integer", "foreignkey": { "table": "servers", "column": "id" }, "notnull": true }, + "sor_label": { "type": "string", "size": 40 }, "status": { "type": "string", "size": 2 }, "type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, "notnull": true }, "valid_from": { "type": "datetime" }, @@ -397,11 +403,11 @@ "mvea": [ "person", "person_role", "external_identity", "external_identity_role" ], "sourced": true }, - + "email_addresses": { "columns": { "id": {}, - "mail": { "type": "string", "size": 256 }, + "mail": {}, "description": {}, "type_id": {}, "verified": { "type": "boolean" } @@ -517,8 +523,8 @@ "history_records": { "columns": { "id": {}, - "action": { "type": "string", "size": 4 }, - "comment": { "type": "string", "size": 256 }, + "action": {}, + "comment": {}, "person_id": {}, "person_role_id": {}, "external_identity_id": {}, @@ -594,6 +600,126 @@ } }, + "mostly_static_pages": { + "columns": { + "id": {}, + "co_id": {}, + "name": {}, + "title": { "type": "string", "size": 256 }, + "description": {}, + "status": {}, + "context": {}, + "body": { "type": "text" } + }, + "indexes": { + "mostly_static_pages_i1": { "columns": [ "co_id" ] } + } + }, + + "enrollment_flows": { + "columns": { + "id": {}, + "co_id": {}, + "name": {}, + "status": {}, + "sor_label": {}, + "authz_type": { "type": "string", "size": 2 }, + "authz_cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" }}, + "authz_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }}, + "collect_enrollee_email": { "type": "boolean" }, + "redirect_on_finalize": { "type": "string", "size": 256 } + }, + "indexes": { + "enrollment_flows_i1": { "columns": [ "co_id" ]}, + "enrollment_flows_i2": { "needed": false, "columns": [ "authz_cou_id" ]}, + "enrollment_flows_i3": { "needed": false, "columns": [ "authz_group_id" ]} + } + }, + + "enrollment_flow_steps": { + "columns": { + "id": {}, + "enrollment_flow_id": { "type": "integer", "foreignkey": { "table": "enrollment_flows", "column": "id" }}, + "description": {}, + "status": {}, + "plugin": {}, + "ordr": {}, + "actor_type": { "type": "string", "size": 2 }, + "message_template_id": {}, + "redirect_on_handoff": { "type": "string", "size": 256 } + }, + "indexes": { + "enrollment_flow_steps_i1": { "columns": [ "enrollment_flow_id" ]}, + "enrollment_flow_steps_i2": { "needed": false, "columns": [ "message_template_id" ]} + } + }, + + "petitions": { + "columns": { + "id": {}, + "enrollment_flow_id": { "type": "integer", "foreignkey": { "table": "enrollment_flows", "column": "id" }}, + "status": {}, + "cou_id": {}, + "enrollee_email": { "type": "string", "size": 256 }, + "enrollee_identifier": { "type": "string", "size": 256 }, + "enrollee_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" }}, + "petitioner_identifier": { "type": "string", "size": 256 }, + "petitioner_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" }}, + "token": { "type": "string", "size": 256 } + }, + "indexes": { + "petitions_i1": { "columns": [ "enrollment_flow_id" ]}, + "petitions_i2": { "columns": [ "cou_id" ]}, + "petitions_i3": { "columns": [ "enrollee_person_id" ]}, + "petitions_i4": { "columns": [ "petitioner_person_id" ]} + } + }, + + "petition_step_results": { + "columns": { + "id": {}, + "petition_id": {}, + "enrollment_flow_step_id": {}, + "comment": {} + }, + "indexes": { + "petition_step_results_i1": { "columns": [ "petition_id" ] }, + "petition_step_results_i2": { "needed": false, "columns": [ "enrollment_flow_step_id" ] } + } + }, + + "petition_history_records": { + "columns": { + "id": {}, + "petition_id": {}, + "enrollment_flow_step_id": {}, + "action": {}, + "comment": {}, + "actor_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } } + }, + "indexes": { + "petition_history_records_i1": { "columns": [ "petition_id" ] }, + "petition_history_records_i2": { "columns": [ "actor_person_id" ] }, + "petition_history_records_i3": { "needed": false, "columns": [ "enrollment_flow_step_id" ] } + } + }, + + "verifications": { + "columns": { + "id": {}, + "code": { "type": "string", "size": 32 }, + "verification_time": { "type": "datetime" }, + "request_expiration_time": { "type": "datetime" }, + "method": { "type": "string", "size": 2 }, + "email_address_id": { "type": "integer", "foreignkey": { "table": "email_addresses", "column": "id" } }, + "petition_id": {} + }, + "indexes": { + "verifications_i1": { "columns": [ "email_address_id" ] }, + "verifications_i2": { "columns": [ "petition_id" ] } + } + }, + "jobs": { "columns": { "id": {}, diff --git a/app/plugins/CoreEnroller/composer.json b/app/plugins/CoreEnroller/composer.json new file mode 100644 index 000000000..5bdea63d7 --- /dev/null +++ b/app/plugins/CoreEnroller/composer.json @@ -0,0 +1,24 @@ +{ + "name": "your-name-here/core-enroller", + "description": "CoreEnroller plugin for CakePHP", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.4.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "CoreEnroller\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CoreEnroller\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/plugins/CoreEnroller/phpunit.xml.dist b/app/plugins/CoreEnroller/phpunit.xml.dist new file mode 100644 index 000000000..6e44e4767 --- /dev/null +++ b/app/plugins/CoreEnroller/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po new file mode 100644 index 000000000..20db7a858 --- /dev/null +++ b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po @@ -0,0 +1,236 @@ +# COmanage Registry Localizations (core_enroller domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry-plugins +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.AttributeCollectors" +msgstr "{0,plural,=1{Attribute Collector} other{Attribute Collectors}}" + +msgid "controller.BasicAttributeCollectors" +msgstr "{0,plural,=1{Basic Attribute Collector} other{Basic Attribute Collectors}}" + +msgid "controller.EnrollmentAttributes" +msgstr "{0,plural,=1{Enrollment Attribute} other{Enrollment Attributes}}" + +msgid "enumeration.VerificationModeEnum.0" +msgstr "None" + +msgid "enumeration.VerificationModeEnum.1" +msgstr "One" + +msgid "enumeration.VerificationModeEnum.A" +msgstr "All" + +msgid "error.EmailVerifiers.candidate" +msgstr "Requested address is not a valid candidate" + +msgid "error.EmailVerifiers.minimum" +msgstr "The required number of verified Email Addresses has not been met" + +msgid "error.EmailVerifiers.verified" +msgstr "Requested address is already verified" + +msgid "error.PetitionAcceptances.exists" +msgstr "An Invitation for this Petition already exists" + +msgid "error.PetitionAcceptances.expired" +msgstr "This Invitation has expired" + +msgid "error.PetitionAcceptances.processed" +msgstr "This Invitation has already been processed" + +msgid "information.EmailVerifiers.done" +msgstr "All email addresses in this Petition have been verified. You may continue on to the next Enrollment Step." + +msgid "information.EmailVerifiers.0" +msgstr "The following email addresses have been found in this Petition. You may continue on to the next Enrollment Step, or you may verify these addresses now." + +msgid "information.EmailVerifiers.1.met" +msgstr "The following email addresses have been found in this Petition. At least one address has been verified, so you may continue on to the next Enrollment Step, or you may verify any remaining addresses now." + +msgid "information.EmailVerifiers.1.none" +msgstr "The following email addresses have been found in this Petition. You must verify at least one in order to proceed to the next Enrollment Step." + +msgid "information.EmailVerifiers.A" +msgstr "The following email addresses have been found in this Petition. You must verify all of them in order to proceed to the next Enrollment Step." + +msgid "information.EmailVerifiers.code_sent" +msgstr "A code has been sent to {0}. Please enter it below. You may also request a new code if you haven't received it after a few minutes, or cancel verification and return to the list of available Email Addresses." + +msgid "field.AttributeCollectors.valid_through.default.after.desc" +msgstr "Days After Finalization" + +msgid "field.BasicAttributeCollectors.affiliation_type_id" +msgstr "Affiliation Type" + +msgid "field.BasicAttributeCollectors.affiliation_type_id.desc" +msgstr "Affiliation assigned to Person Roles created by this Step" + +msgid "field.BasicAttributeCollectors.cou_id.desc" +msgstr "If set, Person Roles created by this Step will be placed in this COU" + +msgid "field.BasicAttributeCollectors.email_address_type_id" +msgstr "Email Address Type" + +msgid "field.BasicAttributeCollectors.email_address_type_id.desc" +msgstr "Type assigned to the Email Address collected by this Step" + +msgid "field.BasicAttributeCollectors.name_type_id" +msgstr "Name Type" + +msgid "field.BasicAttributeCollectors.name_type_id.desc" +msgstr "Type assigned to the Name collected by this Step" + +msgid "field.EmailVerifiers.mode" +msgstr "Email Verification Mode" + +msgid "field.EmailVerifiers.mode.desc" +msgstr "The minimum number of addresses that must be verified in order to complete this step" + +msgid "field.EmailVerifiers.mode" +msgstr "Email Verification Mode" + +msgid "field.EmailVerifiers.mode.desc" +msgstr "The minimum number of addresses that must be verified in order to complete this step" + +msgid "field.EmailVerifiers.request_validity" +msgstr "Request Validity" + +msgid "field.EmailVerifiers.request_validity.desc" +msgstr "Duration, in minutes, of the verification request before it expires" + +msgid "field.EnrollmentAttributes.address_required_fields" +msgstr "Required Address Fields" + +msgid "field.EnrollmentAttributes.address_required_fields.desc" +msgstr "Must be at least as restrictive as the CO Setting configuration, or blank to follow the CO Setting" + +msgid "field.EnrollmentAttributes.address_type_id" +msgstr "Address Type" + +msgid "field.EnrollmentAttributes.attribute_language" +msgstr "Attribute Language" + +msgid "field.EnrollmentAttributes.attribute_mvea_parent" +msgstr "Attribute Attaches To" + +msgid "field.EnrollmentAttributes.attribute_tag" +msgstr "Ad Hoc Attribute Tag" + +msgid "field.EnrollmentAttributes.default_value" +msgstr "Default Value" + +msgid "field.EnrollmentAttributes.default_value_affiliation_type_id" +msgstr "Default Affiliation Type" + +msgid "field.EnrollmentAttributes.default_value_cou_id" +msgstr "Default Cou" + +msgid "field.EnrollmentAttributes.default_value_datetime" +msgstr "Default Datetime" + +msgid "field.EnrollmentAttributes.default_value_datetime.desc" +msgstr "On This Date" + +msgid "field.EnrollmentAttributes.default_value_env_name" +msgstr "Environmental Variable for Default Value" + +msgid "field.EnrollmentAttributes.default_value_group_id" +msgstr "Default Group" + +msgid "field.EnrollmentAttributes.default_value_validity_type" +msgstr "Default Validity Date Type" + +msgid "field.EnrollmentAttributes.email_address_type_id" +msgstr "Email Address Type" + +msgid "field.EnrollmentAttributes.identifier_type_id" +msgstr "Identifier Type" + +msgid "field.EnrollmentAttributes.label" +msgstr "Display Label" + +msgid "field.EnrollmentAttributes.name_required_fields" +msgstr "Required Name Fields" + +msgid "field.EnrollmentAttributes.name_required_fields.desc" +msgstr "Must be at least as restrictive as the CO Setting configuration, or blank to follow the CO Setting" + +msgid "field.EnrollmentAttributes.name_type_id" +msgstr "Name Type" + +msgid "field.EnrollmentAttributes.petition_text" +msgstr "Text Field (Petition Use Only)" + +msgid "field.EnrollmentAttributes.petition_textarea" +msgstr "Text Area (Petition Use Only)" + +msgid "field.EnrollmentAttributes.pronoun_type_id" +msgstr "Pronouns Type" + +msgid "field.EnrollmentAttributes.telephone_number_type_id" +msgstr "Telephone Number Type" + +msgid "field.EnrollmentAttributes.url_type_id" +msgstr "URL Type" + +msgid "field.IdentifierCollectors.type_id.desc" +msgstr "The collected Identifiers will be assigned this Type" + +msgid "field.InvitationAccepters.invitation_validity" +msgstr "Invitation Validity" + +msgid "field.InvitationAccepters.invitation_validity.desc" +msgstr "Time in minutes before the invitation expires, default is 1440 (1 day)" + +msgid "field.InvitationAccepters.welcome_message" +msgstr "Welcome Message" + +msgid "result.accept.accepted" +msgstr "Invitation Accepted" + +msgid "result.accept.declined" +msgstr "Invitation Declined" + +msgid "result.attr.saved" +msgstr "Petition Attributes recorded" + +msgid "result.basicattr.finalized" +msgstr "Name, Email Address, and Person Role created during finalization" + +msgid "result.EmailVerifiers.verified" +msgstr "Verified {0} of {1} available {2}" + +msgid "result.EmailVerifiers.verified.history" +msgstr "Verified email address {0} via {1}" + +msgid "result.IdentifierCollector.collected" +msgstr "Obtained login Identifier {0}" + +msgid "result.InvitationAccepters.accepted" +msgstr "Invitation Accepted at {0}" + +msgid "result.InvitationAccepters.declined" +msgstr "Invitation Declined at {0}" + +msgid "result.InvitationAccepters.none" +msgstr "No response to invitation yet" \ No newline at end of file diff --git a/app/plugins/CoreEnroller/src/Controller/AppController.php b/app/plugins/CoreEnroller/src/Controller/AppController.php new file mode 100644 index 000000000..7075799f1 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'AttributeCollectors.id' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->AttributeCollectors->EnrollmentFlowSteps->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->AttributeCollectors->EnrollmentFlowSteps->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->AttributeCollectors->EnrollmentFlowSteps->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.0.0 + * @param string $id Attribute Collector ID + */ + + public function dispatch(string $id) { + $petition = $this->getPetition(); + + if($this->request->is(['post', 'put'])) { + try { + $this->AttributeCollectors->upsert( + id: (int)$id, + petitionId: $petition->id, + // Remove form metadata from the set of attributes we try to upsert + attributes: array_diff_key($this->request->getData(), ['petition_id' => true, 'token' => true]) + ); + + // On success, indicate the step is completed and generate a redirect + // to the next step + + $link = $this->getPrimaryLink(true); + + return $this->finishStep( + enrollmentFlowStepId: $link->value, + petitionId: $petition->id, + comment: __d('core_enroller', 'result.attr.saved') + ); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + } + + // Fall through and let the form render + + // Pull the configured set of attributes + $this->set('vv_enrollment_attributes', + $this->AttributeCollectors + ->EnrollmentAttributes + ->find() + ->where(['attribute_collector_id' => $id]) + ->all()); + + // We support updating attributes until the Petition is finalized, + // see if there happen to be any values already stored. Note this + // will pull _all_ attributes associated with the Petition, not just + // those associated with this Attribute Collector. + $this->set('vv_petition_attributes', + $this->AttributeCollectors + ->EnrollmentAttributes + ->PetitionAttributes + ->find() + ->where(['petition_id' => $petition->id]) + ->all()); + + $this->render('/Standard/dispatch'); + } + + /** + * Display information about this Step. + * + * @since COmanage Registry v5.0.0 + * @param string $id Attribute Collector ID + */ + + public function display(string $id) { + debug("display something for this petition"); + debug($this->getPetition()); + } +} diff --git a/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php b/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php new file mode 100644 index 000000000..bf3f701f8 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php @@ -0,0 +1,107 @@ + [ + 'BasicAttributeCollectors.id' => 'asc' + ] + ]; + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Attribute Collector ID + */ + + public function dispatch(string $id) { + $petition = $this->getPetition(); + + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); + + $this->set('vv_permitted_name_fields', $settings->name_permitted_fields_array()); + $this->set('vv_required_name_fields', $settings->name_required_fields_array()); + + if($this->request->is(['post', 'put'])) { + + try { + $this->BasicAttributeCollectors->upsert( + id: (int)$id, + petitionId: $petition->id, + // Remove form metadata from the set of attributes we pass to upsert + attributes: array_diff_key($this->request->getData(), ['petition_id' => true, 'token' => true]) + ); + + // On success, indicate the step is completed and generate a redirect + // to the next step + + $link = $this->getPrimaryLink(true); + + return $this->finishStep( + enrollmentFlowStepId: $link->value, + petitionId: $petition->id, + comment: __d('core_enroller', 'result.attr.saved') + ); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + } + + // Fall through and let the form render + + $this->render('/Standard/dispatch'); + } + + /** + * Display information about this Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Attribute Collector ID + */ + + public function display(string $id) { + $petition = $this->getPetition(); + + $this->set('vv_petition_basic_attribute_set', $this->BasicAttributeCollectors + ->PetitionBasicAttributeSets + ->find() + ->where(['PetitionBasicAttributeSets.petition_id' => $petition->id]) + ->firstOrFail()); + } +} diff --git a/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php b/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php new file mode 100644 index 000000000..16e320f06 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php @@ -0,0 +1,219 @@ + [ + 'EmailVerifiers.id' => 'asc' + ] + ]; + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Email Verifier ID + */ + + public function dispatch(string $id) { + $op = $this->requestParam('op'); + + if(!$op) { + $op = 'index'; + } + + $this->set('vv_op', $op); + + $petition = $this->getPetition(); + + $cfg = $this->EmailVerifiers->get($id); + + $candidateAddresses = $this->EmailVerifiers->assembleVerifiableAddresses($cfg, $petition); + + $this->set('vv_config', $cfg); + $this->set('vv_email_addresses', $candidateAddresses); + + // To make things easier for the view, we'll create a separate view var with the + // addresses that have actually been verified. + + $verifiedAddresses = []; + + foreach($candidateAddresses as $a => $v) { + if(!empty($v->verification->verification_time)) { + $verifiedAddresses[$a] = true; + } + } + + $this->set('vv_verified_addresses', $verifiedAddresses); + + // And perform some calculations + $doneCount = count($verifiedAddresses); + $totalCount = count($candidateAddresses); + $allDone = $doneCount == $totalCount; + $minimumMet = $cfg->mode == VerificationModeEnum::None + || ($cfg->mode == VerificationModeEnum::One + && $doneCount > 0) + || ($cfg->mode == VerificationModeEnum::All + && $allDone); + + $this->set('vv_all_done', $allDone); + $this->set('vv_minimum_met', $minimumMet); + + if($op == 'verify') { + // Before we get into the actual logic, check that the requested email address + // is in the set of candidate addresses. + + $mail = StringUtilities::urlbase64decode($this->requestParam('m')); + + if(!array_key_exists($mail, $candidateAddresses)) { + $this->llog('error', "Requested address $mail is not a valid candidate"); + + $this->Flash->error(__d('core_enroller', 'error.EmailVerifiers.candidate')); + } elseif(isset($verifiedAddresses[$mail])) { + $this->llog('debug', "Requested address $mail is already verified"); + + $this->Flash->error(__d('core_enroller', 'error.EmailVerifiers.verified')); + } else { + if($this->request->is('post')) { + $PetitionVerifications = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionVerifications'); + + // We're back with the code. Note many parameters (but not code) will be in + // both the URL and the post body because of how dispatch.php sets up + // FormHelper. + + $code = $this->requestParam('code'); + + try { + $PetitionVerifications->verifyCode( + $petition->id, + $cfg->enrollment_flow_step_id, + $mail, + $code + ); + + $this->llog('debug', "Successfully verified $mail"); + + // On success we need to regenerate the verified address array. + // We redirect back to ourself rather than rebuild all the logic we need. + + $url = [ + 'plugin' => 'CoreEnroller', + 'controller' => 'email_verifiers', + 'action' => 'dispatch', + $cfg->id, + '?' => [ + 'op' => 'index', + 'petition_id' => $petition->id + ] + ]; + + $token = $this->injectToken($petition->id); + + if($token) { + $url['?']['token'] = $token; + } + + return $this->redirect($url); + } + catch(\Exception $e) { + $this->llog('error', $e->getMessage()); + $this->Flash->error($e->getMessage()); + } + } else { + // Generate a Verification request, then render a form to collect it. + // If there is already a pending request, overwrite it (generate a new code). + + $this->EmailVerifiers->sendVerificationRequest($cfg, $petition, $mail); + } + + // Tell dispatch.inc to render a verification form + $this->set('vv_verify_address', $mail); + } + } elseif($op == 'finish') { + if($minimumMet) { + // We're done, set the Petition status to "Verified" + + $this->llog('debug', "Finished verifying email addresses"); + + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $petition->status = PetitionStatusEnum::Verified; + + $Petitions->saveOrFail($petition); + + // Redirect to the next step + + return $this->finishStep( + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + petitionId: $petition->id, + comment: __d('core_enroller', + 'result.EmailVerifiers.verified', + [$doneCount, $totalCount, __d('controller', 'EmailAddresses', $doneCount)]) + ); + } else { + $this->llog('error', "Finish attempted but minimum number of addresses not met"); + $this->Flash->error(__d('core_enroller', 'error.EmailVerifiers.minimum')); + + // Reset the op so the view renders correctly + $this->set('vv_op', 'index'); + } + } + + $this->render('/Standard/dispatch'); + } + + /** + * Display information about this Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Email Verifiers ID + */ + + public function display(string $id) { + $petition = $this->getPetition(); + + $PetitionVerifications = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionVerifications'); + + // Because Petition Verifications are not tracked on a per-step basis, we just pull all + // associated with the Petition + + $this->set('vv_pv', $PetitionVerifications->find() + ->where(['PetitionVerifications.petition_id' => $petition->id]) + ->contain(['Verifications']) + ->all()); + } +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/src/Controller/EnrollmentAttributesController.php b/app/plugins/CoreEnroller/src/Controller/EnrollmentAttributesController.php new file mode 100644 index 000000000..0f423e5e0 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/EnrollmentAttributesController.php @@ -0,0 +1,94 @@ + [ + 'EnrollmentAttributes.ordr' => 'asc' + ] + ]; + + /** + * Callback run prior to the request rendering. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return EventInterface + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + $this->set('vv_supported_attributes', $this->EnrollmentAttributes->supportedAttributes()); + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->EnrollmentAttributes->AttributeCollectors->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->EnrollmentAttributes->AttributeCollectors->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->EnrollmentAttributes->AttributeCollectors->getPrimaryKey()); + } + + $ret = parent::beforeRender($event); + + $attributes = $this->viewBuilder()->getVar('attributes'); + + // Override the auto-generated title + switch($this->request->getParam('action')) { + case 'add': + $this->set( + 'vv_title', + __d('operation', 'add.a-1', [ + __d('core_enroller', 'controller.EnrollmentAttributes', [1]), + $attributes[$this->request->getQuery('attribute_type')] + ]) + ); + break; + case 'edit': + $vv_obj = $this->viewBuilder()->getVar('vv_obj'); + $this->set( + 'vv_title', + __d('operation', 'edit.a-1', [ + __d('core_enroller', 'controller.EnrollmentAttributes', [1]), + $attributes[ $vv_obj['attribute'] ] + ]) + ); + break; + case 'index': + $this->set('vv_title', __d('core_enroller', 'controller.EnrollmentAttributes', [99])); + break; + } + + return $ret; + } +} diff --git a/app/plugins/CoreEnroller/src/Controller/IdentifierCollectorsController.php b/app/plugins/CoreEnroller/src/Controller/IdentifierCollectorsController.php new file mode 100644 index 000000000..2b33b59c0 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/IdentifierCollectorsController.php @@ -0,0 +1,124 @@ + [ + 'IdentifierCollectors.id' => 'asc' + ] + ]; + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Invitation Accepter ID + */ + + public function dispatch(string $id) { + $petition = $this->getPetition(); + + $cfg = $this->IdentifierCollectors->get($id); + + // Because we're not in the "protected" web server application space, we need to read + // the username from the session (as set via TrafficController) rather than via getenv(). + // This also precludes us from using a variable other than $REMOTE_USER. + + $request = $this->getRequest(); + $session = $request->getSession(); + $username = $session->read('Auth.external.user'); + + $PetitionIdentifiers = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionIdentifiers'); + + try { + $PetitionIdentifiers->record($petition->id, $cfg->enrollment_flow_step_id, $username); + + return $this->finishStep( + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + petitionId: $petition->id, + comment: __d('core_enroller', 'result.IdentifierCollector.collected', [$username]) + ); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + } + + /** + * Display information about this Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Invitation Accepters ID + */ + + public function display(string $id) { + $petition = $this->getPetition(); + + $PetitionIdentifiers = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionIdentifiers'); + + $this->set('vv_pi', $PetitionIdentifiers->find()->where(['petition_id' => $petition->id])->first()); + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.1.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + if($action == 'dispatch') { + // We need to perform special logic (vs StandardEnrollerController) + // to ensure that web server authentication is triggered. + + // To start, we trigger the parent logic. This will return + // notauth: Some error occurred, we don't want to override this + // authz: No token in use + // yes: Token validated + + $auth = parent::willHandleAuth($event); + + // The only status we need to override is 'yes', since we always want authentication + // to run in order to be able to grab $REMOTE_USER. + + return ($auth == 'yes' ? 'authz' : $auth); + } + + return parent::willHandleAuth($event); + } +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php b/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php new file mode 100644 index 000000000..3b377d355 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php @@ -0,0 +1,130 @@ + [ + 'InvitationAccepters.id' => 'asc' + ] + ]; + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Invitation Accepter ID + */ + + public function dispatch(string $id) { + $petition = $this->getPetition(); + + $cfg = $this->InvitationAccepters->get($id); + + $this->set('vv_config', $cfg); + + $PetitionAcceptances = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionAcceptances'); + + $pa = $PetitionAcceptances->find()->where(['petition_id' => $petition->id])->first(); + + $this->set('vv_pa', $pa); + + if($this->request->is(['post', 'put'])) { + $data = $this->request->getData(); + + $accepted = (bool)$data['accepted']; + + try { + $PetitionAcceptances->processReply( + $petition->id, + $cfg->enrollment_flow_step_id, + $accepted + ); + + if($accepted) { + // On acceptance, indicate the step is completed and generate a redirect + // to the next step + + $link = $this->getPrimaryLink(true); + + return $this->finishStep( + enrollmentFlowStepId: $link->value, + petitionId: $petition->id, + comment: __d('core_enroller', ($accepted ? 'result.accept.accepted' : 'result.accept.declined')) + ); + } else { + // On decline, set a flash message and redirect to the petition complete landing + $this->Flash->success(__d('core_enroller', 'result.accept.declined')); + + $coId = $this->getCOID(); + + return $this->redirect("/$coId/petition-complete"); + } + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + } else { + // Record that the invitation was viewed + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + + $PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + action: PetitionActionEnum::InvitationViewed, + comment: __d('result', 'Petitions.viewed.inv') + // actorPersonId + ); + } + + // Fall through and let the form render*/ + + $this->render('/Standard/dispatch'); + } + + /** + * Display information about this Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Invitation Accepters ID + */ + + public function display(string $id) { + $petition = $this->getPetition(); + + $PetitionAcceptances = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionAcceptances'); + + $this->set('vv_pa', $PetitionAcceptances->find()->where(['petition_id' => $petition->id])->first()); + } +} diff --git a/app/plugins/CoreEnroller/src/CoreEnrollerPlugin.php b/app/plugins/CoreEnroller/src/CoreEnrollerPlugin.php new file mode 100644 index 000000000..405285cd9 --- /dev/null +++ b/app/plugins/CoreEnroller/src/CoreEnrollerPlugin.php @@ -0,0 +1,93 @@ +plugin( + 'CoreEnroller', + ['path' => '/core-enroller'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + } +} diff --git a/app/plugins/CoreEnroller/src/Lib/Enum/VerificationModeEnum.php b/app/plugins/CoreEnroller/src/Lib/Enum/VerificationModeEnum.php new file mode 100644 index 000000000..4416de0f5 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Lib/Enum/VerificationModeEnum.php @@ -0,0 +1,38 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/BasicAttributeCollector.php b/app/plugins/CoreEnroller/src/Model/Entity/BasicAttributeCollector.php new file mode 100644 index 000000000..01f468029 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/BasicAttributeCollector.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/EmailVerifier.php b/app/plugins/CoreEnroller/src/Model/Entity/EmailVerifier.php new file mode 100644 index 000000000..f867a1c79 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/EmailVerifier.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/EnrollmentAttribute.php b/app/plugins/CoreEnroller/src/Model/Entity/EnrollmentAttribute.php new file mode 100644 index 000000000..5e4976e9b --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/EnrollmentAttribute.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/IdentifierCollector.php b/app/plugins/CoreEnroller/src/Model/Entity/IdentifierCollector.php new file mode 100644 index 000000000..85e00897e --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/IdentifierCollector.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php b/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php new file mode 100644 index 000000000..8017d29b1 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionAcceptance.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionAcceptance.php new file mode 100644 index 000000000..cc3d84c55 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionAcceptance.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionAttribute.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionAttribute.php new file mode 100644 index 000000000..558ac97bc --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionAttribute.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php new file mode 100644 index 000000000..d506679eb --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionIdentifier.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionIdentifier.php new file mode 100644 index 000000000..7045282fe --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionIdentifier.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionVerification.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionVerification.php new file mode 100644 index 000000000..ee9e173a8 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionVerification.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/README.md b/app/plugins/CoreEnroller/src/Model/README.md new file mode 100644 index 000000000..6851e4972 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/README.md @@ -0,0 +1,11 @@ +# CoreEnroller plugin for CakePHP + +## Installation + +You can install this plugin into your CakePHP application using [composer](https://getcomposer.org). + +The recommended way to install composer packages is: + +``` +composer require your-name-here/core-enroller +``` diff --git a/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php new file mode 100644 index 000000000..2c7701b4c --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php @@ -0,0 +1,240 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + + $this->hasMany('CoreEnroller.EnrollmentAttributes') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.AttributeCollectors', 'CoreEnroller.EnrollmentAttributes'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'CoreEnroller.AttributeCollectors' => ['edit'], + 'CoreEnroller.EnrollmentAttributes' => ['index'], + ] + ] + ); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ], + 'related' => [ + 'table' => [ + 'CoreEnroller.EnrollmentAttributes' + ] + ] + ]); + } + + /** + * Perform steps necessary to finalize the Petition. + * + * @since COmanage Registry v5.0.0 + * @param int $id Attribute Collector ID + * @param int $petitionId Petition ID + * @return bool true on success + */ + + public function finalize(int $id, int $petitionId) { +// XXX convert Petition Attributes to operational Attributes +// keep in mind this can be called multiple times if the plugin is +// instantiated more than once in the Enrollment Flow ($id will be different) +// any errors should be logged or otherwise managed, returning false +// or throwing an error will NOT prevent the petition from finalizing + +debug("in AttributeCollector finalize"); + + return true; + } + + /** + * Insert or update a set of Petition Attributes. + * + * @since COmanage Registry v5.0.0 + * @param int $id Attribute Collector ID + * @param int $petitionId Petition ID + * @param array $attributes Petition Attributes + * @return bool true on success + * @throws PersistenceFailedException + */ + + public function upsert(int $id, int $petitionId, array $attributes) { + $attributeCollector = $this->get($id); + + // Do we have existing attributes for this petition? Note this will pull + // _all_ attributes for the Petition, not just those associated with this + // particular Attribute Collector; however we'll only look at the attributes + // we need below. + $currentAttributes = $this->EnrollmentAttributes + ->PetitionAttributes->find('list', [ + 'keyField' => 'enrollment_attribute_id', + 'valueField' => 'id' + ]) + ->where(['petition_id' => $petitionId]) + ->toArray(); + + $petitionAttributes = []; + + foreach($attributes as $enrollmentAttributeLabel => $value) { + // Remove field- prefix from the form field name + $enrollmentAttributeId = (int)substr($enrollmentAttributeLabel, 6); + + $newAttribute = [ + 'petition_id' => $petitionId, + 'enrollment_attribute_id' => $enrollmentAttributeId, + 'value' => $value + ]; + + if(array_key_exists($enrollmentAttributeId, $currentAttributes)) { + // This is an update of an existing attribute + + $newAttribute['id'] = $currentAttributes[$enrollmentAttributeId]; + + $entity = $this->EnrollmentAttributes + ->PetitionAttributes->get($newAttribute['id']); + + // We don't bother with patch entity since the only thing we support + // changing is value + + $entity->value = $value; + + $this->EnrollmentAttributes->PetitionAttributes->saveOrFail($entity); +// XXX we could record petition history that this specific attribute was updated + } else { + // This is a new attribute + $petitionAttributes[] = $newAttribute; + } + } + + if(!empty($petitionAttributes)) { + $entities = $this->EnrollmentAttributes->PetitionAttributes->newEntities($petitionAttributes); + + $this->EnrollmentAttributes->PetitionAttributes->saveManyOrFail($entities); + } + + // Record Petition History + + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + + $PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $attributeCollector->enrollment_flow_step_id, + action: PetitionActionEnum::AttributesUpdated, + comment: __d('core_enroller', 'result.attr.saved') +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $this->registerStringValidation($validator, $schema, 'description', true); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php new file mode 100644 index 000000000..cd0ac0766 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php @@ -0,0 +1,347 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('EmailAddressTypes') + ->setClassName('Types') + ->setForeignKey('email_address_type_id') + ->setProperty('email_address_type'); + $this->belongsTo('NameTypes') + ->setClassName('Types') + ->setForeignKey('name_type_id') + ->setProperty('name_type'); + $this->belongsTo('AffiliationTypes') + ->setClassName('Types') + ->setForeignKey('affiliation_type_id') + ->setProperty('affiliation_type'); + $this->belongsTo('Cous'); + + $this->hasMany('CoreEnroller.PetitionBasicAttributeSets') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + $this->setAutoViewVars([ + 'affiliationTypes' => [ + 'type' => 'type', + 'attribute' => 'PersonRoles.affiliation_type' + ], + 'cous' => [ + 'type' => 'select', + 'model' => 'Cous' + ], + 'emailAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type' + ], + 'nameTypes' => [ + 'type' => 'type', + 'attribute' => 'Names.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ], + 'related' => [ + 'table' => [ + 'CoreEnroller.EnrollmentAttributes' + ] + ] + ]); + } + + /** + * Perform steps necessary to finalize the Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Basic Attribute Collector ID + * @param Petition $petition Petition + * @return bool true on success + */ + + public function finalize(int $id, \App\Model\Entity\Petition $petition) { + $cfg = $this->get($id); + + // At this point there is a Person record allocated and stored in the Petition, + // but it doesn't have any attributes on it, including a Primary Name. + // We assume we're the only attribute collector, so we'll force a Primary Name + // based on the Basic Attribtues, and create a skeletal role. + + if(empty($petition->enrollee_person_id)) { + throw new \InvalidArgumentException(__d('error', 'Petitions.enrollee.notfound', [$petition->id])); + } + + $People = TableRegistry::getTableLocator()->get('People'); + + $person = $People->get($petition->enrollee_person_id); + + $attributes = $this->PetitionBasicAttributeSets + ->find() + ->where([ + 'petition_id' => $petition->id, + // Strictly speaking we only support one instance per Flow, + // but we'll filter on the $id anyway since we have it + 'basic_attribute_collector_id' => $id + ]) + ->firstOrFail(); + + // Since we're not modifying $person, it's a bit clearer if we save each entity + // individually than try to save related + + $Names = TableRegistry::getTableLocator()->get('Names'); + +// XXX enforce CoSettings required/permitted fields here? + $name = [ + 'person_id' => $person->id, + 'primary_name' => true, + 'type_id' => $cfg->name_type_id + ]; + + foreach(['honorific', 'given', 'middle', 'family', 'suffix'] as $n) { + if(!empty($attributes->$n)) { + $name[$n] = $attributes->$n; + } + } + + $Names->saveOrFail($Names->newEntity($name)); + + $EmailAddresses = TableRegistry::getTableLocator()->get('EmailAddresses'); + + $email = [ + 'person_id' => $person->id, + 'mail' => $attributes->mail, + 'type_id' => $cfg->email_address_type_id + ]; + + $EmailAddresses->saveOrFail($EmailAddresses->newEntity($email)); + + $PersonRoles = TableRegistry::getTableLocator()->get('PersonRoles'); + + $personRole = [ + 'person_id' => $person->id, + 'affiliation_type_id' => $cfg->affiliation_type_id, + 'cou_id' => $cfg->cou_id, + 'status' => StatusEnum::Active + ]; + + $PersonRoles->saveOrFail($PersonRoles->newEntity($personRole)); + + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + + $PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + action: PetitionActionEnum::Finalized, + comment: __d('core_enroller', 'result.basicattr.finalized') +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + + return true; + } + + /** + * Insert or update a Basic Petition Attribute Set. + * + * @since COmanage Registry v5.1.0 + * @param int $id Basic Attribute Collector ID + * @param int $petitionId Petition ID + * @param array $attributes Petition Attributes + * @return bool true on success + * @throws PersistenceFailedException + */ + + public function upsert(int $id, int $petitionId, array $attributes) { + $basicAttributeCollector = $this->get($id); + + // Do we have existing attributes for this petition? Note this will pull + // _all_ attributes for the Petition, not just those associated with this + // particular Attribute Collector; however we'll only look at the attributes + // we need below. + $entity = $this->PetitionBasicAttributeSets + ->find() + ->where([ + 'petition_id' => $petitionId, + // Strictly speaking we only support one instance per Flow, + // but we'll filter on the $id anyway since we have it + 'basic_attribute_collector_id' => $id + ]) + ->first(); + + if(!$entity) { + // insert, not update + + $entity = $this->PetitionBasicAttributeSets->newEntity([ + 'basic_attribute_collector_id' => $id, + 'petition_id' => $petitionId + ]); + } + + foreach(['honorific', 'given', 'middle', 'family', 'suffix', 'mail'] as $f) { +// XXX we should probably check CoSettings for name settings + $entity->$f = $attributes[$f] ?? null; + } + + $this->PetitionBasicAttributeSets->saveOrFail($entity); + + // Record Petition History + + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + + $PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $basicAttributeCollector->enrollment_flow_step_id, + action: PetitionActionEnum::AttributesUpdated, + comment: __d('core_enroller', 'result.attr.saved') +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $validator->add('name_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('name_type_id'); + + $validator->add('email_address_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('email_address_type_id'); + + $validator->add('affiliation_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('affiliation_type_id'); + + $validator->add('cou_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('cou_id'); + + return $validator; + } + + /** + * Obtain the set of Email Addresses known to this plugin that are eligible for + * verification. + * + * @since COmanage Registry v5.1.0 + * @param EntityInterface $config Configuration entity for this plugin + * @param int $petitionId Petition ID + * @return array Array of Email Addrsses that are eligible for verification + */ + + public function verifiableEmailAddresses( + EntityInterface $config, + int $petitionId + ): array { + $set = $this->PetitionBasicAttributeSets->find() + ->where([ + 'basic_attribute_collector_id' => $config->id, + 'petition_id' => $petitionId + ]) + ->first(); + + return !empty($set->mail) ? [$set->mail] : []; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php b/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php new file mode 100644 index 000000000..d870fb696 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php @@ -0,0 +1,475 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('MessageTemplates'); + // $this->belongsTo('Types'); + + // We intentionally don't hasMany PetitionIdentifiers since there should only be one + // collector per Petition, and the net result of not having the direct foreign key + // is that if an admin instantiates the plugin multiple times the second instance + // will refuse to do anything. + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + $this->setAutoViewVars([ + 'modes' => [ + 'type' => 'enum', + 'class' => 'CoreEnroller.VerificationModeEnum' + ], + 'messageTemplates' => [ + 'type' => 'select', + 'model' => 'MessageTemplates', + 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::Verification] + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Assemble the set of Email Addresses that may be verified for this Petition, + * and determine the verification status of these addresses. + * + * @since COmanage Registry v5.1.0 + * @param EmailVerifier $emailVerifier Email Verifier Entity + * @param Petition $petition Petition Entity + * @return array Array of unique verifiable Email Addresses + */ + + public function assembleVerifiableAddresses( + EmailVerifier $emailVerifier, + Petition $petition + ): array { + $ret = []; + + // Pull the set of Verifications already associated with this Petition + + $PetitionVerifications = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionVerifications'); + + $verifications = $PetitionVerifications->find() + ->where(['PetitionVerifications.petition_id' => $petition->id]) + ->contain(['Verifications']) + ->all(); + + // The set of verifiable addresses is constructed by asking any Step that runs + // before this Step for any email addresses they may have, and then adding in + // the Enrollee Email, if found. (We don't bother using PetitionStepResults to + // verify what actually ran, we simply assume that we wouldn't be called if it + // wasn't our turn.) + + // We could figure out our Step order and then only request those Steps with a lower + // order value, but that would require an extra query, and Flows generally don't + // have a huge number of Steps, so we can just pull all of them and look at the + // ones we care about. + + $steps = $this->EnrollmentFlowSteps->find() + ->where([ + 'enrollment_flow_id' => $petition->enrollment_flow_id, + 'status' => SuspendableStatusEnum::Active + ]) + ->order(['EnrollmentFlowSteps.ordr' => 'ASC']) + ->contain($this->EnrollmentFlowSteps->getPluginRelations()) + ->all(); + + // We'll start with the Enrollee Email address, if present. If we can verify it this way, + // then we don't need to worry about verifying it if another step collected it also. + if(!empty($petition->enrollee_email)) { + $verified = false; + + // See if we already have a verification for this address + $matchedVerifications = $verifications->match(['mail' => $petition->enrollee_email]); + + if($matchedVerifications->count() > 0) { + // The matched verifications might be pending, we'll have to actually look to + // see if there is a completed Verification. + + foreach($matchedVerifications as $pv) { + if(($pv->verification->isVerified())) { + // This address was already verified + $ret[ $petition->enrollee_email ] = $pv; + $verified = true; + } + } + } + + if(!$verified) { + // We can consider this address verified if there was a transition _to_ a Step + // with an Enrollee actor no later than the current Step. + + foreach($steps as $step) { + if($step->status == SuspendableStatusEnum::Active + && $step->actor_type == EnrollmentActorEnum::Enrollee) { + $this->llog('debug', "Flagging " . $petition->enrollee_email . " as verified via Handoff"); + + $ret[ $petition->enrollee_email ] = $PetitionVerifications->verifyFromHandoff( + $petition->id, + $emailVerifier->enrollment_flow_step_id, + $petition->enrollee_email + ); + + $verified = true; + break; + } + + if($step->id == $emailVerifier->enrollment_flow_step_id) { + // Don't check future Steps + break; + } + } + } + + if(!$verified) { + $ret[ $petition->enrollee_email ] = false; + } + } + + // Query the plugins for steps that haven't run yet + + foreach($steps as $step) { + if($step->id == $emailVerifier->enrollment_flow_step_id) { + // Don't check future Steps + break; + } + + $PluginTable = TableRegistry::getTableLocator()->get($step->plugin); + + if(method_exists($PluginTable, "verifiableEmailAddresses")) { + $pmodel = StringUtilities::pluginToEntityField($step->plugin); + + $paddrs = $PluginTable->verifiableEmailAddresses($step->$pmodel, $petition->id); + + if(!empty($paddrs)) { + foreach($paddrs as $paddr) { + if(!array_key_exists($paddr, $ret)) { + // Do we have a verification for this address? + // This is basically copy/paste from above + $verified = false; + + // See if we already have a verification for this address + $matchedVerifications = $verifications->match(['mail' => $paddr]); + + if($matchedVerifications->count() > 0) { + // The matched verifications might be pending, we'll have to actually look to + // see if there is a completed Verification. + + foreach($matchedVerifications as $pv) { + if(($pv->verification->isVerified())) { + // This address was already verified + $ret[ $paddr] = $pv; + $verified = true; + } + } + } + + if(!$verified) { + $ret[ $paddr ] = false; + } + } + } + } + } + } + + return $ret; + } + + /** + * Perform steps necessary to finalize the Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Invitation Accepter ID + * @param Petition $petition Petition + * @return bool true on success + */ + + public function finalize(int $id, \App\Model\Entity\Petition $petition) { + $cfg = $this->get($id); + + // At this point, the Steps that told us there are email addresses to verify + // have run, so any addresses we verified should be available for us to work + // with. The enrollee_email (if used) may or may not actually be an address + // on the operational records, it's possible we verified it but it won't be used. + + $PetitionVerifications = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionVerifications'); + + // First, pull the set of email addresses we verified. If there aren't any, + // there isn't anything else to do. + + $pVerifications = $PetitionVerifications->find() + ->where(['PetitionVerifications.petition_id' => $petition->id]) + ->contain(['Verifications']) + ->all(); + + if($pVerifications->count() == 0) { + return true; + } + + // Next, pull the set of (unverified) EmailAddresses associated with the Enrollee. + + $EmailAddresses = TableRegistry::getTableLocator()->get('EmailAddresses'); + + $allAddresses = $EmailAddresses->find() + ->where([ + 'EmailAddresses.person_id' => $petition->enrollee_person_id, + 'EmailAddresses.verified IS NOT true' + ]) + ->all(); + + if($allAddresses->count() == 0) { + // Nothing to do + $this->llog('debug', 'No unverified Email Addresses for Person ' . $petition->enrollee_person_id . ' (petition ' . $petition->id . ')'); + return true; + } + + // For each verified address, find the associated EmailAddress (which may + // not exist for the enrollee_email) and flag it as verified. Then, flip the + // associated Verification so that it is foreign keyed to the EmailAddress + // instead of the Petition. + + foreach($pVerifications as $pv) { + // Only proceed if this verification was completed successfully + if(!empty($pv->verification) && $pv->verification->isVerified()) { + $addresses = $allAddresses->match(['mail' => $pv->mail]); + + if($addresses->count() > 0) { + // We could have more than one matching address, although it is somewhat unlikely. + // We skip already verified addresses, but otherwise we'll verify the first address + // we see. (Verifications can only foreign key to a single Email Address, so we + // won't verify more than one address.) + + // As per AR-EmailAddress-4, frozen addresses may be verified (though we're unlikely + // to have any here). We'll also verify Person addresses that have a source address + // (ie: that came from an EIS via a Pipeline). + + foreach($addresses as $addr) { + + if(!$addr->verified) { + $this->llog('debug', 'Marking Email Addresses ' . $addr->id . ' as verified (petition ' . $petition->id . ')'); + + // We want to update the Verification so it is linked to the Email Address, + // but we can't change the primary link on an entity (per AR-GMR-3) + // so we can't add a link to the EmailAddress to the existing Verification. + // We'll need to create a new Verification. + + $EmailAddresses->Verifications->verifyFromPetition($pv->verification->id, $addr->id); + + $addr->verified = true; + $EmailAddresses->saveOrFail($addr); + + break; + } + } + } + } + } + + return true; + } + + /** + * Perform tasks prior to transitioning to this step. + * + * @since COmanage Registry v5.1.0 + * @param EnrollmentFlowStep $step Enrollment Flow Step + * @param Petition $petition Petition + * @return bool true on success + */ + + public function prepare( + \App\Model\Entity\EnrollmentFlowStep $step, + \App\Model\Entity\Petition $petition + ): bool { + // Set this petition to Pending Verification + + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $petition->status = PetitionStatusEnum::PendingVerification; + + $Petitions->saveOrFail($petition); + + return true; + } + + /** + * Send an email verification request. + * + * @since COmanage Registry v5.1.0 + * @param EmailVerifier $emailVerifier Email Verifier configuration entity + * @param Petition $petition Petition entity + * @param string $mail Email Address to verify + */ + + public function sendVerificationRequest( + EmailVerifier $emailVerifier, + Petition $petition, + string $mail + ) { + // First check if there is already an existing Petition Verification. + // If so, use that to get the existing Verification. + + $PetitionVerifications = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionVerifications'); + $Verifications = TableRegistry::getTableLocator()->get('Verifications'); + + $pVerification = $PetitionVerifications->find() + ->where([ + 'PetitionVerifications.petition_id' => $petition->id, + 'PetitionVerifications.mail' => $mail + ]) + ->first(); + + if(!empty($pVerification)) { + // Request a new code + + $this->llog('debug', "Sending replacement verification code to $mail for Petition " . $petition->id); + + $verificationId = $Verifications->requestCodeForPetition( + $petition->id, + $mail, + $emailVerifier->message_template_id, + $emailVerifier->request_validity, + $pVerification->verification_id + ); + + // There's nothing to update in the Petition Verification + } else { + // Request Verification and create an associated Petition Verification + + $this->llog('debug', "Sending verification code to $mail for Petition " . $petition->id); + + $verificationId = $Verifications->requestCodeForPetition( + $petition->id, + $mail, + $emailVerifier->message_template_id, + $emailVerifier->request_validity + ); + + $pVerification = $PetitionVerifications->saveOrFail( + $PetitionVerifications->newEntity([ + 'petition_id' => $petition->id, + 'mail' => $mail, + 'verification_id' => $verificationId + ])); + } + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $validator->add('mode', [ + 'content' => ['rule' => ['inList', VerificationModeEnum::getConstValues()]] + ]); + $validator->notEmptyString('mode'); + + $validator->add('message_template_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('message_template_id'); + + $validator->add('request_validity', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('request_validity'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php b/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php new file mode 100644 index 000000000..ab46c269c --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php @@ -0,0 +1,455 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Orderable'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('CoreEnroller.AttributeCollectors'); + + $this->hasMany('CoreEnroller.PetitionAttributes') +// XXX do we really want to allow deletion once the definition is in use? + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('label'); + + $this->setPrimaryLink('CoreEnroller.attribute_collector_id'); + $this->setRequiresCO(true); + $this->setRedirectGoal(action: 'add', goal: 'index'); + $this->setRedirectGoal(action: 'edit', goal: 'index'); + + $this->setAutoViewVars([ + 'addressRequiredFields' => [ + 'type' => 'enum', + 'class' => 'RequiredAddressFieldsEnum' + ], + 'addressGroupedFields' => [ + 'type' => 'enum', + 'class' => 'GroupedAddressFieldsEnum' + ], + 'addressTypes' => [ + 'type' => 'type', + 'attribute' => 'Addresses.type' + ], + 'attributes' => [ + 'type' => 'hash', + 'hash' => $this->supportedAttributes('list') + ], + 'attributeLanguages' => [ + 'type' => 'enum', + 'class' => 'LanguageEnum' + ], + // We set attributeMveaParents to the maximal set of options so that the form + // load on an edit will render correctly. Note this list must match the validation + // rule, below. + 'attributeMveaParents' => [ + 'type' => 'array', + 'array' => ['Person', 'PersonRole'] + ], + 'defaultValueAffiliationTypes' => [ + 'type' => 'type', + 'attribute' => 'PersonRoles.affiliation_type' + ], + 'defaultValueCous' => [ + 'type' => 'select', + 'model' => 'Cous' + ], + 'defaultValueGroups' => [ + 'type' => 'select', + 'model' => 'Groups', + // only writeable groups are selectable + 'where' => [ + 'group_type IN' => [ + GroupTypeEnum::Admins, + GroupTypeEnum::Owners, + GroupTypeEnum::Standard + ] + ] + ], + 'emailAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type' + ], + 'identifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ], + 'nameRequiredFields' => [ + 'type' => 'enum', + 'class' => 'RequiredNameFieldsEnum' + ], + 'nameTypes' => [ + 'type' => 'type', + 'attribute' => 'Names.type' + ], + 'pronounTypes' => [ + 'type' => 'type', + 'attribute' => 'Pronouns.type' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ], + 'telephoneNumberTypes' => [ + 'type' => 'type', + 'attribute' => 'TelephoneNumbers.type' + ], + 'urlTypes' => [ + 'type' => 'type', + 'attribute' => 'Urls.type' + ], + // Required for attribute collection + 'cosettings' => [ + 'type' => 'auxiliary', + 'model' => 'CoSettings' + ], + 'types' => [ + 'type' => 'auxiliary', + 'model' => 'Types' + ], + ]); + + $this->setLayout([ 'index' => 'iframe', + 'add' => 'iframe', + 'edit' => 'iframe', + 'view' => 'iframe', + ]); + + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.AttributeCollectors', 'CoreEnroller.EnrollmentAttributes'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'CoreEnroller.AttributeCollectors' => ['edit'], + 'CoreEnroller.EnrollmentAttributes' => ['index'], + ], + 'skipTab' => ['CoreEnroller.AttributeCollectors'] + ] + ); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Obtain the set of supported attributes. + * + * @since COmanage Registry v5.0.0 + * @param string $format How to return attributes ('full', 'list') + * @return array Supported attributes + */ + + public function supportedAttributes(string $format='full'): array { + $attrs = []; + + // Single valued Person attributes + $attrs['date_of_birth'] = [ + 'label' => __d('field', 'date_of_birth'), + 'model' => 'Person', + 'fieldType' => 'date' + ]; + + // Single valued Person Role attributes + + $attrs['affiliation_type_id'] = [ + 'label' => __d('field', 'affiliation'), + 'model' => 'PersonRole', + 'fieldType' => 'string' + ]; + + $attrs['cou_id'] = [ + 'label' => __d('controller', 'Cous', [1]), + 'model' => 'PersonRole', + 'fieldType' => 'integer' + ]; + + $attrs['department'] = [ + 'label' => __d('field', 'department'), + 'model' => 'PersonRole', + 'fieldType' => 'string' + ]; + + $attrs['manager_person_id'] = [ + 'label' => __d('field', 'manager'), + 'model' => 'PersonRole', + 'fieldType' => 'integer' + ]; + + $attrs['organization'] = [ + 'label' => __d('field', 'organization'), + 'model' => 'PersonRole', + 'fieldType' => 'string' + ]; + + $attrs['sponsor_person_id'] = [ + 'label' => __d('field', 'sponsor'), + 'model' => 'PersonRole', + 'fieldType' => 'integer' + ]; + + $attrs['title'] = [ + 'label' => __d('field', 'title'), + 'model' => 'PersonRole', + 'fieldType' => 'string' + ]; + + $attrs['valid_from'] = [ + 'label' => __d('field', 'valid_from'), + 'model' => 'PersonRole', + 'fieldType' => 'datetime' + ]; + + $attrs['valid_through'] = [ + 'label' => __d('field', 'valid_through'), + 'model' => 'PersonRole', + 'fieldType' => 'datetime' + ]; + + // MVEAs, which might attach to the Person or Person Role or both + + $attrs['address'] = [ + 'label' => __d('controller', 'Addresses', [1]), + 'mveaModel' => 'Addresses', + 'mveaParents' => ['Person', 'PersonRole'], + 'fieldType' => 'string', + 'autoViewVar' => 'addressTypes', + ]; + + $attrs['adHocAttribute'] = [ + 'label' => __d('controller', 'AdHocAttributes', [1]), + 'mveaModel' => 'AdHocAttributes', + 'mveaParents' => ['Person', 'PersonRole'], + 'fieldType' => 'string' + ]; + + $attrs['emailAddress'] = [ + 'label' => __d('controller', 'EmailAddresses', [1]), + 'mveaModel' => 'EmailAddresses', + 'mveaParents' => ['Person'], + 'fieldType' => 'string' + ]; + + $attrs['identifier'] = [ + 'label' => __d('controller', 'Identifiers', [1]), + 'mveaModel' => 'Identifiers', + 'mveaParents' => ['Person'], + 'fieldType' => 'string' + ]; + + $attrs['name'] = [ + 'label' => __d('controller', 'Names', [1]), + 'mveaModel' => 'Names', + 'mveaParents' => ['Person'], + 'fieldType' => 'string' + ]; + + $attrs['pronoun'] = [ + 'label' => __d('controller', 'Pronouns', [1]), + 'mveaModel' => 'Pronouns', + 'mveaParents' => ['Person'], + 'fieldType' => 'string' + ]; + + $attrs['telephoneNumber'] = [ + 'label' => __d('controller', 'TelephoneNumbers', [1]), + 'mveaModel' => 'TelephoneNumbers', + 'mveaParents' => ['Person', 'PersonRole'], + 'fieldType' => 'string' + ]; + + $attrs['url'] = [ + 'label' => __d('controller', 'Urls', [1]), + 'mveaModel' => 'Urls', + 'mveaParents' => ['Person'], + 'fieldType' => 'string' + ]; + + // Group memberships, as reflected by the Group ID for the membership to be created in + + $attrs['group_id'] = [ + // We name the attribute group_id because the value we need to track is the Group + // to attach the membership to, but the label says Group Member because that'll be + // more obvious + 'label' => __d('controller', 'GroupMembers', [1]), + 'model' => 'Group', + 'fieldType' => 'integer' + ]; + + // Attributes that are only stored in the Petition + + $attrs['petition_text'] = [ + 'label' => __d('core_enroller', 'field.EnrollmentAttributes.petition_text'), + 'model' => 'Petition', + 'fieldType' => 'string' + ]; + + $attrs['petition_textarea'] = [ + 'label' => __d('core_enroller', 'field.EnrollmentAttributes.petition_textarea'), + 'model' => 'Petition', + 'fieldType' => 'text' + ]; + + switch($format) { + case 'list': + // Return as a hash of tag => label values + $l = []; + foreach(array_keys($attrs) as $k) { + $l[$k] = $attrs[$k]['label']; + } + asort($l); + return $l; + } + + return $attrs; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('attribute_collector_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('attribute_collector_id'); + + $this->registerStringValidation($validator, $schema, 'label', true); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->notEmptyString('attribute'); + + $validator->add('attribute_type', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('attribute_type'); + + $this->registerStringValidation($validator, $schema, 'attribute_language', false); + + $validator->add('attribute_mvea_parent', [ + // Note this list must match the autoViewVar, above + 'content' => ['rule' => ['inList', ['Person', 'PersonRole']]] + ]); + $validator->allowEmptyString('attribute_mvea_parent'); + + $this->registerStringValidation($validator, $schema, 'attribute_tag', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('required', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('required'); + + $validator->add('ordr', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('ordr'); + + $this->registerStringValidation($validator, $schema, 'default_value', false); + + $validator->allowEmptyString('default_value_datetime'); + + $this->registerStringValidation($validator, $schema, 'default_value_env_name', false); + + $validator->add('modifiable', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('modifiable'); + + $validator->add('hidden', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('hidden'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/IdentifierCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/IdentifierCollectorsTable.php new file mode 100644 index 000000000..895aeabff --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/IdentifierCollectorsTable.php @@ -0,0 +1,163 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('Types'); + + // We intentionally don't hasMany PetitionIdentifiers since there should only be one + // collector per Petition, and the net result of not having the direct foreign key + // is that if an admin instantiates the plugin multiple times the second instance + // will refuse to do anything. + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + $this->setAutoViewVars([ + 'types' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform steps necessary to finalize the Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Invitation Accepter ID + * @param Petition $petition Petition + * @return bool true on success + */ + + public function finalize(int $id, \App\Model\Entity\Petition $petition) { + $cfg = $this->get($id); + + // Pull the Identifier that was recorded + + $PetitionIdentifiers = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionIdentifiers'); + + $pidentifier = $PetitionIdentifiers->find()->where(['petition_id' => $petition->id])->first(); + + if(!empty($pidentifier->identifier)) { + // Copy the Identifier to the Person record in accordance with the configuration + + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + $id = $Identifiers->newEntity([ + 'person_id' => $petition->enrollee_person_id, + 'identifier' => $pidentifier->identifier, + 'type_id' => $cfg->type_id, + 'status' => SuspendableStatusEnum::Active, + 'login' => true + ]); + + $Identifiers->saveOrFail($id); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $validator->add('type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('type_id'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php b/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php new file mode 100644 index 000000000..f3f19eeb3 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php @@ -0,0 +1,199 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + + // We intentionally don't hasMany PetitionAcceptances since there should only be one + // acceptance per Petition, and the net result of not having the direct foreign key + // is that if an admin instantiates the plugin multiple times the second instance + // will refuse to do anything. + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform steps necessary to finalize the Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Invitation Accepter ID + * @param Petition $petition Petition + * @return bool true on success + */ + + public function finalize(int $id, \App\Model\Entity\Petition $petition) { + // $cfg = $this->get($id); + + // We don't have anything to do for finalization + + return true; + } + + /** + * Perform tasks prior to transitioning to this step. + * + * @since COmanage Registry v5.1.0 + * @param EnrollmentFlowStep $step Enrollment Flow Step + * @param Petition $petition Petition + * @return bool true on success + */ + + public function prepare( + \App\Model\Entity\EnrollmentFlowStep $step, + \App\Model\Entity\Petition $petition + ): bool { + // Allocate an Invitation. We create an empty petition_acceptance to use to measure + // invitation validity (based on the created timestamp). + + $PetitionAcceptances = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionAcceptances'); + + $acceptance = $PetitionAcceptances->find() + ->where(['petition_id' => $petition->id]) + ->first(); + + if(!empty($acceptance)) { + // We'll allow a null (pending) invitation, but if there is already a response + // we throw an error. This probably isn't the best behavior, once we have some + // more requirements we should probably change this to do something else (eg: + // fast forward to the next step). + + if(!is_null($acceptance->accepted)) { + throw new \RuntimeException(__d('core_enroller', 'error.PetitionAcceptances.exists')); + } + + // We don't reset the Petition status, at least pending further requirements. + } else { + // Register a new, pending invitation + + $acceptance = $PetitionAcceptances->newEntity([ + 'petition_id' => $petition->id, + 'accepted' => null + ]); + + // If for some reason there is already an acceptance record + // for this petition we'll basically reset it + $PetitionAcceptances->saveOrFail($acceptance); + + // Set this petition to Pending Invitation + + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $petition->status = PetitionStatusEnum::PendingAcceptance; + + $Petitions->saveOrFail($petition); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $validator->add('invitation_validity', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('invitation_validity'); + + $validator->add('welcome_message', [ + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + $validator->allowEmptyString('welcome_message'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php new file mode 100644 index 000000000..d5eb08c65 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php @@ -0,0 +1,177 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Petitions'); + + $this->setDisplayField('accepted'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => false + ] + ]); + } + + /** + * Process an Invitation reply. + * + * @since COmanage Registry v5.1.0 + * @param int $petitionId Petition ID + * @param int $enrollmentFlowStepId Enrollment Flow Step ID + * @param bool $accepted true if the reply was accepted, false if declined + * @throws Exceptions + */ + + public function processReply(int $petitionId, int $enrollmentFlowStepId, bool $accepted) { + // There should already be an empty Acceptance indicating when the step was handed off + // for Invitation, and for which we need to refer to to determine the invitation validity. + // If it doesn't exist, that's an error. + + $acceptance = $this->find()->where(['petition_id' => $petitionId])->firstOrFail(); + + // If accepted is not null, we've already processed this invitation, so that's also an error. + + if(!is_null($acceptance->accepted)) { + throw new \RuntimeException(__d('core_enroller', 'error.PetitionAcceptances.processed')); + } + + // Next check the create time of the original record. For this, we need the configuration. + + $InvitationAccepters = TableRegistry::getTableLocator()->get('CoreEnroller.InvitationAccepters'); + + $ia = $InvitationAccepters->find() + ->where(['enrollment_flow_step_id' => $enrollmentFlowStepId]) + ->firstOrFail(); + + // A validity of 0 disables expiration + + if($ia->invitation_validity > 0) { + $expires = $acceptance->created->addSeconds($ia->invitation_validity); + + if($expires->isPast()) { + throw new \RuntimeException('error.PetitionAcceptances.expired'); + } + } + + // We're finally ready to process the reply + + $acceptance->accepted = $accepted; + + $this->saveOrFail($acceptance); + + // Set the Petition status appropriately + + $petition = $this->Petitions->get($petitionId); + + $petition->status = $accepted ? PetitionStatusEnum::Accepted : PetitionStatusEnum::Declined; + + $this->Petitions->saveOrFail($petition); + + // Record Petition History + + $this->Petitions->PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $enrollmentFlowStepId, + action: PetitionActionEnum::StatusUpdated, + comment: __d('core_enroller', $accepted ? 'result.accept.accepted' : 'result.accept.declined') +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $validator->add('accepted', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('accepted'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionAttributesTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionAttributesTable.php new file mode 100644 index 000000000..161d873eb --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionAttributesTable.php @@ -0,0 +1,109 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('CoreEnroller.EnrollmentAttributes'); + $this->belongsTo('Petition'); + + $this->setDisplayField('value'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $validator->add('enrollment_attribute_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_attribute_id'); + + $this->registerStringValidation($validator, $schema, 'value', false); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php new file mode 100644 index 000000000..a51e31976 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php @@ -0,0 +1,125 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('CoreEnroller.BasicAttributeCollectors'); + $this->belongsTo('Petition'); + + $this->setDisplayField('mail'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $validator->add('basic_attribute_collector_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('basic_attribute_collector_id'); + + // For now we allow any attribute to be empty and rely on upsert and the UI to + // enforce requirements. + foreach(['honorific', 'given', 'middle', 'family', 'suffix'] as $f) { + $validator->add($f, [ + 'size' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn($f)]], + 'provider' => 'table'], + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + $validator->allowEmptyString($f); + } + + $this->registerStringValidation($validator, $schema, 'mail', false); + $validator->add('mail', [ + 'content' => ['rule' => ['email'], + 'message' => __d('error', 'input.invalid.email')] + ]); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionIdentifiersTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionIdentifiersTable.php new file mode 100644 index 000000000..e9d529c2e --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionIdentifiersTable.php @@ -0,0 +1,139 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Petitions'); + + $this->setDisplayField('identifier'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => false + ] + ]); + } + + /** + * Record an Identifier. + * + * @since COmanage Registry v5.1.0 + * @param int $petitionId Petition ID + * @param int $enrollmentFlowStepId Enrollment Flow Step ID + * @param string $identifier Login Identifier + */ + + public function record(int $petitionId, int $enrollmentFlowStepId, string $identifier) { + // Record the Identifier. We use upsert since at least initially we only support + // one Identifier per Petition. + + $pi = [ + 'petition_id' => $petitionId, + 'identifier' => $identifier + ]; + + $this->upsertOrFail($pi, ['petition_id' => $petitionId]); + + // Record PetitionHistory + + $this->Petitions->PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $enrollmentFlowStepId, + action: PetitionActionEnum::AttributesUpdated, + comment: __d('core_enroller', 'result.IdentifierCollector.collected', [$identifier]) +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $this->registerStringValidation($validator, $schema, 'identifier', true); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionVerificationsTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionVerificationsTable.php new file mode 100644 index 000000000..510dc7889 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionVerificationsTable.php @@ -0,0 +1,209 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Petitions'); + $this->belongsTo('Verifications'); + + $this->setDisplayField('mail'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => false + ] + ]); + } + + /** + * Verify a code. + * + * @since COmanage Registry v5.1.0 + * @param integer $petitionId Petition ID + * @param integer $enrollmentFlowStepId Enrollment Flow Step ID + * @param string $mail Email Address that was verified + * @return bool true if validation is successful + * @throws \InvalidArgumentException + */ + + public function verifyCode(int $petitionId, int $enrollmentFlowStepId, string $mail, string $code): bool { + // Find the PetitionVerification for the requested petition and address, + // then use the verification ID to process the code. + + $pVerification = $this->find() + ->where([ + 'petition_id' => $petitionId, + 'mail' => $mail + ]) + ->firstOrFail(); + + // This will throw an error on failure + $this->Verifications->verifyCode($pVerification->verification_id, $code); + + // Record Petition History + + $this->Petitions->PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $enrollmentFlowStepId, + action: PetitionActionEnum::EmailVerified, + comment: __d('core_enroller', 'result.EmailVerifiers.verified.history', [$mail, __d('enumeration', 'VerificationMethodEnum.C')]) +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + + return true; + } + + /** + * Record an Email Verification from a handoff. + * + * @since COmanage Registry v5.1.0 + * @param integer $petitionId Petition ID + * @param integer $enrollmentFlowStepId Enrollment Flow Step ID + * @param string $mail Email Address that was verified + * @return PetitionVerification PetitionVerification + */ + + public function verifyFromHandoff(int $petitionId, int $enrollmentFlowStepId, string $mail): PetitionVerification { + // We're only called once it has been determined that a handoff effectively verified + // $mail, so we just need to create a Verification and a PetitionVerification. + + $verification = $this->Verifications->handoff($petitionId); + + $pVerification = $this->newEntity([ + 'petition_id' => $petitionId, + 'mail' => $mail, + 'verification_id' => $verification->id + ]); + + $this->saveOrFail($pVerification); + + // Record Petition History + + $this->Petitions->PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $enrollmentFlowStepId, + action: PetitionActionEnum::EmailVerified, + comment: __d('core_enroller', 'result.EmailVerifiers.verified.history', [$mail, __d('enumeration', 'VerificationMethodEnum.PH')]) +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + + // We return in the format as if we used find() and contain() + + $pVerification->verification = $verification; + + return $pVerification; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $this->registerStringValidation($validator, $schema, 'mail', true); + + $validator->add('verification_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('verification_id'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/config/plugin.json b/app/plugins/CoreEnroller/src/config/plugin.json new file mode 100644 index 000000000..dbb666b8a --- /dev/null +++ b/app/plugins/CoreEnroller/src/config/plugin.json @@ -0,0 +1,162 @@ +{ + "types": { + "enroller": [ + "AttributeCollectors", + "BasicAttributeCollectors", + "EmailVerifiers", + "IdentifierCollectors", + "InvitationAccepters" + ] + }, + "schema": { + "tables": { + "attribute_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "description": { "temporary": true, "type": "string", "size": 80 } + }, + "indexes": { + "attribute_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] } + } + }, + "basic_attribute_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "name_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } } + }, + "indexes": { + "basic_attribute_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "basic_attribute_collectors_i2": { "needed": false, "columns": [ "name_type_id" ] }, + "basic_attribute_collectors_i3": { "needed": false, "columns": [ "email_address_type_id" ] }, + "basic_attribute_collectors_i4": { "needed": false, "columns": [ "affiliation_type_id" ] }, + "basic_attribute_collectors_i5": { "needed": false, "columns": [ "cou_id" ] } + } + }, + "email_verifiers": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "mode": { "type": "string", "size": 2 }, + "message_template_id": {}, + "request_validity": { "type": "integer" } + }, + "indexes": { + "email_verifiers_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "email_verifiers_i2": { "needed": false, "columns": [ "message_template_id" ] } + } + }, + "enrollment_attributes": { + "columns": { + "id": {}, + "attribute_collector_id": { "type": "integer", "foreignkey": { "table": "attribute_collectors", "column": "id" } }, + "attribute": { "type": "string", "size": 80 }, + "attribute_type": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "attribute_language": { "type": "string", "size": 16 }, + "attribute_mvea_parent": { "type": "string", "size": 32 }, + "attribute_tag": { "type": "string", "size": 128 }, + "status": {}, + "label": { "type": "string", "size": 80 }, + "description": {}, + "required": { "type": "boolean" }, + "ordr": {}, + "default_value": { "type": "string", "size": 160 }, + "default_value_datetime": { "type": "datetime" }, + "default_value_env_name": { "type": "string", "size": 80 }, + "modifiable": { "type": "boolean" }, + "hidden": { "type": "boolean" } + }, + "indexes": { + "enrollment_attributes_i1": { "columns": [ "attribute_collector_id" ] } + } + }, + "identifier_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "type_id": { "notnull": false, "comment": "Skeletal row needs to be created without type" } + }, + "indexes": { + "identifier_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "identifier_collectors_i2": { "needed": false, "columns": [ "type_id" ] } + } + }, + "invitation_accepters": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "invitation_validity": { "type": "integer" }, + "welcome_message": { "type": "text" } + }, + "indexes": { + "invitation_accepters_i1": { "columns": [ "enrollment_flow_step_id" ] } + } + }, + "petition_acceptances": { + "columns": { + "id": {}, + "petition_id": {}, + "accepted": { "type": "boolean" } + }, + "indexes": { + "petition_acceptances_i1": { "columns": [ "petition_id" ] } + } + }, + "petition_attributes": { + "columns": { + "id": {}, + "petition_id": {}, + "enrollment_attribute_id": { "type": "integer", "foreignkey": { "table": "enrollment_attributes", "column": "id" } }, + "value": { "type": "string", "size": 160 } + }, + "indexes": { + "petition_attributes_i1": { "columns": [ "petition_id" ] }, + "petition_attributes_i2": { "needed": false, "columns": [ "enrollment_attribute_id" ] } + } + }, + "petition_basic_attribute_sets": { + "columns": { + "id": {}, + "petition_id": {}, + "basic_attribute_collector_id": { "type": "integer", "foreignkey": { "table": "basic_attribute_collectors", "column": "id" } }, + "honorific": { "type": "string", "size": 32 }, + "given": { "type": "string", "size": 128 }, + "middle": { "type": "string", "size": 128 }, + "family": { "type": "string", "size": 128 }, + "suffix": { "type": "string", "size": 32 }, + "mail": { "type": "string", "size": 256 } + }, + "indexes": { + "petition_basic_attribute_sets_i1": { "columns": [ "petition_id" ] }, + "petition_basic_attribute_sets_i2": { "columns": [ "basic_attribute_collector_id" ] } + } + }, + "petition_identifiers": { + "columns": { + "id": {}, + "petition_id": {}, + "identifier": { "type": "string", "size": 512 } + }, + "indexes": { + "petition_identifiers_i1": { "columns": [ "petition_id" ] } + } + }, + "petition_verifications": { + "columns": { + "id": {}, + "petition_id": {}, + "mail": {}, + "verification_id": { "type": "integer", "foreignkey": { "table": "verifications", "column": "id" } } + }, + "indexes": { + "petition_verifications_i1": { "columns": [ "petition_id" ] }, + "petition_verifications_i2": { "needed": false, "columns": [ "verification_id" ] } + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/AttributeCollectors/dispatch.inc b/app/plugins/CoreEnroller/templates/AttributeCollectors/dispatch.inc new file mode 100644 index 000000000..95288d700 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/AttributeCollectors/dispatch.inc @@ -0,0 +1,64 @@ +Field->enableFormEditMode(); +// Populate the AutoViewVars. These are the same we do for the EnrollmentAttributes configuration view +$this->Petition->populateAutoViewVars(); +// We just populated the AutoViewVars. Add them to the current context +extract($this->viewVars); + +// Sort according to configuration +$vv_enrollment_attributes_sorted = $vv_enrollment_attributes->sortBy(function ($attribute) { + return $attribute->ordr; +}, SORT_ASC)->toList(); + +// Iterate over the attributes and render +foreach($vv_enrollment_attributes_sorted as $attr) { + // Do not render if this is not active + if ($attr->status !== StatusEnum::Active) { + continue; + } + + // Fieldset with legend for MVEAs + if(\in_array($attr->attribute, ['name', 'address', 'telephoneNumber'], true)) { + print $this->element('CoreEnroller.mveas/mvea-fieldset', compact('attr')); + continue; + } + + // Default + print $this->element('CoreEnroller.field', compact('attr')); +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/AttributeCollectors/fields-nav.inc b/app/plugins/CoreEnroller/templates/AttributeCollectors/fields-nav.inc new file mode 100644 index 000000000..45b5bbb81 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/AttributeCollectors/fields-nav.inc @@ -0,0 +1,39 @@ + 'edit_attributes', + 'order' => 'Default', + 'label' => __d('core_enroller', 'controller.EnrollmentAttributes', [99]), + 'link' => [ + 'plugin' => 'CoreEnroller', + 'controller' => 'enrollment_attributes', + 'action' => 'index', + 'attribute_collector_id' => $vv_obj->id + ], + 'class' => '' +]; diff --git a/app/templates/element/tabs/tabTitleWithCount.php b/app/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc similarity index 80% rename from app/templates/element/tabs/tabTitleWithCount.php rename to app/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc index 07845fa6c..8d3afbdee 100644 --- a/app/templates/element/tabs/tabTitleWithCount.php +++ b/app/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc @@ -1,6 +1,6 @@ Common->getModelTotalCount($model, $where); -} +declare(strict_types = 1); + +// Currently this Configuration View has no fields + +$this->Field->disableFormEditMode(); ?> - - - - \ No newline at end of file +
  • +
    + +
    +
  • + diff --git a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc new file mode 100644 index 000000000..564c21b3a --- /dev/null +++ b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc @@ -0,0 +1,69 @@ +Field->enableFormEditMode(); +?> + +
  • +
    + +
    + + + +
    + + element('form/requiredSpan') : ''?> + element('form/infoDiv/default', [ + 'vv_field_arguments' => [ + 'fieldName' => $n, + 'fieldLabel' => __d('field', $n), + 'fieldOptions' => [ + 'required' => in_array($n, $vv_required_name_fields) + ] + ]]); + ?> +
    + + + +
    +
    +
  • + +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'mail', + 'fieldLabel' => __d('field', 'mail') + ]]); +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/display.php b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/display.php new file mode 100644 index 000000000..4247e7400 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/display.php @@ -0,0 +1,8 @@ + diff --git a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc new file mode 100644 index 000000000..23c5a986c --- /dev/null +++ b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc @@ -0,0 +1,45 @@ +element('form/listItem', [ + 'arguments' => ['fieldName' => 'name_type_id'] + ]); + + print $this->element('form/listItem', [ + 'arguments' => ['fieldName' => 'email_address_type_id'] + ]); + + print $this->element('form/listItem', [ + 'arguments' => ['fieldName' => 'affiliation_type_id'] + ]); + + print $this->element('form/listItem', [ + 'arguments' => ['fieldName' => 'cou_id'] + ]); +} diff --git a/app/plugins/CoreEnroller/templates/EmailVerifiers/dispatch.inc b/app/plugins/CoreEnroller/templates/EmailVerifiers/dispatch.inc new file mode 100644 index 000000000..62b3b9c4e --- /dev/null +++ b/app/plugins/CoreEnroller/templates/EmailVerifiers/dispatch.inc @@ -0,0 +1,147 @@ +element('flash', []); + +// This view is intended to work with dispatch +if($vv_action == 'dispatch') { + if($vv_op == 'index') { + // Render the list of known email addresses and their verification statuses. + // The configuration drives how many email addresses are required to complete this step. + + print '

    '; + + if($vv_all_done) { + print __d('core_enroller', 'information.EmailVerifiers.done'); + } else { + switch($vv_config->mode) { + case VerificationModeEnum::All: + print __d('core_enroller', 'information.EmailVerifiers.A'); + break; + case VerificationModeEnum::None: + print __d('core_enroller', 'information.EmailVerifiers.0'); + break; + case VerificationModeEnum::One: + if($vv_minimum_met) { + print __d('core_enroller', 'information.EmailVerifiers.1.met'); + } else { + print __d('core_enroller', 'information.EmailVerifiers.1.none'); + } + break; + } + } + + print '

    '; + + print ' + + + + + + + + + '; + + foreach(array_keys($vv_email_addresses) as $addr) { + $verified = isset($vv_verified_addresses[$addr]) && $vv_verified_addresses[$addr]; + + $button = ""; + + if(!$verified) { + // We're already in a form here, so we need to use a GET URL to not mess things up. + // This also means we need to manually insert the token and petition ID, which is + // a bit duplicative with templates/Standard/dispatch.php + + $url = [ + 'plugin' => 'CoreEnroller', + 'controller' => 'email_verifiers', + 'action' => 'dispatch', + $vv_config->id, + '?' => [ + 'op' => 'verify', + 'petition_id' => $vv_petition->id, + // We base64 encode the address partly to not have bare email addresses in URLs + // and partly to avoid special characters (like dots) messing up the URL + 'm' => StringUtilities::urlbase64encode($addr) + ] + ]; + + if(isset($vv_token_ok) && $vv_token_ok && !empty($vv_petition->token)) { + $url['?']['token'] = $vv_petition->token; + } + + $button = $this->Html->link(__d('operation', 'verify'), $url); + } + + print ' + + + + + '; + } + + print ' + +
    ' . __d('controller', 'EmailAddresses', [1]) . '' . __d('field', 'status') . '
    ' . $addr . '' . __d('result', ($verified ? 'verified' : 'verified.not')) . $button . '
    + '; + + if($vv_minimum_met) { + $this->Field->enableFormEditMode(); + + print $this->Form->hidden('op', ['default' => 'finish']); + } + } elseif($vv_op == 'verify') { + if(!empty($vv_verify_address)) { + // Render a form prompting for the code that was sent to the Enrollee + + print __d('core_enroller', 'information.EmailVerifiers.code_sent', [$vv_verify_address]); + + $this->Field->enableFormEditMode(); + + print $this->Form->hidden('op', ['default' => 'verify']); + print $this->Form->hidden('co_id', ['default' => $vv_cur_co->id]); + print $this->Form->hidden('m', ['default' => StringUtilities::urlbase64encode($vv_verify_address)]); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'code', + 'fieldLabel' => "Code", //__d('field', 'mail') + 'fieldOptions' => [ + 'required' => true + ] + ]]); + } + } +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/EmailVerifiers/display.php b/app/plugins/CoreEnroller/templates/EmailVerifiers/display.php new file mode 100644 index 000000000..a3aded80a --- /dev/null +++ b/app/plugins/CoreEnroller/templates/EmailVerifiers/display.php @@ -0,0 +1,24 @@ +\n"; + + foreach($vv_pv as $pv) { + print "
  • " . $pv->mail . ": "; + + if(!empty($pv->verification) && $pv->verification->isVerified()) { + print __d('result', 'Verifications.status', [ + VerificationMethodEnum::getLocalization($pv->verification->method), + $this->Time->nice($pv->verification->verification_time, $vv_tz) + ]); + } else { + print __d('field', 'unverified'); + } + + print "
  • "; + } + + print "\n"; +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/EmailVerifiers/fields.inc b/app/plugins/CoreEnroller/templates/EmailVerifiers/fields.inc new file mode 100644 index 000000000..10c07364b --- /dev/null +++ b/app/plugins/CoreEnroller/templates/EmailVerifiers/fields.inc @@ -0,0 +1,45 @@ +element('form/listItem', [ + 'arguments' => ['fieldName' => $field] + ]); + } + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'request_validity', + 'fieldOptions' => [ + 'default' => 60 + ]] + ]); +} diff --git a/app/plugins/CoreEnroller/templates/EnrollmentAttributes/columns.inc b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/columns.inc new file mode 100644 index 000000000..26fcceba5 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/columns.inc @@ -0,0 +1,124 @@ + [ + 'type' => 'link', + 'sortable' => true, +// 'class' => 'cm-modal-link nospin', // launch this in a modal + 'dataAttrs' => [ + ['data-cm-modal-title', __d('operation', 'EnrollmentAttributes', 1)] + ] + ], + 'attribute' => [ +// XXX we probably want to render something more like v4 does + 'type' => 'echo' + ], + 'ordr' => [ + 'type' => 'echo', + 'sortable' => true + ], + 'required' => [ + 'type' => 'boolean', + 'class' => 'YesBooleanEnum' + ] +]; + + +if(empty($attributes)) { + return; +} + +// Map attributes to icons +$attributeToIcons = + [ + 'adHocAttribute' => 'grass', + 'address' => 'home', + 'affiliation_type_id' => 'person_search', + 'cou_id' => 'supervised_user_circle', + 'date_of_birth' => 'event', + 'department' => 'domain', + 'emailAddress' => 'email', + 'group_id' => 'groups', + 'identifier' => 'badge', + 'manager_person_id' => 'supervisor_account', + 'name' => 'face', + 'organization' => 'corporate_fare', + 'pronoun' => 'transgender', + 'sponsor_person_id' => 'person', + 'telephoneNumber' => 'call', + 'petition_textarea' => 'text_fields', + 'petition_text' => 'text_fields', + 'title' => 'title', + 'url' => 'link', + 'valid_from' => 'calendar_month', + 'valid_through' => 'calendar_month' + ]; + +// Build the Add menu +$action_args = array(); +// This is an add action as a result we do not have an obect id +// and we will use the username instead. +$action_args['vv_attr_id'] = $vv_user['username']; +$action_args['vv_actions_type'] = 'mvea-add-menu'; +$action_args['vv_actions_title'] = __d('operation', 'add.attribute'); +$action_args['vv_actions_icon'] = 'add_circle'; +$action_args['vv_actions_icon_class'] = 'material-symbols-outlined'; +$action_args['vv_actions_class'] = 'mvea-add-menu'; +$actionOrderDefault = $this->Menu->getMenuOrder('Default'); +foreach ($attributes as $attr => $hr_name) { + $title = __d('operation', 'add.a-1', [ + __d('core_enroller', 'controller.EnrollmentAttributes', [1]), + $hr_name + ]); + $actionLabel = $hr_name; + // XXX This is already inside a modal. We just need the layout to be correct +// $actionClass = 'cm-modal-link nospin'; + $actionOrder = $actionOrderDefault++; + $actionIcon = $attributeToIcons[$attr] ?? $this->Menu->getMenuIcon('Default'); + $dataAttrs = ['data-cm-modal-title', __d('file-connector', 'EnrollmentAttributes', 1)]; + $actionUrl = [ + 'pluing' => $this->getPlugin(), + 'controller' => $vv_controller, + 'action' => 'add', + '?' => array_merge($linkFilter, ['attribute_type' => $attr]) + ]; + + $action_args['vv_actions'][] = [ + 'order' => $actionOrder, + 'icon' => $actionIcon, + 'iconClass' => '', + 'url' => $actionUrl, + 'class' => $actionClass ?? '', + 'label' => $actionLabel, + 'dataAttrs' => [] + ]; +} diff --git a/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc new file mode 100644 index 000000000..0ea015f21 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc @@ -0,0 +1,251 @@ +request->getQuery('attribute_type'); +} else if($vv_action == 'edit' && !empty($vv_obj) +) { + $attribute_type = $vv_obj->attribute; +} + +if (empty($attribute_type)) { + return; +} + +/* + * Set the attribute hidden field + */ + +$hidden = [ + 'attribute' => $attribute_type +]; + +/* + * Include a "cancel" button next to the "save" button + */ + +$this->set('vv_include_cancel', true); + +/* + * Common Fields for all attributes + * - label + * - description + * - status + * - order + * - required + */ +foreach ( ['label', + 'description', + 'status', + 'ordr' + ] as $field) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => $field + ]]); +} + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'required', + 'fieldOptions' => [ + 'default' => true + ] + ]]); + + +/* + * + * Optional/Per Attribute supported fields + * + */ + + +/* + * MVEAS which are allowed to attach to a Person or PersonRole + */ +if ( + !empty($vv_supported_attributes[$attribute_type]['mveaParents']) + && count($vv_supported_attributes[$attribute_type]['mveaParents']) > 0 +) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'attribute_mvea_parent', + 'fieldOptions' => [ + 'empty' => false + ], + 'fieldType' => 'select', + 'fieldSelectOptions' => array_combine( + $vv_supported_attributes[$attribute_type]['mveaParents'], + $vv_supported_attributes[$attribute_type]['mveaParents'] + ) + ] + ]); + + + // This field is called attribute_type and not attribute_type_id because we want this + // to behave as a hidden value populated by the appropriate select, and we don't want + // Cake to implement foreign key automagic. + $mveaAutoPopulatedVariable = $attribute_type . 'Types'; + // Check if this mvea model supports types, if it does then render the attribute type + // dropdown list + if ($this->get($mveaAutoPopulatedVariable) !== null) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'attribute_type', + 'fieldLabel' => $attributes[$attribute_type] . ' Type', + 'fieldOptions' => [ + 'empty' => false + ], + 'fieldType' => 'select', + 'fieldSelectOptions' => $this->get($mveaAutoPopulatedVariable) + ] + ]); + } +} + +/* + * Default Values field + * Supported for attributes: group_id, cou_id, affiliation_type_id + */ +if(str_ends_with($attribute_type, '_id')) { + $suffix = substr($attribute_type, 0, -3); + $suffix = Inflector::pluralize(Inflector::camelize($suffix)) ; + $defaultValuesPopulated = 'defaultValue' . $suffix; + if ($this->get($defaultValuesPopulated) !== null) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'default_value', + 'fieldOptions' => [ + 'empty' => true + ], + 'fieldType' => 'select', + 'fieldSelectOptions' => $this->get($defaultValuesPopulated) + ] + ]); + } +} + +/* + * Attribute Tag field + * + * Supported for attributes: adHocAttribute + */ +if (\in_array($attribute_type, ['adHocAttribute'], true)) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'attribute_tag', + ]]); +} + +/* + * Attribute Language field + */ +if(\in_array($attribute_type, ['name', 'address', 'pronoun'], true)) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'attribute_language', + ]]); +} + + +/* + * Default Environmental Variable Name field + */ +$defaultValueEnvNameSupported = [ + 'address', 'adHocAttribute', 'emailAddress', 'identifier', 'name' ,'pronoun', 'telephoneNumber', 'url' +]; +$personRoleExclude = [ + 'affiliation_type_id', 'cou_id', 'valid_from', 'valid_through' +]; +if ( + \in_array($attribute_type, $defaultValueEnvNameSupported, true) + || ( + isset($vv_supported_attributes[$attribute_type]['model']) + && $vv_supported_attributes[$attribute_type]['model'] !== 'PersonRole' + && !\in_array($attribute_type, $personRoleExclude, true) + ) +) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'default_value_env_name', + ]]); +} + +/* + * The valid from attribute is a dateTime field + * + */ +if($attribute_type === 'valid_from') { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'default_value_datetime', + 'fieldType' => 'datetime' + ]]); +} + +/* + * The valid through attribute is a textbox/number field + */ +if($attribute_type === 'valid_through') { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'default_value', + 'fieldDescription' => __d('core_enroller', 'field.AttributeCollectors.valid_through.default.after.desc'), + 'fieldOptions' => [ + 'type' => 'number' + ] + ]]); +} + +/* + * Fields modifiable, hidden + * Supported for Models: PersonRole, Person, Group + */ +if ( + isset($vv_supported_attributes[$attribute_type]['model']) + && \in_array($vv_supported_attributes[$attribute_type]['model'], ['PersonRole', 'Person', 'Group'], true) +) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'modifiable', + 'fieldOptions' => [ + 'default' => true + ] + ] + ]); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'hidden' + ]]); +} diff --git a/app/plugins/CoreEnroller/templates/IdentifierCollectors/display.php b/app/plugins/CoreEnroller/templates/IdentifierCollectors/display.php new file mode 100644 index 000000000..30359a605 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/IdentifierCollectors/display.php @@ -0,0 +1,4 @@ +identifier)) { + print __d('core_enroller', 'result.IdentifierCollector.collected', [$vv_pi->identifier]); +} diff --git a/app/plugins/CoreEnroller/templates/IdentifierCollectors/fields.inc b/app/plugins/CoreEnroller/templates/IdentifierCollectors/fields.inc new file mode 100644 index 000000000..d2eaa73be --- /dev/null +++ b/app/plugins/CoreEnroller/templates/IdentifierCollectors/fields.inc @@ -0,0 +1,33 @@ +element('form/listItem', [ + 'arguments' => ['fieldName' => 'type_id'] + ]); +} diff --git a/app/plugins/CoreEnroller/templates/InvitationAccepters/dispatch.inc b/app/plugins/CoreEnroller/templates/InvitationAccepters/dispatch.inc new file mode 100644 index 000000000..5db1bf0f8 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/InvitationAccepters/dispatch.inc @@ -0,0 +1,51 @@ +' . $vv_config->welcome_message . "\n"; + + // Make the Form fields editable + $this->Field->enableFormEditMode(); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'accepted', + 'fieldLabel' => "Accept this invitation?", + 'fieldOptions' => [ + 'type' => 'radio', + 'options' => [ + ['value' => '1', 'text' => 'Accept'], + ['value' => '0', 'text' => 'Decline'], + ], + 'empty' => false, + 'required' => true + ] + ]]); +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/InvitationAccepters/display.php b/app/plugins/CoreEnroller/templates/InvitationAccepters/display.php new file mode 100644 index 000000000..d1d911100 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/InvitationAccepters/display.php @@ -0,0 +1,10 @@ +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'invitation_validity', + 'fieldOptions' => [ + 'default' => 1440 + ] + ] + ]); + + print $this->element('form/listItem', [ + 'arguments' => ['fieldName' => 'welcome_message'] + ]); +} diff --git a/app/plugins/CoreEnroller/templates/element/field.php b/app/plugins/CoreEnroller/templates/element/field.php new file mode 100644 index 000000000..b5cd48d5a --- /dev/null +++ b/app/plugins/CoreEnroller/templates/element/field.php @@ -0,0 +1,123 @@ +Petition->getSupportedEnrollmentAttribute($attr->attribute); + +// Do we have a default value configured? +// Either a value or an Environmental Variable, +// Each default value is mutually exclusive to the rest. We do not have to worry about a conflict. +$options['default'] = match(true) { + isset($attr->default_value) => $attr->default_value, + isset($attr->default_value_env_name) + && getenv($attr->default_value_env_name) !== false => getenv($attr->default_value_env_name), + isset($attr->default_value_datetime) => $attr->default_value_datetime, + default => '' +}; + +// If we are re-rendering the Petition, override the default value with whatever +// was previously saved +if(!empty($vv_petition_attributes)) { + $curEntity = $vv_petition_attributes->firstMatch(['enrollment_attribute_id' => $attr->id]); + + if(!empty($curEntity->value)) { + $options['default'] = $curEntity->value; + } +} + +// Construct the field arguments +$formArguments = [ + // We prefix the attribute ID with a string because Cake seems to sometimes have + // problems with field names that are purely integers (even if cast to strings) + 'fieldName' => 'field-' . $attr->id, + 'fieldLabel' => $attr->label, // fieldLabel is only applicable to checkboxes + 'fieldType' => $supportedAttributes['fieldType'], + 'fieldDescription' => $attr->description, + 'fieldNameAlias' => $attr->attribute // the field name to its enrollment attribute field name +]; + + +/* + * Get the values for the attributes ending with _id + * Supported for attributes: group_id, cou_id, affiliation_type_id + */ +if(str_ends_with($attr->attribute, '_id')) { + $suffix = substr($attr->attribute, 0, -3); + $suffix = Inflector::pluralize(Inflector::camelize($suffix)) ; + $defaultValuesPopulated = 'defaultValue' . $suffix; + if ($this->get($defaultValuesPopulated) !== null) { + $formArguments['fieldType'] = 'select'; + $formArguments['fieldSelectOptions'] = $this->get($defaultValuesPopulated); + } +} + +// READ-ONLY +if (isset($attr->modifiable) && !$attr->modifiable) { + $options['readonly'] = true; +} + +// REQUIRED +if (isset($attr->required) && $attr->required) { + $options['required'] = true; +} + +$hidden = true; +// XXX We need to render a field that is required and expects a value from the environment but +// the env value is empty. +if ( + isset($attr->default_value_env_name, $attr->required) + && empty($options['default']) + && $attr->required +) { + $hidden = false; +} + +// Set the final fieldOptions +$formArguments['fieldOptions'] = $options; + +print match(true) { + // HIDDEN Field + // We print directly, we do not delegate to the element for further processing + // In case this is a hidden field, we need to get only the value + $attr->hidden && $hidden => $this->Form->hidden($formArguments['fieldName'], ['value' => $options['default']]), + // For the case of xxx_person_id fields, we will render the People a Picker element. + str_ends_with($attr->attribute, 'person_id') => $this->element('CoreEnroller.spa-field', [ + 'vueElementName' => 'peopleAutocomplete', + 'formArguments' => $formArguments + ]), +// Default use case + default => $this->element('form/listItem', ['arguments' => $formArguments]) +}; diff --git a/app/plugins/CoreEnroller/templates/element/mveas/fieldset-field.php b/app/plugins/CoreEnroller/templates/element/mveas/fieldset-field.php new file mode 100644 index 000000000..58c89298c --- /dev/null +++ b/app/plugins/CoreEnroller/templates/element/mveas/fieldset-field.php @@ -0,0 +1,64 @@ +Petition->getSupportedEnrollmentAttribute($attr->attribute); + +if(isset($supportedAttributes['mveaModel'])) { + $supportedAttributes = $this->Petition->getSupportedEnrollmentAttribute($attr->attribute); + $modelTable = $this->Petition->getTable($supportedAttributes['mveaModel']); + $isRequiredFromValidationRule = !$modelTable->getValidator()->field($field)->isEmptyAllowed(); +} + +// Construct the field arguments +$formArguments = [ + // We prefix the attribute ID with a string because Cake seems to sometimes have + // problems with field names that are purely integers (even if cast to strings) + 'fieldName' => "field-$field-$attr->id", + 'fieldLabel' => $attr->label, // fieldLabel is only applicable to checkboxes + 'fieldType' => $modelTable->getSchema()->getColumn($field)['type'], + 'fieldNameAlias' => $attr->attribute // the field name to its enrollment attribute field name +]; +?> + +
    "> + + element('form/requiredSpan') : ''?> + Field->formField(...$formArguments) ?> +
    + + diff --git a/app/plugins/CoreEnroller/templates/element/mveas/fieldset-group.php b/app/plugins/CoreEnroller/templates/element/mveas/fieldset-group.php new file mode 100644 index 000000000..380e77e8b --- /dev/null +++ b/app/plugins/CoreEnroller/templates/element/mveas/fieldset-group.php @@ -0,0 +1,81 @@ +attribute . 'GroupedFields'; +$groupedFieldsArray = []; +if(!empty($$groupedFieldsVar)) { + $groupedFieldsArray = collection(array_keys($$groupedFieldsVar))->map(static fn($fields) => explode(',', $fields))->toArray(); +} + +$permitted_fields_list = []; +$permitted_fields_variable_name = 'permitted_fields_' . Inflector::underscore($attr->attribute); +if (!empty($cosettings[0][$permitted_fields_variable_name])) { + $permitted_fields_list = explode(',', $cosettings[0][$permitted_fields_variable_name]); +} +// Address has no permitted fields configuration at CO level. We will get them from +// the model configuration +$supportedAttributes = $this->Petition->getSupportedEnrollmentAttribute($attr->attribute); +$modelTable = $this->Petition->getTable($supportedAttributes['mveaModel']); +if(empty($permitted_fields_list) && !empty($modelTable?->getPermittedFields())) { + $permitted_fields_list = $modelTable->getPermittedFields(); +} + +$permitted_fields_list_flipped = array_flip($permitted_fields_list); +?> + + $fields): ?> +
    + element('CoreEnroller.mveas/fieldset-field', compact('field', 'attr')); + } + // Remove the field we rendered from the permitted list. + unset($permitted_fields_list_flipped[$field]); + } + ?> +
    + + +element('CoreEnroller.mveas/fieldset-field', compact('field', 'attr')); +} +?> + + diff --git a/app/plugins/CoreEnroller/templates/element/mveas/mvea-fieldset.php b/app/plugins/CoreEnroller/templates/element/mveas/mvea-fieldset.php new file mode 100644 index 000000000..9bda744ab --- /dev/null +++ b/app/plugins/CoreEnroller/templates/element/mveas/mvea-fieldset.php @@ -0,0 +1,58 @@ +attribute . 'Types'; +// Check if this mvea model supports types, if it does then render the attribute type +// dropdown list +$fieldLabel = 'Not found'; +if ($this->get($mveaAutoPopulatedVariable) !== null) { + $fieldLabel = $this->get($mveaAutoPopulatedVariable)[$attr->attribute_type] + . ' ' + . Inflector::humanize(Inflector::underscore($attr->attribute)); +} + +?> + +
  • +
    + + + +
    + element('CoreEnroller.mveas/fieldset-group', compact( 'attr')) ?> +
    +
    +
  • diff --git a/app/plugins/CoreEnroller/templates/element/spa-field.php b/app/plugins/CoreEnroller/templates/element/spa-field.php new file mode 100644 index 000000000..d70069f92 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/element/spa-field.php @@ -0,0 +1,61 @@ + + +
  • +
    +
    +
    + + + element('form/requiredSpan') ?> + +
    + +
    + +
    + +
    + Field->constructSPAField( + // The Default field will be used to harvest the attributes + element: $this->Field->formField(...$formArguments), + // Vue/JS element + vueElementName: $vueElementName + ) ?> +
    +
    +
  • diff --git a/app/plugins/CoreEnroller/tests/bootstrap.php b/app/plugins/CoreEnroller/tests/bootstrap.php new file mode 100644 index 000000000..b33afdd31 --- /dev/null +++ b/app/plugins/CoreEnroller/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/plugins/CoreEnroller/tests/schema.sql b/app/plugins/CoreEnroller/tests/schema.sql new file mode 100644 index 000000000..0fe014a26 --- /dev/null +++ b/app/plugins/CoreEnroller/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for CoreEnroller diff --git a/app/plugins/CoreEnroller/webroot/.gitkeep b/app/plugins/CoreEnroller/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po index 714484a6e..b4fab7b4a 100644 --- a/app/resources/locales/en_US/controller.po +++ b/app/resources/locales/en_US/controller.po @@ -51,6 +51,12 @@ msgstr "{0,plural,=1{Dashboard} other{Dashboards}}" msgid "EmailAddresses" msgstr "{0,plural,=1{Email Address} other{Email Addresses}}" +msgid "EnrollmentFlowSteps" +msgstr "{0,plural,=1{Enrollment Flow Step} other{Enrollment Flow Steps}}" + +msgid "EnrollmentFlows" +msgstr "{0,plural,=1{Enrollment Flow} other{Enrollment Flows}}" + msgid "ExternalIdentities" msgstr "{0,plural,=1{External Identity} other{External Identities}}" @@ -96,6 +102,9 @@ msgstr "{0,plural,=1{Job} other{Jobs}}" msgid "MessageTemplates" msgstr "{0,plural,=1{Message Template} other{Message Templates}}" +msgid "MostlyStaticPages" +msgstr "{0,plural,=1{Mostly Static Page} other{Mostly Static Pages}}" + msgid "Names" msgstr "{0,plural,=1{Name} other{Names}}" @@ -108,6 +117,12 @@ msgstr "{0,plural,=1{Person} other{People}}" msgid "PersonRoles" msgstr "{0,plural,=1{Person Role} other{Person Roles}}" +msgid "PetitionHistoryRecords" +msgstr "{0,plural,=1{PetitionHistoryRecord} other{PetitionHistoryRecords}}" + +msgid "Petitions" +msgstr "{0,plural,=1{Petition} other{Petitions}}" + msgid "Pipelines" msgstr "{0,plural,=1{Pipeline} other{Pipelines}}" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index 2d04220f4..691a14338 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -99,6 +99,39 @@ msgstr "Staff" msgid "EduPersonAffiliationEnum.student" msgstr "Student" +msgid "EnrollmentActorEnum.A" +msgstr "Approver" + +msgid "EnrollmentActorEnum.E" +msgstr "Enrollee" + +msgid "EnrollmentActorEnum.P" +msgstr "Petitioner" + +msgid "EnrollmentAuthzEnum.A" +msgstr "CO or COU Admin" + +msgid "EnrollmentAuthzEnum.AU" +msgstr "Any Authenticated User" + +msgid "EnrollmentAuthzEnum.CA" +msgstr "CO Administrator" + +msgid "EnrollmentAuthzEnum.CP" +msgstr "Person" + +msgid "EnrollmentAuthzEnum.GM" +msgstr "Group Member" + +msgid "EnrollmentAuthzEnum.N" +msgstr "None" + +msgid "EnrollmentAuthzEnum.UA" +msgstr "COU Administrator" + +msgid "EnrollmentAuthzEnum.UP" +msgstr "COU Person" + msgid "ExternalIdentityStatusEnum.A" msgstr "Active" @@ -144,6 +177,15 @@ msgstr "Owners" msgid "GroupTypeEnum.S" msgstr "Standard" +msgid "GroupedAddressFieldsEnum.street,room" +msgstr "Street, Room" + +msgid "GroupedAddressFieldsEnum.city,locality" +msgstr "City, Locality" + +msgid "GroupedAddressFieldsEnum.state,postal_code,country" +msgstr "State, Postal Code, Country" + msgid "IdentifierAssignmentContextEnum.CD" msgstr "Department" @@ -303,17 +345,17 @@ msgstr "HTML" msgid "MessageFormatEnum.text" msgstr "Plain Text" -msgid "MessageTemplateContextEnum.AP" -msgstr "Enrollment Approver" +# msgid "MessageTemplateContextEnum.AP" +# msgstr "Enrollment Approver" -msgid "MessageTemplateContextEnum.AU" -msgstr "Authenticator" +# msgid "MessageTemplateContextEnum.AU" +# msgstr "Authenticator" -msgid "MessageTemplateContextEnum.EA" -msgstr "Enrollment Approval" +# msgid "MessageTemplateContextEnum.EA" +# msgstr "Enrollment Approval" -msgid "MessageTemplateContextEnum.EF" -msgstr "Enrollment Finalization" +# msgid "MessageTemplateContextEnum.EF" +# msgstr "Enrollment Finalization" msgid "MessageTemplateContextEnum.EH" msgstr "Enrollment Handoff" @@ -321,14 +363,14 @@ msgstr "Enrollment Handoff" # msgid "MessageTemplateContextEnum.EI" # msgstr "Enrollment Invitation" -msgid "MessageTemplateContextEnum.EV" -msgstr "Enrollment Verification" - msgid "MessageTemplateContextEnum.PL" msgstr "Plugin" -msgid "MessageTemplateContextEnum.XN" -msgstr "Expiration Notification" +msgid "MessageTemplateContextEnum.V" +msgstr "Verification" + +# msgid "MessageTemplateContextEnum.XN" +# msgstr "Expiration Notification" msgid "NotificationStatusEnum.A" msgstr "Acknowledged" @@ -348,6 +390,15 @@ msgstr "Resolved" msgid "NotificationStatusEnum.X" msgstr "Canceled" +msgid "PageContextEnum.EH" +msgstr "Enrollment Handoff" + +msgid "PageContextEnum.ER" +msgstr "Error Landing" + +msgid "PageContextEnum.G" +msgstr "General" + msgid "PermittedNameFieldsEnum.given,family" msgstr "Given, Family" @@ -396,6 +447,70 @@ msgstr "Country Code, Area Code, Number" msgid "PermittedTelephoneNumberFieldsEnum.country_code,area_code,number,extension" msgstr "Country Code, Area Code, Number, Extension" +msgid "PetitionActionEnum.AU" +msgstr "Attributes Updated" + +msgid "PetitionActionEnum.EV" +msgstr "Email Verified" + +msgid "PetitionActionEnum.F" +msgstr "Finalized" + +msgid "PetitionActionEnum.IV" +msgstr "Invitation Viewed" + +msgid "PetitionActionEnum.SU" +msgstr "Status Updated" + +msgid "PetitionStatusEnum.A" +msgstr "Active" + +msgid "PetitionStatusEnum.Y" +msgstr "Approved" + +# This was "Confirmed" in v4 +msgid "PetitionStatusEnum.C" +msgstr "Accepted" + +msgid "PetitionStatusEnum.CR" +msgstr "Created" + +msgid "PetitionStatusEnum.D2" +msgstr "Duplicate" + +msgid "PetitionStatusEnum.F" +msgstr "Finalized" + +msgid "PetitionStatusEnum.FI" +msgstr "Finalizing" + +msgid "PetitionStatusEnum.N" +msgstr "Denied" + +msgid "PetitionStatusEnum.PA" +msgstr "Pending Approval" + +msgid "PetitionStatusEnum.PC" +msgstr "Pending Acceptance" + +msgid "PetitionStatusEnum.PE" +msgstr "Pending Verification" + +msgid "PetitionStatusEnum.PV" +msgstr "Pending Vetting" + +msgid "PetitionStatusEnum.VE" +msgstr "Verified" + +msgid "PetitionStatusEnum.VT" +msgstr "Vetted" + +msgid "PetitionStatusEnum.X" +msgstr "Declined" + +msgid "PetitionStatusEnum.XX" +msgstr "Failed" + msgid "ProvisionerModeEnum.A" msgstr "Immediate" @@ -534,6 +649,21 @@ msgstr "Suspended" msgid "TemplateableStatusEnum.T" msgstr "Template" +msgid "VerificationMethodEnum.C" +msgstr "Code" + +msgid "VerificationMethodEnum.M" +msgstr "Manual" + +msgid "VerificationMethodEnum.PH" +msgstr "Petition Handoff" + +msgid "VerificationMethodEnum.TS" +msgstr "Trusted Source" + +msgid "VerificationMethodEnum.U" +msgstr "URL" + msgid "YesBooleanEnum.0" msgstr "No" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index b86ddf222..dd1ec0969 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -54,8 +54,8 @@ msgstr "Username \"{0}\" not found in api_users table" msgid "auto.viewvar.type.unknown" msgstr "Unknown Auto View Var Type {0}" -msgid "copy.error" -msgstr "Could not copy." +msgid "copy.javascript.clipboard" +msgstr "Could not copy. (Note: This feature requires HTTPS.)" msgid "coid" msgstr "CO ID not found" @@ -66,6 +66,9 @@ msgstr "Cannot change co_id of an existing object" msgid "coid.mismatch" msgstr "Requested CO does not match CO of {0} {1}" +msgid "Cos.active" +msgstr "Requested CO {0} is not active" + msgid "cou.parent" msgstr "COU Parent ID not valid" @@ -124,6 +127,12 @@ msgstr "Email Address is already verified" msgid "EmailAddresses.mail.verify.force.person" msgstr "Email Addresses not associated with People cannot be force verified" +msgid "EnrollmentFlowSteps.message_template" +msgstr "Enrollment Flow Step {0} is transitioning Actor Types but does not have a Mesasge Template configured" + +msgid "EnrollmentFlowSteps.none" +msgstr "This Enrollment Flow has no Active steps and so cannot be run" + msgid "GroupNestings.active" msgstr "Group {0} is not active and so cannot be nested" @@ -229,6 +238,15 @@ msgstr "Job {0} is not in {1} status and cannot be set to {2} (Job is {3})" msgid "Jobs.status.invalid.cancel" msgstr "Job {0} is not in a cancelable status (Job is {1})" +msgid "MostlyStaticPages.default.delete" +msgstr "This page cannot be deleted" + +msgid "MostlyStaticPages.default.modify" +msgstr "This page cannot be renamed, suspended, or given a different context" + +msgid "MostlyStaticPages.slug.invalid" +msgstr "Slug contains invalid characters" + msgid "Names.minimum" msgstr "At least one name is required" @@ -262,6 +280,9 @@ msgstr "Notification status {0} is not a valid resolution" msgid "notprov" msgstr "{0} not provided" +msgid "ordr.unique" +msgstr "Each {0} must have a unique order" + msgid "pagenum.exceeded" msgstr "Page number may not be larger than {0}" @@ -274,6 +295,18 @@ msgstr "Permission Denied" msgid "PersonRoles.valid_from.after" msgstr "Valid From date must be earlier than Valid Through date" +msgid "Petitions.completed" +msgstr "Petition {0} has been completed and cannot be changed" + +msgid "Petitions.enrollee.notfound" +msgstr "Enrollee Person not found in Petition {0}" + +msgid "Petitions.enrollee_email" +msgstr "An Email Address for the Enrollee is required by this Enrollment Flow" + +msgid "Petitions.status.finalizing" +msgstr "Petition {0} is not in Finalizing status" + msgid "Pipelines.plugin.notimpl" msgstr "Pipeline plugin does not implement {0}" @@ -321,3 +354,18 @@ msgstr "Type {0} is in use as a default (via CO Settings)" msgid "unknown" msgstr "Unknown value \"{0}\"" + +msgid "Verifications.already" +msgstr "Email Address is already verified" + +msgid "Verifications.code" +msgstr "Invalid code" + +msgid "Verifications.expired" +msgstr "Verification request has expired" + +msgid "Verifications.petition" +msgstr "Verification does not match requested Petition" + +msgid "Verifications.processed" +msgstr "Verification has already been processed" \ No newline at end of file diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index d2ceb620f..312f39033 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -41,6 +41,9 @@ msgstr "Actor" msgid "api_key" msgstr "API Key" +msgid "authz_type" +msgstr " Authorization Type" + msgid "affiliation" msgstr "Affiliation" @@ -110,6 +113,12 @@ msgstr "Ends at:" msgid "ends_at.tz" msgstr "Ends at ({0})" +msgid "enrollee_email" +msgstr "Enrollee Email" + +msgid "enrollee_identifier" +msgstr "Enrollee Identifier" + msgid "extension" msgstr "Extension" @@ -176,6 +185,12 @@ msgstr "Manager Identifier" msgid "middle" msgstr "Middle" +msgid "modifiable" +msgstr "Modifiable" + +msgid "modified" +msgstr "Modified" + msgid "name" msgstr "Name" @@ -219,6 +234,9 @@ msgstr "IP Address" msgid "required" msgstr "Required" +msgid "element_fallback" +msgstr "Element ID not provided" + msgid "role_key" msgstr "Role Key" @@ -237,6 +255,9 @@ msgstr "Clear global search" msgid "search.placeholder" msgstr "Search..." +msgid "sor_label" +msgstr "System of Record Label" + msgid "source" msgstr "Source" @@ -396,6 +417,33 @@ msgstr "Limit Global Search Scope" msgid "CoSettings.search_global_limited_models.desc" msgstr "If true, Global Search will only search Names, Email Addresses, and Identifiers. This may result in faster searches for larger deployments." +msgid "EnrollmentFlowSteps.actor_type" +msgstr "Actor Type" + +msgid "EnrollmentFlowSteps.message_template_id.desc" +msgstr "If a handoff is required to start this step, this Message Template will be used to notify the next Actor" + +msgid "EnrollmentFlowSteps.redirect_on_handoff" +msgstr "Redirect on Handoff" + +msgid "EnrollmentFlowSteps.redirect_on_handoff.desc" +msgstr "If a handoff is required to start this step, the original Actor will be redirected here instead of the default landing page" + +msgid "EnrollmentFlows.authz_cou_id" +msgstr "Authorized COU" + +msgid "EnrollmentFlows.authz_group_id" +msgstr "Authorized Group" + +msgid "EnrollmentFlows.authz_type" +msgstr "Petitioner Authorization" + +msgid "EnrollmentFlows.collect_enrollee_email" +msgstr "Collect Enrollee Email" + +msgid "EnrollmentFlows.redirect_on_finalize" +msgstr "Redirect on Finalize" + msgid "ExternalIdentitySources.hash_source_record" msgstr "Hash Source Records" @@ -567,6 +615,50 @@ msgstr "Message Subject" msgid "MessageTemplates.subject.desc" msgstr "Subject line for message to be sent. See XXX LINK supported substitutions." +msgid "MostlyStaticPages.body" +msgstr "Body" + +msgid "MostlyStaticPages.name" +msgstr "Slug" + +msgid "MostlyStaticPages.name.desc" +msgstr "The URL fragment for this Page, which must be unique and use only lowercase alphanumeric characters and dashes (-)" + +msgid "MostlyStaticPages.pageUrl" +msgstr "Page Display URL" + +msgid "MostlyStaticPages.pageUrl.desc" +msgstr "The full (public) URL to access the rendered page (this is a read only field)" + +# These are strings for the default Pages that are created for each CO. +# It's not clear they belong here, but we don't have a better place for them right now. +msgid "MostlyStaticPages.default.dh.title" +msgstr "Enrollment Flow Handoff" + +msgid "MostlyStaticPages.default.dh.description" +msgstr "Default Enrollment Flow Handoff Landing Page" + +msgid "MostlyStaticPages.default.dh.body" +msgstr "Thank you for completing this Enrollment Flow Step. No further action is required from you at this time. The next person to act on this Petition has been notified." + +msgid "MostlyStaticPages.default.el.title" +msgstr "An Error Occurred" + +msgid "MostlyStaticPages.default.el.description" +msgstr "Default Error Landing Page" + +msgid "MostlyStaticPages.default.el.body" +msgstr "An unexpected error occurred. Please contact your administrator for further assistance." + +msgid "MostlyStaticPages.default.pc.title" +msgstr "Enrollment Complete" + +msgid "MostlyStaticPages.default.pc.description" +msgstr "Default Petition Finalization Landing Page" + +msgid "MostlyStaticPages.default.pc.body" +msgstr "This Petition is complete and has been finalized. Please contact your administrator for further instructions." + msgid "Notifications.actor_person_id" msgstr "Actor" @@ -597,6 +689,15 @@ msgstr "Resolution Time" msgid "Notifications.subject_person_id" msgstr "Subject" +msgid "Petitions.enrollee.new" +msgstr "New Enrollee" + +msgid "Petitions.enrollee_person_id" +msgstr "Enrollee" + +msgid "Petitions.petitioner_person_id" +msgstr "Petitioner" + msgid "Pipelines.match_email_address_type_id" msgstr "Email Address Type" diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po index 3d35cff34..87ca65138 100644 --- a/app/resources/locales/en_US/information.po +++ b/app/resources/locales/en_US/information.po @@ -54,6 +54,9 @@ msgstr "not set" msgid "pagination.format" msgstr "Page {{page}} of {{pages}}, Viewing {{start}}-{{end}} of {{count}}" +msgid "enrollment.steps" +msgstr "Enrollment Steps" + msgid "ExternalIdentities.source" msgstr "This External Identity was created from {0}." @@ -99,15 +102,30 @@ msgstr "No value" msgid "global.visit.link" msgstr "Visit link" +msgid "HistoryRecords.xref" +msgstr "Additional History Records may be available via Petitions and Provisioning Status" + msgid "pagination.format" msgstr "Page {{page}} of {{pages}}, Viewing {{start}}-{{end}} of {{count}}" +msgid "petition.history" +msgstr "Petition History" + +msgid "petition.information" +msgstr "Petition Information" + +msgid "Petitions.pending" +msgstr "This Petition has now been assigned to someone else. There is no further action for you at this time." + msgid "plugin.active" msgstr "Active" msgid "plugin.active.only" msgstr "Active, Cannot Be Disabled" +msgid "plugin.config.none" +msgstr "This plugin requires no configuration." + msgid "plugin.inactive" msgstr "Inactive" diff --git a/app/resources/locales/en_US/menu.po b/app/resources/locales/en_US/menu.po index 72e4011aa..4d4dc64f3 100644 --- a/app/resources/locales/en_US/menu.po +++ b/app/resources/locales/en_US/menu.po @@ -112,7 +112,7 @@ msgid "co.people.enrollments.pending" msgstr "Pending Enrollments" msgid "co.people.enrollments.pending.desc" -msgstr "See and manage in-progress enrollments (CO Petitions)" +msgstr "See and manage pending enrollments" msgid "co.people.external.source.records" msgstr "External Source Records" @@ -192,8 +192,11 @@ msgstr "medium" msgid "menu.density.large" msgstr "large" +msgid "menu.home" +msgstr "Home" + msgid "menu.introduction" -msgstr "Please select an action from the menu." +msgstr "Welcome to COmanage Registry." msgid "menu.main" msgstr "Main Menu" diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index 3828656ba..0780d84d0 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -24,6 +24,9 @@ # Operations (Commands) +msgid "accept" +msgstr "Accept" + msgid "acknowledge" msgstr "Acknowledge" @@ -36,6 +39,12 @@ msgstr "Add" msgid "add.a" msgstr "Add a New {0}" +msgid "add.a-1" +msgstr "Add a New {0} `{1}`" + +msgid "add.attribute" +msgstr "Add attribute" + msgid "add.member" msgstr "Add member: " @@ -102,9 +111,18 @@ msgstr "Configure {0}" msgid "configure.plugin" msgstr "Configure Plugin" +msgid "continue" +msgstr "Continue" + msgid "copy" msgstr "Copy" +msgid "copy.flowUrl" +msgstr "Copy Enrollment Flow URL" + +msgid "copy.url" +msgstr "Copy URL" + msgid "copy.value" msgstr "Copy value" @@ -114,6 +132,9 @@ msgstr "{0} Configuration" msgid "deactivate" msgstr "Deactivate" +msgid "decline" +msgstr "Decline" + msgid "delete" msgstr "Delete" @@ -129,6 +150,15 @@ msgstr "Edit" msgid "edit.a" msgstr "Edit {0}" +msgid "edit.a-1" +msgstr "Edit {0} `{1}`" + +msgid "edit.PersonRoles.a" +msgstr "Edit Role {0}" + +msgid "edit.ExternalIdentityRoles.a" +msgstr "Edit Role {0}" + msgid "EmailAddresses.verify.force" msgstr "Force Verify" @@ -189,6 +219,9 @@ msgstr "Display records" msgid "page.goto" msgstr "Go to page" +msgid "Petitions.rerun" +msgstr "Rerun" + msgid "pick" msgstr "Pick" @@ -225,6 +258,9 @@ msgstr "Remove" msgid "resend" msgstr "Resend" +msgid "resume" +msgstr "Resume" + msgid "save" msgstr "Save" @@ -240,15 +276,30 @@ msgstr "Please select..." msgid "skip_to_content" msgstr "Skip to main content" +msgid "EnrollmentFlows.start" +msgstr "Start" + msgid "Cos.switch" msgstr "Switch To This CO" msgid "unfreeze" msgstr "Unfreeze" +msgid "verify" +msgstr "Verify" + msgid "view" msgstr "View" msgid "view.a" msgstr "View {0}" +msgid "view.PersonRoles.a" +msgstr "View Role {0}" + +msgid "view.Petitions.a" +msgstr "View Petition {0}" + +msgid "view.ExternalIdentityRoles.a" +msgstr "View Role {0}" + diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index c0aa9e347..abf95555b 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -139,6 +139,9 @@ msgstr "Notification {0} delivered to {1}" msgid "Notifications.resent" msgstr "Notification resent" +msgid "People.added.petition" +msgstr "Created new Person via Enrollment Flow {0} ({1}), Petition {2}" + msgid "People.added.pipeline" msgstr "Created new Person via Pipeline {0} ({1}) using Source {2} ({3}) Key {4}" @@ -148,6 +151,12 @@ msgstr "Person status recalculated from {0} to {1}" msgid "PersonRoles.status.recalculated" msgstr "Person Role status recalculated from {0} to {1}" +msgid "Petitions.finalized" +msgstr "Petition Finalized" + +msgid "Petitions.viewed.inv" +msgstr "Invitation Viewed" + msgid "Pipelines.complete" msgstr "Pipeline {0} complete for EIS {1} source key {2}" @@ -166,6 +175,9 @@ msgstr "Reprovisioning for {0} queued for {1} ({2})" msgid "removed" msgstr "removed" +msgid "result" +msgstr "Result" + msgid "saved" msgstr "Saved" @@ -205,3 +217,12 @@ msgstr "Test message sent" msgid "updated" msgstr "updated" + +msgid "Verifications.status" +msgstr "{0} verification at {1}" + +msgid "verified" +msgstr "Verified" + +msgid "verified.not" +msgstr "Not Verified" diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index 35b51d485..7d3e06ff2 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -520,6 +520,29 @@ protected function populateAvailableCos() { $this->set('vv_available_cos', $availableCos); } + + /** + * Find a parameter that may be submitted via a request URL (for GETs) + * or form data (for POSTs). + * + * @since COmanage Registry v5.0.0 + * @param string $name Parameter name + * @return string Parameter value if found, or null + */ + + protected function requestParam(string $name): ?string { + if($this->request->is('get')) { + if(!empty($this->request->getQuery($name))) { + return $this->request->getQuery($name); + } + } elseif($this->request->is(['post', 'put'])) { + if(!empty($this->request->getData($name))) { + return $this->request->getData($name); + } + } + + return null; + } /** * Determine the (requested) current CO and make it available to the @@ -570,15 +593,23 @@ protected function setCO() { // trigger setting of the viewVar for breadcrumbs and anything else. $link = $this->getPrimaryLink(true); - // getPrimaryLink has already done our work - if($link->attr == 'co_id') { - $coid = $link->value; - } else { - if(!empty($link->co_id)) { - $coid = $link->co_id; + if(!empty($link->attr)) { + // getPrimaryLink has already done our work + if($link->attr == 'co_id') { + $coid = $link->value; + } else { + if(!empty($link->co_id)) { + $coid = $link->co_id; + } } } } + + if(!$coid + && $this->$modelsName->allowUnkeyedCO($this->request->getParam('action')) + && !empty($this->request->getQuery('co_id'))) { + $coid = $this->request->getQuery('co_id'); + } if(!$coid && !$this->$modelsName->allowEmptyCO() diff --git a/app/src/Controller/Component/BreadcrumbComponent.php b/app/src/Controller/Component/BreadcrumbComponent.php index 071733ee8..f6e54df4f 100644 --- a/app/src/Controller/Component/BreadcrumbComponent.php +++ b/app/src/Controller/Component/BreadcrumbComponent.php @@ -191,7 +191,7 @@ public function injectPrimaryLink(object $link, bool $index=true, string $linkLa // In the case we are dealing with non-standard actions we need to fallback to a standard one // in order to get access to the contain array. We will use the permissions to decide which // action to fall back to - if(!in_array($requestAction, [ + if(!\in_array($requestAction, [ 'index', 'view', 'delete', 'add', 'edit' ])) { $permissionsArray = $this->getController()->RegistryAuth->calculatePermissionsForView($requestAction); @@ -276,7 +276,7 @@ public function injectTitleLink( $entity, string $action='edit', ?string $label=null - ) { + ): void { $displayField = $table->getDisplayField(); $this->injectTitleLinks[] = [ @@ -298,7 +298,8 @@ public function injectTitleLink( * @param array $skipPaths Array of regular expressions describing paths to be skipped */ - public function skipAll(array $skipPaths) { + public function skipAll(array $skipPaths): void + { $this->skipAllPaths = $skipPaths; } @@ -311,7 +312,8 @@ public function skipAll(array $skipPaths) { * @param array $skipPaths Array of regular expressions describing paths */ - public function skipConfig(array $skipPaths) { + public function skipConfig(array $skipPaths): void + { $this->skipConfigPaths = $skipPaths; } @@ -323,7 +325,8 @@ public function skipConfig(array $skipPaths) { * @param array $skipPaths Array of regular expressions describing paths */ - public function skipParents(array $skipPaths) { + public function skipParents(array $skipPaths): void + { $this->skipParentPaths = $skipPaths; } } \ No newline at end of file diff --git a/app/src/Controller/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php index 36aa7b4be..e0854862e 100644 --- a/app/src/Controller/Component/RegistryAuthComponent.php +++ b/app/src/Controller/Component/RegistryAuthComponent.php @@ -140,6 +140,7 @@ public function beforeFilter(EventInterface $event) { if(method_exists($controller, 'willHandleAuth')) { // The Controller might handle its own authn/z + // We'll just let any exception bubble up $mode = $controller->willHandleAuth($event); switch($mode) { @@ -158,8 +159,14 @@ public function beforeFilter(EventInterface $event) { break; case 'yes': // The controller will handle both authn and authz, simply return + // (The expectation is that the controller already performed the appropriate + // checks before returning 'yes', on failure 'notauth' should be returned.) return true; break; + case 'notauth': + // The controller has rejected this request as unauthenticated or unauthorized + throw new ForbiddenException(__d('error', 'perm')); + break; default: throw new \InvalidArgumentException("Unknown willHandleAuth return value $mode"); break; diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index c06e4ee40..f027da29a 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -92,21 +92,36 @@ public function configuration() { 'controller' => 'cous', 'action' => 'index' ], + __d('controller', 'EnrollmentFlows', [99]) => [ + 'icon' => 'subscriptions', + 'iconClass' => 'material-symbols-outlined', + 'controller' => 'enrollment_flows', + 'action' => 'index' + ], __d('controller', 'ExternalIdentitySources', [99]) => [ 'icon' => 'cloud_download', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'external_identity_sources', 'action' => 'index' ], __d('controller', 'IdentifierAssignments', [99]) => [ 'icon' => 'badge', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'identifier_assignments', 'action' => 'index' ], __d('controller', 'MessageTemplates', [99]) => [ 'icon' => 'email', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'message_templates', 'action' => 'index' ], + __d('controller', 'MostlyStaticPages', [99]) => [ + 'icon' => 'article', + 'iconClass' => 'material-symbols-outlined', + 'controller' => 'mostly_static_pages', + 'action' => 'index' + ], __d('controller', 'Pipelines', [99]) => [ 'icon' => 'cable', 'controller' => 'pipelines', @@ -114,6 +129,7 @@ public function configuration() { ], __d('controller', 'ProvisioningTargets', [99]) => [ 'icon' => 'cloud_upload', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'provisioning_targets', 'action' => 'index' ], @@ -170,6 +186,7 @@ public function configuration() { $registryMenuItems = [ __d('controller', 'Groups', [99]) => [ 'icon' => 'people', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'groups', 'action' => 'index' ], @@ -180,6 +197,7 @@ public function configuration() { ], __d('controller', 'Servers', [99]) => [ 'icon' => 'computer', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'servers', 'action' => 'index' ] @@ -191,14 +209,22 @@ public function configuration() { $artifactMenuItems = [ __d('controller', 'ExtIdentitySourceRecords', [99]) => [ - 'icon' => 'assignment', + 'icon' => 'badge', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'ext_identity_source_records', 'action' => 'index' ], __d('controller', 'Jobs', [99]) => [ - 'icon' => 'assignment', + 'icon' => 'work_history', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'jobs', 'action' => 'index' + ], + __d('controller', 'Petitions', [99]) => [ + 'icon' => 'pending_actions', + 'iconClass' => 'material-symbols-outlined', + 'controller' => 'petitions', + 'action' => 'index' ] ]; diff --git a/app/src/Controller/EmailAddressesController.php b/app/src/Controller/EmailAddressesController.php index 125390b86..0ed2cfe88 100644 --- a/app/src/Controller/EmailAddressesController.php +++ b/app/src/Controller/EmailAddressesController.php @@ -49,7 +49,7 @@ class EmailAddressesController extends MVEAController { public function forceVerify(string $id) { try { $this->EmailAddresses->forceVerify((int)$id, $this->RegistryAuth->getPersonID($this->getCOID())); - $this->Flash->success("Email Address updated"); // XXX I18n + $this->Flash->success(__d('result', 'EmailAddresses.verify.forced')); } catch(Exception $e) { $this->Flash->error($e->getMessage()); diff --git a/app/src/Controller/EnrollmentFlowStepsController.php b/app/src/Controller/EnrollmentFlowStepsController.php new file mode 100644 index 000000000..866c9a599 --- /dev/null +++ b/app/src/Controller/EnrollmentFlowStepsController.php @@ -0,0 +1,66 @@ + [ + 'EnrollmentFlowSteps.ordr' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + // Pull the Person name for breadcrumb rendering + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->EnrollmentFlowSteps->EnrollmentFlows->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->EnrollmentFlowSteps->EnrollmentFlows->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->EnrollmentFlowSteps->EnrollmentFlows->getPrimaryKey()); + } + + return parent::beforeRender($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/EnrollmentFlowsController.php b/app/src/Controller/EnrollmentFlowsController.php new file mode 100644 index 000000000..79f043fbe --- /dev/null +++ b/app/src/Controller/EnrollmentFlowsController.php @@ -0,0 +1,183 @@ + [ + 'EnrollmentFlows.name' => 'asc' + ] + ]; + + /** + * Calculate authorization for the current request. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the current request is permitted, false otherwise + */ + + public function calculatePermission(): bool { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + $authorized = false; + + // We should only get called for 'start', based on willHandleAuth(), below. + if($action == 'start') { + $actorInfo = $this->getCurrentActor(); + + // We need to pull the config to get the Petitioner Authorization mode + $params = $this->request->getParam('pass'); + + if(empty($params[0])) { + throw new \InvalidArgumentException(__d('error', 'notprov', 'enrollment_flow_id')); + } + + $flow = $this->EnrollmentFlows->get($params[0]); + + switch($flow->authz_type) { + case EnrollmentAuthzEnum::AuthUser: + $authorized = !empty($actorInfo['identifier']); + break; + case EnrollmentAuthzEnum::CoAdmin: + $authorized = $this->RegistryAuth->isCoAdmin($flow->co_id); + break; + case EnrollmentAuthzEnum::CoOrCouAdmin: +// XXX + break; + case EnrollmentAuthzEnum::CouAdmin: +// XXX + break; + case EnrollmentAuthzEnum::CouPerson: +// XXX + break; + case EnrollmentAuthzEnum::GroupMember: +// XXX + break; + case EnrollmentAuthzEnum::Person: +// XXX + break; + case EnrollmentAuthzEnum::None: +// XXX willHandleAuth needs to check for this mode and then return 'open' if set + $authorized = true; + break; + } + } + + return $authorized; + } + + /** + * Start an Enrollment flow. + * + * @since COmanage Registry v5.0.0 + * @param string $id Enrollment Flow ID + */ + + public function start(string $id) { + $flow = $this->EnrollmentFlows->get((int)$id); + +// XXX Is this an AR? + // By default, the Petitioner is the Enrollee if the Flow Authorization is not some + // sort of Admin. (This can be changed by a Plugin later, if appropriate.) + + $isEnrollee = in_array($flow->authz_type, [ + EnrollmentAuthzEnum::AuthUser, + EnrollmentAuthzEnum::CouPerson, + EnrollmentAuthzEnum::GroupMember, + EnrollmentAuthzEnum::Person, + EnrollmentAuthzEnum::None + ]); + + $actor = $this->getCurrentActor(); + + if($this->request->is(['post', 'put'])) { + // We should now have an enrollee email, so we can create the Pettion. + // Saving the entity should syntactically validate the email address. + + $petition = $this->EnrollmentFlows->Petitions->start( + enrollmentFlowId: (int)$id, + petitionerIdentifier: $actor['identifier'], + petitionerPersonId: $actor['person_id'], + isEnrollee: $isEnrollee, + enrolleeEmail: $this->request->getData('enrollee_email') + ); + + // No form to render, simply redirect to the next (ie: first) step + return $this->transitionToStep(petitionId: $petition->id, start: true); + } else { + if(isset($flow->collect_enrollee_email) && $flow->collect_enrollee_email) { + // We need to render a form, so we'll delay creating the petition + // until we come back from the form. Since there's no petition there's + // no meaningful information to pass through and back. + + // Get the title + // XXX We should have a "Title" for end-users that is different from the Enrollment Flow "Name" + // for start and dispatch. + $this->set('vv_title', $flow->name); + + } else { + // No form, so just allocate a new Petition and set appropriate metadata + + $petition = $this->EnrollmentFlows->Petitions->start( + enrollmentFlowId: (int)$id, + petitionerIdentifier: $actor['identifier'], + petitionerPersonId: $actor['person_id'], + isEnrollee: $isEnrollee + ); + + // No form to render, simply redirect to the next (ie: first) step + return $this->transitionToStep(petitionId: $petition->id, start: true); + } + } + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + // We only need to take over authz for start + return ($action == 'start') ? 'authz' : 'no'; + } +} \ No newline at end of file diff --git a/app/src/Controller/ExternalIdentitiesController.php b/app/src/Controller/ExternalIdentitiesController.php index 441f526e3..e1ab7db68 100644 --- a/app/src/Controller/ExternalIdentitiesController.php +++ b/app/src/Controller/ExternalIdentitiesController.php @@ -29,9 +29,8 @@ namespace App\Controller; -// XXX not doing anything with Log yet -use Cake\Log\Log; -use Cake\ORM\TableRegistry; +use Cake\Event\EventInterface; +use Cake\Http\Response; // Use extend MVEAController for breadcrumb rendering. ExternalIdentities is // sort of an MVEA, so maybe it makes sense to treat it as such. @@ -45,4 +44,27 @@ class ExternalIdentitiesController extends MVEAController { 'Names.family' ] ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + // Pull the Person name for breadcrumb rendering + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->ExternalIdentities->People->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->ExternalIdentities->People->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->ExternalIdentities->People->getPrimaryKey()); + } + + return parent::beforeRender($event); + } } \ No newline at end of file diff --git a/app/src/Controller/GroupMembersController.php b/app/src/Controller/GroupMembersController.php index b258d32db..6d7306a9a 100644 --- a/app/src/Controller/GroupMembersController.php +++ b/app/src/Controller/GroupMembersController.php @@ -58,6 +58,7 @@ public function beforeRender(EventInterface $event) { if(!empty($link->value)) { $this->set('vv_bc_parent_obj', $this->GroupMembers->Groups->get($link->value)); $this->set('vv_bc_parent_displayfield', $this->GroupMembers->Groups->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->GroupMembers->Groups->getPrimaryKey()); } return parent::beforeRender($event); diff --git a/app/src/Controller/GroupNestingsController.php b/app/src/Controller/GroupNestingsController.php index a42f86c2f..20b6d76bd 100644 --- a/app/src/Controller/GroupNestingsController.php +++ b/app/src/Controller/GroupNestingsController.php @@ -54,6 +54,7 @@ public function beforeRender(\Cake\Event\EventInterface $event) { if(!empty($link->value)) { $this->set('vv_bc_parent_obj', $this->GroupNestings->Groups->get($link->value)); $this->set('vv_bc_parent_displayfield', $this->GroupNestings->Groups->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->GroupNestings->Groups->getPrimaryKey()); } // We need to calculate the available set of groups for nesting. We do this diff --git a/app/src/Controller/JobHistoryRecordsController.php b/app/src/Controller/JobHistoryRecordsController.php index 66948a95d..95ed62b55 100644 --- a/app/src/Controller/JobHistoryRecordsController.php +++ b/app/src/Controller/JobHistoryRecordsController.php @@ -55,6 +55,7 @@ public function beforeRender(\Cake\Event\EventInterface $event) { if(!empty($link->value)) { $this->set('vv_bc_parent_obj', $this->JobHistoryRecords->Jobs->get($link->value)); $this->set('vv_bc_parent_displayfield', $this->JobHistoryRecords->Jobs->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->JobHistoryRecords->Jobs->getPrimaryKey()); } return parent::beforeRender($event); diff --git a/app/src/Controller/MostlyStaticPagesController.php b/app/src/Controller/MostlyStaticPagesController.php new file mode 100644 index 000000000..ad1ee8171 --- /dev/null +++ b/app/src/Controller/MostlyStaticPagesController.php @@ -0,0 +1,79 @@ + [ + 'MostlyStaticPages.title' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.1.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + $this->set('vv_base_url', \Cake\Routing\Router::url( + url: "/" . $this->getCOID(), + full: true + )); + + return parent::beforeRender($event); + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.1.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + // We only take over authz for display + + if(in_array($action, ['display'])) { + return 'open'; + } + + return 'no'; + } +} \ No newline at end of file diff --git a/app/src/Controller/PagesController.php b/app/src/Controller/PagesController.php index 709348c60..669a4100d 100644 --- a/app/src/Controller/PagesController.php +++ b/app/src/Controller/PagesController.php @@ -20,7 +20,9 @@ use Cake\Http\Exception\ForbiddenException; use Cake\Http\Exception\NotFoundException; use Cake\Http\Response; +use Cake\ORM\TableRegistry; use Cake\View\Exception\MissingTemplateException; +use \App\Lib\Enum\SuspendableStatusEnum; /** * Static content controller @@ -88,6 +90,51 @@ public function display(...$path): ?Response return $this->render(); } + /** + * Render a Mostly Static Page. + * + * @since COmanage Registry v5.1.0 + * @param string $coid CO ID + * @param string $name MSP Name (slug) + */ + + public function show(string $coid, string $name) { + // We use PagesController rather than MostlyStaticPagesController to avoid complexities + // with PrimaryLink lookups. We use show() rather than render() because the latter is + // used by Controller, and rather than display() so we don't confuse things by + // redefining it. We render here rather than redirecting into the MSPController to + // reduce URL bar thrashing. + + $MSPTable = TableRegistry::getTableLocator()->get("MostlyStaticPages"); + + $msp = $MSPTable->find() + ->where([ + 'co_id' => (int)$coid, + 'name' => $name, + 'status' => SuspendableStatusEnum::Active + ]) + ->first(); + + if(empty($msp)) { + if($name == 'error-landing') { + // error-landing should always exist, if not throw an error + + throw new NotFoundException(); + } else { + $this->Flash->error(__d('error', 'notfound', $name)); + + return $this->redirect("/$coid/error-landing"); + } + } + + $this->set('vv_bc_skip', true); // this doesn't do anything? + + $this->set('vv_title', $msp->title); + $this->set('vv_body', $msp->body); + + return $this->render('/MostlyStaticPages/display'); + } + /** * Indicate whether this Controller will handle some or all authnz. * @@ -97,6 +144,17 @@ public function display(...$path): ?Response */ public function willHandleAuth(\Cake\Event\EventInterface $event): string { - return "open"; + $request = $this->getRequest(); + $action = $request->getParam('action'); + + // We only take over authz for display and show + // (These are the only two actions we currently support, but better to require + // an explicit action to add to this list) + + if(in_array($action, ['display', 'show'])) { + return 'open'; + } + + return 'no'; } } diff --git a/app/src/Controller/PetitionsController.php b/app/src/Controller/PetitionsController.php new file mode 100644 index 000000000..9ebb4c4f7 --- /dev/null +++ b/app/src/Controller/PetitionsController.php @@ -0,0 +1,411 @@ + [ + 'Petitions.modified' => 'desc' + ] + ]; + + // Cached copy of the next step information + private $nextStep = null; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.1.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->Petitions->EnrollmentFlows->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->Petitions->EnrollmentFlows->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->Petitions->EnrollmentFlows->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + + /** + * Calculate authorization for the current request. + * + * @since COmanage Registry v5.1.0 + * @return bool True if the current request is permitted, false otherwise + */ + + public function calculatePermission(): bool { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + $authorized = false; + + // We're currently only used for finalize + + if($action == 'finalize') { + // If we're using token auth, we checked the token in willHandleAuth(), + // so all we really need to do here is compare the actor roles (including + // for actors authenticated via the web server) against the role for the + // last step. + + // willHandleAuth() already checked that we have a valid Petition ID, and + // also set $this->nextStep + $currentActor = $this->getCurrentActor((int)$this->request->getParam('pass.0')); + + $authorized = in_array($this->nextStep['lastStep']->actor_type, $currentActor['roles']); + } + + return $authorized; + } + + /** + * Continue a Petition (re-enter an Enrollment Flow). + * + * @since COmanage Registry v5.1.0 + * @param string $id Petition ID + */ + + public function continue(string $id) { + return $this->transitionToStep((int)$id); + } + + /** + * Finalize a Petition. + * + * @since COmanage Registry v5.1.0 + * @param string $id Petition ID + */ + + public function finalize(string $id) { + // We split finalization up into several tasks, since we are constrained by browser and + // web server timeouts, and each step relies on plugins that might or might not behave + // as expected. We use an 'op' flag rather than separate actions in order to simplify + // the authorization logic (which is already custom for finalize). + + // finalize: Tell all plugins to finalize + // assign: Assign Identifiers (if any) + // provision: Run provisioning, then set petition status to Finalized + + $op = $this->requestParam('op'); + + $baseUrl = [ + 'controller' => 'petitions', + 'action' => 'finalize', + $id + ]; + + $token = $this->injectToken((int)$id); + + if($token) { + $baseUrl['?']['token'] = $token; + } + + if(!$op) { + $op = 'finalize'; + } + + try { + if($op == 'finalize') { + // Step 1 + $this->Petitions->finalizePlugins((int)$id); + + // Next operation is assign + $baseUrl['?']['op'] = 'assign'; + + return $this->redirect($baseUrl); + } elseif($op == 'assign') { + // Step 2 + $this->Petitions->assignIdentifiers((int)$id); + + // Next operation is provision + $baseUrl['?']['op'] = 'provision'; + + return $this->redirect($baseUrl); + } elseif($op == 'provision') { + // Step 3 + $this->Petitions->provision((int)$id); + + // We're really done now, update the Petition status and redirect appropriately + // (This should be very fast and not require a separate page reload) + $this->Petitions->finalize((int)$id); + + $this->Flash->success(__d('result', 'Petitions.finalized')); + + // We only use the Redirect on Finalize URL (if specified) on success, + // since otherwise the Flash error won't render + + $petition = $this->Petitions->get((int)$id, ['contain' => ['EnrollmentFlows']]); + + if(!empty($petition->enrollment_flow->redirect_on_finalize)) { + return $this->redirect($petition->enrollment_flow->redirect_on_finalize); + } + } else { + // Unknown op, throw error + + throw new \InvalidArgumentException(__d('error', 'unknown', $op)); + } + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + // Redirect to the default Petition Complete landing page. + + $coId = $this->getCOID(); + + return $this->redirect("/$coId/petition-complete"); + } + + /** + * Redirect into a plugin to render the result of an Enrollment Flow Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Petition ID + */ + + public function result(string $id) { + try { + $stepId = $this->getRequest()->getQuery('enrollment_flow_step_id'); + + if(!$stepId) { + throw new \InvalidArgumentException(__d('error', 'notprov', 'enrollment_flow_step_id')); + } + + // Start by pulling the petition + + $petition = $this->Petitions->get((int)$id); + + // And the Step Result and Configuration + + $stepResult = $this->Petitions + ->PetitionStepResults + ->find() + ->where([ + 'PetitionStepResults.enrollment_flow_step_id' => $stepId, + 'PetitionStepResults.petition_id' => $id + ]) + ->contain(['EnrollmentFlowSteps' => $this->Petitions->PetitionStepResults->EnrollmentFlowSteps->getPluginRelations()]) + ->firstOrFail(); + + // Redirect to /registry-pe/plugin/controller/display/x?petition_id=y + + $pluginEntity = Inflector::singularize(Inflector::underscore(StringUtilities::pluginModel($stepResult->enrollment_flow_step->plugin))); + + return $this->redirect([ + 'plugin' => StringUtilities::pluginPlugin($stepResult->enrollment_flow_step->plugin), + 'controller' => StringUtilities::pluginModel($stepResult->enrollment_flow_step->plugin), + 'action' => 'display', + $stepResult->enrollment_flow_step->$pluginEntity->id, + '?' => [ + 'petition_id' => $petition->id + ] + ]); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + return $this->generateRedirect(null); + } + } + + /** + * Resume an Enrollment Flow. + * + * @since COmanage Registry v5.1.0 + * @param string $id Petition ID + */ + + public function resume(string $id) { + try { + // First retrieve the petition + $petition = $this->Petitions->get((int)$id); + + if($petition->isComplete()) { + // A number of checks should prevent us from having to test for this, + // but just in case... + throw new \InvalidArgumentException(__d('error', 'Petitions.completed', [$id])); + } + + $this->set('vv_petition', $petition); + + // We pull the Petition steps separately (instead of via contains) because + // we want to get all Enrollment Steps to render them + $steps = $this->Petitions->EnrollmentFlows->EnrollmentFlowSteps->find() + ->where(['EnrollmentFlowSteps.enrollment_flow_id' => $petition->enrollment_flow_id, + 'EnrollmentFlowSteps.status' => SuspendableStatusEnum::Active]) + ->contain(array_merge( + ['PetitionStepResults' => ['conditions' => ['PetitionStepResults.petition_id' => $petition->id]]], + $this->Petitions->EnrollmentFlows->EnrollmentFlowSteps->getPluginRelations() + )) + ->order(['EnrollmentFlowSteps.ordr']) + ->all(); + + $this->set('vv_steps', $steps); + + $urls = []; + $nextStepId = null; + + if(!empty($steps)) { + // We need to create dispatch URLs for each step _except_ anything after the + // current one. (ie: the first one with no result is OK, but not after.) + + foreach($steps as $step) { + $pluginModel = StringUtilities::pluginModel($step->plugin); + $pluginName = Inflector::singularize(Inflector::underscore($pluginModel)); + + $urls[ $step->id ] = [ + 'plugin' => StringUtilities::pluginPlugin($step->plugin), + 'controller' => StringUtilities::pluginModel($step->plugin), + 'action' => 'dispatch', + $step->$pluginName->id, + '?' => [ + 'petition_id' => $petition->id + ] + ]; + + // We might need to insert the token... + + if($petition->useToken($step->actor_type)) { + $urls[ $step->id ]['?']['token'] = $petition->token; + } + + if(!$nextStepId && empty($step->petition_step_results)) { + // There is no result for this step, and we haven't found a step + // without a result yet, so this is the next step + + $nextStepId = $step->id; + } + } + } + + $this->set('vv_dispatch_urls', $urls); + $this->set('vv_next_step_id', $nextStepId); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + return $this->generateRedirect(null); + } + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.1.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "notauth", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + // We take over authz for continue (which is really just a glorified redirect, + // but which will send handoff emails under certain circumstances); and for + // finalize but only if the request will be authenticated via Petition Token. + + $petitionId = (int)$this->request->getParam('pass.0'); + + if(!in_array($action, ['continue', 'finalize'])) { + return 'no'; + } + + if(empty($petitionId)) { + $this->llog('error', "No Petition ID specified for finalize"); + return 'notauth'; + } + + if($action == 'continue') { + // For continue, we mostly just check that if the user type is anonymous + // that a token was provided and validates. + $actorInfo = $this->getCurrentActor($petitionId); + + if($actorInfo['type'] == 'anonymous') { + if(!$actorInfo['token_ok']) { + $this->llog('trace', "Token validation failed for Petition " . $petitionId); + return 'notauth'; + } + } + + // We'll allow any authenticated user through since continue is basically + // a redirect + return 'yes'; + } elseif($action == 'finalize') { + // For finalize, the relevant Step is the last one. We'll use calculateNextStep() + // to get the last Step, which will also check if the petition is already completed. + $this->nextStep = $this->Petitions->EnrollmentFlows->calculateNextStep($petitionId); + + if(!$this->nextStep['finalize']) { + // Petition is not ready for finalization + $this->llog('trace', "Petition " . $petitionId . " is not ready for finalization"); + return 'notauth'; + } + + if($this->nextStep['petition']->useToken($this->nextStep['lastStep']->actor_type)) { + // A token is required + + $tokenRoles = $this->validateToken($this->nextStep['petition']); + + if(!$tokenRoles) { + // Token validation failed + $this->llog('trace', "Token validation failed for Petition " . $petitionId); + return 'notauth'; + } + + // If we have a valid token, we need to call calculatePermission now + // since RegistryAuthComponent won't (when we return 'yes'). + + return $this->calculatePermission() ? 'yes' : 'notauth'; + } + + // Token not in use, we'll just handle authz + + return 'authz'; + } + + return 'no'; + } +} \ No newline at end of file diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index c6f2c3ef1..68784c53d 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -91,11 +91,17 @@ public function add() { $errors = $obj->getErrors(); if(!empty($errors)) { - $this->Flash->error(__d('error', 'fields', [ implode(',', - array_map(function($v) use ($errors) { - return __d('error', 'flash', [$v, implode(',', array_values($errors[$v]))]); - }, - array_keys($errors))) ])); + $errorlist = []; + foreach ($errors as $model => $fails) { + foreach ($fails as $issues) { + foreach ($issues as $column => $issue) { + $error_descriptions = array_values($issue); + $col_issues = implode(',', $error_descriptions); + $errorlist[] = __d('error', 'flash', [$column, $col_issues]); + } + } + } + $this->Flash->error(__d('error', 'fields', $errorlist)); } else { $this->Flash->error(__d('error', 'save', [$modelsName])); } @@ -420,9 +426,17 @@ public function edit(string $id) { // Calculate and set title, supertitle and subtitle [$title, $supertitle, $subtitle] = StringUtilities::entityAndActionToTitle($obj, $modelsName, 'edit'); - $this->set('vv_title', $title); - $this->set('vv_supertitle', $supertitle); - $this->set('vv_subtitle', $subtitle); + // We might have calculated the following values earlier. For example, MVEAController runs before the StandarController + // and makes similar calculations. We will keep the ones calculated before we get here + if ($this->viewBuilder()->getVar('vv_title') === null) { + $this->set('vv_title', $title); + } + if ($this->viewBuilder()->getVar('vv_supertitle') === null) { + $this->set('vv_supertitle', $supertitle); + } + if ($this->viewBuilder()->getVar('vv_subtitle') === null) { + $this->set('vv_subtitle', $subtitle); + } // Let the view render $this->render('/Standard/add-edit-view'); @@ -608,150 +622,13 @@ protected function populateAutoViewVars(object $obj=null) { $modelsName = $this->name; // $table = the actual table object $table = $this->$modelsName; - - // Populate certain view vars (eg: selects) automatically. - - // AutoViewVarsTrait - if(method_exists($table, "getAutoViewVars") - && $table->getAutoViewVars()) { - foreach($table->getAutoViewVars() as $vvar => $avv) { - switch($avv['type']) { - case 'array': - // Use the provided array of values. By default, we use the values - // for the keys as well, to generate HTML along the lines of - // . (See also 'hash'.) - $this->set($vvar, array_combine($avv['array'], $avv['array'])); - break; - case 'enum': - // We just want the localized text strings for the defined constants. - $class = '\\App\\Lib\\Enum\\'.$avv['class']; - // We support plugin notation for plugin defined enumerations. - if(strstr($avv['class'], ".")) { - $bits = explode('.', $avv['class'], 2); - $class = '\\'.$bits[0].'\\Lib\\Enum\\'.$bits[1]; - } - $this->set($vvar, $class::getLocalizedConsts()); - break; - case 'hash': - // Like 'array' but we assume we are passed key/value pairs - $this->set($vvar, $avv['hash']); - break; - // "auxiliary" and "select" do basically the same thing, but the former - // returns the full object and the latter just returns a hash suitable - // for a select. "type" is a shorthand for "select" for type_id. - case 'type': - // Inject configuration. Since we're only ever looking at the types - // table, inject the current CO along with the requested attribute - $avv['model'] = 'Types'; - if(is_array($avv['attribute'])) { - $avv['where'] = [ - 'attribute IN' => $avv['attribute'], - 'status' => SuspendableStatusEnum::Active - ]; - } else { - $avv['where'] = [ - 'attribute' => $avv['attribute'], - 'status' => SuspendableStatusEnum::Active - ]; - } - // fall through - case 'auxiliary': -// XXX add list as in match? - case 'select': - $avvmodel = $avv['model']; - $this->$avvmodel = TableRegistry::getTableLocator()->get($avvmodel); - // XXX We should probably move to a more generic approach. - // Models can have various types of parent keys (and sometimes multiple concurrently), - // so it’s better to use PrimaryLinkTrait to handle this. - // if(method_exists($this->$avvmodel, "calculateCoForRecord")) { - // $avv['where']['co_id'] = $this->$avvmodel->calculateCoForRecord($obj) - // } - if($this->$avvmodel->getSchema()->hasColumn('co_id')) { - $avv['where']['co_id'] = $this->getCOID(); - } - - $query = $this->$avvmodel->find($avv['type'] == 'auxiliary' ? 'all' : 'list'); - - if(!empty($avv['find'])) { - if($avv['find'] == 'filterPrimaryLink') { - // We're filtering the requested model, not our current model. - // See if the requested key is available, and if so run the find. - - $linkFilter = $table->getPrimaryLink(); - - if($linkFilter) { - // Try to find the $linkFilter value - $v = null; - - // We might have been passed an object with the current value - if($obj && !empty($obj->$linkFilter)) { - $v = $obj->$linkFilter; - } elseif(!empty($this->request->getQuery($linkFilter))) { - $v = $this->request->getQuery($linkFilter); - } -// XXX also need to check getData()? -// XXX shouldn't this use $this->getPrimaryLink() instead? Or maybe move $this->primaryLink -// to PrimaryLinkTrait and call it there? - - if($v) { - $avv['where'][$table->getAlias().'.'.$linkFilter] = $v; - //$query = $query->where([$table->getAlias().'.'.$linkFilter => $v]); - } - } - } else { - // Use the specified finder, if configured - $query = $query->find($avv['find']); - } - } elseif($table->getSchema()->hasColumn('co_id')) { - // XXX is this the best logic? maybe some relation to filterPrimaryLink? - // By default, filter everything on CO ID - $avv['where']['co_id'] = $this->getCOID(); - //$query = $query->where([$table->getAlias().'.co_id' => $this->getCOID()]); - } - - // Where Rule. The rule will be transfered as is - if(!empty($avv['where'])) { - // Filter on the specified clause (of the form [column=>value]) - $query = $query->where($avv['where']); - } - // Where rule that will be evaluated. We use the custom whereEvan key to - // distinguish from the plain where. Also it might contain more than one conditions - if(!empty($avv['whereEval'])) { - foreach ($avv['whereEval'] as $whereClauseColumn => $chainedMethodDescription) { - $calculatedValue = FunctionUtilities::dynamicChainedFunction( - $this, - $chainedMethodDescription - ); - $query = $query->where([$whereClauseColumn => $calculatedValue]); - } - } - - // Sort the list by display field - if(!empty($avv['model']) && method_exists($this->$avvmodel, "getDisplayField")) { - $query->order([$this->$avvmodel->getDisplayField() => 'ASC']); - } elseif(method_exists($table, "getDisplayField")) { - $query->order([$table->getDisplayField() => 'ASC']); - } - - $this->set($vvar, $query->toArray()); - break; - case 'parent': - $modelsName = $this->name; - // $table = the actual table object - $table = $this->$modelsName; - $this->set($vvar, $table->getParents($this->getCOID())); - break; - case 'plugin': - $PluginTable = $this->getTableLocator()->get('Plugins'); - $this->set($vvar, $PluginTable->getActivePluginModels($avv['pluginType'])); - break; - default: -// XXX I18n? and in match? - throw new \LogicException(__d('error', 'auto.viewvar.type.unknown', [$avv['type']])); - } + // AutoViewVarsTrait + if(method_exists($table, 'getAutoViewVars') && $table->getAutoViewVars()) { + foreach ($table->calculateAutoViewVars($this->getCOID(), $obj) as $vvar => $value) { + $this->set($vvar, $value); } - } + } } /** diff --git a/app/src/Controller/StandardEnrollerController.php b/app/src/Controller/StandardEnrollerController.php new file mode 100644 index 000000000..918a3a70a --- /dev/null +++ b/app/src/Controller/StandardEnrollerController.php @@ -0,0 +1,276 @@ +get('Petitions'); + + // Make the Petition available to the view. Note there may not be a Petition, + // eg if we're editing the plugin's configuration. + + if(!empty($this->petition->id)) { + $this->set( + 'vv_petition', + $Petition->findById($this->petition->id) + // We need to include the Enrollment Flow of the Petition. + // The least, we can get if the co id which cannot be calculated + // for unauthenticated use cases. + ->contain(['EnrollmentFlows']) + ->firstOrFail() + ); + } else { + $this->set('vv_petition', null); + } + + return parent::beforeRender($event); + } + + /** + * Calculate authorization for the current request. + * + * @since COmanage Registry v5.1.0 + * @return bool True if the current request is permitted, false otherwise + */ + + public function calculatePermission(): bool { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + // We currently support $actions of 'dispatch' and 'display' + + $petitionId = $this->requestParam('petition_id'); + + if(!$petitionId) { + $this->llog('error', "petition_id not found in request"); + return false; + } + + $actorInfo = $this->getCurrentActor((int)$petitionId); + $this->petition = $actorInfo['petition']; + + // We only accept anonymous requests for 'dispatch', and only if the token matches. + // We'll further check authorization below. + if($actorInfo['type'] == 'anonymous') { + if($action != 'dispatch') { + $this->llog('trace', "Rejecting anonymous access to unsupported enroller action for petition " . $petitionId); + return false; + } + + // We do the token check here rather than in willHandleAuth() because + // we don't know in willHandleAuth which auth metchanism is in use yet. + + if(!isset($actorInfo['token_ok']) || !$actorInfo['token_ok']) { + $this->llog('trace', "Rejecting incorrect token for access to petition " . $petitionId); + return false; + } + } + + if($action == 'dispatch') { + // We already validated the petition state in willHandleAuth + + $modelsName = $this->name; + $modelId = $this->request->getParam('pass.0'); // XXX check if empty + + if(!$modelId) { + $this->llog('error', "Model ID missing from request"); + return false; + } + + $stepConfig = $this->$modelsName->get($modelId, ['contain' => ['EnrollmentFlowSteps' => ['EnrollmentFlows']]]); + $this->set('vv_step_config', $stepConfig); + $this->set('vv_title', $stepConfig['enrollment_flow_step']['enrollment_flow']['name']); + + // Check that the current actor has the role required for this step. + // Note that role validation has already been performed for anonymous access + // via tokens (via getcurrentActor) so we don't have to recheck that here. + + if(in_array($stepConfig->enrollment_flow_step->actor_type, + $actorInfo['roles'])) { + $this->llog('trace', "Authorizing access to petition " . $petitionId . " step " . $stepConfig->enrollment_flow_step_id); + return true; + } + } elseif($action == 'display') { +// XXX need to replace this with better logic + return true; + } + + $this->llog('trace', "Rejecting unauthorized access to petition " . $petitionId . " step " . $stepConfig->enrollment_flow_step_id); + return false; + } + + /** + * Record a result for the Enrollment Step and redirect to the next Step. + * + * @since COmanage Registry v5.1.0 + * @param int $enrollmentFlowStepId Enrollment Flow Step Id + * @param int $petitionId Petition ID + * @param string $status PetitionStatusEnum + * @param string $comment Comment + * @return \Cake\Http\Response Redirect to next step + */ + + protected function finishStep( + int $enrollmentFlowStepId, + int $petitionId, + // string $status, + string $comment + ): \Cake\Http\Response { + $PetitionStepResults = TableRegistry::getTableLocator()->get('PetitionStepResults'); + + $PetitionStepResults->record( + enrollmentFlowStepId: $enrollmentFlowStepId, + petitionId: $petitionId, + // status: $status, + comment: $comment + ); + + $EnrollmentFlows = TableRegistry::getTableLocator()->get('EnrollmentFlows'); + + return $this->transitionToStep(petitionId: $petitionId); + } + + /** + * Obtain the Petition artifact associated with this request. + * + * @since COmanage Registry v5.1.0 + * @return Petition Petition artifact + */ + + public function getPetition(): ?\App\Model\Entity\Petition { + return $this->petition; + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.1.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + if($action == 'dispatch') { + $petitionId = (int)$this->requestParam('petition_id'); + + if(empty($petitionId)) { + $this->llog('error', "No Petition ID specified for dispatch"); + return 'notauth'; + } + + // Determine if we're going to use a token to authenticate the current request. + // For this, we need the current step's authorization. + + // $this->name = Models (ie: from ModelsTable) + $modelsName = $this->name; + $modelId = $this->request->getParam('pass.0'); + + if(empty($modelId)) { + $this->llog('error', "No step ID specified for dispatch"); + return 'noauth'; + } + + $stepConfig = $this->$modelsName->get($modelId, ['contain' => 'EnrollmentFlowSteps']); + + // Determine if the requested step is past the current/next step. + // We don't allow steps that haven't run yet to be run out of order. + + $EnrollmentFlows = TableRegistry::getTableLocator()->get('EnrollmentFlows'); + + // "next" means "uncompleted step with the lowest ordr value". + // calculateNextStep() will also throw an error if the Petition is complete. + $nextStep = $EnrollmentFlows->calculateNextStep($petitionId); + + if(!empty($nextStep['step']->id)) { + if($stepConfig->enrollment_flow_step->ordr > $nextStep['step']->ordr) { + $this->llog('trace', "Requested step " . $stepConfig->enrollment_flow_step->enrollment_flow_id . " for petition " . $petitionId . " has not yet been reached"); + return 'notauth'; + } + } + + $petition = $nextStep['petition']; + + if($petition->enrollment_flow_id + != $stepConfig->enrollment_flow_step->enrollment_flow_id) { + // Mismatch between Petition Enrollment Flow and requested Step's Enrollment Flow + $this->llog('trace', "Requested step " . $stepConfig->enrollment_flow_step->enrollment_flow_id . " and requested petition " . $petitionId . " are not associated with the same Enrollment Flow"); + return 'notauth'; + } + + if($petition->useToken($stepConfig->enrollment_flow_step->actor_type)) { + // A token is required + + $tokenRoles = $this->validateToken($petition); + + if(!$tokenRoles) { + // Token validation failed + $this->llog('trace', "Token validation failed for Petition " . $petitionId); + return 'notauth'; + } + + // If we have a valid token, we need to call calculatePermission now + // since RegistryAuthComponent won't (when we return 'yes'). + + return $this->calculatePermission() ? 'yes' : 'notauth'; + } + + // Token not in use, we'll just handle authz + + return 'authz'; + } elseif($action == 'display') { + return 'authz'; + } + + return 'no'; + } +} \ No newline at end of file diff --git a/app/src/Controller/StandardPluginController.php b/app/src/Controller/StandardPluginController.php index 3b132fd14..53f96cc35 100644 --- a/app/src/Controller/StandardPluginController.php +++ b/app/src/Controller/StandardPluginController.php @@ -96,6 +96,7 @@ public function beforeRender(\Cake\Event\EventInterface $event) { $this->set('vv_bc_parent_obj', $parentObj); $this->set('vv_bc_parent_displayfield', $parentDisplayField); + $this->set('vv_bc_parent_primarykey', $parentTable->getPrimaryKey()); // Override the title set in StandardController. Since that was set in edit() // which is called before the rendering hooks, this title will take precedence. diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php index d13f76c21..c7752ef70 100644 --- a/app/src/Lib/Enum/ActionEnum.php +++ b/app/src/Lib/Enum/ActionEnum.php @@ -51,6 +51,7 @@ class ActionEnum extends StandardEnum { const NotificationCanceled = 'NOTX'; const NotificationDelivered = 'NOTD'; const NotificationResolved = 'NOTR'; + const PersonAddedPetition = 'ACPP'; const PersonAddedPipeline = 'ACPL'; const PersonMatchedPipeline = 'MCPL'; const PersonPipelineComplete = 'CCPL'; diff --git a/app/templates/Urls/fields-nav.inc b/app/src/Lib/Enum/EnrollmentActorEnum.php similarity index 80% rename from app/templates/Urls/fields-nav.inc rename to app/src/Lib/Enum/EnrollmentActorEnum.php index 8e0c5364f..74665821a 100644 --- a/app/templates/Urls/fields-nav.inc +++ b/app/src/Lib/Enum/EnrollmentActorEnum.php @@ -1,6 +1,6 @@ 'person', - 'active' => 'person', - 'subActive' => 'names' -]; \ No newline at end of file +namespace App\Lib\Enum; + +class EnrollmentActorEnum extends StandardEnum { + const Approver = 'A'; + const Enrollee = 'E'; + const Petitioner = 'P'; +} \ No newline at end of file diff --git a/app/src/Lib/Enum/EnrollmentAuthzEnum.php b/app/src/Lib/Enum/EnrollmentAuthzEnum.php new file mode 100644 index 000000000..f44e025a9 --- /dev/null +++ b/app/src/Lib/Enum/EnrollmentAuthzEnum.php @@ -0,0 +1,43 @@ + 'person', - 'active' => 'person', - 'subActive' => 'pronouns' -]; \ No newline at end of file +namespace App\Lib\Enum; + +class PageContextEnum extends StandardEnum { + const EnrollmentHandoff = 'EH'; + const ErrorLanding = 'ER'; + const General = 'G'; +} \ No newline at end of file diff --git a/app/src/Lib/Enum/PetitionActionEnum.php b/app/src/Lib/Enum/PetitionActionEnum.php new file mode 100644 index 000000000..88f713934 --- /dev/null +++ b/app/src/Lib/Enum/PetitionActionEnum.php @@ -0,0 +1,38 @@ +autoViewVars = $vars; } + + /** + * Calculate the AutoView Vars + * + * @param int $coId + * @param Object|null $obj Current object (eg: from edit), if set + * + * @return \Generator + * @since COmanage Registry v5.0.0 + */ + public function calculateAutoViewVars(int|null $coId, Object $obj = null): \Generator + { + // $table = the actual table object + $table = $this; + + foreach($table->getAutoViewVars() as $vvar => $avv) { + $generatedValue = null; + + switch($avv['type']) { + case 'array': + // Use the provided array of values. By default, we use the values + // for the keys as well, to generate HTML along the lines of + // . (See also 'hash'.) + $generatedValue = array_combine($avv['array'], $avv['array']); + break; + case 'enum': + // We just want the localized text strings for the defined constants. + $class = '\\App\\Lib\\Enum\\'.$avv['class']; + // We support plugin notation for plugin defined enumerations. + if(strstr($avv['class'], ".")) { + $bits = explode('.', $avv['class'], 2); + $class = '\\'.$bits[0].'\\Lib\\Enum\\'.$bits[1]; + } + + $generatedValue = $class::getLocalizedConsts(); + break; + case 'hash': + // Like 'array' but we assume we are passed key/value pairs + $generatedValue = $avv['hash']; + break; + // "auxiliary" and "select" do basically the same thing, but the former + // returns the full object and the latter just returns a hash suitable + // for a select. "type" is a shorthand for "select" for type_id. + case 'type': + // Inject configuration. Since we're only ever looking at the types + // table, inject the current CO along with the requested attribute + $avv['model'] = 'Types'; + if(\is_array($avv['attribute'])) { + $avv['where'] = [ + 'attribute IN' => $avv['attribute'], + 'status' => SuspendableStatusEnum::Active + ]; + } else { + $avv['where'] = [ + 'attribute' => $avv['attribute'], + 'status' => SuspendableStatusEnum::Active + ]; + } + // fall through + case 'auxiliary': +// XXX add list as in match? + case 'select': + $avvmodel = $avv['model']; + $AModel = TableRegistry::getTableLocator()->get($avvmodel); + // XXX We should probably move to a more generic approach. + // Models can have various types of parent keys (and sometimes multiple concurrently), + // so it’s better to use PrimaryLinkTrait to handle this. + // if(method_exists($this->$avvmodel, "calculateCoForRecord")) { + // $avv['where']['co_id'] = $this->$avvmodel->calculateCoForRecord($obj) + // } + if($AModel->getSchema()->hasColumn('co_id')) { + $avv['where']['co_id'] = $coId; + } + + $query = $AModel->find($avv['type'] == 'auxiliary' ? 'all' : 'list'); + + if(!empty($avv['find'])) { + if($avv['find'] == 'filterPrimaryLink') { + // We're filtering the requested model, not our current model. + // See if the requested key is available, and if so run the find. + + $linkFilter = $table->getPrimaryLink(); + + if($linkFilter) { + // Try to find the $linkFilter value + $v = null; + + // We might have been passed an object with the current value + if($obj && !empty($obj->$linkFilter)) { + $v = $obj->$linkFilter; + } elseif(!empty($this->request->getQuery($linkFilter))) { + $v = $this->request->getQuery($linkFilter); + } +// XXX also need to check getData()? +// XXX shouldn't this use $this->getPrimaryLink() instead? Or maybe move $this->primaryLink +// to PrimaryLinkTrait and call it there? + + if($v) { + $avv['where'][$table->getAlias().'.'.$linkFilter] = $v; + //$query = $query->where([$table->getAlias().'.'.$linkFilter => $v]); + } + } + } else { + // Use the specified finder, if configured + $query = $query->find($avv['find']); + } + } elseif($table->getSchema()->hasColumn('co_id')) { + // XXX is this the best logic? maybe some relation to filterPrimaryLink? + // By default, filter everything on CO ID + $avv['where']['co_id'] = $coId; + //$query = $query->where([$table->getAlias().'.co_id' => $coId]); + } + + // Where Rule. The rule will be transfered as is + if(!empty($avv['where'])) { + // Filter on the specified clause (of the form [column=>value]) + $query = $query->where($avv['where']); + } + + // Where rule that will be evaluated. We use the custom whereEvan key to + // distinguish from the plain where. Also it might contain more than one conditions + if(!empty($avv['whereEval'])) { + foreach ($avv['whereEval'] as $whereClauseColumn => $chainedMethodDescription) { + $calculatedValue = FunctionUtilities::dynamicChainedFunction( + $this, + $chainedMethodDescription + ); + $query = $query->where([$whereClauseColumn => $calculatedValue]); + } + } + + // Sort the list by display field + if(!empty($avv['model']) && method_exists($AModel, "getDisplayField")) { + $query->order([$AModel->getDisplayField() => 'ASC']); + } elseif(method_exists($table, "getDisplayField")) { + $query->order([$table->getDisplayField() => 'ASC']); + } + + $generatedValue = $query->toArray(); + break; + case 'parent': + $generatedValue = $table->getParents($coId); + break; + case 'plugin': + $PluginTable = TableRegistry::getTableLocator()->get('Plugins'); + $generatedValue = $PluginTable->getActivePluginModels($avv['pluginType']); + break; + default: +// XXX I18n? and in match? + throw new \LogicException(__d('error', 'auto.viewvar.type.unknown', [$avv['type']])); + } + + yield $vvar => $generatedValue; + } + } } diff --git a/app/src/Lib/Traits/EnrollmentControllerTrait.php b/app/src/Lib/Traits/EnrollmentControllerTrait.php new file mode 100644 index 000000000..d4bfb5f59 --- /dev/null +++ b/app/src/Lib/Traits/EnrollmentControllerTrait.php @@ -0,0 +1,368 @@ +cache['actor'])) { + return $this->cache['actor']; + } + + $ret = [ + 'type' => 'anonymous', + 'person_id' => null, + 'identifier' => $this->RegistryAuth->getAuthenticatedUser(), + 'token_ok' => false, + 'roles' => [], + 'petition' => null + ]; + + if(!empty($ret['identifier'])) { + // Can we map this identifier to a Person ID? + + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + try { + $ret['person_id'] = $Identifiers->lookupPersonByLogin($this->getCOID(), $ret['identifier']); + } catch(RecordNotFoundException $e) { + $ret['person_id'] = null; + } + + if(!empty($ret['person_id'])) { + $ret['type'] = 'person'; + } else { + $ret['type'] = 'identifier'; + } + } + + if($petitionId) { + // Pull the Petition to figure out what roles the person has + + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $petition = $Petitions->get($petitionId); + + if($ret['type'] == 'person' && !empty($ret['person_id'])) { + // A person can be both Petitioner and Enrollee, so check both + + if($ret['person_id'] === $petition->petitioner_person_id) { + $ret['roles'][] = EnrollmentActorEnum::Petitioner; + } + + if($ret['person_id'] === $petition->enrollee_person_id) { + $ret['roles'][] = EnrollmentActorEnum::Enrollee; + } + } elseif($ret['type'] == 'identifier' && !empty($ret['identifier'])) { + if($ret['identifier'] === $petition->petitioner_identifier) { + $ret['roles'][] = EnrollmentActorEnum::Petitioner; + } + + if($ret['identifier'] === $petition->enrollee_identifier) { + $ret['roles'][] = EnrollmentActorEnum::Enrollee; + } + + if(empty($petition->petitioner_identifier) + && empty($petition->enrollee_identifier)) { + // We have an identifier at run time but none in the petition. + // If we can validate a token we can store the identifier and + // use it instead. (eg: An Enrollee receives an initial handoff + // email/invitation.) + + // Note in general we should only accept an Enrollee identifier + // this way. Petitioner identifiers should be collected at Petition + // start, and Approvers shouldn't use tokens. + + $tokenRoles = $this->validateToken($petition); + + if(!empty($tokenRoles) && in_array(EnrollmentActorEnum::Enrollee, $tokenRoles)) { + $this->llog('trace', "Transitioning Enrollee to authenticated identifier " + . $ret['identifier'] . " for Petition " . $petition->id); + + // Update the Petition to store the identifier and remove the token + $petition->enrollee_identifier = $ret['identifier']; + $petition->token = null; + +// XXX Also add petition history? + $Petitions->saveOrFail($petition); + + $ret['roles'][] = EnrollmentActorEnum::Enrollee; + } + } + } elseif($ret['type'] == 'anonymous') { + $ret['roles'] = $this->validateToken($petition); + + if($ret['roles'] !== false) { + $ret['token_ok'] = true; + + // Tell the form generator (dispatch.php) to use the token + $this->set('vv_token_ok', true); + } + } + + // XXX need to add checks for Approver somehow; a Petitioner can also be an Approver + // though probably not commonly + + // Since we have the Petition entity, make it easier for other functions + // to access it + $ret['petition'] = $petition; + } + + if($petitionId) { + // If we have a Petition ID we cache the info. We don't cache without one + // since that is how the EnrollmentFlowsControlller::start() calls us, and + // no role data is available at that point. + + $this->cache['actor'] = $ret; + } + + $this->llog('trace', (!empty($ret['petition']->id) ? "Petition " . $ret['petition']->id : "New Petition") + . " current actor: type=" . $ret['type'] + . ", personid=" . $ret['person_id'] + . ", identifier=" . $ret['identifier'] + . ", token=" . $ret['token_ok']); + + return $ret; + } + + /** + * Determine whether or not a token should be injected into URLs created by Enroller plugins. + * + * For normal use cases, tranisitionToStep() and dispatch.php will handle token management, + * but if Enroller plugins need to create custom flows for unregistered enrollees, this call + * will determine if a token needs to be injected into the URL. + * + * @since COmanage Registry v5.1.0 + * @return string Token to insert, or false if no token is required + */ + + protected function injectToken(int $petitionId): string|false { + $actor = $this->getCurrentActor($petitionId); + + if($actor['token_ok']) { + return $actor['petition']->token; + } + + return false; + } + + /** + * Transition to an Enrollment Flow Step. Typically this will be the next step, + * but this also permits re-entering a flow. + * + * @since COmanage Registry v5.1.0 + * @param int $petitionId Petition ID + * @param bool $start True if transitioning from start + * @throws Cake\Network\Exception\SocketException On SMTP error + * @throws RuntimeException If no SMTP server configured + */ + + protected function transitionToStep(int $petitionId, bool $start=false) { + $EnrollmentFlows = TableRegistry::getTableLocator()->get('EnrollmentFlows'); + + $stepInfo = $EnrollmentFlows->calculateNextStep($petitionId); + $petition = $stepInfo['petition']; + + $coId = $EnrollmentFlows->findCoForRecord($petition->enrollment_flow_id); + +/* no need to to this, we don't cache on start() + if($start) { + // $actorInfo was cached before the Petition was created, so force it to reload + unset($this->cache['actor']); + }*/ + + $actorInfo = $this->getCurrentActor($petitionId); + + // Before we process the handoff, give the plugin an opportunity to run any + // preparatory steps. We don't specifically support errors here, ie: if a plugin + // throws an Exception we let it bubble up because it's not really clear what we + // should do if a plugin fails. + + // (If this is the last step, 'step' will be null, and there's no prepare() to call.) + + if(!empty($stepInfo['step'])) { + $EnrollmentFlows->EnrollmentFlowSteps->prepare($stepInfo['step'], $petition); + } + + // We need to compare the current actor type with the actor type configured for the + // next step. If they are the same, we can simply redirect. If they are different, + // we need to hand off via a notification, and then redirect the current actor to + // a generic landing page. + + // Note we perform basically the same logic for finalize as regular steps, + // except we need to explicitly set $nextActorType to the current actor type. + // In particular, if there is a token we need to insert it for finalize as well. + + $nextActorType = null; + + if($stepInfo['finalize']) { + // Authorization for finalize is the same as the last step configured to run. + $nextActorType = $stepInfo['lastStep']->actor_type; + } else { + $nextActorType = $stepInfo['step']->actor_type; + } + + if(in_array($nextActorType, $actorInfo['roles'])) { + // The current actor is eligible to perform the next step, so simply redirect. + // Note we will need to re-insert the token if currently in use. + + if($petition->useToken($nextActorType)) { + $stepInfo['url']['?']['token'] = $this->requestParam('token'); + } + + return $this->redirect($stepInfo['url']); + } else { + // We need to hand off. We do this by creating a Notification for the recipient + // (or recipient group) and then redirect to a landing page. + + // The target URL can be used as is if we have a person_id or identifier + // for the appropriate role. If not, we need to append the petition token. + // Note that we only permit a single non-authenticated email address since + // we don't support different anonymous petitioners and enrollees. + + if($petition->useToken($nextActorType)) { + // We only have an enrollee_email field to use since either the petitioner _is_ + // the enrollee (in which case that address is sufficient, eg: self signup), + // or they are not the same person, in which case the petition _must_ be + // authenticated (eg: an admin). + + $token = $EnrollmentFlows->Petitions->getToken($petitionId); + + // For simplicity, we just inject the continue URL into the message. + $entryUrl = [ + 'controller' => 'petitions', + 'action' => 'continue', + $petition->id, + '?' => [ + 'token' => $token //$this->requestParam('token') + ] + ]; + + // Message Templates handle substitutions, so if none is configured it's an error + if(empty($stepInfo['step']->message_template_id)) { + throw new \RuntimeException(__d('error', 'EnrollmentFlowSteps.message_template', [ $stepInfo['step']->id ])); + } + + $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates'); + + // Perform substitutions + + $msg = $MessageTemplates->generateMessage( + id: $stepInfo['step']->message_template_id, + entryUrl: $entryUrl, + ); + + // Send the message. sendEmailToAddress will throw an Exception if SMTP failed, + // but if there is no SMTP server configured we'll just get false back. + + if(!DeliveryUtilities::sendEmailToAddress( + coId: $coId, + recipient: $petition->enrollee_email, + subject: $msg['subject'], + body_text: $msg['body_text'], + body_html: $msg['body_html'] + )) { + throw new \RuntimeException("Message delivery failed"); // XXX I18n. can we get an exception from sendEmailToAddress instead? + } + } else { + // XXX Register a notification or send an email or whatever + // (once notification infrastructure is available) + +debug("Handing off to actor type " . $nextActorType . " would send a notitication to visit " + . \Cake\Routing\Router::url(url: $stepInfo['url'], full: true)); + } + + // Redirect to a landing page indicating that no further action is required at this time + if(!empty($stepInfo['step']->redirect_on_handoff)) { + // Use the step specific handoff URL + return $this->redirect($stepInfo['step']->redirect_on_handoff); + } else { + // Redirect to the default Enrollment Handoff URL for this CO + return $this->redirect("/$coId/default-handoff"); + } + } + } + + /** + * Validate a token associated with the requested petition. + * + * @since COmanage Registry v5.1.0 + * @param Petition $petition Petition entity + * @return array|bool Roles associated with the token, or false on token error + */ + + protected function validateToken(Petition $petition): array|bool { + $reqToken = $this->requestParam('token'); + + // We can't use $petition->useToken because we don't have a role + + if(!empty($petition->token) + // Completed Petitions no longer accept tokens for authorization + && !$petition->isComplete() + && ($reqToken == $petition->token)) { + // Token match. The roles are whichever of petitioner and enrollee + // _don't_ have a Petition value. + + $roles = []; + + if(empty($petition->petitioner_identifier) + && empty($petition->petitioner_person_id)) { + $roles[] = EnrollmentActorEnum::Petitioner; + } + + if(empty($petition->enrollee_identifier) + && (empty($petition->enrollee_person_id) + || $petition->status == PetitionStatusEnum::Finalizing)) { + $roles[] = EnrollmentActorEnum::Enrollee; + } + + return $roles; + } + + return false; + } +} diff --git a/app/src/Lib/Traits/LayoutTrait.php b/app/src/Lib/Traits/LayoutTrait.php new file mode 100644 index 000000000..b178b5076 --- /dev/null +++ b/app/src/Lib/Traits/LayoutTrait.php @@ -0,0 +1,74 @@ +layout)) { + return $this->layout[$action]; + } + + return match($action) { + 'add', + 'view', + 'edit' => 'iframe', + default => 'default' + }; + } + + /** + * Set the layout variable + * + * @param array $layout + * + * @return void + * @since COmanage Registry v5.0.0 + */ + public function setLayout(array $layout): void + { + $this->layout = $layout; + } +} \ No newline at end of file diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index eeaec4665..42917277e 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -207,9 +207,10 @@ public function expressionsConstructor(Query $query, QueryExpression $exp, strin // Use the `lower` function to apply uniformity for the search 'string' => $exp->like($query->func()->lower([$attributeWithModelPrefix => 'identifier']), strtolower('%' . $search . '%')), - 'integer', + 'select', // AutoviewVar type + 'parent', // AutoviewVar type 'boolean', - 'parent' => $exp->add([$attributeWithModelPrefix => $search]), + 'integer' => $exp->add([$attributeWithModelPrefix => $search]), 'date' => $exp->add([$attributeWithModelPrefix => FrozenTime::parseDate($search, 'y-M-d')]), 'timestamp' => $this->constructDateComparisonClause($exp, $attributeWithModelPrefix, $search), default => $exp->eq($query->func()->lower([$attributeWithModelPrefix => 'identifier']), @@ -238,6 +239,12 @@ public function getSearchableAttributes(string $controller, string $vv_tz=null): $modelname = Inflector::classify(Inflector::underscore($controller)); $filterConfig = $this->getFilterConfig(); + // We get the filter keys and we will force include the fields that we + // have excluded in the filterMetadataFields() method. This way we have a + // method to exclude a field globally but then force its usage when needed through + // configuration + $filterKeys = array_keys($filterConfig); + // Gather up related models defined in the $filterConfig // XXX For now, we'll list these first - but we should probably provide a better way to order these. foreach ($filterConfig as $field => $f) { @@ -274,9 +281,19 @@ public function getSearchableAttributes(string $controller, string $vv_tz=null): ]; } - foreach ($this->filterMetadataFields() as $column => $type) { + // Include meta fields that are defined in the configuration + // FORCE USAGE + $filterMetadatFielsList = $this->filterMetadataFields(); + foreach ($filterKeys as $key) { + if (isset($filterMetadatFielsList['meta'][$key])) { + $filterMetadatFielsList[$key] = $filterMetadatFielsList['meta'][$key]; + } + + } + + foreach ($filterMetadatFielsList as $column => $type) { // If the column is an array, then we are accessing the Metadata fields. Skip - if(is_array($type)) { + if(\is_array($type)) { continue; } diff --git a/app/src/Lib/Traits/TabTrait.php b/app/src/Lib/Traits/TabTrait.php new file mode 100644 index 000000000..66f029f25 --- /dev/null +++ b/app/src/Lib/Traits/TabTrait.php @@ -0,0 +1,71 @@ +tabsConfig[$action])) { + return $this->tabsConfig[$action]; + } + + return $this->tabsConfig; + } + + /** + * @param array $tabsConfig + * + * @return void + * @since COmanage Registry v5.0.0 + */ + public function setTabsConfig(array $tabsConfig): void + { + $this->tabsConfig = $tabsConfig; + } + +} \ No newline at end of file diff --git a/app/src/Lib/Traits/TableMetaTrait.php b/app/src/Lib/Traits/TableMetaTrait.php index 2a41883d0..39d4fd88a 100644 --- a/app/src/Lib/Traits/TableMetaTrait.php +++ b/app/src/Lib/Traits/TableMetaTrait.php @@ -84,7 +84,13 @@ protected function filterMetadataFields() { 'source_pronoun_id', 'source_telephone_number_id', 'source_url_id', - 'owners_group_id' + 'owners_group_id', + 'enrollee_person_id', + 'petitioner_person_id', + 'authz_group_id', + 'authz_cou_id', + 'redirect_on_finalize', + 'collect_enrollee_email' ]; $newa = array(); diff --git a/app/src/Lib/Traits/UpsertTrait.php b/app/src/Lib/Traits/UpsertTrait.php new file mode 100644 index 000000000..449c4c772 --- /dev/null +++ b/app/src/Lib/Traits/UpsertTrait.php @@ -0,0 +1,87 @@ +find() + ->where($whereClause) + ->epilog('FOR UPDATE') + ->first(); + + if($entity) { + // This is an update + + $entity = $this->patchEntity($entity, $data); + } else { + // This is an insert + + $entity = $this->newEntity($data); + } + + return $this->save($entity); + } + + /** + * Perform an upsert, or throw an exception on failure. + * + * @since COmanage Registry v5.1.0 + * @param array $data Data to persist + * @param array $whereClause Conditions to search for current entity + * @return Cake\Datasource\EntityInterface|false Persisted entity, or false on failure + * @throws Cake\ORM\Exception\PersistenceFailedException + */ + + public function upsertOrFail( + array $data, + array $whereClause + ): \Cake\Datasource\EntityInterface { + $entity = $this->upsert($data, $whereClause); + + if($entity === false) { + throw new Cake\ORM\Exception\PersistenceFailedException($entity, ['upsert']); + } + + return $entity; + } +} diff --git a/app/src/Lib/Util/CountryCodeUtilities.php b/app/src/Lib/Util/CountryCodeUtilities.php new file mode 100644 index 000000000..aea299ad9 --- /dev/null +++ b/app/src/Lib/Util/CountryCodeUtilities.php @@ -0,0 +1,263 @@ + 'UK (+44)', + 1 => 'USA (+1)', + 213 => 'Algeria (+213)', + 376 => 'Andorra (+376)', + 244 => 'Angola (+244)', + 1264 => 'Anguilla (+1264)', + 1268 => 'Antigua & Barbuda (+1268)', + 54 => 'Argentina (+54)', + 374 => 'Armenia (+374)', + 297 => 'Aruba (+297)', + 61 => 'Australia (+61)', + 43 => 'Austria (+43)', + 994 => 'Azerbaijan (+994)', + 1242 => 'Bahamas (+1242)', + 973 => 'Bahrain (+973)', + 880 => 'Bangladesh (+880)', + 1246 => 'Barbados (+1246)', + 375 => 'Belarus (+375)', + 32 => 'Belgium (+32)', + 501 => 'Belize (+501)', + 229 => 'Benin (+229)', + 1441 => 'Bermuda (+1441)', + 975 => 'Bhutan (+975)', + 591 => 'Bolivia (+591)', + 387 => 'Bosnia Herzegovina (+387)', + 267 => 'Botswana (+267)', + 55 => 'Brazil (+55)', + 673 => 'Brunei (+673)', + 359 => 'Bulgaria (+359)', + 226 => 'Burkina Faso (+226)', + 257 => 'Burundi (+257)', + 855 => 'Cambodia (+855)', + 237 => 'Cameroon (+237)', + 1 => 'Canada (+1)', + 238 => 'Cape Verde Islands (+238)', + 1345 => 'Cayman Islands (+1345)', + 236 => 'Central African Republic (+236)', + 56 => 'Chile (+56)', + 86 => 'China (+86)', + 57 => 'Colombia (+57)', + 269 => 'Comoros (+269)', + 242 => 'Congo (+242)', + 682 => 'Cook Islands (+682)', + 506 => 'Costa Rica (+506)', + 385 => 'Croatia (+385)', + 53 => 'Cuba (+53)', + 90392 => 'Cyprus North (+90392)', + 357 => 'Cyprus South (+357)', + 42 => 'Czech Republic (+42)', + 45 => 'Denmark (+45)', + 253 => 'Djibouti (+253)', + 1809 => 'Dominica (+1809)', + 1809 => 'Dominican Republic (+1809)', + 593 => 'Ecuador (+593)', + 20 => 'Egypt (+20)', + 503 => 'El Salvador (+503)', + 240 => 'Equatorial Guinea (+240)', + 291 => 'Eritrea (+291)', + 372 => 'Estonia (+372)', + 251 => 'Ethiopia (+251)', + 500 => 'Falkland Islands (+500)', + 298 => 'Faroe Islands (+298)', + 679 => 'Fiji (+679)', + 358 => 'Finland (+358)', + 33 => 'France (+33)', + 594 => 'French Guiana (+594)', + 689 => 'French Polynesia (+689)', + 241 => 'Gabon (+241)', + 220 => 'Gambia (+220)', + 7880 => 'Georgia (+7880)', + 49 => 'Germany (+49)', + 233 => 'Ghana (+233)', + 350 => 'Gibraltar (+350)', + 30 => 'Greece (+30)', + 299 => 'Greenland (+299)', + 1473 => 'Grenada (+1473)', + 590 => 'Guadeloupe (+590)', + 671 => 'Guam (+671)', + 502 => 'Guatemala (+502)', + 224 => 'Guinea (+224)', + 245 => 'Guinea - Bissau (+245)', + 592 => 'Guyana (+592)', + 509 => 'Haiti (+509)', + 504 => 'Honduras (+504)', + 852 => 'Hong Kong (+852)', + 36 => 'Hungary (+36)', + 354 => 'Iceland (+354)', + 91 => 'India (+91)', + 62 => 'Indonesia (+62)', + 98 => 'Iran (+98)', + 964 => 'Iraq (+964)', + 353 => 'Ireland (+353)', + 972 => 'Israel (+972)', + 39 => 'Italy (+39)', + 1876 => 'Jamaica (+1876)', + 81 => 'Japan (+81)', + 962 => 'Jordan (+962)', + 7 => 'Kazakhstan (+7)', + 254 => 'Kenya (+254)', + 686 => 'Kiribati (+686)', + 850 => 'Korea North (+850)', + 82 => 'Korea South (+82)', + 965 => 'Kuwait (+965)', + 996 => 'Kyrgyzstan (+996)', + 856 => 'Laos (+856)', + 371 => 'Latvia (+371)', + 961 => 'Lebanon (+961)', + 266 => 'Lesotho (+266)', + 231 => 'Liberia (+231)', + 218 => 'Libya (+218)', + 417 => 'Liechtenstein (+417)', + 370 => 'Lithuania (+370)', + 352 => 'Luxembourg (+352)', + 853 => 'Macao (+853)', + 389 => 'Macedonia (+389)', + 261 => 'Madagascar (+261)', + 265 => 'Malawi (+265)', + 60 => 'Malaysia (+60)', + 960 => 'Maldives (+960)', + 223 => 'Mali (+223)', + 356 => 'Malta (+356)', + 692 => 'Marshall Islands (+692)', + 596 => 'Martinique (+596)', + 222 => 'Mauritania (+222)', + 269 => 'Mayotte (+269)', + 52 => 'Mexico (+52)', + 691 => 'Micronesia (+691)', + 373 => 'Moldova (+373)', + 377 => 'Monaco (+377)', + 976 => 'Mongolia (+976)', + 1664 => 'Montserrat (+1664)', + 212 => 'Morocco (+212)', + 258 => 'Mozambique (+258)', + 95 => 'Myanmar (+95)', + 264 => 'Namibia (+264)', + 674 => 'Nauru (+674)', + 977 => 'Nepal (+977)', + 31 => 'Netherlands (+31)', + 687 => 'New Caledonia (+687)', + 64 => 'New Zealand (+64)', + 505 => 'Nicaragua (+505)', + 227 => 'Niger (+227)', + 234 => 'Nigeria (+234)', + 683 => 'Niue (+683)', + 672 => 'Norfolk Islands (+672)', + 670 => 'Northern Marianas (+670)', + 47 => 'Norway (+47)', + 968 => 'Oman (+968)', + 680 => 'Palau (+680)', + 507 => 'Panama (+507)', + 675 => 'Papua New Guinea (+675)', + 595 => 'Paraguay (+595)', + 51 => 'Peru (+51)', + 63 => 'Philippines (+63)', + 48 => 'Poland (+48)', + 351 => 'Portugal (+351)', + 1787 => 'Puerto Rico (+1787)', + 974 => 'Qatar (+974)', + 262 => 'Reunion (+262)', + 40 => 'Romania (+40)', + 7 => 'Russia (+7)', + 250 => 'Rwanda (+250)', + 378 => 'San Marino (+378)', + 239 => 'Sao Tome & Principe (+239)', + 966 => 'Saudi Arabia (+966)', + 221 => 'Senegal (+221)', + 381 => 'Serbia (+381)', + 248 => 'Seychelles (+248)', + 232 => 'Sierra Leone (+232)', + 65 => 'Singapore (+65)', + 421 => 'Slovak Republic (+421)', + 386 => 'Slovenia (+386)', + 677 => 'Solomon Islands (+677)', + 252 => 'Somalia (+252)', + 27 => 'South Africa (+27)', + 34 => 'Spain (+34)', + 94 => 'Sri Lanka (+94)', + 290 => 'St. Helena (+290)', + 1869 => 'St. Kitts (+1869)', + 1758 => 'St. Lucia (+1758)', + 249 => 'Sudan (+249)', + 597 => 'Suriname (+597)', + 268 => 'Swaziland (+268)', + 46 => 'Sweden (+46)', + 41 => 'Switzerland (+41)', + 963 => 'Syria (+963)', + 886 => 'Taiwan (+886)', + 7 => 'Tajikstan (+7)', + 66 => 'Thailand (+66)', + 228 => 'Togo (+228)', + 676 => 'Tonga (+676)', + 1868 => 'Trinidad & Tobago (+1868)', + 216 => 'Tunisia (+216)', + 90 => 'Turkey (+90)', + 7 => 'Turkmenistan (+7)', + 993 => 'Turkmenistan (+993)', + 1649 => 'Turks & Caicos Islands (+1649)', + 688 => 'Tuvalu (+688)', + 256 => 'Uganda (+256)', + 380 => 'Ukraine (+380)', + 971 => 'United Arab Emirates (+971)', + 598 => 'Uruguay (+598)', + 7 => 'Uzbekistan (+7)', + 678 => 'Vanuatu (+678)', + 379 => 'Vatican City (+379)', + 58 => 'Venezuela (+58)', + 84 => 'Vietnam (+84)', + 84 => 'Virgin Islands - British (+1284)', + 84 => 'Virgin Islands - US (+1340)', + 681 => 'Wallis & Futuna (+681)', + 969 => 'Yemen (North)(+969)', + 967 => 'Yemen (South)(+967)', + 260 => 'Zambia (+260)', + 263 => 'Zimbabwe (+263)', + ]; + + return $ccodes[$code] ?? $ccodes; + } +} \ No newline at end of file diff --git a/app/src/Lib/Util/DeliveryUtilities.php b/app/src/Lib/Util/DeliveryUtilities.php index eb6068806..8b7c7db42 100644 --- a/app/src/Lib/Util/DeliveryUtilities.php +++ b/app/src/Lib/Util/DeliveryUtilities.php @@ -51,7 +51,8 @@ class DeliveryUtilities { * @param string $cc Addresses to cc * @param string $bcc Addresses to bcc * @param string $replyTo Reply-To address to use, instead of the default - * @return bool Returns true if mail was sent, false otherwise (including if no SMTP server was sent) + * @return bool Returns true if mail was sent, false if no SMTP server was set + * @throws Cake\Network\Exception\SocketException */ public static function sendEmailToAddress( @@ -127,9 +128,18 @@ public static function sendEmailToAddress( 'tls' => $smtp->use_tls ]); - $result = $transport->send($message); + try { + $result = $transport->send($message); + } + catch(Cake\Network\Exception\SocketException $e) { + self::slog('error', $e->getMessage()); + + throw $e; + } self::slog('debug', "Mail for $to sent successfully"); + + return true; } /** @@ -148,6 +158,7 @@ public static function sendEmailToAddress( * @param string $address Recipient Email Address * @param Person $subjectPerson Subject Person, including Primary Name * @param Notification $notification Notification + * @param string $code Verification code * @return array 'recipient': Recipient email address ("to" only, not "cc" or "bcc") */ @@ -156,15 +167,19 @@ public static function sendEmailFromTemplate( ?int $personId=null, ?string $address=null, ?\App\Model\Entity\Person $subjectPerson=null, - ?\App\Model\Entity\Notification $notification=null + ?\App\Model\Entity\Notification $notification=null, + ?string $code=null ): array { $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates'); + $messageTemplate = $MessageTemplates->get($messageTemplateId); + // Generate the message from the template $message = $MessageTemplates->generateMessage( id: $messageTemplateId, subjectPerson: $subjectPerson, - notification: $notification + notification: $notification, + code: $code ); if($notification) { @@ -179,12 +194,32 @@ public static function sendEmailFromTemplate( $Notifications->save($notification); } - return self::sendEmailToPerson( - personId: $personId, - subject: $message['subject'], - body_text: $message['body_text'] ?? "", - body_html: $message['body_html'] ?? "" - ); + if($personId) { + return self::sendEmailToPerson( + personId: $personId, + subject: $message['subject'], + body_text: $message['body_text'] ?? "", + body_html: $message['body_html'] ?? "", + cc: $messageTemplate->cc, + bcc: $messageTemplate->bcc, + replyTo: $messageTemplate->reply_to + ); + } else { + self::sendEmailToAddress( + coId: $messageTemplate->co_id, + recipient: $address, + subject: $message['subject'], + body_text: $message['body_text'] ?? "", + body_html: $message['body_html'] ?? "", + cc: $messageTemplate->cc, + bcc: $messageTemplate->bcc, + replyTo: $messageTemplate->reply_to + ); + + return [ + 'recipient' => $address + ]; + } } /** diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php index 97437c183..55dde467f 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -162,6 +162,7 @@ public static function entityAndActionToTitle($entity, $linkTable = TableRegistry::getTableLocator()->get($modelPath); $msgId = "{$action}.a"; + $msgIdOverride = "{$action}.{$modelsName}.a"; if(Inflector::singularize(self::entityToClassName($entity)) !== Inflector::singularize($modelsName)) { $linkTable = TableRegistry::getTableLocator()->get(self::entityToClassName($entity)); @@ -188,7 +189,10 @@ public static function entityAndActionToTitle($entity, && method_exists($linkTable, 'generateDisplayField')) { // We don't use a trait for this since each table will implement different logic - $title = __d($domain, $msgId, $linkTable->generateDisplayField($entity)); + $title = __d($domain, $msgIdOverride, $linkTable->generateDisplayField($entity)); + if ($msgIdOverride === $title) { + $title = __d($domain, $msgId, $linkTable->generateDisplayField($entity)); + } $supertitle = $linkTable->generateDisplayField($entity); // Pass the display field also into subtitle for dealing with External IDs $subtitle = $linkTable->generateDisplayField($entity); @@ -197,7 +201,10 @@ public static function entityAndActionToTitle($entity, $field = $linkTable->getDisplayField(); if(!empty($entity->$field)) { - $title = __d($domain, $msgId, $entity->$field); + $title = __d($domain, $msgIdOverride, $entity->$field); + if($msgIdOverride === $title) { + $title = __d($domain, $msgId, $entity->$field); + } } else { $title = __d($domain, $msgId, __d('controller', $modelsName, [1])); } @@ -220,12 +227,13 @@ public static function foreignKeyToClassName(string $s): string { /** * Localize a controller name, accounting for plugins. - * - * @since COmanage Registry v5.0.0 - * @param string $controllerName Name of controller to localize - * @param string $pluginName Plugin name, if appropriate - * @param bool $plural Whether to use plural localization + * + * @param string $controllerName Name of controller to localize + * @param string|null $pluginName Plugin name, if appropriate + * @param bool $plural Whether to use plural localization + * * @return string Localized text string + * @since COmanage Registry v5.0.0 */ public static function localizeController(string $controllerName, ?string $pluginName, bool $plural=false): string { @@ -267,6 +275,19 @@ public static function pluginPlugin(string $s): string { return $bits[0]; } + /** + * Convert a plugin name (in Plugin.Model format) to the field name it will be found + * in as a related model to the Pluggable Entity (ie: $entity->my_plugin). + * + * @since COmanage Registry v5.1.0 + * @param string $plugin Plugin path, in Plugin.Model format + * @return string Plugin field name, in underscore_format + */ + + public static function pluginToEntityField(string $plugin): string { + return Inflector::singularize(Inflector::underscore(self::pluginModel($plugin))); + } + /** * Determine the Entity name from a Table object. * diff --git a/app/src/Lib/Util/TableUtilities.php b/app/src/Lib/Util/TableUtilities.php index b0753967a..c97680cc6 100644 --- a/app/src/Lib/Util/TableUtilities.php +++ b/app/src/Lib/Util/TableUtilities.php @@ -31,6 +31,8 @@ use Cake\ORM\Table; use Cake\ORM\TableRegistry; +use Cake\Datasource\ConnectionManager; +use Cake\Utility\Inflector; class TableUtilities { /** @@ -58,4 +60,108 @@ public static function getTableFromRegistry(string $alias, array $options): Tabl return $Locator->get($alias, $options); } } + + /** + * We calculate the model name from the primary link, the primary link value is the id + * of the record. We use these to traverse backwards to all the records associations + * Then we return a list where the keys are the model names and the values are the ids + * + * @param string $primaryLinkKey + * @param int $primaryLinkValue + * @param array $results + * + * @return void + * @since COmanage Registry v5.0.0 + */ + public static function treeTraversalFromPrimaryLink( + string $primaryLinkKey, + int $primaryLinkValue, + array &$results, + string $primaryLinkClassName = null + ): void + { + $db = ConnectionManager::get('default'); + // Create a schema collection. + $collection = $db->getSchemaCollection(); + $listOfTables = $collection->listTables(); + + $primaryLinkModelName = StringUtilities::foreignKeyToClassName(($primaryLinkKey)); + // Check if the table exists. + // We can not handle + + // We need to save the id by its alias not the containing class + $results[$primaryLinkModelName] = $primaryLinkValue; + + if ($primaryLinkClassName !== null) { + $primaryLinkModelName = $primaryLinkClassName; + $results[$primaryLinkModelName] = $primaryLinkValue; + } + + // Get a table reference + $ModelTable = TableRegistry::getTableLocator()->get($primaryLinkModelName); + // Get the Record from the database + $resp = $ModelTable->find() + ->where(['id' => $primaryLinkValue]) + ->first() + ->toArray(); + + // Find all the foreign keys and fetch the rest of the tree + foreach($resp as $col => $val) { + if ( + $val !== null + && $col !== $primaryLinkKey + && str_ends_with($col, '_id') + ) { + $fkModel = StringUtilities::foreignKeyToClassName(($col)); + $fk_table = Inflector::underscore($fkModel); + if (\in_array($fk_table, $listOfTables, true)) { + self::treeTraversalFromPrimaryLink($col, $val, $results); + } + } + } + } + + /** + * With a model name and the id know we return a list where the + * keys are the model names and the values are the ids + * + * @param string $modelName + * @param int $id + * @param array $results + * + * @return void + * @since COmanage Registry v5.0.0 + */ + public static function treeTraversalFromId(string $modelName, int $id, array &$results): void + { + $db = ConnectionManager::get('default'); + // Create a schema collection. + $collection = $db->getSchemaCollection(); + $listOfTables = $collection->listTables(); + + $results[$modelName] = $id; + // Get a table reference + $ModelTable = TableRegistry::getTableLocator()->get($modelName); + // Get the Record from the database + $resp = $ModelTable->find() + ->where(['id' => $id]) + ->first()?->toArray(); + + if ($resp !== null) { + // Find all the foreign keys and fetch the rest of the tree + foreach($resp as $col => $val) { + if ( + $val !== null + && $col !== $modelName + && str_ends_with($col, '_id') + ) { + $fkModel = StringUtilities::foreignKeyToClassName(($col)); + $fk_table = Inflector::underscore($fkModel); + if (\in_array($fk_table, $listOfTables, true)) { + self::treeTraversalFromPrimaryLink($col, $val, $results); + } + } + } + } + } } \ No newline at end of file diff --git a/app/src/Model/Behavior/OrderableBehavior.php b/app/src/Model/Behavior/OrderableBehavior.php index 917bec5bb..a33fbe74a 100644 --- a/app/src/Model/Behavior/OrderableBehavior.php +++ b/app/src/Model/Behavior/OrderableBehavior.php @@ -51,7 +51,27 @@ public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $opti $Table = $event->getSubject(); - $query = $Table->find(); + // We constrain our search by primary key, so ordr will be consecutive within the + // same configuration (eg provisioning_targets within a CO). Note tables can have + // multiple primary links (though this is more for MVEAs than configuration objects) + // though only one should be populated. + + $primaryLink = null; + + $primaryLinks = $Table->getPrimaryLinks(); + + foreach($primaryLinks as $p) { + if(!empty($data[$p])) { + $primaryLink = $p; + break; + } + } + + if(!$primaryLink) { + throw new \RuntimeException("No primary link found in OrderableBehavior::beforeMarshal for " . $Table->getTable()); + } + + $query = $Table->find()->where([$primaryLink => $data[$p]]); $query->select(['maxorder' => $query->func()->max('ordr', ['ordr'])]); $row = $query->first(); diff --git a/app/templates/AdHocAttributes/fields-nav.inc b/app/src/Model/Entity/EnrollmentFlow.php similarity index 78% rename from app/templates/AdHocAttributes/fields-nav.inc rename to app/src/Model/Entity/EnrollmentFlow.php index 9b42482f4..1e427a8bf 100644 --- a/app/templates/AdHocAttributes/fields-nav.inc +++ b/app/src/Model/Entity/EnrollmentFlow.php @@ -1,6 +1,6 @@ 'person', - 'active' => 'person', - 'subActive' => 'ad_hoc_attributes' -]; \ No newline at end of file +namespace App\Model\Entity; + +use Cake\ORM\Entity; + +class EnrollmentFlow extends Entity { + use \App\Lib\Traits\ReadOnlyEntityTrait; + + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/templates/Addresses/fields-nav.inc b/app/src/Model/Entity/EnrollmentFlowStep.php similarity index 78% rename from app/templates/Addresses/fields-nav.inc rename to app/src/Model/Entity/EnrollmentFlowStep.php index 86686fdf9..7090eb7e4 100644 --- a/app/templates/Addresses/fields-nav.inc +++ b/app/src/Model/Entity/EnrollmentFlowStep.php @@ -1,6 +1,6 @@ 'person', - 'active' => 'person', - 'subActive' => 'addresses' -]; \ No newline at end of file +namespace App\Model\Entity; + +use Cake\ORM\Entity; + +class EnrollmentFlowStep extends Entity { + use \App\Lib\Traits\ReadOnlyEntityTrait; + + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/MostlyStaticPage.php b/app/src/Model/Entity/MostlyStaticPage.php new file mode 100644 index 000000000..ee604e7a9 --- /dev/null +++ b/app/src/Model/Entity/MostlyStaticPage.php @@ -0,0 +1,56 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity is a default Page (shipped out of the box and relied on by other + * parts of the Application). + * + * @since COmanage Registry v5.1.0 + * @return bool true if this entity is a defalut Page, false otherwise + */ + + public function isDefaultPage(): bool { + // We use the original value because if we're in the middle of a save we'll have + // the proposed new value even though we haven't persisted it yet + return in_array($this->getOriginal('name'), ['default-handoff', 'error-landing', 'petition-complete']); + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/Petition.php b/app/src/Model/Entity/Petition.php new file mode 100644 index 000000000..161c75285 --- /dev/null +++ b/app/src/Model/Entity/Petition.php @@ -0,0 +1,119 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this Petition is complete, ie in a state where no further changes + * are permitted. + * + * @since COmanage Registry v5.0.0 + * @return bool True if this Petition is complete, false otherwise + */ + + public function isComplete(): bool { + return in_array($this->status, [ + PetitionStatusEnum::Declined, + PetitionStatusEnum::Denied, + PetitionStatusEnum::Duplicate, + PetitionStatusEnum::Failed, + PetitionStatusEnum::Finalized + // A Finalizing Petition is NOT complete + // PetitionStatusEnum::Finalizing + ]); + } + + /** + * Determine if this Petition can be resumed, ie: is not complete. + * + * @since COmanage Registry v5.0.0 + * @return bool True if this Petition can be resumed, false otherwise + */ + + public function isResumable(): bool { + return !$this->isComplete(); + } + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Cake Entity + * @return bool true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // Completed petitions are read only, along with the usual stuff + + return $this->isComplete() || $this->traitIsReadOnly(); + } + + /** + * Determine whether or not a token should be used to authenticate this Petition + * for the requested Actor Role. Note token validation is NOT handled by this call. + * + * @since COmanage Registry v5.0.0 + * @param EnrollmentActorEnum $actorRole Requested Actor Role + * @return bool true if a token should be used, false otherwise + */ + + public function useToken(string $actorRole): bool { + // A token should be used when the current role does not have an associated + // authenticated identifier or person ID + if(($actorRole == EnrollmentActorEnum::Petitioner + && empty($this->petitioner_identifier) + && empty($this->petitioner_person_id)) + || + ($actorRole == EnrollmentActorEnum::Enrollee + && empty($this->enrollee_identifier) + // Once finalization begins, we'll have an enrollee_person_id but they + // most likel won't be able to authenticate + && (empty($this->enrollee_person_id) || $this->status == PetitionStatusEnum::Finalizing))) { + // Note presence of a token is not an indicator as to whether a token should be used + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/PetitionHistoryRecord.php b/app/src/Model/Entity/PetitionHistoryRecord.php new file mode 100644 index 000000000..eaa5a5f42 --- /dev/null +++ b/app/src/Model/Entity/PetitionHistoryRecord.php @@ -0,0 +1,54 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Cake Entity + * @return boolean true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // Petition history records can't be altered once created + + return true; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/PetitionStepResult.php b/app/src/Model/Entity/PetitionStepResult.php new file mode 100644 index 000000000..9b64c15ca --- /dev/null +++ b/app/src/Model/Entity/PetitionStepResult.php @@ -0,0 +1,54 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Cake Entity + * @return boolean true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // Petition Step results can't be altered once created + + return true; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/Verification.php b/app/src/Model/Entity/Verification.php new file mode 100644 index 000000000..b714c716c --- /dev/null +++ b/app/src/Model/Entity/Verification.php @@ -0,0 +1,53 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity is Verified. + * + * @since COmanage Registry v5.1.0 + * @param Entity $entity Cake Entity + * @return boolean true if the entity has been verified, false otherwise + */ + + public function isVerified(): bool { + return !empty($this->verification_time); + } +} \ No newline at end of file diff --git a/app/src/Model/Table/AdHocAttributesTable.php b/app/src/Model/Table/AdHocAttributesTable.php index e1092e6b9..a8f2edf2e 100644 --- a/app/src/Model/Table/AdHocAttributesTable.php +++ b/app/src/Model/Table/AdHocAttributesTable.php @@ -37,6 +37,7 @@ class AdHocAttributesTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; @@ -44,18 +45,6 @@ class AdHocAttributesTable extends Table { use \App\Lib\Traits\SearchFilterTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } /** * Perform Cake Model initialization. diff --git a/app/src/Model/Table/AddressesTable.php b/app/src/Model/Table/AddressesTable.php index f1fc87173..3a0d80187 100644 --- a/app/src/Model/Table/AddressesTable.php +++ b/app/src/Model/Table/AddressesTable.php @@ -39,14 +39,15 @@ class AddressesTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\SearchFilterTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\SearchFilterTrait; // Default "out of the box" types for this model. Entries here should be // given a default localization in app/resources/locales/*/defaultType.po @@ -58,18 +59,9 @@ class AddressesTable extends Table { 'postal' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } + + // Default permitted Fields. Used for the Attribute Collection + private $permittedFields = ['locality', 'state', 'postal_code', 'country', 'street', 'room']; /** * Perform Cake Model initialization. @@ -273,4 +265,14 @@ public function validationDefault(Validator $validator): Validator { return $validator; } + + /** + * Get the hardcoded list of the Default Permitted Fields + * + * @since COmanage Registry v5.0.0 + * @return array List of permitted fields + */ + public function getPermittedFields(): array { + return $this->permittedFields; + } } \ No newline at end of file diff --git a/app/src/Model/Table/CosTable.php b/app/src/Model/Table/CosTable.php index 061e964b8..8aec0857b 100644 --- a/app/src/Model/Table/CosTable.php +++ b/app/src/Model/Table/CosTable.php @@ -86,6 +86,9 @@ public function initialize(array $config): void { $this->hasMany('Jobs') ->setDependent(true) ->setCascadeCallbacks(true); + $this->hasMany('MostlyStaticPages') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('People') ->setDependent(true) ->setCascadeCallbacks(true); @@ -440,6 +443,9 @@ public function setup(int $id): bool { // AR-CO-6 Create the default groups $this->Groups->addDefaults($id); + // AR-MostlyStaticPages-3 Set up the default landing pages + $this->MostlyStaticPages->addDefaults($id); + // Set up the default settings $this->CoSettings->addDefaults($id); diff --git a/app/src/Model/Table/CousTable.php b/app/src/Model/Table/CousTable.php index ee4d59b21..0afbe7534 100644 --- a/app/src/Model/Table/CousTable.php +++ b/app/src/Model/Table/CousTable.php @@ -76,6 +76,7 @@ public function initialize(array $config): void { ->setProperty('parent'); // AR-COU-6 If a COU is deleted, the special groups associated with the COU will also be deleted. + $this->hasMany('EnrollmentFlows'); $this->hasMany('Groups') ->setDependent(true) ->setCascadeCallbacks(true); diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php index baa6c77af..18237d643 100644 --- a/app/src/Model/Table/EmailAddressesTable.php +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -44,6 +44,7 @@ class EmailAddressesTable extends Table { use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\LabeledLogTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; @@ -66,19 +67,7 @@ class EmailAddressesTable extends Table { 'recovery' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } - + /** * Perform Cake Model initialization. * @@ -102,6 +91,10 @@ public function initialize(array $config): void { ->setClassName('EmailAddresses') ->setForeignKey('source_email_address_id') ->setProperty('source_email_address'); + + $this->hasOne('Verifications') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->setDisplayField('mail'); @@ -110,7 +103,7 @@ public function initialize(array $config): void { $this->setRequiresCO(true); $this->setRedirectGoal('self'); $this->setAllowLookupPrimaryLink(['forceVerify', 'unfreeze']); - $this->setEditContains(['ExternalIdentities', 'SourceEmailAddresses']); + $this->setEditContains(['ExternalIdentities', 'SourceEmailAddresses', 'Verifications']); $this->setAutoViewVars([ 'types' => [ @@ -162,6 +155,8 @@ public function afterMarshal( $entity->verified = false; $data['verified'] = false; + + $this->Verifications->unverify($entity->id); } } @@ -251,6 +246,9 @@ public function forceVerify( $email->verified = true; $this->save($email); + // Create a Verification record + $this->Verifications->manual($id); + $HistoryRecords = TableRegistry::getTableLocator()->get('HistoryRecords'); $HistoryRecords->recordForPerson( diff --git a/app/src/Model/Table/EnrollmentFlowStepsTable.php b/app/src/Model/Table/EnrollmentFlowStepsTable.php new file mode 100644 index 000000000..99f359388 --- /dev/null +++ b/app/src/Model/Table/EnrollmentFlowStepsTable.php @@ -0,0 +1,253 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Orderable'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlows'); + $this->belongsTo('MessageTemplates'); + $this->hasMany('PetitionStepResults'); + + $this->setPluginRelations(); + + $this->setDisplayField('description'); + + $this->setPrimaryLink('enrollment_flow_id'); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + + $this->setAutoViewVars([ + 'actorTypes' => [ + 'type' => 'enum', + 'class' => 'EnrollmentActorEnum' + ], + 'messageTemplates' => [ + 'type' => 'select', + 'model' => 'MessageTemplates', + 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentHandoff] + ], + 'plugins' => [ + 'type' => 'plugin', + 'pluginType' => 'enroller' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'configure' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + // We handle dispatch authorization in the Controller + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ], + 'related' => [ + 'table' => [ + 'EnrollmentFlowSteps' + ] + ] + ]); + + $this->setTabsConfig( + [ + 'index' => [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlows', 'EnrollmentFlowSteps', 'Petitions'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlows' => ['edit', 'view'], + 'EnrollmentFlowSteps' => ['index'], + 'Petitions' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['EnrollmentFlowSteps', 'Petitions'] + ], + 'edit' => [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlowSteps', 'EnrollmentFlowSteps.Plugin', 'EnrollmentFlowSteps.Hierarchy'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'EnrollmentFlowSteps.Plugin' => ['edit'], + // This means that we are looking at the plugins associated model + // EnrollmentFlowSteps -> plugin -> @plugin + // XXX There might be plugins that have no hasMany associations. We will check + // for these use cases inside the element. + 'EnrollmentFlowSteps.Hierarchy' => ['index'] + ], + ] + ] + ); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.1.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-EnrollmentFlowStep-1 Two Enrollment Flow Steps in the same Enrollment Flow + // cannot have the same order value. This is because we need to deterministically + // determine the next step (in order to make sure we don't accidentally skip one) + // and the last step run (in particular for determining the correct authorization + // for finalize). + $rules->add($rules->isUnique(['ordr', 'enrollment_flow_id'], __d('error', 'ordr.unique', [__d('controller', 'EnrollmentFlowSteps', [1])]))); + + return $rules; + } + + /** + * Prepare for handoff to an Enrollment Flow Step. + * + * @since COmanage Registry v5.1.0 + * @param EnrollmentFlowStep $step Enrollment Flow Step + * @param Petition $petition Petition to prepare for + */ + + public function prepare( + \App\Model\Entity\EnrollmentFlowStep $step, + \App\Model\Entity\Petition $petition + ) { + // We give the plugin for this Step an opportunity to do something before + // the handoff to the Step takes place. This is intended, for example, to + // allow the Petition to be set to a Pending status prior to the next Actor + // taking an action. + + // (We accept a Step entity rather than an ID because in the context in which + // we're called we already have the Step, so no need to make another DB call.) + + $Plugin = TableRegistry::getTableLocator()->get($step->plugin); + + if(method_exists($Plugin, "prepare")) { + $Plugin->prepare($step, $petition); + } + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_id'); + + $this->registerStringValidation($validator, $schema, 'description', true); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'plugin', true); + + $validator->add('ordr', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('ordr'); + + $validator->add('actor_type', [ + 'content' => ['rule' => ['inList', EnrollmentActorEnum::getConstValues()]] + ]); + $validator->notEmptyString('actor_type'); + + $validator->add('message_template_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('message_template_id'); + + $validator->add('redirect_on_handoff', [ + 'content' => ['rule' => 'url'] + ]); + $validator->allowEmptyString('redirect_on_handoff'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/EnrollmentFlowsTable.php b/app/src/Model/Table/EnrollmentFlowsTable.php new file mode 100644 index 000000000..f8527065a --- /dev/null +++ b/app/src/Model/Table/EnrollmentFlowsTable.php @@ -0,0 +1,299 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('Cous') + ->setForeignKey('authz_cou_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('authz_cou'); + $this->belongsTo('Groups') + ->setForeignKey('authz_group_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('authz_group'); + + $this->hasMany('Petitions'); + $this->hasMany('EnrollmentFlowSteps') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('name'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['start']); + $this->setRedirectGoal('self'); + + $this->setAutoViewVars([ + 'authzTypes' => [ + 'type' => 'enum', + 'class' => 'EnrollmentAuthzEnum' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'TemplateableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + // We handle start authorization in the Controller + 'start' => true, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ], + 'related' => [ + 'table' => [ + 'EnrollmentFlowSteps' + ] + ] + ]); + + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlows', 'EnrollmentFlowSteps', 'Petitions'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlows' => ['edit', 'view'], + 'EnrollmentFlowSteps' => ['index'], + 'Petitions' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['EnrollmentFlowSteps', 'Petitions'] + ] + ); + } + + /** + * Calculate the next Step for an Enrollment Flow. + * + * @since COmanage Registry v5.0.0 + * @param int $petitionId Petition ID + * @return array url: URL to redirect to + * step: EnrollmentFlowStep + * finalize: True if there are no further steps + * lastStep: If finalize is true, the last EnrollmentFlowStep + * petition: The Petition entity + * @throws InvalidArgumentException + */ + + public function calculateNextStep(int $petitionId): array { + // Start by retrieving the Petition + + $petition = $this->Petitions->get($petitionId); + + // Completed Petitions have no next step + if($petition->isComplete()) { + throw new \InvalidArgumentException(__d('error', 'Petitions.completed', [$petitionId])); + } + + // Pull the set of Petition Step Results for this Petition. + + $results = $this->Petitions->PetitionStepResults->find('list', [ + 'keyField' => 'enrollment_flow_step_id', + 'valueField' => 'status' + ]) + ->where(['petition_id' => $petition->id]) + ->order(['enrollment_flow_step_id' => 'ASC']) + ->toArray(); + + // Pull the Enrollment Flow Steps for this Enrollment Flow, in order, + // and ignoring suspended Steps. + + $steps = $this->EnrollmentFlowSteps->find() + ->where([ + 'enrollment_flow_id' => $petition->enrollment_flow_id, + 'status' => SuspendableStatusEnum::Active + ]) + ->order(['EnrollmentFlowSteps.ordr' => 'ASC']) + ->contain($this->EnrollmentFlowSteps->getPluginRelations()) + ->all(); + + // Look for the first Enrollment Flow Step without a corresponding Result, + // this is our next Step. + + if(empty($steps)) { + // AR-EnrollmentFlow-1 An Enrollment Flow must have at least one Enrollment Flow Step + // defined in order to be run. This is because the authorization for finalize is + // calculated as the last configured step, so if there is no such step we cannpt + // calculate permission correctly. Also, a Flow with no Steps has no purpose. + + throw new \InvalidArgumentException(__d('error', 'EnrollmentFlowSteps.none')); + } + + foreach($steps as $step) { + if(!array_key_exists($step->id, $results)) { + // We do not have an array for this step, so it is the next step + + // We need the plugin name to find its instantiation ID + $pluginModel = StringUtilities::pluginModel($step->plugin); + $pluginName = Inflector::singularize(Inflector::underscore($pluginModel)); + + return [ + 'url' => [ + 'plugin' => StringUtilities::pluginPlugin($step->plugin), + 'controller' => StringUtilities::pluginModel($step->plugin), + 'action' => 'dispatch', + $step->$pluginName->id, + '?' => [ + 'petition_id' => $petition->id + ] + ], + 'step' => $step, + 'finalize' => false, + 'lastStep' => null, + 'petition' => $petition + ]; + } + } + + // If we didn't find a Step, it's time to Finalize the Petition. + + return [ + 'url' => [ + 'plugin' => null, + 'controller' => 'Petitions', + 'action' => 'finalize', + $petition->id + ], + 'step' => null, + 'finalize' => true, + 'lastStep' => $steps->last(), + 'petition' => $petition + ]; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'name', true); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', TemplateableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'sor_label', false); + + $validator->add('authz_type', [ + 'content' => ['rule' => ['inList', EnrollmentAuthzEnum::getConstValues()]] + ]); + $validator->notEmptyString('authz_type'); + +// XXX this becomes required when authz_type=CouAdmin || CouPerson + $validator->add('authz_cou_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('authz_cou_id'); + +// XXX this becomes required when authz_type=GroupMember + $validator->add('authz_group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('authz_group_id'); + + $validator->add('collect_enrollee_email', [ + 'content' => ['rule' => 'boolean'] + ]); + $validator->allowEmptyString('collect_enrollee_email'); + + $validator->add('redirect_on_finalize', [ + 'content' => ['rule' => 'url'] + ]); + $validator->allowEmptyString('redirect_on_finalize'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php index c8356f2f4..e16c54f74 100644 --- a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php +++ b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php @@ -43,24 +43,14 @@ class ExtIdentitySourceRecordsTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\LabeledLogTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\QueryModificationTrait; use \App\Lib\Traits\SearchFilterTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } + use \App\Lib\Traits\TabTrait; /** * Perform Cake Model initialization. @@ -113,6 +103,38 @@ public function initialize(array $config): void { ] ]);*/ + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['People', 'PersonRoles', 'ExternalIdentities'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'People' => ['edit', 'view'], + 'PersonRoles' => ['index'], + 'ExternalIdentities' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['PersonRoles', 'ExternalIdentities'], + 'nested' => [ + // Ordered list of Tabs + 'tabs' => ['ExternalIdentities', 'ExternalIdentityRoles'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'ExternalIdentities' => ['edit', 'view'], + 'ExternalIdentityRoles' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['ExternalIdentityRoles'], + ] + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ diff --git a/app/src/Model/Table/ExternalIdentitiesTable.php b/app/src/Model/Table/ExternalIdentitiesTable.php index 9f945a039..dca4900e6 100644 --- a/app/src/Model/Table/ExternalIdentitiesTable.php +++ b/app/src/Model/Table/ExternalIdentitiesTable.php @@ -47,7 +47,8 @@ class ExternalIdentitiesTable extends Table { use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; use \App\Lib\Traits\SearchFilterTrait; - + use \App\Lib\Traits\TabTrait; + /** * Perform Cake Model initialization. * @@ -147,7 +148,40 @@ public function initialize(array $config): void { 'class' => 'StatusEnum' ] ]); - + + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['People', 'PersonRoles', 'ExternalIdentities'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'People' => ['edit', 'view'], + 'PersonRoles' => ['index'], + 'ExternalIdentities' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['PersonRoles', 'ExternalIdentities'], + 'nested' => [ + // Ordered list of Tabs + 'tabs' => ['ExternalIdentities', 'ExternalIdentityRoles'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'ExternalIdentities' => ['edit', 'view'], + 'ExternalIdentityRoles' => ['index'] + ], + // What model will have a counter-badge after the tab title + 'counter' => ['ExternalIdentityRoles'], + ] + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) // See also CFM-126 diff --git a/app/src/Model/Table/ExternalIdentityRolesTable.php b/app/src/Model/Table/ExternalIdentityRolesTable.php index 35a267ba1..2d780f8ce 100644 --- a/app/src/Model/Table/ExternalIdentityRolesTable.php +++ b/app/src/Model/Table/ExternalIdentityRolesTable.php @@ -46,10 +46,11 @@ class ExternalIdentityRolesTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\SearchFilterTrait; + use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\SearchFilterTrait; - + /** * Perform Cake Model initialization. * @@ -90,7 +91,7 @@ public function initialize(array $config): void { ->setDependent(true) ->setCascadeCallbacks(true); - $this->setDisplayField('id'); + $this->setDisplayField('title'); $this->setPrimaryLink('external_identity_id'); $this->setRequiresCO(true); @@ -118,7 +119,39 @@ public function initialize(array $config): void { 'attribute' => 'PersonRoles.affiliation_type' ] ]); - + + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['People', 'PersonRoles', 'ExternalIdentities'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'People' => ['edit', 'view'], + 'PersonRoles' => ['index'], + 'ExternalIdentities' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['PersonRoles', 'ExternalIdentities'], + 'nested' => [ + // Ordered list of Tabs + 'tabs' => ['ExternalIdentities', 'ExternalIdentityRoles'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'ExternalIdentities' => ['edit', 'view'], + 'ExternalIdentityRoles' => ['edit', 'view', 'index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['ExternalIdentityRoles'], + ] + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) // See also CFM-126 diff --git a/app/src/Model/Table/ExternalIdentitySourcesTable.php b/app/src/Model/Table/ExternalIdentitySourcesTable.php index f50542d44..45312a0c4 100644 --- a/app/src/Model/Table/ExternalIdentitySourcesTable.php +++ b/app/src/Model/Table/ExternalIdentitySourcesTable.php @@ -48,6 +48,7 @@ class ExternalIdentitySourcesTable extends Table { use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; + use \App\Lib\Traits\TabTrait; // Cache of the EIS configuration, keyed on id protected $eisCache = null; @@ -99,7 +100,24 @@ public function initialize(array $config): void { 'class' => 'SyncModeEnum' ] ]); - + + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered-list of Tabs + 'tabs' => ['ExternalIdentitySources', 'ExternalIdentitySources.Plugin', 'ExternalIdentitySources@action.search'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'ExternalIdentitySources' => ['edit', 'view', 'search'], + 'ExternalIdentitySources.Plugin' => ['edit'], + 'ExternalIdentitySources@action.search' => [], + ], + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ diff --git a/app/src/Model/Table/GroupMembersTable.php b/app/src/Model/Table/GroupMembersTable.php index 3aaca7a71..5b8b61865 100644 --- a/app/src/Model/Table/GroupMembersTable.php +++ b/app/src/Model/Table/GroupMembersTable.php @@ -46,27 +46,16 @@ class GroupMembersTable extends Table { use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\LabeledLogTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\SearchFilterTrait; + use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\SearchFilterTrait; - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - 'add','edit','view' => 'iframe', - default => 'default' - }; - } - /** * Perform Cake Model initialization. * @@ -143,6 +132,18 @@ public function initialize(array $config): void { 'order' => 1 ], ]); + + $this->setTabsConfig( + [ + 'tabs' => ['Groups', 'GroupMembers', 'GroupNestings'], + 'action' => [ + 'Groups' => ['edit', 'view'], + 'GroupMembers' => ['index'], + 'GroupNestings' => ['index'], + ], + 'counter' => ['GroupMembers'] + ] + ); } /** diff --git a/app/src/Model/Table/GroupNestingsTable.php b/app/src/Model/Table/GroupNestingsTable.php index 0fdda7cb2..6eb28c8c5 100644 --- a/app/src/Model/Table/GroupNestingsTable.php +++ b/app/src/Model/Table/GroupNestingsTable.php @@ -43,9 +43,10 @@ class GroupNestingsTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - + /** * Perform Cake Model initialization. * @@ -96,6 +97,18 @@ public function initialize(array $config): void { ] ]); + $this->setTabsConfig( + [ + 'tabs' => ['Groups', 'GroupMembers', 'GroupNestings'], + 'action' => [ + 'Groups' => ['edit', 'view'], + 'GroupMembers' => ['index'], + 'GroupNestings' => ['index'], + ], + 'counter' => ['GroupMembers'] + ] + ); + // XXX Keeping for functionality reference // $this->setAutoViewVars([ // 'groupMembers' => [ diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index f42470ee1..4df6db0cd 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/app/src/Model/Table/GroupsTable.php @@ -52,10 +52,11 @@ class GroupsTable extends Table { use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\SearchFilterTrait; + use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\SearchFilterTrait; - + /** * Perform Cake Model initialization. * @@ -182,7 +183,25 @@ public function initialize(array $config): void { ] ] ]); - + + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['Groups', 'GroupMembers', 'GroupNestings'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'Groups' => ['edit', 'view'], + 'GroupMembers' => ['index'], + 'GroupNestings' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['GroupMembers'] + ] + ); + $this->setPermissions([ // XXX update for couAdmins, etc // Actions that operate over an entity (ie: require an $id) diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index 8d2f45978..cbc80efb0 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -40,14 +40,15 @@ class IdentifiersTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\SearchFilterTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\SearchFilterTrait; // Default "out of the box" types for this model. Entries here should be // given a default localization in app/resources/locales/*/defaultType.po @@ -73,19 +74,7 @@ class IdentifiersTable extends Table { 'uid' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } - + /** * Perform Cake Model initialization. * @@ -242,7 +231,7 @@ public function lookupPersonByLogin(int $coId, string $identifier): int { return $id->person_id; } - + /** * Perform a keyword search. * diff --git a/app/src/Model/Table/MessageTemplatesTable.php b/app/src/Model/Table/MessageTemplatesTable.php index d6aa26421..79381c71e 100644 --- a/app/src/Model/Table/MessageTemplatesTable.php +++ b/app/src/Model/Table/MessageTemplatesTable.php @@ -43,7 +43,6 @@ class MessageTemplatesTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\PermissionsTrait; - use \App\Lib\Traits\PluggableModelTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -65,6 +64,7 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('Cos'); + $this->hasMany('EnrollmentFlowSteps'); $this->hasMany('Notifications'); $this->setDisplayField('description'); @@ -108,8 +108,10 @@ public function initialize(array $config): void { * * @since COmanage Registry v5.0.0 * @param int $id Message Template ID - * @param Person $subjectPerson Subject Person, including Primary Name + * @param array $entryUrl Entry URL for responding to a handoff or notification * @param Notification $notification Notification + * @param Person $subjectPerson Subject Person, including Primary Name + * @param string $code Verification code * @return array 'subject': Message subject * 'body_text': Plaintext message * 'body_html': HTML message @@ -117,13 +119,16 @@ public function initialize(array $config): void { public function generateMessage( int $id, + array $entryUrl=[], + \App\Model\Entity\Notification $notification=null, \App\Model\Entity\Person $subjectPerson=null, - \App\Model\Entity\Notification $notification=null + ?string $code=null ): array { + // We return "" instead of null by default for compatibility with DeliveryUtilities $ret = [ - 'subject' => null, - 'body_text' => null, - 'body_html' => null + 'subject' => "", + 'body_text' => "", + 'body_html' => "" ]; // First retrieve the requested template @@ -133,18 +138,31 @@ public function generateMessage( // entities were provided. $substitutions = []; - - if($subjectPerson && !empty($subjectPerson->primary_name)) { - $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name; + + // Lookup the CO Name + $co = $this->Cos->get($template->co_id); + + $substitutions['CO_NAME'] = $co->name; + + if(!empty($entryUrl)) { + $substitutions['ENTRY_URL'] = \Cake\Routing\Router::url( + array_merge($entryUrl, ['_full' => true]) + ); } if($notification) { $substitutions['NOTIFICATION_COMMENT'] = $notification->comment; $substitutions['NOTIFICATION_SOURCE'] = $notification->source; } + + if($subjectPerson && !empty($subjectPerson->primary_name)) { + $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name; + } - // Finally run the substitutions through each of the supported parts + $substitutions['VERIFICATION_CODE'] = $code; + // Finally run the substitutions through each of the supported parts + foreach(array_keys($ret) as $part) { if(!empty($template->$part)) { // Process the (@SUBSTITUTIONS) for this part diff --git a/app/src/Model/Table/MostlyStaticPagesTable.php b/app/src/Model/Table/MostlyStaticPagesTable.php new file mode 100644 index 000000000..36704cabe --- /dev/null +++ b/app/src/Model/Table/MostlyStaticPagesTable.php @@ -0,0 +1,337 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + + $this->setDisplayField('name'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'contexts' => [ + 'type' => 'enum', + 'class' => 'PageContextEnum' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Add the default Mostly Static Pages. + * + * @since COmanage Registry v5.1.0 + * @param int $coId CO ID + * @return bool true on success + * @throws PersistenceFailedException + */ + + public function addDefaults(int $coId) { + // Any pages added here should also be added to MostlyStaticPage.php::isDefaultPage + $records = [ + [ + 'co_id' => $coId, + 'name' => 'default-handoff', + 'title' => __d('field', 'MostlyStaticPages.default.dh.title'), + 'description' => __d('field', 'MostlyStaticPages.default.dh.description'), + 'status' => SuspendableStatusEnum::Active, + 'context' => PageContextEnum::EnrollmentHandoff, + 'body' => __d('field', 'MostlyStaticPages.default.dh.body') + ], + [ + 'co_id' => $coId, + 'name' => 'error-landing', + 'title' => __d('field', 'MostlyStaticPages.default.el.title'), + 'description' => __d('field', 'MostlyStaticPages.default.el.description'), + 'status' => SuspendableStatusEnum::Active, + 'context' => PageContextEnum::ErrorLanding, + 'body' => __d('field', 'MostlyStaticPages.default.el.body') + ], + [ + 'co_id' => $coId, + 'name' => 'petition-complete', + 'title' => __d('field', 'MostlyStaticPages.default.pc.title'), + 'description' => __d('field', 'MostlyStaticPages.default.pc.description'), + 'status' => SuspendableStatusEnum::Active, + 'context' => PageContextEnum::EnrollmentHandoff, + 'body' => __d('field', 'MostlyStaticPages.default.pc.body') + ] + ]; + + // Convert the arrays to entities + $entities = $this->newEntities($records); + + // throws PersistenceFailedException on failure + $this->saveManyOrFail($entities); + + return true; + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.1.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { +// XXX document these in the wiki + // AR-MostlyStaticPage-1 Two Mostly Static Pages within the same CO cannot share the same name + $rules->add($rules->isUnique(['name', 'co_id'], __d('error', 'exists', [__d('field', 'MostlyStaticPages.name')]))); + + // AR-MostlyStaticPage-3 Default Pages can not be deleted, or have their names, status, or + // context changed + $rules->addUpdate([$this, 'ruleModifiedDefaultPage'], + 'modifiedDefaultPage', + ['errorField' => 'name']); + + $rules->addDelete([$this, 'ruleIsDefaultPage'], + 'isDefaultPage', + ['errorField' => 'name']); + + return $rules; + } + + /** + * Generate a message based on a Message Template, using the provided entities to + * perform variable substitution. + * + * @since COmanage Registry v5.0.0 + * @param int $id Message Template ID + * @param array $entryUrl Entry URL for responding to a handoff or notification + * @param Notification $notification Notification + * @param Person $subjectPerson Subject Person, including Primary Name + * @return array 'subject': Message subject + * 'body_text': Plaintext message + * 'body_html': HTML message + * + + public function generateMessage( + int $id, + array $entryUrl=[], + \App\Model\Entity\Notification $notification=null, + \App\Model\Entity\Person $subjectPerson=null + ): array { + // We return "" instead of null by default for compatibility with DeliveryUtilities + $ret = [ + 'subject' => "", + 'body_text' => "", + 'body_html' => "" + ]; + + // First retrieve the requested template + $template = $this->get($id); + + // Next build an array of supported substitutions for which appropriate + // entities were provided. + + $substitutions = []; + + // Lookup the CO Name + $co = $this->Cos->get($template->co_id); + + $substitutions['CO_NAME'] = $co->name; + + if(!empty($entryUrl)) { +// debug($entryUrl); + $substitutions['ENTRY_URL'] = \Cake\Routing\Router::url( + array_merge($entryUrl, ['_full' => true]) + ); + } + + if($notification) { + $substitutions['NOTIFICATION_COMMENT'] = $notification->comment; + $substitutions['NOTIFICATION_SOURCE'] = $notification->source; + } + + if($subjectPerson && !empty($subjectPerson->primary_name)) { + $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name; + } + + // Finally run the substitutions through each of the supported parts + +// debug($substitutions); + foreach(array_keys($ret) as $part) { + if(!empty($template->$part)) { + // Process the (@SUBSTITUTIONS) for this part + $searchKeys = []; + $replaceVals = []; + + foreach(array_keys($substitutions) as $k) { + $searchKeys[] = "(@" . $k . ")"; + $replaceVals[] = $substitutions[$k] ?? "(?)"; + } + + $ret[$part] = str_replace($searchKeys, $replaceVals, $template->$part); + } + } + + return $ret; + }*/ + + /** + * Application Rule to determine if the current entity is a default Page. + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return string|bool true if the Rule check passes, false otherwise + * @since COmanage Registry v5.1.0 + */ + + public function ruleIsDefaultPage($entity, array $options): string|bool { + if($entity->isDefaultPage()) { + return __d('error', 'MostlyStaticPages.default.delete'); + } + + return true; + } + + /** + * Application Rule to determine if a default Page has been modified. + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return string|bool true if the Rule check passes, false otherwise + * @since COmanage Registry v5.1.0 + */ + + public function ruleModifiedDefaultPage($entity, array $options): string|bool { + if($entity->isDefaultPage() + && ($entity->isDirty('name') || $entity->isDirty('status') || $entity->isDirty('context'))) { + return __d('error', 'MostlyStaticPages.default.modify'); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'name', true); + + // AR-MostlyStaticPage-2 A Mostly Static Page name may consist only of lowercase alphanumeric + // characters and dashes + $validator->add('name', [ + 'filter' => [ + 'rule' => ['custom', '/^[a-z0-9-]+$/'], + 'message' => __d('error', 'MostlyStaticPages.slug.invalid') + ] + ]); + + $this->registerStringValidation($validator, $schema, 'title', true); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('context', [ + 'content' => ['rule' => ['inList', PageContextEnum::getConstValues()]] + ]); + $validator->notEmptyString('context'); + + $validator->add('body', [ + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + $validator->allowEmptyString('body'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php index 6785ab3dc..df19131cf 100644 --- a/app/src/Model/Table/NamesTable.php +++ b/app/src/Model/Table/NamesTable.php @@ -44,6 +44,7 @@ class NamesTable extends Table { use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\LabeledLogTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; @@ -53,7 +54,6 @@ class NamesTable extends Table { use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; - // Default "out of the box" types for this model. Entries here should be // given a default localization in app/resources/locales/*/defaultType.po protected $defaultTypes = [ @@ -65,18 +65,6 @@ class NamesTable extends Table { 'preferred' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } /** * Perform Cake Model initialization. diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index aee5d57e3..e829ecd0b 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -50,9 +50,10 @@ class PeopleTable extends Table { use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\SearchFilterTrait; + use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\SearchFilterTrait; /** * Perform Cake Model initialization. @@ -128,6 +129,10 @@ public function initialize(array $config): void { $this->hasMany('PersonRoles') ->setDependent(true) ->setCascadeCallbacks(true); + $this->hasMany('Petitions') + ->setDependent(true) + ->setCascadeCallbacks(true) + ->setForeignKey('enrollee_person_id'); $this->hasMany('Pronouns') ->setDependent(true) ->setCascadeCallbacks(true); @@ -221,7 +226,25 @@ public function initialize(array $config): void { 'order' => 99 ] ]); - + + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['People', 'PersonRoles', 'ExternalIdentities'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'People' => ['edit', 'view'], + 'PersonRoles' => ['index'], + 'ExternalIdentities' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['PersonRoles', 'ExternalIdentities'] + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) // See also CFM-126 diff --git a/app/src/Model/Table/PersonRolesTable.php b/app/src/Model/Table/PersonRolesTable.php index ac797fee5..324b81f0f 100644 --- a/app/src/Model/Table/PersonRolesTable.php +++ b/app/src/Model/Table/PersonRolesTable.php @@ -53,7 +53,8 @@ class PersonRolesTable extends Table { use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; use \App\Lib\Traits\SearchFilterTrait; - + use \App\Lib\Traits\TabTrait; + // Default "out of the box" types for this model. Entries here should be // given a default localization in app/resources/locales/*/defaultType.po protected $defaultTypes = [ @@ -163,7 +164,25 @@ public function initialize(array $config): void { 'model' => 'Cous' ] ]); - + + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['People', 'PersonRoles', 'ExternalIdentities'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'People' => ['edit', 'view'], + 'PersonRoles' => ['edit', 'view', 'index'], + 'ExternalIdentities' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['PersonRoles', 'ExternalIdentities'] + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) // See also CFM-126 diff --git a/app/src/Model/Table/PetitionHistoryRecordsTable.php b/app/src/Model/Table/PetitionHistoryRecordsTable.php new file mode 100644 index 000000000..64791c497 --- /dev/null +++ b/app/src/Model/Table/PetitionHistoryRecordsTable.php @@ -0,0 +1,203 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Petitions'); + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('ActorPeople') + ->setClassName('People') + ->setForeignKey('actor_person_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('actor_person'); + + $this->setDisplayField('comment'); + + $this->setPrimaryLink(['petition_id']); + $this->setRequiresCO(true); + + $this->setViewContains([ + // contain results in a join when the relation is belongsTo (or hasOne), + // and joining the same table twice makes the database unhappy, so we + // force ActorPeople to use multiple queries. + 'ActorPeople' => ['Names' => ['queryBuilder' => function ($q) { + return $q->where(['primary_name' => true]); + }]] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform actions while marshaling data, before validation. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param ArrayObject $data Object data, in array format + * @param ArrayObject $options Entity save options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + if(!empty($data['comment'])) { + // Truncate the comment to fit the column width + $column = $this->getSchema()->getColumn('comment'); + + $data['comment'] = substr($data['comment'], 0, $column['length']); + } + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param JobHistoryRecord $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\JobHistoryRecord $entity): string { + // Comments may be too long to render, so we just use the model name + // (which will get appended with the record ID) + + return __d('controller', 'PetitionHistoryRecords', [1]); + } + + /** + * Record a Petition History Record. + * + * @since COmanage Registry v5.0.0 + * @param int $petitionId Petition ID + * @param string $enrollmentFlowStepId Enrollment Flow Step ID, or null for start or finalize + * @param string $action PetitionActionEnum + * @param string $comment Comment + * @param int $actorPersonId Actor Person ID + * @return int Petition History Record ID + */ + + public function record( + int $petitionId, + ?int $enrollmentFlowStepId=null, + string $action, + string $comment, + ?int $actorPersonId=null + ): int { + $obj = $this->newEntity([ + 'petition_id' => $petitionId, + 'enrollment_flow_step_id' => $enrollmentFlowStepId, + 'action' => $action, + 'comment' => $comment, + 'actor_person_id' => $actorPersonId + ]); + + $this->saveOrFail($obj); + + return $obj->id; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + // There is no enrollment_flow_step_id for start or finalize + $validator->allowEmptyString('enrollment_flow_step_id'); + + $this->registerStringValidation($validator, $schema, 'action', true); + + $this->registerStringValidation($validator, $schema, 'comment', true); + + $validator->add('actor_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('actor_person_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PetitionStepResultsTable.php b/app/src/Model/Table/PetitionStepResultsTable.php new file mode 100644 index 000000000..a714d2612 --- /dev/null +++ b/app/src/Model/Table/PetitionStepResultsTable.php @@ -0,0 +1,135 @@ +addBehavior('Changelog'); + $this->addBehavior('Timestamp'); + $this->addBehavior('Timezone'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('Petitions'); + + $this->setDisplayField('comment'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Record a Petition Step Result. + * + * @since COmanage Registry v5.0.0 + * @param int $enrollmentFlowStepId Enrollment Flow Step ID + * @param int $petitionId Petition ID + * @param string $status Status + * @param string $comment Comment + * @return int Petition Step Result ID + */ + + public function record( + int $enrollmentFlowStepId, + int $petitionId, + string $comment + ): int { + $obj = $this->newEntity([ + 'enrollment_flow_step_id' => $enrollmentFlowStepId, + 'petition_id' => $petitionId, + 'comment' => $comment + ]); + + $this->saveOrFail($obj); + + return $obj->id; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $this->registerStringValidation($validator, $schema, 'comment', true); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PetitionsTable.php b/app/src/Model/Table/PetitionsTable.php new file mode 100644 index 000000000..fd760387e --- /dev/null +++ b/app/src/Model/Table/PetitionsTable.php @@ -0,0 +1,545 @@ +addBehavior('Changelog'); + $this->addBehavior('Timestamp'); + $this->addBehavior('Timezone'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Cous'); + $this->belongsTo('EnrollmentFlows'); + $this->belongsTo('EnrolleePeople') + ->setClassName('People') + ->setForeignKey('enrollee_person_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('enrollee_person'); + $this->belongsTo('PetitionerPeople') + ->setClassName('People') + ->setForeignKey('petitioner_person_id') + ->setProperty('petitioner_person'); + + $this->hasMany('PetitionHistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('PetitionStepResults') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->hasOne('Verifications') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['continue', 'finalize', 'pending', 'result', 'resume']); + + // These are required for the link to work from the Artifacts page + $this->setAllowUnkeyedPrimaryCO(['index']); + $this->setAllowEmptyPrimaryLink(['index']); + + $this->setIndexContains([ + 'Cous', + 'EnrolleePeople' => ['PrimaryName' => ['foreignKey' => 'person_id']], + 'EnrollmentFlows', + 'PetitionerPeople' => ['PrimaryName' => ['foreignKey' => 'person_id']] + ]); + $this->setViewContains([ + 'EnrollmentFlows' => ['EnrollmentFlowSteps' => ['sort' => ['ordr' => 'ASC']]], + 'EnrolleePeople' => ['PrimaryName' => ['foreignKey' => 'person_id']], + 'PetitionerPeople' => ['PrimaryName' => ['foreignKey' => 'person_id']], + 'PetitionHistoryRecords', + 'PetitionStepResults' + ]); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'PetitionStatusEnum' + ], + 'couIds' => [ + 'type' => 'select', + 'model' => 'Cous' + ] + ]); + + $this->setFilterConfig( + [ + 'cou_id' => [ + // We want to keep the default column configuration and add extra functionality. + // Here the extra functionality is additional to select options since the cou_id + // is of type select + // XXX If the extras key is present, no other provided key will be evaluated. The rest + // of the configuration will be expected from the TableMetaTrait::filterMetadataFields() + 'extras' => [ + 'options' => [ + 'isnotnull' => __d('operation','any'), + 'isnull' => __d('operation','none'), + __d('information','table.list', 'COUs') => '@DATA@', + ] + ] + ] + ] + ); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + // We handle assign authorization in the Controller + // 'assign' => true, + // We handle continue authorization in the Controller + 'continue' => true, + 'delete' => false, + 'edit' => false, + // We handle finalize authorization in the Controller + 'finalize' => true, + // We handle provision authorization in the Controller + // 'provision' => true, + // result just issues a redirect, so we're generous with permissions + 'result' => ['platformAdmin', 'coAdmin'], + // resume renders a landing page, the admin can copy a URL and resend it + // to the appropriate actor if the actor is not also an admin + 'resume' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['result'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlows', 'EnrollmentFlowSteps', 'Petitions'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlows' => ['edit', 'view'], + 'EnrollmentFlowSteps' => ['index'], + 'Petitions' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['EnrollmentFlowSteps', 'Petitions'] + ] + ); + } + + /** + * Assign Identifiers for a Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Petition ID + * @throws InvalidArgumentException + */ + + public function assignIdentifiers(int $id) { + // AR-Petition-1 When a Petition is finalized, any configured Identifier Assignments will be run. + $this->llog('rule', "AR-Petition-1 Running Identifier Assignments for Petition $id"); + + $petition = $this->get($id); + + if($petition->status != PetitionStatusEnum::Finalizing) { + throw new \InvalidArgumentException(__d('error', 'Petitions.status.finalizing', [$id])); + } + + if(!$petition->enrollee_person_id) { + // No Person associated with the Petition, so nothing to do + $this->llog('debug', "No Enrollee Person ID found in Petition $id, so not assigning identifiers"); + return; + } + + $IdentifierAssignments = TableRegistry::getTableLocator()->get('IdentifierAssignments'); + + $ret = $IdentifierAssignments->assign( + entityType: 'People', + entityId: $petition->enrollee_person_id, + provision: false, +// $actorPersonId: XXX + ); + + if(!empty($ret['assigned'])) { + $this->PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: null, + action: PetitionActionEnum::Finalized, + comment: __d('result', 'IdentifierAssignments.assigned.ok', [implode(',', array_keys($ret['assigned']))]) + // actorPersonId + ); + } + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // An Enrollee email address may be required by the Enrollment Flow configuration + $rules->add([$this, 'ruleEnrolleeEmail'], + 'enrolleeEmail', + ['errorField' => 'enrollee_email']); + + return $rules; + } + + /** + * Finalize a Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Petition ID + */ + + public function finalize(int $id) { + $petition = $this->get($id); + + if($petition->isComplete()) { + throw new \InvalidArgumentException(__d('error', 'Petitions.completed', [$id])); + } + + // Update the Petition status and create a History Record. + $petition->status = PetitionStatusEnum::Finalized; + + $this->saveOrFail($petition); + + $this->PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: null, + action: PetitionActionEnum::Finalized, + comment: __d('result', 'Petitions.finalized') + // actorPersonId + ); + } + + /** + * Perform Plugin finalization for a Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Petition ID + */ + + public function finalizePlugins(int $id) { + // This is intended to be the first part of finalization, so we set the Petition status + // to Finalizing. + + $petition = $this->get($id, ['contain' => [ + 'EnrollmentFlows' => [ + 'EnrollmentFlowSteps' => array_merge( + $this->EnrollmentFlows->EnrollmentFlowSteps->getPluginRelations(), + ['sort' => ['EnrollmentFlowSteps.ordr' => 'ASC']] + ) + ]]]); + + if($petition->isComplete()) { + throw new \InvalidArgumentException(__d('error', 'Petitions.completed', [$id])); + } + + $petition->status = PetitionStatusEnum::Finalizing; + + $this->saveOrFail($petition); + + // If there is no Person attached to this Petition, allocate a new Person now + // (with no attributes). + + if(empty($petition->enrollee_person_id)) { + $People = TableRegistry::getTableLocator()->get('People'); + + $person = $People->newEntity([ + 'co_id' => $petition->enrollment_flow->co_id, + 'status' => StatusEnum::Active + ]); + + $People->saveOrFail($person); + + $petition->enrollee_person_id = $person->id; + + // Save here in case any plugin tries to reload petition info + $this->saveOrFail($petition); + + $People->recordHistory( + entity: $person, + action: ActionEnum::PersonAddedPetition, + comment: __d('result', + 'People.added.petition', [ + $petition->enrollment_flow->description, + $petition->enrollment_flow->id, + $petition->id]) + ); + + $this->llog('trace', 'Created new Person ' . $person->id . ' for Petition ' . $petition->id); + } + + // Tell each plugin to finalize + + if(!empty($petition->enrollment_flow->enrollment_flow_steps)) { + foreach($petition->enrollment_flow->enrollment_flow_steps as $step) { + if($step->status == SuspendableStatusEnum::Suspended) { + // Skip suspended steps + continue; + } + + $Plugin = TableRegistry::getTableLocator()->get($step->plugin); + + // Plugins cannot interrupt finalization by returning false or + // throwing errors, but if we catch an Exception we'll at least log it + try { + if(method_exists($Plugin, "finalize")) { + // We have "CoreEnroller.AttributeCollectors" but we want "attribute_collector" + $pmodel = Inflector::underscore(Inflector::singularize(StringUtilities::pluginModel($step->plugin))); + + $Plugin->finalize($step->$pmodel->id, $petition); + } + } + catch(\Exception $e) { + $this->llog('error', "Plugin " . $step->plugin . " error during finalization of petition " . $petition->id . ": " . $e->getMessage()); + } + } + } + } + + /** + * Obtain a Petition's token. + * + * @since COmanage Registry v5.1.0 + * @param int $id Petition ID + * @return string Petition token + */ + + public function getToken(int $id): string { + // We use this function rather than have the invoking code access the + // entity directly so we can allocate the token and persist it if there + // isn't yet one. + + $petition = $this->get($id); + + if(empty($petition->token)) { + // No token, so allocate one + $petition->token = RandomString::generateToken(); + + $this->save($petition); + } + + return $petition->token; + } + + /** + * Run Provisioning for a Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Petition ID + */ + + public function provision(int $id) { + // AR-Petition-2 When a Petition is finalized, any configured Provisioners will run, including those in Enrollment Only mode. + $this->llog('rule', "AR-Petition-2 Running Provisioning for Petition $id"); + + $petition = $this->get($id); + + if($petition->status != PetitionStatusEnum::Finalizing) { + throw new \InvalidArgumentException(__d('error', 'Petitions.status.finalizing', [$id])); + } + + if(!$petition->enrollee_person_id) { + // No Person associated with the Petition, so nothing to do + $this->llog('debug', "No Enrollee Person ID found in Petition $id, so not Provisioning"); + return; + } + + $People = TableRegistry::getTableLocator()->get('People'); + + $People->requestProvisioning(id: $petition->enrollee_person_id, context: ProvisioningContextEnum::Enrollment); + } + + /** + * Application Rule to determine if an Enrollee Email is required. + * + * @since COmanage Registyr v5.1.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleEnrolleeEmail($entity, $options) { + // Whether or not an Enrollee Email address is required depends on the + // Enrollment Flow configuration, so it's a bit cleaner to do this as an + // application rule rather than a validation rule. Note this is _not_ an + // official Registry Application Rule. + + if(!empty($entity->enrollment_flow_id)) { + $EnrollmentFlows = TableRegistry::getTableLocator()->get('EnrollmentFlows'); + + $flow = $EnrollmentFlows->get($entity->enrollment_flow_id); + + if(isset($flow->collect_enrollee_email) && $flow->collect_enrollee_email) { + // Enrollee Email is required + + if(empty($entity->enrollee_email)) { + return __d('error', 'Petitions.enrollee_email'); + } + } + } + + return true; + } + + /** + * Start a new Petition. + * + * @since Registry v5.1.0 + * @param int $enrollmentFlowId Enrollment Flow ID + * @param string $petitionerIdentifier Authenticated Petitioner Identifier (NOT Person ID) + * @param int $petitionerPersonId Petitioner Person ID, if known + * @param bool $isEnrollee If true, the Petitioner is also the Enrollee + * @return Petition Newly created Petition + */ + + public function start( + int $enrollmentFlowId, + string $petitionerIdentifier=null, + int $petitionerPersonId=null, + bool $isEnrollee=false, + string $enrolleeEmail=null + ): \App\Model\Entity\Petition { + $petition = $this->newEntity([ + 'enrollment_flow_id' => $enrollmentFlowId, + 'status' => PetitionStatusEnum::Created, + 'petitioner_identifier' => $petitionerIdentifier, + 'petitioner_person_id' => $petitionerPersonId, + 'enrollee_email' => $enrolleeEmail, + 'enrollee_identifier' => $isEnrollee ? $petitionerIdentifier : null, + 'enrollee_person_id' => $isEnrollee ? $petitionerPersonId : null + ]); + + $this->saveOrFail($petition); + + // We don't create Petition History on start since it's sort of implied + // by the Petition having been created + + return $petition; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_id'); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', PetitionStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('cou_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('cou_id'); + + $this->registerStringValidation($validator, $schema, 'enrollee_identifier', false); + + $validator->add('enrollee_email', [ + 'content' => ['rule' => ['email'], + 'message' => __d('error', 'input.invalid.email')] + ]); + // See ruleEnrolleeEmail for additional logic + $validator->allowEmptyString('enrollee_email'); + + $validator->add('enrollee_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('enrollee_person_id'); + + $this->registerStringValidation($validator, $schema, 'petitioner_identifier', false); + + $validator->add('petitioner_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('petitioner_person_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PronounsTable.php b/app/src/Model/Table/PronounsTable.php index 82bb98c6a..399a921de 100644 --- a/app/src/Model/Table/PronounsTable.php +++ b/app/src/Model/Table/PronounsTable.php @@ -38,6 +38,7 @@ class PronounsTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; @@ -54,19 +55,7 @@ class PronounsTable extends Table { 'default' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } - + /** * Perform Cake Model initialization. * diff --git a/app/src/Model/Table/TelephoneNumbersTable.php b/app/src/Model/Table/TelephoneNumbersTable.php index 6f40d7ae6..dd2776ee2 100644 --- a/app/src/Model/Table/TelephoneNumbersTable.php +++ b/app/src/Model/Table/TelephoneNumbersTable.php @@ -38,6 +38,7 @@ class TelephoneNumbersTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; @@ -46,7 +47,7 @@ class TelephoneNumbersTable extends Table { use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; - + // Default "out of the box" types for this model. Entries here should be // given a default localization in app/resources/locales/*/defaultType.po protected $defaultTypes = [ @@ -58,19 +59,7 @@ class TelephoneNumbersTable extends Table { 'office' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } - + /** * Perform Cake Model initialization. * diff --git a/app/src/Model/Table/UrlsTable.php b/app/src/Model/Table/UrlsTable.php index eb6e53707..307ccf854 100644 --- a/app/src/Model/Table/UrlsTable.php +++ b/app/src/Model/Table/UrlsTable.php @@ -37,6 +37,7 @@ class UrlsTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; @@ -54,19 +55,7 @@ class UrlsTable extends Table { 'personal' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } - + /** * Perform Cake Model initialization. * diff --git a/app/src/Model/Table/VerificationsTable.php b/app/src/Model/Table/VerificationsTable.php new file mode 100644 index 000000000..f75ab25cc --- /dev/null +++ b/app/src/Model/Table/VerificationsTable.php @@ -0,0 +1,342 @@ +addBehavior('Changelog'); + $this->addBehavior('Timestamp'); + $this->addBehavior('Timezone'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('EmailAddresses'); + $this->belongsTo('Petitions'); + + // Verifications aren't generally going to be directly rendered or managed + $this->setDisplayField('id'); + + $this->setPrimaryLink(['email_address_id', 'petition_id']); + $this->setRequiresCO(false); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => false + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => false + ] + ]); + } + + /** + * Record a handoff Verification. + * + * @since COmanage Registry v5.1.0 + * @param int $petitionId Petition ID + * @return Verification Verification entity + */ + + public function handoff(int $petitionId): Verification { + // Note when this is called the relevant EmailAddress probably doesn't exist yet, + // so we just link the Verification to the Petition (since otherwise we'd need to + // foreign key into a plugin table, which we're not allowed to do from core code) + // and expect the Enrollment Flow plugin to clean this up later. + + // Because we don't know the email address we also can't perform a uniqueness check + // (there might be multiple verifications for the same Petition). + + $verification = $this->newEntity([ + 'petition_id' => $petitionId, + 'method' => VerificationMethodEnum::PetitionHandoff, + 'verification_time' => date('Y-m-d H:i:s', time()) + ]); + + $this->saveOrFail($verification); + + return $verification; + } + + /** + * Record a manual Verification. + * + * @since COmanage Registry v5.1.0 + * @param int $emailAddressId Email Address ID + */ + + public function manual(int $emailAddressId) { + // First, see if we have a Verification for this Email Address + + $verification = $this->find()->where(['email_address_id' => $emailAddressId])->first(); + + if($verification) { + if(!empty($verification->verification_time)) { + // If there is a campleted Verification, we don't allow a manual Verification + + throw new \InvalidArgumentException(__d('error', 'Verifications.already')); + } else { + // If there is a pending Verification, we'll override and update it + + $verification->code = null; + $verification->method = VerificationMethodEnum::Manual; + $verification->verification_time = date('Y-m-d H:i:s', time()); + } + } else { + // Create a new Verification + + $verification = $this->newEntity([ + 'email_address_id' => $emailAddressId, + 'method' => VerificationMethodEnum::Manual, + 'verification_time' => date('Y-m-d H:i:s', time()) + ]); + } + + $this->save($verification); + + // We don't record history here because we may not have a Person context yet (ie: Petitions) + } + + /** + * Request a Verification for the specified petition and email address. + * + * @since COmanage Registry v5.1.0 + * @param int $petitionId Petition ID + * @param string $mail Email Address to verify + * @param int $messageTemplateId Message Template ID + * @param int $validity Request validity, in minutes + * @param int $verificationId If set, resend Verification for this request + * @return int Verification ID + */ + + public function requestCodeForPetition( + int $petitionId, + string $mail, + int $messageTemplateId, + int $validity, + int $verificationId=null + ): int { + // First generate a new code + $code = RandomString::generateCode(); + $expiry = date('Y-m-d H:i:s', time() + ($validity * 60)); + + $verification = null; + + // If there's already a Verification, pull it, check it, and update it + if($verificationId) { + $verification = $this->get($verificationId); + + if($verification->petition_id != $petitionId) { + throw new \InvalidArgumentException(__d('error', 'Verifications.petition')); + } + + $verification->code = $code; + $verification->request_expiration_time = $expiry; + } else { + $verification = $this->newEntity([ + 'code' => $code, + 'verification_time' => null, + 'request_expiration_time' => $expiry, + 'method' => null, + 'email_address_id' => null, + 'petition_id' => $petitionId + ]); + } + + $this->saveOrFail($verification); + + // Send the verification message + + DeliveryUtilities::sendEmailFromTemplate( + address: $mail, + messageTemplateId: $messageTemplateId, + code: $code + ); + + return $verification->id; + } + + /** + * Unverify a Verification. + * + * @since COmanage Registry v5.1.0 + * @param int $emailAddressId Email Address ID + */ + + public function unverify(int $emailAddressId) { + // First, see if we have a Verification for this Email Address + + $verification = $this->find()->where(['email_address_id' => $emailAddressId])->first(); + + if($verification) { + $verification->code = null; + $verification->method = null; + $verification->verification_time = null; + $verification->request_expiration_time = null; + + $this->save($verification); + } + // If we don't have a verification, we don't do anything + } + + /** + * Check a verification code. + * + * @since COmanage Registry v5.1.0 + * @param int $id Verification ID + * @param string $code Code, as provided by the verifier + * @return bool true if validation is successful + * @throws \InvalidArgumentException + */ + + public function verifyCode(int $id, string $code): bool { + $verification = $this->get($id); + + if($verification->verification_time) { + $this->llog('debug', "Verification $id has already been processed"); + throw new \InvalidArgumentException(__d('error', 'Verifications.processed')); + } + + if($verification->request_expiration_time->lt(FrozenTime::now())) { + $this->llog('debug', "Verification $id has expired"); + throw new \InvalidArgumentException(__d('error', 'Verifications.expired')); + } + + if($verification->code !== $code) { + $this->llog('debug', "Invalid code provided for Verification $id"); + throw new \InvalidArgumentException(__d('error', 'Verifications.code')); + } + + $this->llog('debug', "Successfully processed Verification $id"); + + $verification->method = VerificationMethodEnum::Code; + $verification->verification_time = time(); + + $this->saveOrFail($verification); + + return true; + } + + /** + * Create a new Verification from an existing Verification associated with a Petition, + * but linked to the specified Email Address. + * + * @since COmanage Registry v5.1.0 + * @param int $id Verification ID + * @param int $emailAddressId Email Address ID + */ + + public function verifyFromPetition(int $id, int $emailAddressId) { + // Due to AR-GMR-3, we can't reassign a Verification from a Petition to an Email Address, + // so we duplicate it instead. + + $oldVerification = $this->get($id); + + $newVerification = $this->newEntity($oldVerification->toArray()); + $newVerification->petition_id = null; + $newVerification->email_address_id = $emailAddressId; + + $this->saveOrFail($newVerification); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + // Fields here are generally not required because not all types of verifications + // user all fields, and some fields are not populated at the initial verification + // request. + + $this->registerStringValidation($validator, $schema, 'code', false); + + $validator->add('verification_time', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('verification_time'); + + $validator->add('request_expiration_time', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('request_expiration_time'); + + $validator->add('method', [ + 'content' => ['rule' => ['inList', VerificationMethodEnum::getConstValues()]] + ]); + $validator->allowEmptyString('method'); + + $validator->add('email_address_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('email_address_id'); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('petition_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index ca2fa9431..90d6c0de8 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -38,6 +38,13 @@ class FieldHelper extends Helper { public $helpers = ['Form', 'Html']; + /** + * List of predefined editable form actions + */ + public const EDITABLE_ACTIONS = [ + 'add', 'edit', // CRUD actions + ]; + // Is this read-only or read-write? protected bool $editable = true; @@ -66,6 +73,7 @@ class FieldHelper extends Helper { * @param array $config The configuration settings provided to this helper. * * @return void + * @since COmanage Registry v5.0.0 */ public function initialize(array $config): void { @@ -74,7 +82,8 @@ public function initialize(array $config): void $this->reqFields = $this->getView()->get('vv_required_fields'); $this->modelName = $this->getView()->getName(); $this->action = $this->getView()->get('vv_action'); - $this->editable = \in_array($this->action, ['add', 'edit']); + $vv_is_editable = filter_var($this->getView()->get('vv_is_editable'), FILTER_VALIDATE_BOOLEAN); + $this->editable = \in_array($this->action, self::EDITABLE_ACTIONS, true) || $vv_is_editable; $this->pluginName = $this->getView()->getPlugin(); $this->entity = $this->getView()->get('vv_obj'); $this->fieldTypes = $this->getView()->get('vv_field_types'); @@ -86,6 +95,7 @@ public function initialize(array $config): void * @param string $fieldName * * @return array + * @since COmanage Registry v5.0.0 */ public function calculateLabelAndDescription(string $fieldName): array { @@ -172,14 +182,20 @@ public function calculateLabelAndDescription(string $fieldName): array * Calculate the list of classes for the li element * * @return string + * @since COmanage Registry v5.0.0 */ public function calculateLiClasses(): string { $fieldName = $this->getView()->get('fieldName'); $vv_field_arguments = $this->getView()->get('vv_field_arguments'); + // Get the fieldtype directly from the configuration or calculate it + // The latter will always work for simple model forms. The first one is used + // for more complex use cases + $fieldType = $vv_field_arguments['fieldType'] ?? $this->getFieldType($fieldName); + // Class calculation by field Type - $classes = match ($this->getFieldType($fieldName)) { + $classes = match ($fieldType) { 'date', 'datetime', 'timestamp' => 'fields-datepicker ', @@ -199,24 +215,64 @@ public function calculateLiClasses(): string $classes .= 'fields-people-autocomplete '; } + // Each field should have a class like `fields-` + $field = $vv_field_arguments['fieldNameAlias'] ?? $fieldName ?? 'unknown'; + $classes .= " fields-$field"; + return $classes; } + /** + * Construct the SPA field element + * + * @param string $element HTML element created with the CAKEPHP HTML Helper + * @param string $vueElementName The name of the JavaScript module + * + * @return string + * @since COmanage Registry v5.0.0 + */ + public function constructSPAField(string $element, string $vueElementName): string { + // Parse the ID attribute + $regexId = '/id="(.*?)"/m'; + preg_match_all($regexId, $element, $matchesId, PREG_SET_ORDER, 0); + + // Parse the Name attribute + $regexName = '/name="(.*?)"/m'; + preg_match_all($regexName, $element, $matchesName, PREG_SET_ORDER, 0); + + // Parse the Class attribute + $regexClass = '/class="(.*?)"/m'; + preg_match_all($regexClass, $element, $matchesClass, PREG_SET_ORDER, 0); + if(!empty($matchesId[0][1]) && !empty($matchesName[0][1])) { + return $this->getView()->element($vueElementName, [ + 'htmlId' => $matchesId[0][1], + 'fieldName' => $matchesName[0][1], + 'containerClasses' => $matchesClass[0][1], + 'type' => 'field', + // we want the label to be an empty string to hide the default label introduced by the module. + 'label' => '' + ]); + } + + // Fallback to an error element + return $this->getView()->element('elementFallback'); + } + /** * Emit a date/time form control. * This is a wrapper function for $this->control() * * @param string $fieldName Form field * @param string $dateType Standard, DateOnly, FromTime, ThroughTime - * @param string|null $label + * @param array|null $fieldArgs * * @return string HTML element * @since COmanage Registry v5.0.0 */ public function dateField(string $fieldName, - string $dateType=DateTypeEnum::Standard, - string $label=null): string + string $dateType = DateTypeEnum::Standard, + array $fieldArgs = null): string { // Initialize $dateFormat = $dateType === DateTypeEnum::DateOnly ? 'yyyy-MM-dd' : 'yyyy-MM-dd HH:mm:ss'; @@ -227,6 +283,10 @@ public function dateField(string $fieldName, ? FrozenTime::parse($queryParams[$fieldName]) : $this->getEntity()?->$fieldName; + // Petition Attribute Collection use case + if($date_object === null && !empty($fieldArgs['default'])) { + $date_object = $fieldArgs['default']; + } // Create the options array for the (text input) form control $coptions = []; @@ -234,8 +294,12 @@ public function dateField(string $fieldName, // that will interact with the field value. Allowing direct access to the input field is for // accessibility purposes. - // ACTION VIEW - if($this->action == 'view') { + // ACTION VIEW or Readonly Field + // The latter applies for the attribute collection view + if($this->action == 'view' + || + (isset($fieldArgs['readonly']) && $fieldArgs['readonly']) + ) { // return the date as plaintext $element = $this->getView()->element('form/notSetDiv', [], [ 'cache' => '_html_elements', @@ -251,7 +315,8 @@ public function dateField(string $fieldName, // Special-case the very common "valid_from" and "valid_through" fields, so we won't need // to specify their types in fields.inc. - $pickerType = match ($fieldName) { + $pickerTypeName = $fieldArgs['fieldNameAlias'] ?? $fieldName; + $pickerType = match ($pickerTypeName) { 'valid_from' => DateTypeEnum::FromTime, 'valid_through' => DateTypeEnum::ThroughTime, default => $dateType @@ -260,9 +325,6 @@ public function dateField(string $fieldName, // Append the timezone to the label $coptions['class'] = 'form-control datepicker'; $coptions['placeholder'] = $dateFormat; - if(!empty($label)) { - $coptions['label'] = $label; - } $coptions['pattern'] = $datePattern; $coptions['title'] = __d('field', $dateTitle); @@ -292,18 +354,28 @@ public function dateField(string $fieldName, 'pickerFloor' => $pickerFloor, ]; + $fieldLabel = ''; + if(!empty($fieldArgs['label'])) { + $fieldLabel = $this->Form->label($fieldName, $fieldArgs['label']); + } // Create a text field to hold our value and call the datePicker - return $this->Form->text($fieldName, $coptions) . $this->getView()->element('datePicker', $date_picker_args); + return $fieldLabel // label + . $this->Form->text($fieldName, $coptions) // hidden input + . $this->getView()->element('datePicker', $date_picker_args); // datepicker field } /** * Create the actual Form element * - * @param string $fieldName Form field - * @param array|null $fieldOptions The second parameter of the Form->control helper. List of element options - * @param string|null $fieldLabel Custom label thext - * @param string $fieldPrefix If the field has a specil prefix provide the value - * @param string|null $fieldType Field type to override the one calculated from the schema + * @param string $fieldName Form field + * @param array|null $fieldOptions The second parameter of the Form->control helper. List of element options + * @param string|null $fieldLabel Custom label text. Applicable to checkboxes ONLY + * @param string $fieldPrefix If the field has a specil prefix provide the value + * @param string|null $fieldType Field type to override the one calculated from the schema + * @param array|null $fieldSelectOptions Options array to override the one calculated options from the AutoPopulate property + * fieldType has to be 'select' + * @param string|null $fieldNameAlias Used for the Petition Attribute Collection form. The form uses generic field name. + * The variable is used to map the generic field name to the actual enrollment attribute name * * @return string HTML element * @since COmanage Registry v5.0.0 @@ -312,7 +384,9 @@ public function formField(string $fieldName, array $fieldOptions = null, string $fieldLabel = null, string $fieldPrefix = '', - string $fieldType = null): string + string $fieldType = null, + array $fieldSelectOptions = null, + string $fieldNameAlias = null): string { $fieldArgs = $fieldOptions ?? []; $fieldArgs['label'] = $fieldOptions['label'] ?? false; @@ -339,8 +413,8 @@ public function formField(string $fieldName, // Check if the empty option comes with a value if($fieldArgs['empty'] - && !empty($fieldOptions['empty']) - && \is_string($fieldOptions['empty'])) { + && isset($fieldOptions['empty']) + && \is_bool($fieldOptions['empty'])) { $fieldArgs['empty'] = $fieldOptions['empty']; } @@ -354,7 +428,7 @@ public function formField(string $fieldName, $this->getView()->set($optionName, $optionValues); } - // Is this a multiple select + // Is this multiple select? $fieldArgs['multiple'] = !empty($fieldOptions['multiple']); // Manipulate the vv_object for the hasPrefix use case @@ -362,6 +436,11 @@ public function formField(string $fieldName, // Get the field type from the map of fields (e.g. 'boolean', 'string', 'timestamp') $fieldType = $fieldType ?? $this->getFieldType($fieldName); + // $fieldType=select requires the $fieldSelectOptions. If the options are empty, we will + // force the usage of the default option + if(empty($fieldSelectOptions) && $fieldType === 'select') { + $fieldType = ''; + } // Generate the form control or pass along the markup generated in a wrapper function return match($fieldType) { // A boolean field is a checkbox. Set the label and class to improve rendering @@ -372,9 +451,11 @@ public function formField(string $fieldName, 'label' => $fieldLabel, 'class' => 'form-check-input', ]), - 'date' => $this->dateField($fieldName, DateTypeEnum::DateOnly), + 'select' => $this->Form->select($fieldName, $fieldSelectOptions, $fieldArgs), + 'text' => $this->Form->textarea($fieldName, $fieldArgs), + 'date' => $this->dateField(fieldName: $fieldName, dateType: DateTypeEnum::DateOnly, fieldArgs: $fieldArgs), 'datetime', - 'timestamp' => $this->dateField($fieldName), + 'timestamp' => $this->dateField(fieldName: $fieldName, fieldArgs: $fieldArgs), default => $this->Form->control($fieldName, $fieldArgs) }; } @@ -456,6 +537,28 @@ public function isEditable(): bool return $this->editable; } + /** + * Enable Form Edit mode. This will allow fields to be editable + * and the submit button will be rendered + * + * @return void + */ + public function enableFormEditMode(): void + { + $this->editable = true; + } + + /** + * Disable Form's edit mode. Fields will be become readonly/disabled + * and the submit button will be removed from the DOM + * + * @return void + */ + public function disableFormEditMode(): void + { + $this->editable = false; + } + /** * @param string $field * diff --git a/app/src/View/Helper/PetitionHelper.php b/app/src/View/Helper/PetitionHelper.php new file mode 100644 index 000000000..2de9a6364 --- /dev/null +++ b/app/src/View/Helper/PetitionHelper.php @@ -0,0 +1,98 @@ +entity = $this->getView()->get('vv_obj'); + $this->petition = $this->getView()->get('vv_petition'); + $this->enrollmentAttributesTable = new EnrollmentAttributesTable(); + } + + /** + * Get the Enrollment Attribute hardcoded configuration + * + * @param string $attribute + * + * @return array + * @since COmanage Registry v5.0.0 + */ + public function getSupportedEnrollmentAttribute(string $attribute): array + { + return $this->enrollmentAttributesTable->supportedAttributes()[$attribute]; + } + + /** + * Calculate and populate the Enrollment Attributes auto view vars + * + * @since COmanage Registry v5.0.0 + */ + public function populateAutoViewVars(): void + { + // XXX Find the co id + foreach ( + $this->enrollmentAttributesTable->calculateAutoViewVars($this->petition?->enrollment_flow?->co_id,$this->entity) as $vvar => $value + ) { + $this->getView()->set($vvar, $value); + } + } + + /** + * Get the table validation rules + * + * @param string $tableName + * + * @return Table + * @since COmanage Registry v5.0.0 + */ + public function getTable(string $tableName): Table + { + return TableRegistry::getTableLocator()->get($tableName); + } +} \ No newline at end of file diff --git a/app/src/View/Helper/TabHelper.php b/app/src/View/Helper/TabHelper.php new file mode 100644 index 000000000..9bc78da51 --- /dev/null +++ b/app/src/View/Helper/TabHelper.php @@ -0,0 +1,533 @@ +getView()->getRequest()->getParam('controller'); + $modelName = $tab; + $controller = $modelName; + $plugin = null; + + // Action calculation + $action = $this->getTabAction($tab, $isNested); + // id or query parameter calculation + $linkFilter = $this->getLinkFilter($tab, $curId, $action, $isNested); + + // Controller + Plugin calculation + if(str_ends_with($tab, '.Plugin')) { + // This is always the second tab of the plugin and it is configuration + $controller = $curController; + $action = 'configure'; + } else if (str_ends_with($tab, '.Hierarchy')) { + $modelName = $this->retrievePluginName($tab, (int)$curId); + [$plugin, ] = explode('.', $modelName); + foreach ($this->getHasManyAssociationModels($modelName) as $association) { + [$plugin, $controller] = explode('.', $association); + break; + } + } else if (str_contains($tab, '@action')) { + // We have a plugin path + [$controller,] = explode('@', $modelName); + [, $action] = explode('.', $modelName); + } else if (str_contains($tab, '.')) { + // We have a plugin path + [$plugin, $controller] = explode('.', $modelName); + } + + $url = [ + 'plugin' => $plugin, + 'controller' => $controller, + 'action' => $action + ]; + + if ($action === 'index') { + $url['?'] = $linkFilter; + } else { + $url[] = $curId; + } + + return $url; + } + + /** + * Calculate the link Class + * + * @param string $tab + * @param bool $isNested + * + * @return string + * @since COmanage Registry v5.0.0 + */ + public function getLinkClass(string $tab, bool $isNested = false): string + { + $vv_sub_nav_attributes = $this->getView()->get('vv_sub_nav_attributes'); + + $curController = $this->getView()->getRequest()->getParam('controller'); + $curAction = $this->getView()->getRequest()->getParam('action'); + $plugin = $this->getView()->getPlugin(); + $fullModelName = $curController; + if(isset($plugin)) { + $fullModelName = "{$plugin}.{$curController}"; + } + + // The list of Nested models is in order. Which means that the first tab is always the parent and the one + // that will be set as active + $parentModelForNested = $vv_sub_nav_attributes['nested']['tabs'][0] ?? 0; + + // Calculate Tab Style Class(es) + return match(true) { + // Matches the tab to the current controller. It addresses the simple subnavigation + // The fullModelName can either be a simple Model or a Plugin with the path. + $tab === $fullModelName && \in_array($curAction, ['index', 'edit', 'view']), + // Always mark active the parent Tab + !$isNested && $parentModelForNested !== null && $tab === $parentModelForNested, + // Matches the action tab links, e.g. FileSource/search + $tab === "{$curController}@action.{$curAction}" => 'nav-link active', + default => 'nav-link' + }; + } + + /** + * Check the belongsTo tree hierarchy + * + * @param string $tab + * @param string $modelFullName + * @param int $depth + * + * @return bool + * @since COmanage Registry v5.0.0 + */ + public function tabBelongsToModelPath(string $tab, string $modelFullName, int &$depth = 0): bool + { + $model = TableRegistry::getTableLocator()->get($modelFullName); + // We'll start by getting the set of models directly associated with the CO model. + $associations = $model->associations(); + + $depth++; + foreach($associations->getByType(['belongsTo', 'belongsToMany']) as $ta) { + if($ta->getClassName() === $tab) { + return true; + } + return $this->tabBelongsToModelPath($tab, $ta->getClassName(), $depth); + } + + return false; + } + + /** + * Calculate the ID for the tab link + * + * @param string|null $tabName + * @param bool $isNested + * + * @return int + * @since COmanage Registry v5.0.0 + */ + public function getCurrentId(string $tabName = null, bool $isNested = false): int + { + $vv_obj = $this->getView()->get('vv_obj'); + $vv_primary_link = $this->getView()->get('vv_primary_link'); + $vv_bc_title_links = $this->getView()->get('vv_bc_title_links'); + $request = $this->getView()->getRequest(); + $curController = $request->getParam('controller'); + $vv_sub_nav_attributes = $this->getView()->get('vv_sub_nav_attributes'); + $tab_actions = !$isNested ? $vv_sub_nav_attributes['action'] : $vv_sub_nav_attributes['nested']['action']; + $tabs = !$isNested ? $vv_sub_nav_attributes['tabs'] : $vv_sub_nav_attributes['nested']['tabs']; + + $tid = $request->getQuery($vv_primary_link) + ?? $vv_obj->id + ?? end($vv_bc_title_links[0]['target']); + + // Get the ids of all the associated Model records + $results = []; + if ($request->getQuery($vv_primary_link) !== null) { + TableUtilities::treeTraversalFromPrimaryLink($vv_primary_link, (int)$tid, $results, ); + } else { + TableUtilities::treeTraversalFromId($curController, (int)$tid, $results); + } + + $tabAction = $this->getTabAction($tabName, $isNested); + + if( + !$isNested + && ($tabAction === 'index' || $curController !== $tabName) + && \in_array($tabName, $tabs, true) + ) { + return (int)$results[ $tabs[0] ]; + } else if ( + !$isNested + && ($tabAction !== 'index' || $curController === $tabName) + && \in_array($tabName, $tabs, true) + ) { + return (int)$tid; + } else if ( + $isNested + && $curController !== $tabName + && !str_contains($tabName, '.') + && \in_array($tabAction, ['view', 'edit'], true) + && \in_array($tabAction, $tab_actions[$tabName], true) + ) { + return (int)$results[ $tabName ]; + } + + return (int)$tid; + } + + /** + * Check the belongsTo tree hierarchy + * + * @param string $modelName + * + * @return \Generator + * @since COmanage Registry v5.0.0 + */ + public function getHasManyAssociationModels(string $modelName): \Generator + { + $model = TableRegistry::getTableLocator()->get($modelName); + // We'll start by getting the set of models directly associated with the CO model. + $associations = $model->associations()->getByType(['hasMany', 'hasOne']); + + if(empty($associations)) { + // Yield null if empty + yield; + } + + foreach($associations as $ta) { + $this->setAssociation($ta->getClassName()); + yield $ta->getClassName(); + } + } + + /** + * Construct the link filter + * + * @param string $tab + * @param int|string $curId + * @param string $tabAction + * @param bool $isNested + * + * @return int[]|string[] + * @since COmanage Registry v5.0.0 + */ + public function getLinkFilter( + string $tab, + int|string $curId, + string $tabAction, + bool $isNested = false + ): array { + $vv_sub_nav_attributes = $this->getView()->get('vv_sub_nav_attributes'); + $subnav_tabs = $vv_sub_nav_attributes['tabs']; + $subnav_allowed_actions = $vv_sub_nav_attributes['action']; + if ($isNested) { + $subnav_tabs = $vv_sub_nav_attributes['nested']['tabs']; + $subnav_allowed_actions = $vv_sub_nav_attributes['nested']['action']; + } + $fullModelsName = $tab; + $modelName = $tab; + $curController = $this->getView()->getRequest()->getParam('controller'); + + // We have two use cases. The first one is for the Core models and the second one is for the + // plugins. In case we have a plugin we need to retrieve the name from the database + if(str_contains($tab, '.Plugin')) { + $modelName = $this->retrievePluginName($tab, (int)$curId); + $this->setPluginName($modelName); + $fullModelsName = $modelName; + } else if (str_contains($tab, '.Hierarchy')) { + $modelName = $this->retrievePluginName($tab, (int)$curId); + [$plugin, ] = explode('.', $modelName); + foreach ($this->getHasManyAssociationModels($modelName) as $association) { + $fullModelsName = $association; + break; + } + } else if(str_contains($tab, '@action')) { + [$modelName, ] = explode('@', $tab); + $fullModelsName = $modelName; + } else if(str_contains($tab, '.')) { + [$plugin, $modelName] = explode('.', $tab); + } + + $modelsTable = TableRegistry::getTableLocator()->get($fullModelsName); + $primary_link_list = $modelsTable->getPrimaryLinks(); + $primary_link = null; + if(count($primary_link_list) > 1) { + $primary_link = collection($primary_link_list) + ->filter(function($link) use ($subnav_tabs) { + $linkToClass = StringUtilities::foreignKeyToClassName($link); + return \in_array($linkToClass, $subnav_tabs, true); + })->first(); + } else if (\is_array($primary_link_list) && !empty($primary_link_list)) { + $primary_link = $primary_link_list[0]; + } + + $foreignKey = StringUtilities::classNameToForeignKey($modelName); + + return match(true) { + // The current controller and the tab controller match + // If the action is edit or view then we return the id since we actually have no filter + $fullModelsName === $curController + && $tabAction !== 'index' => ['id' => $curId], + // If the current controller and the tab constructed controller do not match + // but we have an edit or view action then we have no link filter and no id. We return empty + $fullModelsName !== $curController + && \in_array($tabAction, ['edit', 'view'], true) + && isset($subnav_allowed_actions[$fullModelsName]) => [], + // If the action is index then filter using the primary link + \in_array($tabAction, ['index'], true) + && $primary_link !== 'co_id' => [$primary_link => $curId], + // - If the primary link is the co_id, it means this is a root element. As a result, we will construct + // the link filter key from the controller itself. + $primary_link === 'co_id' => [$foreignKey => $curId], + // We fallback to the primary link directly. + default => [$primary_link => $curId] + }; + } + + /** + * @return string|null + * @since COmanage Registry v5.0.0 + */ + public function getPluginName(): ?string + { + return $this->pluginName; + } + + /** + * Calculate the Tab link action + * - First level Tab: + * The first level usually allows edit/view action for the first tab link and index for the rest. + * There are exceptions like: + * * External Identity Sources: This has a plugin structure. The first and second tab + * refer to the same action: + * - Plugin instantiation edit view + * - Plugin configuration view + * + * - Second level Tab: + * The second level follows the same logic. The first Tab Link is a view/edit + * while the rest are always an index Link + * + * @param string $tab + * @param bool $isNested + * + * @return string|null + * @since COmanage Registry v5.0.0 + */ + public function getTabAction(string $tab, bool $isNested = false): ?string + { + $vv_sub_nav_attributes = $this->getView()->get('vv_sub_nav_attributes'); + $subnav_tabs = $vv_sub_nav_attributes['tabs']; + $subnav_allowed_actions = $vv_sub_nav_attributes['action']; + if ($isNested) { + $subnav_tabs = $vv_sub_nav_attributes['nested']['tabs']; + $subnav_allowed_actions = $vv_sub_nav_attributes['nested']['action']; + } + $vv_action = $this->getView()->get('vv_action'); + $curController = $this->getView()->getRequest()->getParam('controller'); + + $modelName = $tab; + $controller = $modelName; + + $customActionRegex = '/^.*?(@action.)(\w+)/m'; + $customAction = preg_match_all($customActionRegex, $tab, $matches, PREG_SET_ORDER, 0); + return match(true) { + // We get the action from the configuration + filter_var($customAction, FILTER_VALIDATE_BOOLEAN) => $matches[0][2], + // First level ONLY: return the index action (applies to all tabs except the first one) + $subnav_tabs[0] !== $tab + && \in_array('index', $subnav_allowed_actions[$tab], true) => 'index', + // Second level ONLY: return the action of the url. We could just say edit + // but we might have a use case with a different action? + $controller === $curController + && \in_array($vv_action, $subnav_allowed_actions[$tab], true) + && $isNested => $vv_action, + // Return the first action we allow in the configuration + default => $subnav_allowed_actions[$tab][0] + }; + } + + /** + * @param string|null $pluginName + * + * @return void + * @since COmanage Registry v5.0.0 + */ + public function setPluginName(?string $pluginName): void + { + $this->pluginName = $pluginName; + } + + /** + * Get the plugin name from the database + * + * @param string $tab + * @param int $curId + * + * @return string + * @since COmanage Registry v5.0.0 + */ + public function retrievePluginName(string $tab, int $curId): string + { + // Get the name of the Core Model + [$coreModel, $dummy] = explode('.', $tab); + $ModelTable = TableRegistry::getTableLocator()->get($coreModel); + $response = $ModelTable + ->find() + ->select(['plugin']) + ->where(['id' => $curId]) + ->first(); + + return $response?->plugin; + } + + /** + * Get reference to Model Table + * + * @param string $modelsName + * + * @return Table + * @since COmanage Registry v5.0.0 + */ + public function getModelTableReference(string $modelsName): Table + { + return TableRegistry::getTableLocator()->get($modelsName); + } + + /** + * Select count(*) + * + * @param string $modelName Model name in `group_members` format + * @param array $whereClause where clause array + * + * @return int + * @since COmanage Registry v5.0.0 + */ + public function getModelTotalCount(string $modelName, array $whereClause): int + { + $modelsName = Inflector::camelize($modelName); + $ModelTable = TableRegistry::getTableLocator()->get($modelsName); + $count = $ModelTable->find() + ->where($whereClause) + ->count(); + + return $count; + } + + /** + * Get Person Status by ID + * + * @param int $personId + * + * @return string + * @since COmanage Registry v5.0.0 + */ + public function getPersonStatus(int $personId): string + { + $peopleTable = TableRegistry::getTableLocator()->get('people'); + $response = $peopleTable + ->find() + ->select(['status']) + ->where(['id' => $personId]) + ->first(); + + return $response?->status; + } + + /** + * Get Person Full Name by + * + * @param int $personId + * + * @return string + * @since COmanage Registry v5.0.0 + */ + public function getPersonPrimaryName(int $personId): string + { + $namesTable = TableRegistry::getTableLocator()->get('names'); + $response = $namesTable + ->find() + ->select(['given', 'family', 'middle', 'honorific', 'suffix']) + ->where(['person_id' => $personId]) + ->where(['primary_name' => true]) + ->first(); + + return $response?->full_name; + } + + /** + * @return string|null + * @since COmanage Registry v5.0.0 + */ + public function getAssociation(): ?string + { + return $this->association; + } + + /** + * @param string|null $association + * + * @return void + * @since COmanage Registry v5.0.0 + */ + public function setAssociation(?string $association): void + { + $this->association = $association; + } +} \ No newline at end of file diff --git a/app/src/View/Helper/VueHelper.php b/app/src/View/Helper/VueHelper.php index 552218571..ceef8a5a8 100644 --- a/app/src/View/Helper/VueHelper.php +++ b/app/src/View/Helper/VueHelper.php @@ -47,7 +47,7 @@ class VueHelper extends Helper { 'SuspendableStatusEnum.S' ], 'error' => [ - 'copy.error' + 'copy.javascript.clipboard' ], 'field' => [ 'email', diff --git a/app/templates/Dashboards/configuration.php b/app/templates/Dashboards/configuration.php index 94c51b1bb..1016a428e 100644 --- a/app/templates/Dashboards/configuration.php +++ b/app/templates/Dashboards/configuration.php @@ -43,7 +43,8 @@ $cfg): ?>
  • ' . $cfg['icon'] . '' + $iconClass = !empty($cfg['iconClass']) ? $cfg['iconClass'] : 'material-symbols'; + $linkContent = '' . '' . $label . ''; print $this->Html->link( $linkContent, @@ -63,7 +64,8 @@ $cfg): ?>
  • ' . $cfg['icon'] . '' + $iconClass = !empty($cfg['iconClass']) ? $cfg['iconClass'] : 'material-symbols'; + $linkContent = '' . '' . $label . ''; print $this->Html->link( $linkContent, @@ -83,7 +85,8 @@ $cfg): ?>
  • ' . $cfg['icon'] . '' + $iconClass = !empty($cfg['iconClass']) ? $cfg['iconClass'] : 'material-symbols'; + $linkContent = '' . '' . $label . ''; print $this->Html->link( $linkContent, @@ -105,8 +108,9 @@