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}}
| + | + | + |
|---|---|---|
| {{#if editAccess}}{{/if}} | {{/if}} +{{translate name category='fields' scope=../scope}} | +
+ {{{var key ../this}}}
+ |
+