From 40a13c16eb178a65cec0decaaa060381bc62bef7 Mon Sep 17 00:00:00 2001 From: yuri Date: Mon, 14 May 2018 11:49:25 +0300 Subject: [PATCH] data privacy --- application/Espo/Acl/EmailAddress.php | 63 +++++ application/Espo/Acl/PhoneNumber.php | 63 +++++ application/Espo/AclPortal/EmailAddress.php | 56 +++++ application/Espo/AclPortal/PhoneNumber.php | 57 +++++ application/Espo/Controllers/DataPrivacy.php | 55 +++++ .../Templates/Metadata/Person/entityDefs.json | 12 +- .../Templates/Metadata/Person/scopes.json | 3 +- .../metadata/entityDefs/Account.json | 3 +- .../metadata/entityDefs/Contact.json | 12 +- .../Resources/metadata/entityDefs/Lead.json | 12 +- .../Resources/metadata/scopes/Account.json | 3 +- .../Resources/metadata/scopes/Contact.json | 3 +- .../Crm/Resources/metadata/scopes/Lead.json | 3 +- .../Espo/Modules/Crm/Services/MassEmail.php | 1 + .../Espo/Repositories/EmailAddress.php | 148 +++++++++--- application/Espo/Repositories/PhoneNumber.php | 123 +++++++--- .../Espo/Resources/i18n/en_US/Admin.json | 3 +- .../Espo/Resources/i18n/en_US/Global.json | 8 +- .../Espo/Resources/i18n/en_US/Role.json | 6 +- .../Espo/Resources/i18n/en_US/Settings.json | 6 +- .../Espo/Resources/layouts/Role/detail.json | 15 +- .../Resources/layouts/Settings/settings.json | 2 +- .../Espo/Resources/metadata/app/acl.json | 12 +- .../Resources/metadata/entityDefs/Role.json | 7 + .../metadata/entityDefs/Settings.json | 4 + .../Resources/metadata/fields/address.json | 3 +- .../Espo/Resources/metadata/fields/array.json | 3 +- .../metadata/fields/attachmentMultiple.json | 3 +- .../Resources/metadata/fields/currency.json | 3 +- .../Espo/Resources/metadata/fields/date.json | 3 +- .../Resources/metadata/fields/datetime.json | 3 +- .../metadata/fields/datetimeOptional.json | 3 +- .../Espo/Resources/metadata/fields/email.json | 3 +- .../Espo/Resources/metadata/fields/enum.json | 3 +- .../Espo/Resources/metadata/fields/file.json | 3 +- .../Espo/Resources/metadata/fields/image.json | 3 +- .../Espo/Resources/metadata/fields/int.json | 3 +- .../Resources/metadata/fields/multiEnum.json | 3 +- .../Resources/metadata/fields/personName.json | 3 +- .../Espo/Resources/metadata/fields/phone.json | 3 +- .../Espo/Resources/metadata/fields/text.json | 3 +- .../Espo/Resources/metadata/fields/url.json | 3 +- .../Resources/metadata/fields/varchar.json | 3 +- .../Resources/metadata/fields/wysiwyg.json | 3 +- application/Espo/Services/DataPrivacy.php | 215 ++++++++++++++++++ client/res/templates/fields/email/detail.tpl | 4 + client/res/templates/fields/email/list.tpl | 2 +- client/res/templates/fields/phone/detail.tpl | 10 +- client/res/templates/fields/phone/list.tpl | 2 +- .../personal-data/modals/personal-data.tpl | 1 + .../templates/personal-data/record/record.tpl | 22 ++ client/src/email-helper.js | 30 ++- client/src/views/admin/field-manager/edit.js | 22 ++ client/src/views/fields/email.js | 30 ++- client/src/views/fields/phone.js | 19 +- .../personal-data/modals/personal-data.js | 105 +++++++++ .../src/views/personal-data/record/record.js | 153 +++++++++++++ client/src/views/record/detail.js | 22 ++ 58 files changed, 1247 insertions(+), 127 deletions(-) create mode 100644 application/Espo/Acl/EmailAddress.php create mode 100644 application/Espo/Acl/PhoneNumber.php create mode 100644 application/Espo/AclPortal/EmailAddress.php create mode 100644 application/Espo/AclPortal/PhoneNumber.php create mode 100644 application/Espo/Controllers/DataPrivacy.php create mode 100644 application/Espo/Services/DataPrivacy.php create mode 100644 client/res/templates/personal-data/modals/personal-data.tpl create mode 100644 client/res/templates/personal-data/record/record.tpl create mode 100644 client/src/views/personal-data/modals/personal-data.js create mode 100644 client/src/views/personal-data/record/record.js diff --git a/application/Espo/Acl/EmailAddress.php b/application/Espo/Acl/EmailAddress.php new file mode 100644 index 0000000000..9cb38109ae --- /dev/null +++ b/application/Espo/Acl/EmailAddress.php @@ -0,0 +1,63 @@ +id; + + $isFobidden = false; + + $repository = $this->getEntityManager()->getRepository('EmailAddress'); + + if (!$user->isAdmin()) { + $entityWithSameAddressList = $repository->getEntityListByAddressId($id, $excludeEntity); + foreach ($entityWithSameAddressList as $e) { + if (!$this->getAclManager()->check($user, $e, 'edit')) { + $isFobidden = true; + if ( + $e->get('isPortalUser') && $excludeEntity->getEntityType() === 'Contact' && + $e->get('contactId') === $excludeEntity->id + ) { + $isFobidden = false; + } + if ($isFobidden) break; + } + } + } + return !$isFobidden; + } +} + diff --git a/application/Espo/Acl/PhoneNumber.php b/application/Espo/Acl/PhoneNumber.php new file mode 100644 index 0000000000..948dd26255 --- /dev/null +++ b/application/Espo/Acl/PhoneNumber.php @@ -0,0 +1,63 @@ +id; + + $isFobidden = false; + + $repository = $this->getEntityManager()->getRepository('PhoneNumber'); + + if (!$user->isAdmin()) { + $entityWithSameNumberList = $repository->getEntityListByPhoneNumberId($id, $excludeEntity); + foreach ($entityWithSameNumberList as $e) { + if (!$this->getAclManager()->check($user, $e, 'edit')) { + $isFobidden = true; + if ( + $e->get('isPortalUser') && $excludeEntity->getEntityType() === 'Contact' && + $e->get('contactId') === $excludeEntity->id + ) { + $isFobidden = false; + } + if ($isFobidden) break; + } + } + } + + return !$isFobidden; + } +} diff --git a/application/Espo/AclPortal/EmailAddress.php b/application/Espo/AclPortal/EmailAddress.php new file mode 100644 index 0000000000..1bcd746e60 --- /dev/null +++ b/application/Espo/AclPortal/EmailAddress.php @@ -0,0 +1,56 @@ +id; + + $isFobidden = false; + + $repository = $this->getEntityManager()->getRepository('EmailAddress'); + + if (!$user->isAdmin()) { + $entityWithSameAddressList = $repository->getEntityListByAddressId($id, $excludeEntity); + foreach ($entityWithSameAddressList as $e) { + if (!$this->getAclManager()->check($user, $e, 'edit')) { + $isFobidden = true; + break; + } + } + } + return !$isFobidden; + } +} diff --git a/application/Espo/AclPortal/PhoneNumber.php b/application/Espo/AclPortal/PhoneNumber.php new file mode 100644 index 0000000000..8c19188d54 --- /dev/null +++ b/application/Espo/AclPortal/PhoneNumber.php @@ -0,0 +1,57 @@ +id; + + $isFobidden = false; + + $repository = $this->getEntityManager()->getRepository('PhoneNumber'); + + if (!$user->isAdmin()) { + $entityWithSameNumberList = $repository->getEntityListByPhoneNumberId($id, $excludeEntity); + foreach ($entityWithSameNumberList as $e) { + if (!$this->getAclManager()->check($user, $e, 'edit')) { + $isFobidden = true; + break; + } + } + } + + return !$isFobidden; + } +} diff --git a/application/Espo/Controllers/DataPrivacy.php b/application/Espo/Controllers/DataPrivacy.php new file mode 100644 index 0000000000..c21f824c42 --- /dev/null +++ b/application/Espo/Controllers/DataPrivacy.php @@ -0,0 +1,55 @@ +getAcl()->get('dataPrivacyPermission') === 'no') { + throw new Forbidden(); + } + } + + public function postActionErase($params, $data) + { + if (empty($data->entityType) || empty($data->id) || empty($data->fieldList) || !is_array($data->fieldList)) { + throw new BadRequest(); + } + + return $this->getServiceFactory()->create('DataPrivacy')->erase($data->entityType, $data->id, $data->fieldList); + } + +} diff --git a/application/Espo/Core/Templates/Metadata/Person/entityDefs.json b/application/Espo/Core/Templates/Metadata/Person/entityDefs.json index d9b346d68c..7ccdf8c90e 100644 --- a/application/Espo/Core/Templates/Metadata/Person/entityDefs.json +++ b/application/Espo/Core/Templates/Metadata/Person/entityDefs.json @@ -1,7 +1,8 @@ { "fields": { "name": { - "type": "personName" + "type": "personName", + "isPersonalData": true }, "salutationName": { "type": "enum", @@ -24,15 +25,18 @@ "type": "text" }, "emailAddress": { - "type": "email" + "type": "email", + "isPersonalData": true }, "phoneNumber": { "type": "phone", "typeList": ["Mobile", "Office", "Home", "Fax", "Other"], - "defaultType": "Mobile" + "defaultType": "Mobile", + "isPersonalData": true }, "address": { - "type": "address" + "type": "address", + "isPersonalData": true }, "addressStreet": { "type": "text", diff --git a/application/Espo/Core/Templates/Metadata/Person/scopes.json b/application/Espo/Core/Templates/Metadata/Person/scopes.json index 62badf46e1..a7cafed656 100644 --- a/application/Espo/Core/Templates/Metadata/Person/scopes.json +++ b/application/Espo/Core/Templates/Metadata/Person/scopes.json @@ -7,5 +7,6 @@ "aclPortalLevelList": ["all", "account", "contact", "own", "no"], "customizable": true, "importable": true, - "notifications": true + "notifications": true, + "hasPersonalData": true } \ No newline at end of file diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json index c6ca3c8201..3500228b64 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Account.json @@ -10,7 +10,8 @@ "type": "url" }, "emailAddress": { - "type": "email" + "type": "email", + "isPersonalData": true }, "phoneNumber": { "type": "phone", diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Contact.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Contact.json index 85841ec09b..62a65633ac 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Contact.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Contact.json @@ -1,7 +1,8 @@ { "fields": { "name": { - "type": "personName" + "type": "personName", + "isPersonalData": true }, "salutationName": { "type": "enum", @@ -71,18 +72,21 @@ "type": "text" }, "emailAddress": { - "type": "email" + "type": "email", + "isPersonalData": true }, "phoneNumber": { "type": "phone", "typeList": ["Mobile", "Office", "Home", "Fax", "Other"], - "defaultType": "Mobile" + "defaultType": "Mobile", + "isPersonalData": true }, "doNotCall": { "type": "bool" }, "address": { - "type": "address" + "type": "address", + "isPersonalData": true }, "addressStreet": { "type": "text", diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Lead.json b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Lead.json index 873ea6f04a..be579d5aee 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Lead.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityDefs/Lead.json @@ -1,7 +1,8 @@ { "fields": { "name": { - "type": "personName" + "type": "personName", + "isPersonalData": true }, "salutationName": { "type": "enum", @@ -59,7 +60,8 @@ "type": "url" }, "address": { - "type": "address" + "type": "address", + "isPersonalData": true }, "addressStreet": { "type": "text", @@ -83,12 +85,14 @@ "trim": true }, "emailAddress": { - "type": "email" + "type": "email", + "isPersonalData": true }, "phoneNumber": { "type": "phone", "typeList": ["Mobile", "Office", "Home", "Fax", "Other"], - "defaultType": "Mobile" + "defaultType": "Mobile", + "isPersonalData": true }, "doNotCall": { "type": "bool" diff --git a/application/Espo/Modules/Crm/Resources/metadata/scopes/Account.json b/application/Espo/Modules/Crm/Resources/metadata/scopes/Account.json index 37cb356e46..59321cbbe9 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/scopes/Account.json +++ b/application/Espo/Modules/Crm/Resources/metadata/scopes/Account.json @@ -9,5 +9,6 @@ "stream": true, "importable": true, "notifications": true, - "object": true + "object": true, + "hasPersonalData": true } diff --git a/application/Espo/Modules/Crm/Resources/metadata/scopes/Contact.json b/application/Espo/Modules/Crm/Resources/metadata/scopes/Contact.json index 9dc30671b2..e513e12463 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/scopes/Contact.json +++ b/application/Espo/Modules/Crm/Resources/metadata/scopes/Contact.json @@ -9,5 +9,6 @@ "stream": true, "importable": true, "notifications": true, - "object": true + "object": true, + "hasPersonalData": true } diff --git a/application/Espo/Modules/Crm/Resources/metadata/scopes/Lead.json b/application/Espo/Modules/Crm/Resources/metadata/scopes/Lead.json index cf05cb78e3..fafe1c7c36 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/scopes/Lead.json +++ b/application/Espo/Modules/Crm/Resources/metadata/scopes/Lead.json @@ -10,5 +10,6 @@ "importable": true, "notifications": true, "object": true, - "statusField": "status" + "statusField": "status", + "hasPersonalData": true } diff --git a/application/Espo/Modules/Crm/Services/MassEmail.php b/application/Espo/Modules/Crm/Services/MassEmail.php index 04fdf01502..65288b5239 100644 --- a/application/Espo/Modules/Crm/Services/MassEmail.php +++ b/application/Espo/Modules/Crm/Services/MassEmail.php @@ -222,6 +222,7 @@ class MassEmail extends \Espo\Services\Record foreach ($entityList as $target) { $emailAddress = $target->get('emailAddress'); if (!$target->get('emailAddress')) continue; + if (strpos($emailAddress, 'ERASED:') === 0) continue; $emailAddressRecord = $this->getEntityManager()->getRepository('EmailAddress')->getByAddress($emailAddress); if ($emailAddressRecord) { if ($emailAddressRecord->get('invalid') || $emailAddressRecord->get('optOut')) { diff --git a/application/Espo/Repositories/EmailAddress.php b/application/Espo/Repositories/EmailAddress.php index 0feb0504f1..263088ffd8 100644 --- a/application/Espo/Repositories/EmailAddress.php +++ b/application/Espo/Repositories/EmailAddress.php @@ -41,32 +41,43 @@ class EmailAddress extends \Espo\Core\ORM\Repositories\RDB { parent::init(); $this->addDependency('user'); + $this->addDependency('acl'); + $this->addDependency('aclManager'); } - public function getIdListFormAddressList(array $arr = []) + protected function getAcl() { - return $this->getIds($arr); + return $this->getInjection('acl'); } - public function getIds(array $arr = []) + public function getIdListFormAddressList(array $addressList = []) + { + return $this->getIds($addressList); + } + + public function getIds(array $addressList = []) { $ids = array(); - if (!empty($arr)) { - $a = array_map(function ($item) { - return strtolower($item); - }, $arr); - $eas = $this->where(array( - 'lower' => array_map(function ($item) { - return strtolower($item); - }, $arr) - ))->find(); + if (!empty($addressList)) { + $lowerAddressList = []; + foreach ($addressList as $address) { + $lowerAddressList[] = trim(strtolower($address)); + } + + $eaCollection = $this->where([ + [ + 'lower' => $lowerAddressList + ] + ])->find(); + $ids = array(); $exist = array(); - foreach ($eas as $ea) { + foreach ($eaCollection as $ea) { $ids[] = $ea->id; $exist[] = $ea->get('lower'); } - foreach ($arr as $address) { + foreach ($addressList as $address) { + $address = trim($address); if (empty($address) || !filter_var($address, FILTER_VALIDATE_EMAIL)) { continue; } @@ -118,6 +129,42 @@ class EmailAddress extends \Espo\Core\ORM\Repositories\RDB return $this->where(array('lower' => strtolower($address)))->findOne(); } + public function getEntityListByAddressId($emailAddressId, $exceptionEntity = null) + { + $entityList = []; + + $pdo = $this->getEntityManager()->getPDO(); + $sql = " + SELECT entity_email_address.entity_type AS 'entityType', entity_email_address.entity_id AS 'entityId' + FROM entity_email_address + WHERE + entity_email_address.email_address_id = ".$pdo->quote($emailAddressId)." AND + entity_email_address.deleted = 0 + "; + if ($exceptionEntity) { + $sql .= " + AND ( + entity_email_address.entity_type <> " .$pdo->quote($exceptionEntity->getEntityType()) . " + OR + entity_email_address.entity_id <> " .$pdo->quote($exceptionEntity->id) . " + ) + "; + } + + $sth = $pdo->prepare($sql); + $sth->execute(); + while ($row = $sth->fetch()) { + if (empty($row['entityType']) || empty($row['entityId'])) continue; + if (!$this->getEntityManager()->hasRepository($row['entityType'])) continue; + $entity = $this->getEntityManager()->getEntity($row['entityType'], $row['entityId']); + if ($entity) { + $entityList[] = $entity; + } + } + + return $entityList; + } + public function getEntityByAddressId($emailAddressId, $entityType = null, $onlyName = false) { $pdo = $this->getEntityManager()->getPDO(); @@ -219,14 +266,14 @@ class EmailAddress extends \Espo\Core\ORM\Repositories\RDB $hash = array(); foreach ($emailAddressData as $row) { - $key = $row->emailAddress; + $key = trim($row->emailAddress); if (!empty($key)) { $key = strtolower($key); $hash[$key] = [ 'primary' => !empty($row->primary) ? true : false, 'optOut' => !empty($row->optOut) ? true : false, 'invalid' => !empty($row->invalid) ? true : false, - 'emailAddress' => $row->emailAddress + 'emailAddress' => trim($row->emailAddress) ]; } } @@ -249,6 +296,7 @@ class EmailAddress extends \Espo\Core\ORM\Repositories\RDB $toUpdate = array(); $toRemove = array(); + $revertData = []; foreach ($hash as $key => $data) { $new = true; @@ -261,9 +309,10 @@ class EmailAddress extends \Espo\Core\ORM\Repositories\RDB if (array_key_exists($key, $hashPrev)) { $new = false; $changed = - $hash[$key]['optOut'] != $hashPrev[$key]['optOut'] || - $hash[$key]['invalid'] != $hashPrev[$key]['invalid'] || - $hash[$key]['emailAddress'] !== $hashPrev[$key]['emailAddress']; + $hash[$key]['optOut'] != $hashPrev[$key]['optOut'] || + $hash[$key]['invalid'] != $hashPrev[$key]['invalid'] || + $hash[$key]['emailAddress'] !== $hashPrev[$key]['emailAddress']; + if ($hash[$key]['primary']) { if ($hash[$key]['primary'] == $hashPrev[$key]['primary']) { $primary = false; @@ -303,12 +352,7 @@ class EmailAddress extends \Espo\Core\ORM\Repositories\RDB foreach ($toUpdate as $address) { $emailAddress = $this->getByAddress($address); if ($emailAddress) { - $skipSave = false; - if (!$this->getInjection('user')->isAdmin()) { - if ($this->getEntityByAddressId($emailAddress->id, 'User', true)) { - $skipSave = true; - } - } + $skipSave = $this->checkChangeIsForbidden($emailAddress, $entity); if (!$skipSave) { $emailAddress->set(array( 'optOut' => $hash[$address]['optOut'], @@ -316,6 +360,11 @@ class EmailAddress extends \Espo\Core\ORM\Repositories\RDB 'name' => $hash[$address]['emailAddress'] )); $this->save($emailAddress); + } else { + $revertData[$address] = [ + 'optOut' => $emailAddress->get('optOut'), + 'invalid' => $emailAddress->get('invalid') + ]; } } } @@ -332,17 +381,25 @@ class EmailAddress extends \Espo\Core\ORM\Repositories\RDB )); $this->save($emailAddress); } else { - if ( - $emailAddress->get('optOut') != $hash[$address]['optOut'] || - $emailAddress->get('invalid') != $hash[$address]['invalid'] || - $emailAddress->get('emailAddress') != $hash[$address]['emailAddress'] - ) { - $emailAddress->set(array( - 'optOut' => $hash[$address]['optOut'], - 'invalid' => $hash[$address]['invalid'], - 'name' => $hash[$address]['emailAddress'] - )); - $this->save($emailAddress); + $skipSave = $this->checkChangeIsForbidden($emailAddress, $entity); + if (!$skipSave) { + if ( + $emailAddress->get('optOut') != $hash[$address]['optOut'] || + $emailAddress->get('invalid') != $hash[$address]['invalid'] || + $emailAddress->get('emailAddress') != $hash[$address]['emailAddress'] + ) { + $emailAddress->set(array( + 'optOut' => $hash[$address]['optOut'], + 'invalid' => $hash[$address]['invalid'], + 'name' => $hash[$address]['emailAddress'] + )); + $this->save($emailAddress); + } + } else { + $revertData[$address] = [ + 'optOut' => $emailAddress->get('optOut'), + 'invalid' => $emailAddress->get('invalid') + ]; } } @@ -391,7 +448,20 @@ class EmailAddress extends \Espo\Core\ORM\Repositories\RDB } } + if (!empty($revertData)) { + foreach ($emailAddressData as $row) { + if (!empty($revertData[$row->emailAddress])) { + $row->optOut = $revertData[$row->emailAddress]['optOut']; + $row->invalid = $revertData[$row->emailAddress]['invalid']; + } + } + $entity->set('emailAddressData', $emailAddressData); + } + } else { + if (!$entity->has('emailAddress')) { + return; + } $entityRepository = $this->getEntityManager()->getRepository($entity->getEntityName()); if (!empty($emailAddressValue)) { if ($emailAddressValue != $entity->getFetched('emailAddress')) { @@ -436,5 +506,9 @@ class EmailAddress extends \Espo\Core\ORM\Repositories\RDB } } } -} + protected function checkChangeIsForbidden($entity, $excudeEntity) + { + return !$this->getInjection('aclManager')->getImplementation('EmailAddress')->checkEditInEntity($this->getInjection('user'), $entity, $excudeEntity); + } +} diff --git a/application/Espo/Repositories/PhoneNumber.php b/application/Espo/Repositories/PhoneNumber.php index 706757c764..843d7fbc56 100644 --- a/application/Espo/Repositories/PhoneNumber.php +++ b/application/Espo/Repositories/PhoneNumber.php @@ -41,33 +41,40 @@ class PhoneNumber extends \Espo\Core\ORM\Repositories\RDB { parent::init(); $this->addDependency('user'); + $this->addDependency('acl'); + $this->addDependency('aclManager'); } - public function getIds($arr = array()) + protected function getAcl() + { + return $this->getInjection('acl'); + } + + public function getIds($numberList = []) { $ids = array(); - if (!empty($arr)) { - $a = array_map(function ($item) { - return $item; - }, $arr); - $phoneNumbers = $this->where(array( - 'name' => array_map(function ($item) { - return $item; - }, $arr) - ))->find(); + if (!empty($numberList)) { + $phoneNumbers = $this->where([ + [ + 'name' => $numberList, + 'hash' => null + ] + ])->find(); + $ids = array(); $exist = array(); foreach ($phoneNumbers as $phoneNumber) { $ids[] = $phoneNumber->id; $exist[] = $phoneNumber->get('name'); } - foreach ($arr as $phone) { - if (empty($phone)) { + foreach ($numberList as $number) { + $number = trim($number); + if (empty($number)) { continue; } - if (!in_array($phone, $exist)) { + if (!in_array($number, $exist)) { $phoneNumber = $this->get(); - $phoneNumber->set('name', $phone); + $phoneNumber->set('name', $number); $this->save($phoneNumber); $ids[] = $phoneNumber->id; } @@ -112,6 +119,42 @@ class PhoneNumber extends \Espo\Core\ORM\Repositories\RDB return $this->where(array('name' => $number))->findOne(); } + public function getEntityListByPhoneNumberId($phoneNumberId, $exceptionEntity = null) + { + $entityList = []; + + $pdo = $this->getEntityManager()->getPDO(); + $sql = " + SELECT entity_phone_number.entity_type AS 'entityType', entity_phone_number.entity_id AS 'entityId' + FROM entity_phone_number + WHERE + entity_phone_number.phone_number_id = ".$pdo->quote($phoneNumberId)." AND + entity_phone_number.deleted = 0 + "; + if ($exceptionEntity) { + $sql .= " + AND ( + entity_phone_number.entity_type <> " .$pdo->quote($exceptionEntity->getEntityType()) . " + OR + entity_phone_number.entity_id <> " .$pdo->quote($exceptionEntity->id) . " + ) + "; + } + + $sth = $pdo->prepare($sql); + $sth->execute(); + while ($row = $sth->fetch()) { + if (empty($row['entityType']) || empty($row['entityId'])) continue; + if (!$this->getEntityManager()->hasRepository($row['entityType'])) continue; + $entity = $this->getEntityManager()->getEntity($row['entityType'], $row['entityId']); + if ($entity) { + $entityList[] = $entity; + } + } + + return $entityList; + } + public function getEntityByPhoneNumberId($phoneNumberId, $entityType = null) { $pdo = $this->getEntityManager()->getPDO(); @@ -167,7 +210,7 @@ class PhoneNumber extends \Espo\Core\ORM\Repositories\RDB $hash = array(); foreach ($phoneNumberData as $row) { - $key = $row->phoneNumber; + $key = trim($row->phoneNumber); if (!empty($key)) { $hash[$key] = array( 'primary' => $row->primary ? true : false, @@ -182,7 +225,7 @@ class PhoneNumber extends \Espo\Core\ORM\Repositories\RDB if (!empty($key)) { $hashPrev[$key] = array( 'primary' => $row->primary ? true : false, - 'type' => $row->type, + 'type' => $row->type ); } } @@ -192,6 +235,7 @@ class PhoneNumber extends \Espo\Core\ORM\Repositories\RDB $toUpdate = array(); $toRemove = array(); + $revertData = []; foreach ($hash as $key => $data) { $new = true; @@ -243,17 +287,16 @@ class PhoneNumber extends \Espo\Core\ORM\Repositories\RDB foreach ($toUpdate as $number) { $phoneNumber = $this->getByNumber($number); if ($phoneNumber) { - $skipSave = false; - if (!$this->getInjection('user')->isAdmin()) { - if ($this->getEntityByPhoneNumberId($phoneNumber->id, 'User')) { - $skipSave = true; - } - } + $skipSave = $this->checkChangeIsForbidden($phoneNumber, $entity); if (!$skipSave) { $phoneNumber->set(array( 'type' => $hash[$number]['type'], )); $this->save($phoneNumber); + } else { + $revertData[$number] = [ + 'type' => $phoneNumber->get('type') + ]; } } } @@ -269,11 +312,18 @@ class PhoneNumber extends \Espo\Core\ORM\Repositories\RDB )); $this->save($phoneNumber); } else { - if ($phoneNumber->get('type') != $hash[$number]['type']) { - $phoneNumber->set(array( - 'type' => $hash[$number]['type'], - )); - $this->save($phoneNumber); + $skipSave = $this->checkChangeIsForbidden($phoneNumber, $entity); + if (!$skipSave) { + if ($phoneNumber->get('type') != $hash[$number]['type']) { + $phoneNumber->set(array( + 'type' => $hash[$number]['type'], + )); + $this->save($phoneNumber); + } + } else { + $revertData[$number] = [ + 'type' => $phoneNumber->get('type') + ]; } } @@ -321,7 +371,20 @@ class PhoneNumber extends \Espo\Core\ORM\Repositories\RDB $sth->execute(); } } + + if (!empty($revertData)) { + foreach ($phoneNumberData as $row) { + if (!empty($revertData[$row->phoneNumber])) { + $row->type = $revertData[$row->phoneNumber]['type']; + } + } + $entity->set('phoneNumberData', $phoneNumberData); + } + } else { + if (!$entity->has('phoneNumber')) { + return; + } $entityRepository = $this->getEntityManager()->getRepository($entity->getEntityName()); if (!empty($phoneNumberValue)) { if ($phoneNumberValue !== $entity->getFetched('phoneNumber')) { @@ -370,5 +433,9 @@ class PhoneNumber extends \Espo\Core\ORM\Repositories\RDB } } } -} + protected function checkChangeIsForbidden($entity, $excudeEntity) + { + return !$this->getInjection('aclManager')->getImplementation('PhoneNumber')->checkEditInEntity($this->getInjection('user'), $entity, $excudeEntity); + } +} diff --git a/application/Espo/Resources/i18n/en_US/Admin.json b/application/Espo/Resources/i18n/en_US/Admin.json index 74fa820a4d..23591976c3 100644 --- a/application/Espo/Resources/i18n/en_US/Admin.json +++ b/application/Espo/Resources/i18n/en_US/Admin.json @@ -160,7 +160,8 @@ "dynamicLogicOptions": "Conditional options", "probabilityMap": "Stage Probabilities (%)", "readOnly": "Read-only", - "maxFileSize": "Max File Size (Mb)" + "maxFileSize": "Max File Size (Mb)", + "isPersonalData": "Is Personal Data" }, "messages": { "upgradeVersion": "EspoCRM will be upgraded to version {version}. Please be patient as this may take a while.", diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 263aed2888..d81bb2d4f7 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -227,7 +227,10 @@ "New notifications": "New notifications", "Manage Categories": "Manage Categories", "Manage Folders": "Manage Folders", - "Convert to": "Convert to" + "Convert to": "Convert to", + "View Personal Data": "View Personal Data", + "Personal Data": "Personal Data", + "Erase": "Erase" }, "messages": { "pleaseWait": "Please wait...", @@ -283,7 +286,8 @@ "massFollowResultSingle": "{count} record now is followed", "massUnfollowResultSingle": "{count} record now is not followed", "massFollowZeroResult": "Nothing got followed", - "massUnfollowZeroResult": "Nothing got unfollowed" + "massUnfollowZeroResult": "Nothing got unfollowed", + "erasePersonalDataConfirmation": "Checked fields will be erased permanently. Are you sure?" }, "boolFilters": { "onlyMy": "Only My", diff --git a/application/Espo/Resources/i18n/en_US/Role.json b/application/Espo/Resources/i18n/en_US/Role.json index 56b2bd6f2b..75cae9cb54 100644 --- a/application/Espo/Resources/i18n/en_US/Role.json +++ b/application/Espo/Resources/i18n/en_US/Role.json @@ -6,7 +6,8 @@ "userPermission": "User Permission", "portalPermission": "Portal Permission", "groupEmailAccountPermission": "Group Email Account Permission", - "exportPermission": "Export Permission" + "exportPermission": "Export Permission", + "dataPrivacyPermission": "Data Privacy Permission" }, "links": { "users": "Users", @@ -17,7 +18,8 @@ "userPermission": "Allows to restrict an ability for users to view activities, calendar and stream of other users.\n\nall - can view all\n\nteam - can view activities of teammates only\n\nno - can't view", "portalPermission": "Defines an access to portal information, ability to post messages to portal users.", "groupEmailAccountPermission": "Defines an access to group email accounts, an ability to send emails from group SMTP.", - "exportPermission": "Defines wheter users have an ability to export records." + "exportPermission": "Defines wheter users have an ability to export records.", + "dataPrivacyPermission": "Allows to view and erase personal data." }, "labels": { "Access": "Access", diff --git a/application/Espo/Resources/i18n/en_US/Settings.json b/application/Espo/Resources/i18n/en_US/Settings.json index b6d40fb79f..fc904cfde9 100644 --- a/application/Espo/Resources/i18n/en_US/Settings.json +++ b/application/Espo/Resources/i18n/en_US/Settings.json @@ -97,7 +97,8 @@ "authTokenPreventConcurrent": "Only one auth token per user", "scopeColorsDisabled": "Disable scope colors", "tabColorsDisabled": "Disable tab colors", - "tabIconsDisabled": "Disable tab icons" + "tabIconsDisabled": "Disable tab icons", + "emailAddressIsOptedOutByDefault": "Mark new email addresses as opted-out" }, "options": { "weekStart": { @@ -150,7 +151,8 @@ "aclAllowDeleteCreated": "Users will be able to remove records they created even if they don't have a delete access.", "textFilterUseContainsForVarchar": "If not checked then 'starts with' operator is used. You can use the wildcard '%'.", "streamEmailNotificationsEntityList": "Email notifications about stream updates of followed records. Users will receive email notifications only for specified entity types.", - "authTokenPreventConcurrent": "Users won't be able to be logged in on multiple devices simultaneously." + "authTokenPreventConcurrent": "Users won't be able to be logged in on multiple devices simultaneously.", + "emailAddressIsOptedOutByDefault": "When creating new record email addess will be marked as opted-out." }, "labels": { "System": "System", diff --git a/application/Espo/Resources/layouts/Role/detail.json b/application/Espo/Resources/layouts/Role/detail.json index 6080bb67cb..37d1a59620 100644 --- a/application/Espo/Resources/layouts/Role/detail.json +++ b/application/Espo/Resources/layouts/Role/detail.json @@ -3,13 +3,22 @@ "rows": [ [ {"name": "name"}, + false, + false + ] + ] + }, + { + "rows": [ + [ {"name": "exportPermission"}, - {"name": "userPermission"} + {"name": "userPermission"}, + {"name": "assignmentPermission"} ], [ - {"name": "assignmentPermission"}, {"name": "portalPermission"}, - {"name": "groupEmailAccountPermission"} + {"name": "groupEmailAccountPermission"}, + {"name": "dataPrivacyPermission"} ] ] } diff --git a/application/Espo/Resources/layouts/Settings/settings.json b/application/Espo/Resources/layouts/Settings/settings.json index 7a77b68eb4..9653c6bff1 100644 --- a/application/Espo/Resources/layouts/Settings/settings.json +++ b/application/Espo/Resources/layouts/Settings/settings.json @@ -6,7 +6,7 @@ [{"name": "exportDisabled"}, {"name": "globalSearchEntityList"}], [{"name": "followCreatedEntities"}, {"name": "b2cMode"}], [{"name": "aclStrictMode"}, {"name": "aclAllowDeleteCreated"}], - [{"name": "textFilterUseContainsForVarchar"}, false] + [{"name": "textFilterUseContainsForVarchar"}, {"name": "emailAddressIsOptedOutByDefault"}] ] }, { diff --git a/application/Espo/Resources/metadata/app/acl.json b/application/Espo/Resources/metadata/app/acl.json index 0ef86f0ce7..9c1785d149 100644 --- a/application/Espo/Resources/metadata/app/acl.json +++ b/application/Espo/Resources/metadata/app/acl.json @@ -112,28 +112,32 @@ "userPermission", "portalPermission", "groupEmailAccountPermission", - "exportPermission" + "exportPermission", + "dataPrivacyPermission" ], "valuePermissionHighestLevels": { "assignmentPermission": "all", "userPermission": "all", "portalPermission": "yes", "groupEmailAccountPermission": "all", - "exportPermission": "yes" + "exportPermission": "yes", + "dataPrivacyPermission": "yes" }, "permissionsDefaults": { "assignmentPermission": "all", "userPermission": "all", "portalPermission": "no", "groupEmailAccountPermission": "no", - "exportPermission": "yes" + "exportPermission": "yes", + "dataPrivacyPermission": "no" }, "permissionsStrictDefaults": { "assignmentPermission": "no", "userPermission": "no", "portalPermission": "no", "groupEmailAccountPermission": "no", - "exportPermission": "no" + "exportPermission": "no", + "dataPrivacyPermission": "no" }, "scopeLevelTypesDefaults": { "boolean": true, diff --git a/application/Espo/Resources/metadata/entityDefs/Role.json b/application/Espo/Resources/metadata/entityDefs/Role.json index 5061659905..3552c03898 100644 --- a/application/Espo/Resources/metadata/entityDefs/Role.json +++ b/application/Espo/Resources/metadata/entityDefs/Role.json @@ -41,6 +41,13 @@ "tooltip": true, "translation": "Role.options.levelList" }, + "dataPrivacyPermission": { + "type": "enum", + "options": ["not-set", "yes", "no"], + "default": "not-set", + "tooltip": true, + "translation": "Role.options.levelList" + }, "data": { "type": "jsonObject" }, diff --git a/application/Espo/Resources/metadata/entityDefs/Settings.json b/application/Espo/Resources/metadata/entityDefs/Settings.json index 4710456b18..8c7667d226 100644 --- a/application/Espo/Resources/metadata/entityDefs/Settings.json +++ b/application/Espo/Resources/metadata/entityDefs/Settings.json @@ -465,6 +465,10 @@ }, "tabIconsDisabled": { "type": "bool" + }, + "emailAddressIsOptedOutByDefault": { + "type": "bool", + "tooltip": true } } } diff --git a/application/Espo/Resources/metadata/fields/address.json b/application/Espo/Resources/metadata/fields/address.json index a7da6f605d..282004617c 100644 --- a/application/Espo/Resources/metadata/fields/address.json +++ b/application/Espo/Resources/metadata/fields/address.json @@ -40,5 +40,6 @@ "notMergeable":true, "notCreatable": false, "filter": true, - "skipOrmDefs": true + "skipOrmDefs": true, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/array.json b/application/Espo/Resources/metadata/fields/array.json index 948c62e035..08436dfdce 100644 --- a/application/Espo/Resources/metadata/fields/array.json +++ b/application/Espo/Resources/metadata/fields/array.json @@ -34,5 +34,6 @@ "fieldDefs":{ "type":"jsonArray" }, - "translatedOptions": true + "translatedOptions": true, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/attachmentMultiple.json b/application/Espo/Resources/metadata/fields/attachmentMultiple.json index 5889409391..998964d6ab 100644 --- a/application/Espo/Resources/metadata/fields/attachmentMultiple.json +++ b/application/Espo/Resources/metadata/fields/attachmentMultiple.json @@ -34,5 +34,6 @@ "fieldDefs": { "layoutListDisabled": true }, - "hookClassName": "\\Espo\\Core\\Utils\\FieldManager\\Hooks\\AttachmentMultipleType" + "hookClassName": "\\Espo\\Core\\Utils\\FieldManager\\Hooks\\AttachmentMultipleType", + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/currency.json b/application/Espo/Resources/metadata/fields/currency.json index 667563a81a..8e3da26b3b 100644 --- a/application/Espo/Resources/metadata/fields/currency.json +++ b/application/Espo/Resources/metadata/fields/currency.json @@ -36,5 +36,6 @@ "readOnly": true } }, - "filter": true + "filter": true, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/date.json b/application/Espo/Resources/metadata/fields/date.json index 95bdb79f5f..fbc514f9cb 100644 --- a/application/Espo/Resources/metadata/fields/date.json +++ b/application/Espo/Resources/metadata/fields/date.json @@ -60,5 +60,6 @@ "filter": true, "fieldDefs":{ "notNull":false - } + }, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/datetime.json b/application/Espo/Resources/metadata/fields/datetime.json index b18b247bf1..7bae42cb86 100644 --- a/application/Espo/Resources/metadata/fields/datetime.json +++ b/application/Espo/Resources/metadata/fields/datetime.json @@ -57,5 +57,6 @@ "filter": true, "fieldDefs":{ "notNull":false - } + }, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/datetimeOptional.json b/application/Espo/Resources/metadata/fields/datetimeOptional.json index d9e87c316e..282b6b4eb5 100644 --- a/application/Espo/Resources/metadata/fields/datetimeOptional.json +++ b/application/Espo/Resources/metadata/fields/datetimeOptional.json @@ -70,5 +70,6 @@ "type":"datetime", "notNull":false }, - "view": "views/fields/datetime-optional" + "view": "views/fields/datetime-optional", + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/email.json b/application/Espo/Resources/metadata/fields/email.json index 3342f8a980..c19e701ff0 100644 --- a/application/Espo/Resources/metadata/fields/email.json +++ b/application/Espo/Resources/metadata/fields/email.json @@ -13,5 +13,6 @@ "filter": true, "fieldDefs":{ "notStorable":true - } + }, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/enum.json b/application/Espo/Resources/metadata/fields/enum.json index 255d961423..f528624290 100644 --- a/application/Espo/Resources/metadata/fields/enum.json +++ b/application/Espo/Resources/metadata/fields/enum.json @@ -37,5 +37,6 @@ "fieldDefs":{ "type":"varchar" }, - "translatedOptions": true + "translatedOptions": true, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/file.json b/application/Espo/Resources/metadata/fields/file.json index 2f2eaf6ffe..64fa97c7a4 100644 --- a/application/Espo/Resources/metadata/fields/file.json +++ b/application/Espo/Resources/metadata/fields/file.json @@ -32,5 +32,6 @@ "type": "belongsTo", "entity": "Attachment", "skipOrmDefs": true - } + }, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/image.json b/application/Espo/Resources/metadata/fields/image.json index 7ee9243c47..6b639524b5 100644 --- a/application/Espo/Resources/metadata/fields/image.json +++ b/application/Espo/Resources/metadata/fields/image.json @@ -33,5 +33,6 @@ "type": "belongsTo", "entity": "Attachment", "skipOrmDefs": true - } + }, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/int.json b/application/Espo/Resources/metadata/fields/int.json index 8f26fc1299..6a10dabfff 100644 --- a/application/Espo/Resources/metadata/fields/int.json +++ b/application/Espo/Resources/metadata/fields/int.json @@ -30,5 +30,6 @@ "type":"bool" } ], - "filter": true + "filter": true, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/multiEnum.json b/application/Espo/Resources/metadata/fields/multiEnum.json index 2377d62295..4a6e578793 100644 --- a/application/Espo/Resources/metadata/fields/multiEnum.json +++ b/application/Espo/Resources/metadata/fields/multiEnum.json @@ -29,5 +29,6 @@ "fieldDefs":{ "type":"jsonArray" }, - "translatedOptions": true + "translatedOptions": true, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/personName.json b/application/Espo/Resources/metadata/fields/personName.json index c32224d271..4003c1d8ec 100644 --- a/application/Espo/Resources/metadata/fields/personName.json +++ b/application/Espo/Resources/metadata/fields/personName.json @@ -28,5 +28,6 @@ "notMergeable":true, "notCreatable":true, "filter":true, - "skipOrmDefs": true + "skipOrmDefs": true, + "personalData": true } \ No newline at end of file diff --git a/application/Espo/Resources/metadata/fields/phone.json b/application/Espo/Resources/metadata/fields/phone.json index d5f14c175e..4d03d3fcfb 100644 --- a/application/Espo/Resources/metadata/fields/phone.json +++ b/application/Espo/Resources/metadata/fields/phone.json @@ -25,5 +25,6 @@ "fieldDefs":{ "notStorable":true }, - "translatedOptions": true + "translatedOptions": true, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/text.json b/application/Espo/Resources/metadata/fields/text.json index 604849097d..3452b50832 100644 --- a/application/Espo/Resources/metadata/fields/text.json +++ b/application/Espo/Resources/metadata/fields/text.json @@ -36,5 +36,6 @@ "type":"bool" } ], - "filter": true + "filter": true, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/url.json b/application/Espo/Resources/metadata/fields/url.json index b655e08f50..4b333320f5 100644 --- a/application/Espo/Resources/metadata/fields/url.json +++ b/application/Espo/Resources/metadata/fields/url.json @@ -25,5 +25,6 @@ "filter": true, "fieldDefs":{ "type":"varchar" - } + }, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/varchar.json b/application/Espo/Resources/metadata/fields/varchar.json index c3c7759cf1..11efbbd875 100644 --- a/application/Espo/Resources/metadata/fields/varchar.json +++ b/application/Espo/Resources/metadata/fields/varchar.json @@ -27,5 +27,6 @@ "type":"bool" } ], - "filter": true + "filter": true, + "personalData": true } diff --git a/application/Espo/Resources/metadata/fields/wysiwyg.json b/application/Espo/Resources/metadata/fields/wysiwyg.json index 2cfe0c937c..08d66a6d69 100644 --- a/application/Espo/Resources/metadata/fields/wysiwyg.json +++ b/application/Espo/Resources/metadata/fields/wysiwyg.json @@ -25,5 +25,6 @@ "filter": true, "fieldDefs":{ "type":"text" - } + }, + "personalData": true } diff --git a/application/Espo/Services/DataPrivacy.php b/application/Espo/Services/DataPrivacy.php new file mode 100644 index 0000000000..0bf5cf9da8 --- /dev/null +++ b/application/Espo/Services/DataPrivacy.php @@ -0,0 +1,215 @@ +addDependency('fileManager'); + $this->addDependency('acl'); + $this->addDependency('aclManager'); + $this->addDependency('metadata'); + $this->addDependency('serviceFactory'); + $this->addDependency('dateTime'); + $this->addDependency('number'); + $this->addDependency('entityManager'); + $this->addDependency('defaultLanguage'); + $this->addDependency('fieldManagerUtil'); + $this->addDependency('user'); + } + + protected function getAcl() + { + return $this->getInjection('acl'); + } + + protected function getMetadata() + { + return $this->getInjection('metadata'); + } + + protected function getServiceFactory() + { + return $this->getInjection('serviceFactory'); + } + + protected function getFileManager() + { + return $this->getInjection('fileManager'); + } + + protected function getEntityManager() + { + return $this->getInjection('entityManager'); + } + + public function erase($entityType, $id, array $fieldList) + { + if ($this->getAcl()->get('dataPrivacyPermission') === 'no') { + throw new Forbidden(); + } + + if ($this->getServiceFactory()->checkExists($entityType)) { + $service = $this->getServiceFactory()->create($entityType); + } else { + $service = $this->getServiceFactory()->create('Record'); + $service->setEntityType($entityType); + } + + $entity = $this->getEntityManager()->getEntity($entityType, $id); + + if (!$entity) { + throw new NotFound(); + } + + if (!$this->getAcl()->check($entity, 'edit')) { + throw new Forbidden("No edit access."); + } + + $forbiddenFieldList = $this->getAcl()->getScopeForbiddenFieldList($entityType, 'edit'); + + foreach ($fieldList as $field) { + if (in_array($field, $forbiddenFieldList)) { + throw new Forbidden("Field '{$field}' is forbidden to edit."); + } + } + + $service->loadAdditionalFields($entity); + + $filedManager = $this->getInjection('fieldManagerUtil'); + + foreach ($fieldList as $field) { + $type = $this->getMetadata()->get(['entityDefs', $entityType, 'fields', $field, 'type']); + $attributeList = $filedManager->getActualAttributeList($entityType, $field); + + if ($type === 'email') { + $emailAddressList = $entity->get('emailAddresses'); + foreach ($emailAddressList as $emailAddress) { + if ( + $this + ->getInjection('aclManager') + ->getImplementation('EmailAddress') + ->checkEditInEntity($this->getInjection('user'), $emailAddress, $entity) + ) { + $hash = $this->getEntityManager()->getRepository('EmailAddress')->hashAddress($emailAddress->get('name')); + $emailAddress->set('hash', $hash); + $emailAddress->set('name', 'ERASED:' . $emailAddress->id); + $emailAddress->set('optOut', true); + $this->getEntityManager()->saveEntity($emailAddress); + } + } + + $entity->clear($field); + $entity->clear($field . 'Data'); + + continue; + } + else if ($type === 'phone') { + $phoneNumberList = $entity->get('phoneNumbers'); + foreach ($phoneNumberList as $phoneNumber) { + if ( + $this + ->getInjection('aclManager') + ->getImplementation('PhoneNumber') + ->checkEditInEntity($this->getInjection('user'), $phoneNumber, $entity) + ) { + $hash = $this->getEntityManager()->getRepository('PhoneNumber')->hashNumber($phoneNumber->get('name')); + $phoneNumber->set('hash', $hash); + $phoneNumber->set('name', 'ERASED:' . $phoneNumber->id); + $this->getEntityManager()->saveEntity($phoneNumber); + } + } + + $entity->clear($field); + $entity->clear($field . 'Data'); + + continue; + } + else if ($type === 'file' || $type === 'image') { + $attachmentId = $entity->get($field . 'Id'); + if ($attachmentId) { + $attachment = $this->getEntityManager()->getEntity('Attachment', $attachmentId); + $this->getEntityManager()->removeEntity($attachment); + } + + } + else if ($type === 'attachmentMultiple') { + $attachmentList = $entity->get($field); + foreach ($attachmentList as $attachment) { + $this->getEntityManager()->removeEntity($attachment); + } + } + + foreach ($attributeList as $attribute) { + if (in_array($entity->getAttributeType($attribute), [$entity::VARCHAR, $entity::TEXT]) && $entity->get($attribute)) { + $entity->set($attribute, null); + } else { + $entity->set($attribute, null); + } + } + } + + $this->getEntityManager()->saveEntity($entity); + + return true; + } + + public function exportPdf() + { + + + $htmlizer = new Htmlizer( + $this->getFileManager(), + $this->getInjection('dateTime'), + $this->getInjection('number'), + $this->getAcl(), + $this->getInjection('entityManager'), + $this->getInjection('metadata'), + $this->getInjection('defaultLanguage') + ); + + $pdf = new \Espo\Core\Pdf\Tcpdf(); + + $fontFace = $this->getConfig()->get('pdfFontFace', $this->fontFace); + + $pdf->setFont($fontFace, '', $this->fontSize, '', true); + $pdf->setPrintHeader(false); + + } +} + diff --git a/client/res/templates/fields/email/detail.tpl b/client/res/templates/fields/email/detail.tpl index ed27d9b542..02bc9b5f44 100644 --- a/client/res/templates/fields/email/detail.tpl +++ b/client/res/templates/fields/email/detail.tpl @@ -2,10 +2,14 @@ {{#each emailAddressData}}
{{#unless invalid}} + {{#unless erased}} {{/unless}} + {{/unless}} {{emailAddress}} {{#unless invalid}} + {{#unless erased}} + {{/unless}} {{/unless}}
diff --git a/client/res/templates/fields/email/list.tpl b/client/res/templates/fields/email/list.tpl index 5fc95c69dc..ff3d271ecf 100644 --- a/client/res/templates/fields/email/list.tpl +++ b/client/res/templates/fields/email/list.tpl @@ -1 +1 @@ -{{value}} +{{#unless isErased}}{{/unless}}{{value}}{{#unless isErased}}{{/unless}} diff --git a/client/res/templates/fields/phone/detail.tpl b/client/res/templates/fields/phone/detail.tpl index 5bec25fd00..e1d82b36a6 100644 --- a/client/res/templates/fields/phone/detail.tpl +++ b/client/res/templates/fields/phone/detail.tpl @@ -1,7 +1,15 @@ {{#if phoneNumberData}} {{#each phoneNumberData}}
- {{#if ../doNotCall}}{{/if}}{{phoneNumber}}{{#if ../doNotCall}}{{/if}} + {{#if ../doNotCall}}{{/if}} + {{#unless erased}} + + {{/unless}} + {{phoneNumber}} + {{#unless erased}} + + {{/unless}} + {{#if ../doNotCall}}{{/if}} ({{translateOption type scope=../../scope field=../../name}})
{{/each}} diff --git a/client/res/templates/fields/phone/list.tpl b/client/res/templates/fields/phone/list.tpl index 21bac8f98c..9a05b55cc5 100644 --- a/client/res/templates/fields/phone/list.tpl +++ b/client/res/templates/fields/phone/list.tpl @@ -1 +1 @@ -{{#if doNotCall}}{{/if}}{{value}}{{#if doNotCall}}{{/if}} +{{#if doNotCall}}{{/if}}{{#unless isErased}}{{/unless}}{{value}}{{#unless isErased}}{{/unless}}{{#if doNotCall}}{{/if}} diff --git a/client/res/templates/personal-data/modals/personal-data.tpl b/client/res/templates/personal-data/modals/personal-data.tpl new file mode 100644 index 0000000000..47efa51b7e --- /dev/null +++ b/client/res/templates/personal-data/modals/personal-data.tpl @@ -0,0 +1 @@ +
{{{record}}}
\ No newline at end of file diff --git a/client/res/templates/personal-data/record/record.tpl b/client/res/templates/personal-data/record/record.tpl new file mode 100644 index 0000000000..309e3d505b --- /dev/null +++ b/client/res/templates/personal-data/record/record.tpl @@ -0,0 +1,22 @@ +{{#if fieldDataList.length}} + + {{#if editAccess}} + + + + + + {{/if}} +{{#each fieldDataList}} + + {{#if ../editAccess}}{{/if}} + + + +{{/each}} +
{{#if editAccess}}{{/if}}{{translate name category='fields' scope=../scope}} +
{{{var key ../this}}}
+
+{{else}} +{{translate 'No Data'}} +{{/if}} \ No newline at end of file diff --git a/client/src/email-helper.js b/client/src/email-helper.js index 97dbe242a2..95e284dce8 100644 --- a/client/src/email-helper.js +++ b/client/src/email-helper.js @@ -32,6 +32,8 @@ Espo.define('email-helper', [], function () { this.language = language; this.user = user; this.dateTime = dateTime; + + this.erasedPlaceholder = 'ERASED:'; } _.extend(EmailHelper.prototype, { @@ -120,15 +122,39 @@ Espo.define('email-helper', [], function () { item = item.trim(); if (item != this.getUser().get('emailAddress')) { if (isReplyOnSent) { - attributes.to += ';' + item; + if (attributes.to) { + attributes.to += ';' + } + attributes.to += item; } else { - attributes.cc += ';' + item; + if (attributes.cc) { + attributes.cc += ';' + } + attributes.cc += item; } } }, this); attributes.cc = attributes.cc.replace(/^(\; )/,""); } + if (attributes.to) { + var toList = attributes.to.split(';'); + toList = toList.filter(function (item) { + if (item.indexOf(this.erasedPlaceholder) === 0) return false; + return true; + }, this); + attributes.to = toList.join(';'); + } + + if (attributes.cc) { + var ccList = attributes.cc.split(';'); + ccList = ccList.filter(function (item) { + if (item.indexOf(this.erasedPlaceholder) === 0) return false; + return true; + }, this); + attributes.cc = ccList.join(';'); + } + if (model.get('parentId')) { attributes['parentId'] = model.get('parentId'); attributes['parentName'] = model.get('parentName'); diff --git a/client/src/views/admin/field-manager/edit.js b/client/src/views/admin/field-manager/edit.js index b18e37f9e4..4c109f8061 100644 --- a/client/src/views/admin/field-manager/edit.js +++ b/client/src/views/admin/field-manager/edit.js @@ -102,6 +102,14 @@ Espo.define('views/admin/field-manager/edit', ['view', 'model'], function (Dep, this.type = model.getFieldType(this.field); } + if ( + this.getMetadata().get(['scopes', this.scope, 'hasPersonalData']) + && + this.getMetadata().get(['fields', this.type, 'personalData']) + ) { + this.hasPersonalData = true; + } + Promise.race([ new Promise(function (resolve) { if (this.isNew) { @@ -137,6 +145,13 @@ Espo.define('views/admin/field-manager/edit', ['view', 'model'], function (Dep, this.paramList.push(o); }, this); + if (this.hasPersonalData) { + this.paramList.push({ + name: 'isPersonalData', + type: 'bool' + }); + } + this.paramList.forEach(function (o) { this.model.defs.fields[o.name] = o; }, this); @@ -159,7 +174,14 @@ Espo.define('views/admin/field-manager/edit', ['view', 'model'], function (Dep, rows: 1 }); + if (this.hasPersonalData) { + this.createFieldView('bool', 'isPersonalData', null, {}); + } + this.createFieldView('text', 'tooltipText', null, { + trim: true, + rows: 1 + }); this.hasDynamicLogicPanel = false; if ( diff --git a/client/src/views/fields/email.js b/client/src/views/fields/email.js index e178865aec..55814022fb 100644 --- a/client/src/views/fields/email.js +++ b/client/src/views/fields/email.js @@ -47,7 +47,7 @@ Espo.define('views/fields/email', 'views/fields/varchar', function (Dep) { var notValid = false; data.forEach(function (row, i) { var emailAddress = row.emailAddress; - if (!re.test(emailAddress)) { + if (!re.test(emailAddress) && emailAddress.indexOf(this.erasedPlaceholder) !== 0) { var msg = this.translate('fieldShouldBeEmail', 'messages').replace('{field}', this.getLabelText()); this.showValidationMessage(msg, 'div.email-address-block:nth-child(' + (i + 1).toString() + ') input'); notValid = true; @@ -76,10 +76,16 @@ Espo.define('views/fields/email', 'views/fields/varchar', function (Dep) { if (this.model.isNew() || !this.model.get(this.name)) { if (!emailAddressData || !emailAddressData.length) { - emailAddressData = [{ + var optOut = false; + if (this.model.isNew()) { + optOut = this.emailAddressOptedOutByDefault; + } else { + optOut = this.model.get(this.isOptedOutFieldName) + } + emailAddressData = [{ emailAddress: this.model.get(this.name) || '', primary: true, - optOut: false, + optOut: optOut, invalid: false }]; } @@ -90,7 +96,7 @@ Espo.define('views/fields/email', 'views/fields/varchar', function (Dep) { } if ((!emailAddressData || emailAddressData.length === 0) && this.model.get(this.name)) { - emailAddressData = [{ + emailAddressData = [{ emailAddress: this.model.get(this.name), primary: true, optOut: false, @@ -98,12 +104,22 @@ Espo.define('views/fields/email', 'views/fields/varchar', function (Dep) { }]; } + if (emailAddressData) { + emailAddressData = Espo.Utils.clone(emailAddressData); + emailAddressData.forEach(function (item) { + item.erased = item.emailAddress.indexOf(this.erasedPlaceholder) === 0 + }, this); + } + var data = _.extend({ emailAddressData: emailAddressData }, Dep.prototype.data.call(this)); if (this.mode === 'list' || this.mode === 'detail') { data.isOptedOut = this.model.get(this.isOptedOutFieldName); + if (this.model.get(this.name)) { + data.isErased = this.model.get(this.name).indexOf(this.erasedPlaceholder) === 0 + } } return data; @@ -177,7 +193,7 @@ Espo.define('views/fields/email', 'views/fields/varchar', function (Dep) { o = { emailAddress: '', primary: data.length ? false : true, - optOut: false, + optOut: this.emailAddressOptedOutByDefault, invalid: false, lower: '' }; @@ -346,6 +362,10 @@ Espo.define('views/fields/email', 'views/fields/varchar', function (Dep) { setup: function () { this.dataFieldName = this.name + 'Data'; this.isOptedOutFieldName = this.name + 'IsOptedOut'; + + this.erasedPlaceholder = 'ERASED:'; + + this.emailAddressOptedOutByDefault = this.getConfig().get('emailAddressIsOptedOutByDefault'); }, fetchEmailAddressData: function () { diff --git a/client/src/views/fields/phone.js b/client/src/views/fields/phone.js index 2d270cf99d..f934195322 100644 --- a/client/src/views/fields/phone.js +++ b/client/src/views/fields/phone.js @@ -68,6 +68,13 @@ Espo.define('views/fields/phone', 'views/fields/varchar', function (Dep) { phoneNumberData = this.model.get(this.dataFieldName) || false; } + if (phoneNumberData) { + phoneNumberData = Espo.Utils.clone(phoneNumberData); + phoneNumberData.forEach(function (item) { + item.erased = item.phoneNumber.indexOf(this.erasedPlaceholder) === 0 + }, this); + } + if ((!phoneNumberData || phoneNumberData.length === 0) && this.model.get(this.name)) { phoneNumberData = [{ phoneNumber: this.model.get(this.name), @@ -77,10 +84,18 @@ Espo.define('views/fields/phone', 'views/fields/varchar', function (Dep) { }]; } - return _.extend({ + var data = _.extend({ phoneNumberData: phoneNumberData, doNotCall: this.model.get('doNotCall') }, Dep.prototype.data.call(this)); + + if (this.mode === 'detail' || this.mode === 'list') { + if (this.model.get(this.name)) { + data.isErased = this.model.get(this.name).indexOf(this.erasedPlaceholder) === 0 + } + } + + return data; }, events: { @@ -220,6 +235,8 @@ Espo.define('views/fields/phone', 'views/fields/varchar', function (Dep) { this.reRender(); }, this); } + + this.erasedPlaceholder = 'ERASED:'; }, fetchPhoneNumberData: function () { diff --git a/client/src/views/personal-data/modals/personal-data.js b/client/src/views/personal-data/modals/personal-data.js new file mode 100644 index 0000000000..5e20b5cd3b --- /dev/null +++ b/client/src/views/personal-data/modals/personal-data.js @@ -0,0 +1,105 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2018 Yuri Kuznetsov, Taras Machyshyn, Oleksiy Avramenko + * Website: http://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +Espo.define('views/personal-data/modals/personal-data', ['views/modal'], function (Dep) { + + return Dep.extend({ + + className: 'dialog dialog-record', + + template: 'personal-data/modals/personal-data', + + backdrop: true, + + setup: function () { + Dep.prototype.setup.call(this); + + this.buttonList = [ + { + name: 'cancel', + label: 'Close' + } + ]; + + this.header = this.getLanguage().translate('Personal Data'); + this.header += ': ' + Handlebars.Utils.escapeExpression(this.model.get('name')); + + if (this.getAcl().check(this.model, 'edit')) { + this.buttonList.unshift({ + name: 'erase', + label: 'Erase', + style: 'danger', + disabled: true + }); + } + + this.fieldList = []; + + this.scope = this.model.name; + + this.createView('record', 'views/personal-data/record/record', { + el: this.getSelector() + ' .record', + model: this.model + }, function (view) { + this.listenTo(view, 'check', function (fieldList) { + this.fieldList = fieldList; + if (fieldList.length) { + this.enableButton('erase'); + } else { + this.disableButton('erase'); + } + }); + + if (!view.fieldList.length) { + this.disableButton('export'); + } + }); + }, + + actionErase: function () { + this.confirm({ + message: this.translate('erasePersonalDataConfirmation', 'messages'), + confirmText: this.translate('Erase') + }, function () { + this.disableButton('erase'); + this.ajaxPostRequest('DataPrivacy/action/erase', { + fieldList: this.fieldList, + entityType: this.scope, + id: this.model.id + }).then(function () { + Espo.Ui.success(this.translate('Done')); + + this.trigger('erase'); + }.bind(this)).fail(function () { + this.enableButton('erase'); + }.bind(this)); + }.bind(this)); + } + + }); +}); diff --git a/client/src/views/personal-data/record/record.js b/client/src/views/personal-data/record/record.js new file mode 100644 index 0000000000..62150b9f10 --- /dev/null +++ b/client/src/views/personal-data/record/record.js @@ -0,0 +1,153 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM - Open Source CRM application. + * Copyright (C) 2014-2018 Yuri Kuznetsov, Taras Machyshyn, Oleksiy Avramenko + * Website: http://www.espocrm.com + * + * EspoCRM is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * EspoCRM is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with EspoCRM. If not, see http://www.gnu.org/licenses/. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU General Public License version 3. + * + * In accordance with Section 7(b) of the GNU General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +Espo.define('views/personal-data/record/record', 'views/record/base', function (Dep) { + + return Dep.extend({ + + template: 'personal-data/record/record', + + events: _.extend({ + 'click .checkbox': function (e) { + var name = $(e.currentTarget).data('name'); + if (e.currentTarget.checked) { + if (!~this.checkedFieldList.indexOf(name)) { + this.checkedFieldList.push(name); + } + + if (this.checkedFieldList.length == this.fieldList.length) { + this.$el.find('.checkbox-all').prop('checked', true); + } else { + this.$el.find('.checkbox-all').prop('checked', false); + } + } else { + var index = this.checkedFieldList.indexOf(name); + if (~index) { + this.checkedFieldList.splice(index, 1); + } + + this.$el.find('.checkbox-all').prop('checked', false); + } + + this.trigger('check', this.checkedFieldList); + }, + + 'click .checkbox-all': function (e) { + if (e.currentTarget.checked) { + this.checkedFieldList = Espo.Utils.clone(this.fieldList); + + this.$el.find('.checkbox').prop('checked', true); + } else { + this.checkedFieldList = []; + + this.$el.find('.checkbox').prop('checked', false); + } + + this.trigger('check', this.checkedFieldList); + }, + }, Dep.prototype.events), + + data: function () { + var data = {}; + data.fieldDataList = this.getFieldDataList(); + data.scope = this.scope; + data.editAccess = this.editAccess; + return data; + }, + + setup: function () { + Dep.prototype.setup.call(this); + + this.scope = this.model.name; + + this.fieldList = []; + + this.checkedFieldList = []; + + this.editAccess = this.getAcl().check(this.model, 'edit'); + + var fieldDefs = this.getMetadata().get(['entityDefs', this.scope, 'fields']) || {}; + + var fieldList = []; + + for (var field in fieldDefs) { + var defs = fieldDefs[field]; + if (defs.isPersonalData) { + fieldList.push(field); + } + } + + fieldList.forEach(function (field) { + var type = fieldDefs[field].type; + var attributeList = this.getFieldManager().getActualAttributeList(type, field); + + var isNotEmpty = false; + attributeList.forEach(function (attribute) { + var value = this.model.get(attribute); + if (value) { + if (Object.prototype.toString.call(value) === '[object Array]') { + if (value.length) { + return; + } + } + isNotEmpty = true; + } + }, this); + + var hasAccess = !~this.getAcl().getScopeForbiddenFieldList(this.scope, 'view').indexOf(field); + + if (isNotEmpty && hasAccess) { + this.fieldList.push(field); + } + }, this); + + this.fieldList = this.fieldList.sort(function (v1, v2) { + return this.translate(v1, 'fields', this.scope).localeCompare(this.translate(v2, 'fields', this.scope)); + }.bind(this)); + + this.fieldList.forEach(function (field) { + this.createField(field, null, null, 'detail', true); + }, this); + }, + + getFieldDataList: function () { + var forbiddenList = this.getAcl().getScopeForbiddenFieldList(this.scope, 'edit'); + + var list = []; + this.fieldList.forEach(function (field) { + list.push({ + name: field, + key: field + 'Field', + editAccess: this.editAccess && !~forbiddenList.indexOf(field) + }); + }, this); + return list; + } + + }); +}); diff --git a/client/src/views/record/detail.js b/client/src/views/record/detail.js index bf2c744765..b397e72c04 100644 --- a/client/src/views/record/detail.js +++ b/client/src/views/record/detail.js @@ -245,6 +245,15 @@ Espo.define('views/record/detail', ['views/record/base', 'view-record-helper'], }); } } + + if (this.type === 'detail' && this.getMetadata().get(['scopes', this.scope, 'hasPersonalData'])) { + if (this.getAcl().get('dataPrivacyPermission') !== 'no') { + this.dropdownItemList.push({ + 'label': 'View Personal Data', + 'name': 'viewPersonalData' + }); + } + } }, hideActionItem: function (name) { @@ -874,6 +883,19 @@ Espo.define('views/record/detail', ['views/record/base', 'view-record-helper'], } }, + actionViewPersonalData: function () { + this.createView('viewPersonalData', 'views/personal-data/modals/personal-data', { + model: this.model + }, function (view) { + view.render(); + + this.listenToOnce(view, 'erase', function () { + this.clearView('viewPersonalData'); + this.model.fetch(); + }, this); + }); + }, + actionPrintPdf: function () { this.createView('pdfTemplate', 'views/modals/select-template', { entityType: this.model.name