+
+
+ ';
+
+ 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 '
+
+
+
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));
+}
+
+?>
+
+
+ = $this->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): ?>
diff --git a/app/templates/Pipelines/fields.inc b/app/templates/Pipelines/fields.inc
index 58a956008..59468f406 100644
--- a/app/templates/Pipelines/fields.inc
+++ b/app/templates/Pipelines/fields.inc
@@ -68,8 +68,7 @@ if($vv_action == 'add' || $vv_action == 'edit') {
'fieldName' => 'match_strategy',
'fieldOptions' => [
'onChange' => 'updateGadgets()'
- ],
- 'fieldType' => 'select'
+ ]
]
]);
diff --git a/app/templates/Standard/add-edit-view.php b/app/templates/Standard/add-edit-view.php
index fd4386874..179c8ec70 100644
--- a/app/templates/Standard/add-edit-view.php
+++ b/app/templates/Standard/add-edit-view.php
@@ -49,10 +49,6 @@
}
}
-if(file_exists($templatePath . DS . "fields-links.inc")) {
- include($templatePath . DS . "fields-links.inc");
-}
-
// $linkFilter is used for models that belong to a specific parent model (eg: co_id)
$linkFilter = [];
@@ -69,26 +65,39 @@
$flashArgs['vv_banners'] = $banners;
}
-// If subnavigation is present a supertitle and the subnavigation will be placed above
-// the normal page title. The flash messages will be shown up there as well.
-if(!empty($subnav)) {
- // Include the $flashArgs for the subnavigation element
- $subnav['flashArgs'] = $flashArgs;
- if(!empty($topLinks) && ($modelsName == 'People' && $vv_action == 'edit')) {
- // We are in Person canvas mode: pass along the top links for building the Actions menu.
- $subnav['topLinks'] = $topLinks;
+// Subnavigation
+$hasSubnav = false;
+if(file_exists(ROOT . DS . 'templates' . DS . 'Standard/subnavigation.inc')) {
+ include(ROOT . DS . 'templates' . DS . 'Standard/subnavigation.inc');
+ $hasSubnav = $this->get('hasSupertitle');
+}
+
+// When under a subnavigation we do not want a title with Edit or Add or View followed by a number
+// We might find ourselved in that situation since we calculate the title for the breadcrumbs and
+// this simple description is not wrong. It is just not appropriate for the subnavigation title
+$title = $vv_title;
+$re = '/^(Add|Edit|View)\s([a-zA-Z]+?)\s[0-9]+/m';
+$pregMatch = preg_match_all($re, $vv_title, $matches, PREG_SET_ORDER, 0);
+if (
+ $hasSubnav
+ && filter_var($pregMatch, FILTER_VALIDATE_BOOLEAN)
+) {
+ $vvObjTable = $this->Tab->getModelTableReference($fullModelsName);
+ $displayField = $vvObjTable->getDisplayField();
+ if($displayField !== 'id') {
+ $title = __d('operation', "$vv_action.$modelsName.a", [$vv_obj->$displayField]);
+ } else {
+ $title = __d('operation', $vv_action . '.a', [__d('controller', $modelsName, 1)]);
}
- // Generate the subnavigation title and tabs
- print $this->element('subnavigation', $subnav);
}
?>
+
+set('vv_fields_inc', 'dispatch.inc');
+$this->set('vv_submit_button_label', __d('operation', 'continue'));
+
+// By default, the form will POST to the current controller
+// Note we need to open the form for view so Cake will autopopulate values
+print $this->Form->create();
+
+// Form body
+print '
';
+
+// Inject the Petition ID into the form, though it will most likely
+// still be available in the URL.
+print $this->Form->hidden('petition_id', ['value' => $vv_petition->id]);
+// Inject the token, if indicated.
+if(isset($vv_token_ok) && $vv_token_ok && !empty($vv_petition->token)) {
+ print $this->Form->hidden('token', ['value' => $vv_petition->token]);
+}
+
+print $this->Form->end();
diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php
index 1e12d4e9d..1d07fe396 100644
--- a/app/templates/Standard/index.php
+++ b/app/templates/Standard/index.php
@@ -58,10 +58,6 @@
if(!is_readable($incFile)) {
throw new \InvalidArgumentException("$incFile is not readable");
}
-include($incFile);
-if(isset($indexColumns)) {
- $this->set('vv_indexColumns', $indexColumns);
-}
// $linkFilter is used for models that belong to a specific parent model (eg: co_id)
$linkFilter = [];
@@ -79,19 +75,24 @@
$flashArgs['vv_banners'] = $banners;
}
-// If subnavigation is present a supertitle and the subnavigation will be placed above
-// the normal page title. The flash messages will be shown up there as well.
-if(!empty($subnav)) {
- // Include the $flashArgs for the subnavigation element
- $subnav['flashArgs'] = $flashArgs;
- // Generate the subnavigation title and tabs
- print $this->element('subnavigation', $subnav);
+// First, complete all the initial calculations and then start including
+// this way we have more ViewVars available
+include($incFile);
+if(isset($indexColumns)) {
+ $this->set('vv_indexColumns', $indexColumns);
+}
+
+// Subnavigation
+$hasSubnav = false;
+if(file_exists(ROOT . DS . 'templates' . DS . 'Standard/subnavigation.inc')) {
+ include(ROOT . DS . 'templates' . DS . 'Standard/subnavigation.inc');
+ $hasSubnav = $this->get('hasSupertitle');
}
?>
-
+
= $vv_title; ?>
= $vv_title; ?>
@@ -102,32 +103,48 @@
// Action list for top menu dropdown / button listing
// Index view top link action item can be atomized using the user's identifier
// since there will not always be an object id available. Like the case of add action
- if($vv_permissions['add']) {
- $action_args = array();
+ if(
+ // Action menu is only supported for index views and add actions
+ $vv_permissions['add']
+ // A plugin might provide its own set of action_args, in this scenario
+ // we will bypass the default behavior
+ && empty($action_args)
+ ) {
+ $action_args = [];
$action_args['vv_attr_id'] = $vv_user['username'];
- $action_args['vv_actions'] = array();
+ $action_args['vv_actions'] = [];
// Include the Add link to actions menu unless suppressed by the page
if(empty($suppressAddLink)) {
- $action_args['vv_actions'][] = [
+ $action_configuration = [
'order' => $this->Menu->getMenuOrder('Add'),
'icon' => $this->Menu->getMenuIcon('Add'),
+ 'iconClass' => 'material-symbols-outlined',
'url' => [
'controller' => $modelsName,
'action' => 'add',
'?' => $linkFilter
],
'label' => __d(
- 'operation',
- 'add.a',
- \App\Lib\Util\StringUtilities::localizeController(
- controllerName: $modelsName,
- pluginName: $this->getPlugin(),
- plural: false))
+ 'operation',
+ 'add.a',
+ \App\Lib\Util\StringUtilities::localizeController(
+ controllerName: $modelsName,
+ pluginName: $this->getPlugin(),
+ plural: false))
];
+
+ // Check to see if the model names a specific layout
+ if(method_exists($modelsTable, 'getLayout')) {
+ $action_configuration['class'] = 'cm-modal-link nospin'; // launch this in a modal
+ $action_configuration['dataAttrs'] = [
+ ['data-cm-modal-title', __d('operation', 'EnrollmentAttributes', 1)]
+ ];
+ }
+
+ $action_args['vv_actions'][] = $action_configuration;
}
-
-
+
if(!empty($peoplePicker)) {
// There is a page-level autocomplete people picker.
$action_args['vv_people_picker'] = $peoplePicker;
@@ -170,8 +187,7 @@
';
}
@@ -473,8 +495,14 @@
case 'echo':
default:
// By default our label is the column value, but it might be overridden
- $label = $entity->$col . $suffix;
+ $label = $prefix . $entity->$col . $suffix;
+ // If there is no calculated default value but a default is configured,
+ // use that instead
+ if(empty($label) && !empty($cfg['default'])) {
+ $label = $cfg['default'];
+ }
+
if(!empty($cfg['model']) && !empty($cfg['field'])) {
$m = $cfg['model'];
$f = $cfg['field'];
@@ -523,7 +551,7 @@
$linkClass .= ' row-link-edit';
} elseif ($a == 'view') {
$linkClass .= ' row-link-view';
- $readOnlyIcon = ' edit_off';
+ $readOnlyIcon = ' edit_off';
} else {
$linkClass .= ' row-link-' . $a;
}
@@ -573,7 +601,7 @@
$linkClass .= ' row-link-edit';
} elseif ($a == 'view') {
$linkClass .= ' row-link-view';
- $readOnlyIcon = ' edit_off';
+ $readOnlyIcon = ' edit_off';
}
$args = ['class' => $linkClass];
$isFirstLink = false;
diff --git a/app/templates/Standard/subnavigation.inc b/app/templates/Standard/subnavigation.inc
new file mode 100644
index 000000000..9192aff94
--- /dev/null
+++ b/app/templates/Standard/subnavigation.inc
@@ -0,0 +1,57 @@
+getPlugin()) ? $this->getPlugin() . '.' . $modelsName : $modelsName;
+$modelsTable = $this->Tab->getModelTableReference($fullModelsName);
+if(method_exists($modelsTable, 'getTabsConfig')) {
+ $tabsConfiguration = $modelsTable->getTabsConfig($vv_action);
+ $subnav = [
+ 'flashArgs' => $flashArgs ?? [],
+ ...$tabsConfiguration
+ ];
+ if(!empty($topLinks) && $vv_action == 'edit') {
+ // Pass along the toplink. These are useful for building the action menu and the plugin sub navigation
+ $subnav['topLinks'] = $topLinks;
+ }
+ if (
+ ( // Simple use case
+ isset($tabsConfiguration['action'][$fullModelsName])
+ && in_array($vv_action, $tabsConfiguration['action'][$fullModelsName], true)
+ )
+ ||
+ ( // Use case with two levels
+ isset($tabsConfiguration['nested']['action'][$fullModelsName])
+ && in_array($vv_action, $tabsConfiguration['nested']['action'][$fullModelsName], true)
+ )
+ ) {
+ print $this->element('subnavigation/navBar', ['subNavAttributes' => $subnav]);
+ }
+}
\ No newline at end of file
diff --git a/app/templates/TelephoneNumbers/fields-nav.inc b/app/templates/TelephoneNumbers/fields-nav.inc
deleted file mode 100644
index e6f2507e6..000000000
--- a/app/templates/TelephoneNumbers/fields-nav.inc
+++ /dev/null
@@ -1,35 +0,0 @@
- 'person',
- 'active' => 'person',
- 'subActive' => 'telephone_numbers'
-];
\ No newline at end of file
diff --git a/app/templates/element/badgeList.php b/app/templates/element/badgeList.php
index 4c4b3f66b..872e477bc 100644
--- a/app/templates/element/badgeList.php
+++ b/app/templates/element/badgeList.php
@@ -27,7 +27,7 @@
* 'color' => BadgeColorModeEnum::Blue,
* 'outline' => false,
* 'pill' => true,
- * 'icon' => 'material-icons-key',
+ * 'icon' => 'material-symbols-key',
* ),
* );
*
@@ -54,7 +54,7 @@
$badge_classes[] = "rounded-pill";
}
if(!empty($badge['icon'])) {
- $icon = '' . $badge["icon"] .'';
+ $icon = '' . $badge["icon"] .'';
}
if(isset($badge['outline']) && $badge['outline']) {
$badge_classes[] = "bg-outline-" . $badge['color'];
diff --git a/app/templates/element/filter/default.php b/app/templates/element/filter/default.php
index 40aa654df..dd15a7e16 100644
--- a/app/templates/element/filter/default.php
+++ b/app/templates/element/filter/default.php
@@ -47,7 +47,7 @@
$label = Inflector::humanize(
Inflector::underscore(
- $options['label'] ?? $columns[$key]['label']
+ strtolower($options['label'] ?? $columns[$key]['label'])
)
);
diff --git a/app/templates/element/filter/legend.php b/app/templates/element/filter/legend.php
index 06a84d9c8..8380a8eb3 100644
--- a/app/templates/element/filter/legend.php
+++ b/app/templates/element/filter/legend.php
@@ -40,7 +40,7 @@
diff --git a/app/templates/element/filter/topButtons.php b/app/templates/element/filter/topButtons.php
index ee318888b..e5cea0050 100644
--- a/app/templates/element/filter/topButtons.php
+++ b/app/templates/element/filter/topButtons.php
@@ -74,7 +74,7 @@
data-identifier="= $data_identifier ?>"
type="button" aria-controls="= $aria_controls ?>"
title="= __d('operation', 'clear.filters',[2]) ?>">
- cancel
+ cancel= $filter_title ?>
diff --git a/app/templates/Names/fields-nav.inc b/app/templates/element/form/elementFallback.php
similarity index 80%
rename from app/templates/Names/fields-nav.inc
rename to app/templates/element/form/elementFallback.php
index 955afd929..feb181049 100644
--- a/app/templates/Names/fields-nav.inc
+++ b/app/templates/element/form/elementFallback.php
@@ -1,6 +1,6 @@
'person',
- 'active' => 'person',
- 'subActive' => 'names'
-];
\ No newline at end of file
+declare(strict_types = 1);
+?>
+
+= __d('field', 'element_fallback') ?>
diff --git a/app/templates/element/form/fieldDiv.php b/app/templates/element/form/fieldDiv.php
index 2f383ca68..53a8e6284 100644
--- a/app/templates/element/form/fieldDiv.php
+++ b/app/templates/element/form/fieldDiv.php
@@ -34,6 +34,12 @@
// Name Div
print $this->element('form/nameDiv');
+ // This configuration isn't necessary anymore.
+ if(isset($vv_field_arguments['fieldDescription'])) {
+ unset($vv_field_arguments['fieldDescription']);
+ $this->set('vv_field_arguments', $vv_field_arguments);
+ }
+
// Info Div
if(isset($vv_field_arguments['fieldPrefix'])) {
print $this->element('form/infoDiv/withPrefix');
diff --git a/app/templates/element/form/infoDiv/autocomplete.php b/app/templates/element/form/infoDiv/autocomplete.php
index 787a54c1a..c97c8e365 100644
--- a/app/templates/element/form/infoDiv/autocomplete.php
+++ b/app/templates/element/form/infoDiv/autocomplete.php
@@ -54,6 +54,6 @@
print $this->Form->hidden($fieldName, $vv_field_arguments['fieldOptions']) . $this->element('peopleAutocomplete', $autocompleteArgs);
?>
- info
+ info
= __d('operation','autocomplete.people.desc',['2']) ?>
diff --git a/app/templates/element/form/nameDiv.php b/app/templates/element/form/nameDiv.php
index dfd74c93c..db9774612 100644
--- a/app/templates/element/form/nameDiv.php
+++ b/app/templates/element/form/nameDiv.php
@@ -57,6 +57,7 @@
[$label, $desc] = $this->Field->calculateLabelAndDescription($fn);
$label = $vv_field_arguments['fieldLabel'] ?? $label;
+$desc = $vv_field_arguments['fieldDescription'] ?? $desc;
// We determine if a field is rquired by first getting the "expected" value
// from FieldHelper, then overriding that value if an argument was passed in.
diff --git a/app/templates/element/form/submit.php b/app/templates/element/form/submit.php
index 2063db9f4..70b6ab0a0 100644
--- a/app/templates/element/form/submit.php
+++ b/app/templates/element/form/submit.php
@@ -37,6 +37,11 @@
@@ -53,7 +54,8 @@
if(!isset($suppress_submit) || !$suppress_submit) {
// The Submit element will be printed only if we are adding or updating, and if not
// suppressed by the field configuration
- print $this->element('form/submit', ['label' => __d('operation', 'save')]);
+ $vv_submit_button_label = $vv_submit_button_label ?? __d('operation', 'save');
+ print $this->element('form/submit', ['label' => $vv_submit_button_label]);
}
?>