From 045abe164861deab1f13aadb3de41c23e93f6928 Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Thu, 28 Nov 2024 06:31:14 -0500 Subject: [PATCH] Initial commit of Email Verifier Enroller Plugin (CFM-333) --- app/config/schema/schema.json | 21 +- .../resources/locales/en_US/core_enroller.po | 65 ++- .../Controller/EmailVerifiersController.php | 214 ++++++++ .../src/Lib/Enum/VerificationModeEnum.php | 38 ++ .../src/Model/Entity/EmailVerifier.php | 49 ++ .../src/Model/Entity/PetitionVerification.php | 49 ++ .../Table/BasicAttributeCollectorsTable.php | 30 +- .../src/Model/Table/EmailVerifiersTable.php | 475 ++++++++++++++++++ .../Model/Table/InvitationAcceptersTable.php | 9 - .../Table/PetitionVerificationsTable.php | 194 +++++++ .../CoreEnroller/src/config/plugin.json | 43 +- .../templates/EmailVerifiers/dispatch.inc | 147 ++++++ .../templates/EmailVerifiers/display.php | 24 + .../templates/EmailVerifiers/fields.inc | 45 ++ .../templates/EnrollmentAttributes/fields.inc | 2 +- app/resources/locales/en_US/enumeration.po | 72 ++- app/resources/locales/en_US/error.po | 15 + app/resources/locales/en_US/operation.po | 3 + app/resources/locales/en_US/result.po | 9 + .../Controller/EmailAddressesController.php | 2 +- .../Controller/StandardEnrollerController.php | 28 +- .../Lib/Enum/MessageTemplateContextEnum.php | 2 +- app/src/Lib/Enum/PetitionActionEnum.php | 1 + app/src/Lib/Enum/PetitionStatusEnum.php | 3 + app/src/Lib/Enum/VerificationMethodEnum.php | 38 ++ app/src/Lib/Random/RandomString.php | 23 + app/src/Lib/Traits/AutoViewVarsTrait.php | 10 +- .../Lib/Traits/EnrollmentControllerTrait.php | 25 +- app/src/Lib/Util/DeliveryUtilities.php | 41 +- app/src/Lib/Util/StringUtilities.php | 13 + app/src/Model/Entity/Verification.php | 53 ++ app/src/Model/Table/EmailAddressesTable.php | 11 +- app/src/Model/Table/MessageTemplatesTable.php | 6 +- .../Table/PetitionHistoryRecordsTable.php | 14 +- app/src/Model/Table/PetitionsTable.php | 8 +- app/src/Model/Table/VerificationsTable.php | 340 +++++++++++++ app/templates/EmailAddresses/fields.inc | 31 +- 37 files changed, 2049 insertions(+), 104 deletions(-) create mode 100644 app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php create mode 100644 app/plugins/CoreEnroller/src/Lib/Enum/VerificationModeEnum.php create mode 100644 app/plugins/CoreEnroller/src/Model/Entity/EmailVerifier.php create mode 100644 app/plugins/CoreEnroller/src/Model/Entity/PetitionVerification.php create mode 100644 app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php create mode 100644 app/plugins/CoreEnroller/src/Model/Table/PetitionVerificationsTable.php create mode 100644 app/plugins/CoreEnroller/templates/EmailVerifiers/dispatch.inc create mode 100644 app/plugins/CoreEnroller/templates/EmailVerifiers/display.php create mode 100644 app/plugins/CoreEnroller/templates/EmailVerifiers/fields.inc create mode 100644 app/src/Lib/Enum/VerificationMethodEnum.php create mode 100644 app/src/Model/Entity/Verification.php create mode 100644 app/src/Model/Table/VerificationsTable.php diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index 21e03464a..11ce07bc1 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -25,6 +25,7 @@ "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" }, @@ -402,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" } @@ -703,6 +704,22 @@ } }, + "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/resources/locales/en_US/core_enroller.po b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po index 7b03d6f22..20db7a858 100644 --- a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po +++ b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po @@ -31,9 +31,23 @@ msgstr "{0,plural,=1{Basic Attribute Collector} other{Basic Attribute Collectors msgid "controller.EnrollmentAttributes" msgstr "{0,plural,=1{Enrollment Attribute} other{Enrollment Attributes}}" -# These are pseudo-enumerations, only used for fields.inc -msgid "enumeration.DefaultValueValidityType.after" -msgstr "Days After Finalization" +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" @@ -44,6 +58,27 @@ 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" @@ -65,6 +100,24 @@ 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" @@ -164,6 +217,12 @@ 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}" diff --git a/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php b/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php new file mode 100644 index 000000000..179713650 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php @@ -0,0 +1,214 @@ + [ + '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, $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/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/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/Table/BasicAttributeCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php index 1ec67cead..cd0ac0766 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php @@ -30,6 +30,7 @@ namespace CoreEnroller\Model\Table; use Cake\Datasource\ConnectionManager; +use Cake\Datasource\EntityInterface; use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; @@ -41,11 +42,12 @@ class BasicAttributeCollectorsTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; use \App\Lib\Traits\CoLinkTrait; + use \App\Lib\Traits\LayoutTrait; + use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\LayoutTrait; /** * Perform Cake Model initialization. @@ -134,7 +136,7 @@ public function initialize(array $config): void { * Perform steps necessary to finalize the Petition. * * @since COmanage Registry v5.1.0 - * @param int $id Attribute Collector ID + * @param int $id Basic Attribute Collector ID * @param Petition $petition Petition * @return bool true on success */ @@ -318,4 +320,28 @@ public function validationDefault(Validator $validator): Validator { 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/InvitationAcceptersTable.php b/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php index 2fbdfe5fa..f3f19eeb3 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php @@ -162,15 +162,6 @@ public function prepare( $petition->status = PetitionStatusEnum::PendingAcceptance; $Petitions->saveOrFail($petition); - - // Record history - $Petitions->PetitionHistoryRecords->record( - petitionId: $petition->id, - enrollmentFlowStepId: $step->id, - action: PetitionActionEnum::InvitationViewed, - comment: __d('result', 'Petitions.viewed.inv') - // actorPersonId - ); } return true; 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..6b7438bb6 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionVerificationsTable.php @@ -0,0 +1,194 @@ +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 string $mail Email Address that was verified + * @return bool true if validation is successful + * @throws \InvalidArgumentException + */ + + public function verifyCode(int $petitionId, string $mail, string $code) { + // 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(); + + return $this->Verifications->verifyCode($pVerification->verification_id, $code); + } + + /** + * 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 index 679d2cf60..dbb666b8a 100644 --- a/app/plugins/CoreEnroller/src/config/plugin.json +++ b/app/plugins/CoreEnroller/src/config/plugin.json @@ -37,31 +37,17 @@ "basic_attribute_collectors_i5": { "needed": false, "columns": [ "cou_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" ] } - } - }, "email_verifiers": { "columns": { "id": {}, "enrollment_flow_step_id": {}, - "mode": { "type": "string", "size": 2 } + "mode": { "type": "string", "size": 2 }, + "message_template_id": {}, + "request_validity": { "type": "integer" } }, "indexes": { - "email_verifiers_i1": { "columns": [ "enrollment_flow_step_id" ] } + "email_verifiers_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "email_verifiers_i2": { "needed": false, "columns": [ "message_template_id" ] } } }, "enrollment_attributes": { @@ -132,6 +118,23 @@ "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": {}, @@ -147,7 +150,7 @@ "id": {}, "petition_id": {}, "mail": {}, - "verification_id": {} + "verification_id": { "type": "integer", "foreignkey": { "table": "verifications", "column": "id" } } }, "indexes": { "petition_verifications_i1": { "columns": [ "petition_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..84fe135d5 --- /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_verified_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/fields.inc b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc index 9b17608f7..0ea015f21 100644 --- a/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc +++ b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc @@ -220,7 +220,7 @@ if($attribute_type === 'valid_through') { print $this->element('form/listItem', [ 'arguments' => [ 'fieldName' => 'default_value', - 'fieldDescription' => __d('core_enroller', 'enumeration.DefaultValueValidityType.after'), + 'fieldDescription' => __d('core_enroller', 'field.AttributeCollectors.valid_through.default.after.desc'), 'fieldOptions' => [ 'type' => 'number' ] diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index c6f01644c..e730e6c30 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -177,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" @@ -336,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" @@ -354,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" @@ -453,6 +462,9 @@ msgstr "Status Updated" msgid "PetitionStatusEnum.A" msgstr "Active" +msgid "PetitionStatusEnum.EV" +msgstr "Email Verified" + msgid "PetitionStatusEnum.Y" msgstr "Approved" @@ -478,9 +490,18 @@ 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" @@ -532,14 +553,8 @@ msgstr "Street" msgid "RequiredAddressFieldsEnum.street,locality,state,postal_code" msgstr "Street, City, State, Postal Code" -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 "RequiredAddressFieldsEnum.street,locality,state,postal_code,country" +msgstr "Street, City, State, Postal Code, Country" msgid "RequiredNameFieldsEnum.given" msgstr "Given" @@ -631,6 +646,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 dfcad93d6..7e7e40d7a 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -351,3 +351,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/operation.po b/app/resources/locales/en_US/operation.po index 687fda9fe..0780d84d0 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -285,6 +285,9 @@ msgstr "Switch To This CO" msgid "unfreeze" msgstr "Unfreeze" +msgid "verify" +msgstr "Verify" + msgid "view" msgstr "View" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index 6f940ea69..abf95555b 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -217,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/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/StandardEnrollerController.php b/app/src/Controller/StandardEnrollerController.php index e320eb39f..918a3a70a 100644 --- a/app/src/Controller/StandardEnrollerController.php +++ b/app/src/Controller/StandardEnrollerController.php @@ -21,7 +21,7 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ @@ -45,24 +45,30 @@ class StandardEnrollerController extends StandardPluginController { /** * Callback run prior to the request render. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param EventInterface $event Cake Event * @return \Cake\Http\Response HTTP Response */ public function beforeRender(\Cake\Event\EventInterface $event) { $Petition = TableRegistry::getTableLocator()->get('Petitions'); - // Make the Petition available to the view - $this->set( - 'vv_petition', - $this->petition?->id ? + + // 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() : null + ->firstOrFail() ); + } else { + $this->set('vv_petition', null); + } return parent::beforeRender($event); } @@ -70,7 +76,7 @@ public function beforeRender(\Cake\Event\EventInterface $event) { /** * Calculate authorization for the current request. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @return bool True if the current request is permitted, false otherwise */ @@ -143,7 +149,7 @@ public function calculatePermission(): bool { /** * Record a result for the Enrollment Step and redirect to the next Step. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param int $enrollmentFlowStepId Enrollment Flow Step Id * @param int $petitionId Petition ID * @param string $status PetitionStatusEnum @@ -174,7 +180,7 @@ protected function finishStep( /** * Obtain the Petition artifact associated with this request. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @return Petition Petition artifact */ @@ -185,7 +191,7 @@ public function getPetition(): ?\App\Model\Entity\Petition { /** * Indicate whether this Controller will handle some or all authnz. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param EventInterface $event Cake event, ie: from beforeFilter * @return string "no", "open", "authz", or "yes" */ diff --git a/app/src/Lib/Enum/MessageTemplateContextEnum.php b/app/src/Lib/Enum/MessageTemplateContextEnum.php index 1b3ec1660..7fd09e849 100644 --- a/app/src/Lib/Enum/MessageTemplateContextEnum.php +++ b/app/src/Lib/Enum/MessageTemplateContextEnum.php @@ -36,7 +36,7 @@ class MessageTemplateContextEnum extends StandardEnum { // const EnrollmentFinalization = 'EF'; const EnrollmentHandoff = 'EH'; // const EnrollmentInvitation = 'EI'; -- we probably don't want to use this, use Handoff instead -// const EnrollmentVerification = 'EV'; // const ExpirationNotification = 'XN'; const Plugin = 'PL'; + const Verification = 'V'; } \ No newline at end of file diff --git a/app/src/Lib/Enum/PetitionActionEnum.php b/app/src/Lib/Enum/PetitionActionEnum.php index 89a59122d..88f713934 100644 --- a/app/src/Lib/Enum/PetitionActionEnum.php +++ b/app/src/Lib/Enum/PetitionActionEnum.php @@ -31,6 +31,7 @@ class PetitionActionEnum extends StandardEnum { const AttributesUpdated = 'AU'; + const EmailVerified = 'EV'; const Finalized = 'F'; const InvitationViewed = 'IV'; const StatusUpdated = 'SU'; diff --git a/app/src/Lib/Enum/PetitionStatusEnum.php b/app/src/Lib/Enum/PetitionStatusEnum.php index 6d7e08662..936466e88 100644 --- a/app/src/Lib/Enum/PetitionStatusEnum.php +++ b/app/src/Lib/Enum/PetitionStatusEnum.php @@ -42,5 +42,8 @@ class PetitionStatusEnum extends StandardEnum { const Finalized = 'F'; const PendingAcceptance = 'PC'; const PendingApproval = 'PA'; + const PendingVerification = 'PE'; const PendingVetting = 'PV'; + const Verified = 'VE'; + const Vetted = 'VT'; } \ No newline at end of file diff --git a/app/src/Lib/Enum/VerificationMethodEnum.php b/app/src/Lib/Enum/VerificationMethodEnum.php new file mode 100644 index 000000000..c28d92b1a --- /dev/null +++ b/app/src/Lib/Enum/VerificationMethodEnum.php @@ -0,0 +1,38 @@ +$avvmodel = TableRegistry::getTableLocator()->get($avvmodel); + $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($this->$avvmodel->getSchema()->hasColumn('co_id')) { + if($AModel->getSchema()->hasColumn('co_id')) { $avv['where']['co_id'] = $coId; } - $query = $this->$avvmodel->find($avv['type'] == 'auxiliary' ? 'all' : 'list'); + $query = $AModel->find($avv['type'] == 'auxiliary' ? 'all' : 'list'); if(!empty($avv['find'])) { if($avv['find'] == 'filterPrimaryLink') { @@ -190,8 +190,8 @@ public function calculateAutoViewVars(int|null $coId, Object $obj = null): \Gene } // Sort the list by display field - if(!empty($avv['model']) && method_exists($this->$avvmodel, "getDisplayField")) { - $query->order([$this->$avvmodel->getDisplayField() => 'ASC']); + if(!empty($avv['model']) && method_exists($AModel, "getDisplayField")) { + $query->order([$AModel->getDisplayField() => 'ASC']); } elseif(method_exists($table, "getDisplayField")) { $query->order([$table->getDisplayField() => 'ASC']); } diff --git a/app/src/Lib/Traits/EnrollmentControllerTrait.php b/app/src/Lib/Traits/EnrollmentControllerTrait.php index 27aae0f1e..bd67e8643 100644 --- a/app/src/Lib/Traits/EnrollmentControllerTrait.php +++ b/app/src/Lib/Traits/EnrollmentControllerTrait.php @@ -168,6 +168,27 @@ protected function getCurrentActor(?int $petitionId=null): array { 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. @@ -316,6 +337,8 @@ protected function transitionToStep(int $petitionId, bool $start=false) { 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) && ($reqToken == $petition->token)) { // Token match. The roles are whichever of petitioner and enrollee // _don't_ have a Petition value. @@ -337,4 +360,4 @@ protected function validateToken(Petition $petition): array|bool { return false; } -} \ No newline at end of file +} diff --git a/app/src/Lib/Util/DeliveryUtilities.php b/app/src/Lib/Util/DeliveryUtilities.php index f5a15a830..8b7c7db42 100644 --- a/app/src/Lib/Util/DeliveryUtilities.php +++ b/app/src/Lib/Util/DeliveryUtilities.php @@ -158,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") */ @@ -166,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) { @@ -189,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 d25ba6336..55dde467f 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -275,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/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/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php index 62b41c3db..18237d643 100644 --- a/app/src/Model/Table/EmailAddressesTable.php +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -91,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'); @@ -99,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' => [ @@ -151,6 +155,8 @@ public function afterMarshal( $entity->verified = false; $data['verified'] = false; + + $this->Verifications->unverify($entity->id); } } @@ -240,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/MessageTemplatesTable.php b/app/src/Model/Table/MessageTemplatesTable.php index 94ad0d413..79381c71e 100644 --- a/app/src/Model/Table/MessageTemplatesTable.php +++ b/app/src/Model/Table/MessageTemplatesTable.php @@ -111,6 +111,7 @@ public function initialize(array $config): void { * @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 @@ -120,7 +121,8 @@ public function generateMessage( int $id, array $entryUrl=[], \App\Model\Entity\Notification $notification=null, - \App\Model\Entity\Person $subjectPerson=null + \App\Model\Entity\Person $subjectPerson=null, + ?string $code=null ): array { // We return "" instead of null by default for compatibility with DeliveryUtilities $ret = [ @@ -157,6 +159,8 @@ public function generateMessage( $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name; } + $substitutions['VERIFICATION_CODE'] = $code; + // Finally run the substitutions through each of the supported parts foreach(array_keys($ret) as $part) { diff --git a/app/src/Model/Table/PetitionHistoryRecordsTable.php b/app/src/Model/Table/PetitionHistoryRecordsTable.php index c6809289d..64791c497 100644 --- a/app/src/Model/Table/PetitionHistoryRecordsTable.php +++ b/app/src/Model/Table/PetitionHistoryRecordsTable.php @@ -132,22 +132,22 @@ public function generateDisplayField(\App\Model\Entity\JobHistoryRecord $entity) return __d('controller', 'PetitionHistoryRecords', [1]); } - + /** * Record a Petition History Record. * * @since COmanage Registry v5.0.0 - * @param int $petitionId Petition ID - * @param int|null $enrollmentFlowStepId Enrollment Flow Step ID, or null for start or finalize - * @param string $action PetitionActionEnum - * @param string $comment Comment - * @param int|null $actorPersonId Actor Person ID + * @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, + ?int $enrollmentFlowStepId=null, string $action, string $comment, ?int $actorPersonId=null diff --git a/app/src/Model/Table/PetitionsTable.php b/app/src/Model/Table/PetitionsTable.php index 9c6352c42..913029d84 100644 --- a/app/src/Model/Table/PetitionsTable.php +++ b/app/src/Model/Table/PetitionsTable.php @@ -90,6 +90,10 @@ public function initialize(array $config): void { ->setDependent(true) ->setCascadeCallbacks(true); + $this->hasOne('Verifications') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->setDisplayField('id'); $this->setPrimaryLink('enrollment_flow_id'); @@ -110,8 +114,8 @@ public function initialize(array $config): void { 'EnrollmentFlows' => ['EnrollmentFlowSteps' => ['sort' => ['ordr' => 'ASC']]], 'EnrolleePeople' => ['PrimaryName' => ['foreignKey' => 'person_id']], 'PetitionerPeople' => ['PrimaryName' => ['foreignKey' => 'person_id']], - 'PetitionStepResults', - 'PetitionHistoryRecords' + 'PetitionHistoryRecords', + 'PetitionStepResults' ]); $this->setAutoViewVars([ diff --git a/app/src/Model/Table/VerificationsTable.php b/app/src/Model/Table/VerificationsTable.php new file mode 100644 index 000000000..78e744c10 --- /dev/null +++ b/app/src/Model/Table/VerificationsTable.php @@ -0,0 +1,340 @@ +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) { + $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); + } + + /** + * 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/templates/EmailAddresses/fields.inc b/app/templates/EmailAddresses/fields.inc index 3c4d5065c..b1ec9d109 100644 --- a/app/templates/EmailAddresses/fields.inc +++ b/app/templates/EmailAddresses/fields.inc @@ -25,6 +25,8 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ +use \App\Lib\Enum\VerificationMethodEnum; + if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { print $this->element('form/listItem', [ 'arguments' => [ @@ -44,14 +46,27 @@ if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { 'fieldName' => 'description' ]]); -// XXX CFM-129 need to implement verification - print $this->element('form/listItem', [ - 'arguments' => [ - 'fieldName' => 'verified', - 'fieldOptions' => [ - 'readonly' => true - ] - ]]); + if($vv_obj->verified && !empty($vv_obj->verification)) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'verified', + 'fieldOptions' => [ + 'readonly' => true + ], + 'status' => __d('result', 'Verifications.status', [ VerificationMethodEnum::getLocalization($vv_obj->verification->method), + $this->Time->nice($vv_obj->verification->verification_time, $vv_tz) + ]) + ]]); + } else { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'verified', + 'fieldOptions' => [ + 'readonly' => true + ], + 'status' => __d('enumeration', 'YesBooleanEnum.0') + ]]); + } print $this->element('form/listItem', [ 'arguments' => [