diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php index c17543d2..e5607b96 100644 --- a/src/app/Backends/LDAP.php +++ b/src/app/Backends/LDAP.php @@ -1,1378 +1,1383 @@ close(); self::$ldap = null; } } /** * Create a domain in LDAP. * * @param \App\Domain $domain The domain to create. * * @throws \Exception */ public static function createDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $mgmtRootDN = \config('ldap.admin.root_dn'); $domainBaseDN = self::baseDN($domain->namespace); $aci = [ '(targetattr = "*")' . '(version 3.0; acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)' ]; $entry = [ 'aci' => $aci, 'associateddomain' => $domain->namespace, 'inetdomainbasedn' => $domainBaseDN, 'objectclass' => [ 'top', 'domainrelatedobject', 'inetdomain' ], ]; $dn = "associateddomain={$domain->namespace},{$config['domain_base_dn']}"; self::setDomainAttributes($domain, $entry); if (!$ldap->get_entry($dn)) { self::addEntry( $ldap, $dn, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } // create ou, roles, ous $entry = [ 'description' => $domain->namespace, 'objectclass' => [ 'top', 'organizationalunit' ], 'ou' => $domain->namespace, ]; $entry['aci'] = array( '(targetattr = "*")' . '(version 3.0;acl "Deny Unauthorized"; deny (all)' . '(userdn != "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)") ' . 'AND NOT roledn = "ldap:///cn=kolab-admin,' . $mgmtRootDN . '";)', '(targetattr != "userPassword")' . '(version 3.0;acl "Search Access";allow (read,compare,search,write)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . ' || ldap:///ou=People,' . $domainBaseDN . '??sub?(objectclass=inetorgperson)");)', '(targetattr = "*")' . '(version 3.0;acl "Kolab Administrators";allow (all)' . '(roledn = "ldap:///cn=kolab-admin,' . $domainBaseDN . ' || ldap:///cn=kolab-admin,' . $mgmtRootDN . '");)', '(target = "ldap:///ou=*,' . $domainBaseDN . '")' . '(targetattr="objectclass || aci || ou")' . '(version 3.0;acl "Allow Domain sub-OU Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///uid=*,ou=People,' . $domainBaseDN . '")(targetattr="*")' . '(version 3.0;acl "Allow Domain First User Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', '(target = "ldap:///cn=*,' . $domainBaseDN . '")(targetattr="objectclass || cn")' . '(version 3.0;acl "Allow Domain Role Registration"; allow (add)' . '(userdn = "ldap:///uid=kolab-service,ou=Special Users,' . $mgmtRootDN . '");)', ); if (!$ldap->get_entry($domainBaseDN)) { self::addEntry( $ldap, $domainBaseDN, $entry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } foreach (['Groups', 'People', 'Resources', 'Shared Folders'] as $item) { $itemDN = self::baseDN($domain->namespace, $item); if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'ou' => $item, 'description' => $item, 'objectclass' => [ 'top', 'organizationalunit' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } foreach (['kolab-admin'] as $item) { $itemDN = "cn={$item},{$domainBaseDN}"; if (!$ldap->get_entry($itemDN)) { $itemEntry = [ 'cn' => $item, 'description' => "{$item} role", 'objectclass' => [ 'top', 'ldapsubentry', 'nsmanagedroledefinition', 'nsroledefinition', 'nssimpleroledefinition' ] ]; self::addEntry( $ldap, $itemDN, $itemEntry, "Failed to create domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } } // TODO: Assign kolab-admin role to the owner? if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a group in LDAP. * * @param \App\Group $group The group to create. * * @throws \Exception */ public static function createGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $group->email, 2)[1]; $cn = $ldap->quote_string($group->name); $dn = "cn={$cn}," . self::baseDN($domainName, 'Groups'); $entry = [ 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], ]; self::setGroupAttributes($ldap, $group, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create group {$group->email} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a resource in LDAP. * * @param \App\Resource $resource The resource to create. * * @throws \Exception */ public static function createResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $resource->email, 2)[1]; $cn = $ldap->quote_string($resource->name); $dn = "cn={$cn}," . self::baseDN($domainName, 'Resources'); $entry = [ 'mail' => $resource->email, 'objectclass' => [ 'top', 'kolabresource', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', ]; self::setResourceAttributes($ldap, $resource, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create resource {$resource->email} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to create. * * @throws \Exception */ public static function createSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainName = explode('@', $folder->email, 2)[1]; $cn = $ldap->quote_string($folder->name); $dn = "cn={$cn}," . self::baseDN($domainName, 'Shared Folders'); $entry = [ 'mail' => $folder->email, 'objectclass' => [ 'top', 'kolabsharedfolder', 'mailrecipient', ], ]; self::setSharedFolderAttributes($ldap, $folder, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); if (empty(self::$ldap)) { $ldap->close(); } } /** * Create a user in LDAP. * * Only need to add user if in any of the local domains? Figure that out here for now. Should * have Context-Based Access Controls before the job is queued though, probably. * * Use one of three modes; * * 1) The authenticated user account. * * * Only valid if the authenticated user is a domain admin. * * We don't know the originating user here. * * We certainly don't have its password anymore. * * 2) The hosted kolab account. * * 3) The Directory Manager account. * * @param \App\User $user The user account to create. * * @throws \Exception */ public static function createUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $entry = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person' ], 'mail' => $user->email, 'uid' => $user->email, 'nsroledn' => [] ]; if (!self::getUserEntry($ldap, $user->email, $dn)) { if (empty($dn)) { self::throwException($ldap, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")"); } self::setUserAttributes($user, $entry); self::addEntry( $ldap, $dn, $entry, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a domain from LDAP. * * @param \App\Domain $domain The domain to delete * * @throws \Exception */ public static function deleteDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $domainBaseDN = self::baseDN($domain->namespace); if ($ldap->get_entry($domainBaseDN)) { $result = $ldap->delete_entry_recursive($domainBaseDN); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } if ($ldap_domain = $ldap->find_domain($domain->namespace)) { if ($ldap->get_entry($ldap_domain['dn'])) { $result = $ldap->delete_entry($ldap_domain['dn']); if (!$result) { self::throwException( $ldap, "Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")" ); } } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a group from LDAP. * * @param \App\Group $group The group to delete. * * @throws \Exception */ public static function deleteGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getGroupEntry($ldap, $group->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete group {$group->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a resource from LDAP. * * @param \App\Resource $resource The resource to delete. * * @throws \Exception */ public static function deleteResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getResourceEntry($ldap, $resource->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete resource {$resource->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a shared folder from LDAP. * * @param \App\SharedFolder $folder The shared folder to delete. * * @throws \Exception */ public static function deleteSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete shared folder {$folder->id} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Delete a user from LDAP. * * @param \App\User $user The user account to delete. * * @throws \Exception */ public static function deleteUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); if (self::getUserEntry($ldap, $user->email, $dn)) { $result = $ldap->delete_entry($dn); if (!$result) { self::throwException( $ldap, "Failed to delete user {$user->email} from LDAP (" . __LINE__ . ")" ); } } if (empty(self::$ldap)) { $ldap->close(); } } /** * Get a domain data from LDAP. * * @param string $namespace The domain name * * @return array|false|null * @throws \Exception */ public static function getDomain(string $namespace) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($namespace); if ($ldapDomain) { $domain = $ldap->get_entry($ldapDomain['dn']); } if (empty(self::$ldap)) { $ldap->close(); } return $domain ?? null; } /** * Get a group data from LDAP. * * @param string $email The group email. * * @return array|false|null * @throws \Exception */ public static function getGroup(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $group = self::getGroupEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $group; } /** * Get a resource data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getResource(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $resource = self::getResourceEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $resource; } /** * Get a shared folder data from LDAP. * * @param string $email The resource email. * * @return array|false|null * @throws \Exception */ public static function getSharedFolder(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $folder = self::getSharedFolderEntry($ldap, $email, $dn); if (empty(self::$ldap)) { $ldap->close(); } return $folder; } /** * Get a user data from LDAP. * * @param string $email The user email. * * @return array|false|null * @throws \Exception */ public static function getUser(string $email) { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $user = self::getUserEntry($ldap, $email, $dn, true); if (empty(self::$ldap)) { $ldap->close(); } return $user; } /** * Update a domain in LDAP. * * @param \App\Domain $domain The domain to update. * * @throws \Exception */ public static function updateDomain(Domain $domain): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $ldapDomain = $ldap->find_domain($domain->namespace); if (!$ldapDomain) { self::throwException( $ldap, "Failed to update domain {$domain->namespace} in LDAP (domain not found)" ); } $oldEntry = $ldap->get_entry($ldapDomain['dn']); $newEntry = $oldEntry; self::setDomainAttributes($domain, $newEntry); if (array_key_exists('inetdomainstatus', $newEntry)) { $newEntry['inetdomainstatus'] = (string) $newEntry['inetdomainstatus']; } $result = $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update domain {$domain->namespace} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a group in LDAP. * * @param \App\Group $group The group to update * * @throws \Exception */ public static function updateGroup(Group $group): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getGroupEntry($ldap, $group->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (group not found)" ); } self::setGroupAttributes($ldap, $group, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update group {$group->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a resource in LDAP. * * @param \App\Resource $resource The resource to update * * @throws \Exception */ public static function updateResource(Resource $resource): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getResourceEntry($ldap, $resource->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (resource not found)" ); } self::setResourceAttributes($ldap, $resource, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update resource {$resource->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a shared folder in LDAP. * * @param \App\SharedFolder $folder The shared folder to update * * @throws \Exception */ public static function updateSharedFolder(SharedFolder $folder): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn); if (empty($oldEntry)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (folder not found)" ); } self::setSharedFolderAttributes($ldap, $folder, $newEntry); $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update shared folder {$folder->id} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Update a user in LDAP. * * @param \App\User $user The user account to update. * * @throws \Exception */ public static function updateUser(User $user): void { $config = self::getConfig('admin'); $ldap = self::initLDAP($config); $newEntry = $oldEntry = self::getUserEntry($ldap, $user->email, $dn, true); if (!$oldEntry) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (user not found)" ); } self::setUserAttributes($user, $newEntry); if (array_key_exists('objectclass', $newEntry)) { if (!in_array('inetuser', $newEntry['objectclass'])) { $newEntry['objectclass'][] = 'inetuser'; } } if (array_key_exists('inetuserstatus', $newEntry)) { $newEntry['inetuserstatus'] = (string) $newEntry['inetuserstatus']; } if (array_key_exists('mailquota', $newEntry)) { $newEntry['mailquota'] = (string) $newEntry['mailquota']; } $result = $ldap->modify_entry($dn, $oldEntry, $newEntry); if (!is_array($result)) { self::throwException( $ldap, "Failed to update user {$user->email} in LDAP (" . __LINE__ . ")" ); } if (empty(self::$ldap)) { $ldap->close(); } } /** * Initialize connection to LDAP */ private static function initLDAP(array $config, string $privilege = 'admin') { if (self::$ldap) { return self::$ldap; } $ldap = new \Net_LDAP3($config); $connected = $ldap->connect(); if (!$connected) { throw new \Exception("Failed to connect to LDAP"); } $bound = $ldap->bind( \config("ldap.{$privilege}.bind_dn"), \config("ldap.{$privilege}.bind_pw") ); if (!$bound) { throw new \Exception("Failed to bind to LDAP"); } return $ldap; } /** * Set domain attributes */ private static function setDomainAttributes(Domain $domain, array &$entry) { $entry['inetdomainstatus'] = $domain->status; } /** * Convert group member addresses in to valid entries. */ private static function setGroupAttributes($ldap, Group $group, &$entry) { $settings = $group->getSettings(['sender_policy']); $entry['kolaballowsmtpsender'] = json_decode($settings['sender_policy'] ?: '[]', true); $entry['cn'] = $group->name; $entry['uniquemember'] = []; $groupDomain = explode('@', $group->email, 2)[1]; $domainBaseDN = self::baseDN($groupDomain); $validMembers = []; foreach ($group->members as $member) { list($local, $domainName) = explode('@', $member); $memberDN = "uid={$member},ou=People,{$domainBaseDN}"; $memberEntry = $ldap->get_entry($memberDN); // if the member is in the local domain but doesn't exist, drop it if ($domainName == $groupDomain && !$memberEntry) { continue; } // add the member if not in the local domain if (!$memberEntry) { $memberEntry = [ 'cn' => $member, 'mail' => $member, 'objectclass' => [ 'top', 'inetorgperson', 'organizationalperson', 'person' ], 'sn' => 'unknown' ]; $ldap->add_entry($memberDN, $memberEntry); } $entry['uniquemember'][] = $memberDN; $validMembers[] = $member; } // Update members in sql (some might have been removed), // skip model events to not invoke another update job if ($group->members !== $validMembers) { $group->members = $validMembers; Group::withoutEvents(function () use ($group) { $group->save(); }); } } /** * Set common resource attributes */ private static function setResourceAttributes($ldap, Resource $resource, &$entry) { $entry['cn'] = $resource->name; $entry['owner'] = null; $entry['kolabinvitationpolicy'] = null; $entry['acl'] = ''; $settings = $resource->getSettings(['invitation_policy', 'folder']); $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; // Here's how Wallace's resources module works: // - if policy is ACT_MANUAL and owner mail specified: a tentative response is sent, event saved, // and mail sent to the owner to accept/decline the request. // - if policy is ACT_ACCEPT_AND_NOTIFY and owner mail specified: an accept response is sent, // event saved, and notification (not confirmation) mail sent to the owner. // - if there's no owner (policy irrelevant): an accept response is sent, event saved. // - if policy is ACT_REJECT: a decline response is sent // - note that the notification email is being send if COND_NOTIFY policy is set or saving failed. // - all above assume there's no conflict, if there's a conflict the decline response is sent automatically // (notification is sent if policy = ACT_ACCEPT_AND_NOTIFY). // - the only supported policies are: 'ACT_MANUAL', 'ACT_ACCEPT' (defined but not used anywhere), // 'ACT_REJECT', 'ACT_ACCEPT_AND_NOTIFY'. // For now we ignore the notifications feature if (!empty($settings['invitation_policy'])) { if ($settings['invitation_policy'] === 'accept') { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } elseif ($settings['invitation_policy'] === 'reject') { $entry['kolabinvitationpolicy'] = 'ACT_REJECT'; } elseif (preg_match('/^manual:(\S+@\S+)$/', $settings['invitation_policy'], $m)) { if (self::getUserEntry($ldap, $m[1], $userDN)) { $entry['owner'] = $userDN; $entry['acl'] = $m[1] . ', full'; $entry['kolabinvitationpolicy'] = 'ACT_MANUAL'; } else { $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT'; } } } } /** * Set common shared folder attributes */ private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry) { $settings = $folder->getSettings(['acl', 'folder']); $entry['cn'] = $folder->name; $entry['kolabfoldertype'] = $folder->type; $entry['kolabtargetfolder'] = $settings['folder'] ?? ''; $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : ''; } /** * Set common user attributes */ private static function setUserAttributes(User $user, array &$entry) { + $isDegraded = $user->isDegraded(true); $settings = $user->getSettings(['first_name', 'last_name', 'organization']); $firstName = $settings['first_name']; $lastName = $settings['last_name']; - $cn = "unknown"; $displayname = ""; if ($firstName) { if ($lastName) { $cn = "{$firstName} {$lastName}"; $displayname = "{$lastName}, {$firstName}"; } else { $lastName = "unknown"; $cn = "{$firstName}"; $displayname = "{$firstName}"; } } else { $firstName = ""; if ($lastName) { $cn = "{$lastName}"; $displayname = "{$lastName}"; } else { $lastName = "unknown"; } } $entry['cn'] = $cn; $entry['displayname'] = $displayname; $entry['givenname'] = $firstName; $entry['sn'] = $lastName; $entry['userpassword'] = $user->password_ldap; $entry['inetuserstatus'] = $user->status; $entry['o'] = $settings['organization']; $entry['mailquota'] = 0; $entry['alias'] = $user->aliases->pluck('alias')->toArray(); $roles = []; foreach ($user->entitlements as $entitlement) { \Log::debug("Examining {$entitlement->sku->title}"); switch ($entitlement->sku->title) { case "mailbox": break; case "storage": $entry['mailquota'] += 1048576; break; default: $roles[] = $entitlement->sku->title; break; } } $hostedRootDN = \config('ldap.hosted.root_dn'); $entry['nsroledn'] = []; if (in_array("2fa", $roles)) { $entry['nsroledn'][] = "cn=2fa-user,{$hostedRootDN}"; } - if (in_array("activesync", $roles)) { - $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; - } + if ($isDegraded) { + $entry['nsroledn'][] = "cn=degraded-user,{$hostedRootDN}"; + $entry['mailquota'] = \config('app.storage.min_qty') * 1048576; + } else { + if (in_array("activesync", $roles)) { + $entry['nsroledn'][] = "cn=activesync-user,{$hostedRootDN}"; + } - if (!in_array("groupware", $roles)) { - $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; + if (!in_array("groupware", $roles)) { + $entry['nsroledn'][] = "cn=imap-user,{$hostedRootDN}"; + } } } /** * Get LDAP configuration for specified access level */ private static function getConfig(string $privilege) { $config = [ 'domain_base_dn' => \config('ldap.domain_base_dn'), 'domain_filter' => \config('ldap.domain_filter'), 'domain_name_attribute' => \config('ldap.domain_name_attribute'), 'hosts' => \config('ldap.hosts'), 'sort' => false, 'vlv' => false, 'log_hook' => 'App\Backends\LDAP::logHook', ]; return $config; } /** * Get group entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Group email (mail) * @param string $dn Reference to group DN * * @return null|array Group entry, False on error, NULL if not found */ private static function getGroupEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($domainName, 'Groups'); $attrs = ['dn', 'cn', 'mail', 'uniquemember', 'objectclass', 'kolaballowsmtpsender']; // For groups we're using search() instead of get_entry() because // a group name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a resource entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the resource DN * * @return null|array Resource entry, NULL if not found */ private static function getResourceEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($domainName, 'Resources'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'kolabinvitationpolicy', 'owner', 'acl']; // For resources we're using search() instead of get_entry() because // a resource name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get a shared folder entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email Resource email (mail) * @param string $dn Reference to the shared folder DN * * @return null|array Shared folder entry, NULL if not found */ private static function getSharedFolderEntry($ldap, $email, &$dn = null) { $domainName = explode('@', $email, 2)[1]; $base_dn = self::baseDN($domainName, 'Shared Folders'); $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl']; // For shared folders we're using search() instead of get_entry() because // a folder name is not constant, so e.g. on update we might have // the new name, but not the old one. Email address is constant. return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn); } /** * Get user entry from LDAP. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $email User email (uid) * @param string $dn Reference to user DN * @param bool $full Get extra attributes, e.g. nsroledn * * @return ?array User entry, NULL if not found */ private static function getUserEntry($ldap, $email, &$dn = null, $full = false) { $domainName = explode('@', $email, 2)[1]; $dn = "uid={$email}," . self::baseDN($domainName, 'People'); $entry = $ldap->get_entry($dn); if ($entry && $full) { if (!array_key_exists('nsroledn', $entry)) { $roles = $ldap->get_entry_attributes($dn, ['nsroledn']); if (!empty($roles)) { $entry['nsroledn'] = (array) $roles['nsroledn']; } } } return $entry ?: null; } /** * Logging callback */ public static function logHook($level, $msg): void { if ( ( $level == LOG_INFO || $level == LOG_DEBUG || $level == LOG_NOTICE ) && !\config('app.debug') ) { return; } switch ($level) { case LOG_CRIT: $function = 'critical'; break; case LOG_EMERG: $function = 'emergency'; break; case LOG_ERR: $function = 'error'; break; case LOG_ALERT: $function = 'alert'; break; case LOG_WARNING: $function = 'warning'; break; case LOG_INFO: $function = 'info'; break; case LOG_DEBUG: $function = 'debug'; break; case LOG_NOTICE: $function = 'notice'; break; default: $function = 'info'; } if (is_array($msg)) { $msg = implode("\n", $msg); } $msg = '[LDAP] ' . $msg; \Log::{$function}($msg); } /** * A wrapper for Net_LDAP3::add_entry() with error handler * * @param \Net_LDAP3 $ldap Ldap connection * @param string $dn Entry DN * @param array $entry Entry attributes * @param ?string $errorMsg A message to throw as an exception on error * * @throws \Exception */ private static function addEntry($ldap, string $dn, array $entry, $errorMsg = null) { // try/catch because Laravel converts warnings into exceptions // and we want more human-friendly error message than that try { $result = $ldap->add_entry($dn, $entry); } catch (\Exception $e) { $result = false; } if (!$result) { if (!$errorMsg) { $errorMsg = "LDAP Error (" . __LINE__ . ")"; } if (isset($e)) { $errorMsg .= ": " . $e->getMessage(); } self::throwException($ldap, $errorMsg); } } /** * Find a single entry in LDAP by using search. * * @param \Net_LDAP3 $ldap Ldap connection * @param string $base_dn Base DN * @param string $filter Search filter * @param array $attrs Result attributes * @param string $dn Reference to a DN of the found entry * * @return null|array LDAP entry, NULL if not found */ private static function searchEntry($ldap, $base_dn, $filter, $attrs, &$dn = null) { $result = $ldap->search($base_dn, $filter, 'sub', $attrs); if ($result && $result->count() == 1) { $entries = $result->entries(true); $dn = key($entries); $entry = $entries[$dn]; $entry['dn'] = $dn; return $entry; } return null; } /** * Throw exception and close the connection when needed * * @param \Net_LDAP3 $ldap Ldap connection * @param string $message Exception message * * @throws \Exception */ private static function throwException($ldap, string $message): void { if (empty(self::$ldap) && !empty($ldap)) { $ldap->close(); } throw new \Exception($message); } /** * Create a base DN string for specified object * * @param string $domainName Domain namespace * @param ?string $ouName Optional name of the sub-tree (OU) * * @return string Full base DN */ private static function baseDN(string $domainName, string $ouName = null): string { $hostedRootDN = \config('ldap.hosted.root_dn'); $dn = "ou={$domainName},{$hostedRootDN}"; if ($ouName) { $dn = "ou={$ouName},{$dn}"; } return $dn; } } diff --git a/src/app/Console/Commands/User/DegradeCommand.php b/src/app/Console/Commands/User/DegradeCommand.php new file mode 100644 index 00000000..a878dbd7 --- /dev/null +++ b/src/app/Console/Commands/User/DegradeCommand.php @@ -0,0 +1,39 @@ +getUser($this->argument('user')); + + if (!$user) { + $this->error('User not found.'); + return 1; + } + + $user->degrade(); + } +} diff --git a/src/app/Console/Commands/User/StatusCommand.php b/src/app/Console/Commands/User/StatusCommand.php index 402e6b27..678cc0c5 100644 --- a/src/app/Console/Commands/User/StatusCommand.php +++ b/src/app/Console/Commands/User/StatusCommand.php @@ -1,58 +1,59 @@ getUser($this->argument('user'), true); if (!$user) { $this->error("User not found."); $this->error("Try ./artisan scalpel:user:read --attr=email --attr=tenant_id " . $this->argument('user')); return 1; } $statuses = [ 'active' => User::STATUS_ACTIVE, 'suspended' => User::STATUS_SUSPENDED, 'deleted' => User::STATUS_DELETED, 'ldapReady' => User::STATUS_LDAP_READY, 'imapReady' => User::STATUS_IMAP_READY, + 'degraded' => User::STATUS_DEGRADED, ]; $user_state = []; foreach (\array_keys($statuses) as $state) { $func = 'is' . \ucfirst($state); if ($user->$func()) { $user_state[] = $state; } } $this->info("Status: " . \implode(',', $user_state)); } } diff --git a/src/app/Console/Commands/User/UndegradeCommand.php b/src/app/Console/Commands/User/UndegradeCommand.php new file mode 100644 index 00000000..85f75312 --- /dev/null +++ b/src/app/Console/Commands/User/UndegradeCommand.php @@ -0,0 +1,39 @@ +getUser($this->argument('user')); + + if (!$user) { + $this->error('User not found.'); + return 1; + } + + $user->undegrade(); + } +} diff --git a/src/app/Console/Commands/Wallet/ChargeCommand.php b/src/app/Console/Commands/Wallet/ChargeCommand.php index 268ae556..b5a96d0c 100644 --- a/src/app/Console/Commands/Wallet/ChargeCommand.php +++ b/src/app/Console/Commands/Wallet/ChargeCommand.php @@ -1,72 +1,73 @@ argument('wallet')) { // Find specified wallet by ID $wallet = $this->getWallet($wallet); if (!$wallet) { $this->error("Wallet not found."); return 1; } if (!$wallet->owner) { $this->error("Wallet's owner is deleted."); return 1; } $wallets = [$wallet]; } else { // Get all wallets, excluding deleted accounts $wallets = \App\Wallet::select('wallets.*') ->join('users', 'users.id', '=', 'wallets.user_id') ->withEnvTenantContext('users') ->whereNull('users.deleted_at') ->cursor(); } foreach ($wallets as $wallet) { $charge = $wallet->chargeEntitlements(); if ($charge > 0) { $this->info( "Charged wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}" ); // Top-up the wallet if auto-payment enabled for the wallet \App\Jobs\WalletCharge::dispatch($wallet); } if ($wallet->balance < 0) { - // Check the account balance, send notifications, suspend, delete + // Check the account balance, send notifications, (suspend, delete,) degrade + // Also sends reminders to the degraded account owners \App\Jobs\WalletCheck::dispatch($wallet); } } } } diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php index a24456ff..4d89f0fe 100644 --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -1,590 +1,590 @@ first(); // This isn't a room, bye bye if (!$room) { return $this->errorResponse(404, \trans('meet.room-not-found')); } // Only the moderator can do it if (!$this->isModerator($room)) { return $this->errorResponse(403); } if (!$room->requestAccept($reqid)) { return $this->errorResponse(500, \trans('meet.session-request-accept-error')); } return response()->json(['status' => 'success']); } /** * Deny the room join request. * * @param string $id Room identifier (name) * @param string $reqid Request identifier * * @return \Illuminate\Http\JsonResponse */ public function denyJoinRequest($id, $reqid) { $room = Room::where('name', $id)->first(); // This isn't a room, bye bye if (!$room) { return $this->errorResponse(404, \trans('meet.room-not-found')); } // Only the moderator can do it if (!$this->isModerator($room)) { return $this->errorResponse(403); } if (!$room->requestDeny($reqid)) { return $this->errorResponse(500, \trans('meet.session-request-deny-error')); } return response()->json(['status' => 'success']); } /** * Close the room session. * * @param string $id Room identifier (name) * * @return \Illuminate\Http\JsonResponse */ public function closeRoom($id) { $room = Room::where('name', $id)->first(); // This isn't a room, bye bye if (!$room) { return $this->errorResponse(404, \trans('meet.room-not-found')); } $user = Auth::guard()->user(); // Only the room owner can do it if (!$user || $user->id != $room->user_id) { return $this->errorResponse(403); } if (!$room->deleteSession()) { return $this->errorResponse(500, \trans('meet.session-close-error')); } return response()->json([ 'status' => 'success', 'message' => __('meet.session-close-success'), ]); } /** * Create a connection for screen sharing. * * @param string $id Room identifier (name) * * @return \Illuminate\Http\JsonResponse */ public function createConnection($id) { $room = Room::where('name', $id)->first(); // This isn't a room, bye bye if (!$room) { return $this->errorResponse(404, \trans('meet.room-not-found')); } $connection = $this->getConnectionFromRequest(); if ( !$connection || $connection->session_id != $room->session_id || ($connection->role & Room::ROLE_PUBLISHER) == 0 ) { return $this->errorResponse(403); } $response = $room->getSessionToken(Room::ROLE_SCREEN); return response()->json(['status' => 'success', 'token' => $response['token']]); } /** * Dismiss the participant/connection from the session. * * @param string $id Room identifier (name) * @param string $conn Connection identifier * * @return \Illuminate\Http\JsonResponse */ public function dismissConnection($id, $conn) { $connection = Connection::where('id', $conn)->first(); // There's no such connection, bye bye if (!$connection || $connection->room->name != $id) { return $this->errorResponse(404, \trans('meet.connection-not-found')); } // Only the moderator can do it if (!$this->isModerator($connection->room)) { return $this->errorResponse(403); } if (!$connection->dismiss()) { return $this->errorResponse(500, \trans('meet.connection-dismiss-error')); } return response()->json(['status' => 'success']); } /** * Listing of rooms that belong to the authenticated user. * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = Auth::guard()->user(); $rooms = Room::where('user_id', $user->id)->orderBy('name')->get(); if (count($rooms) == 0) { // Create a room for the user (with a random and unique name) while (true) { $name = strtolower(\App\Utils::randStr(3, 3, '-')); if (!Room::where('name', $name)->count()) { break; } } $room = Room::create([ 'name' => $name, 'user_id' => $user->id ]); $rooms = collect([$room]); } $result = [ 'list' => $rooms, 'count' => count($rooms), ]; return response()->json($result); } /** * Join the room session. Each room has one owner, and the room isn't open until the owner * joins (and effectively creates the session). * * @param string $id Room identifier (name) * * @return \Illuminate\Http\JsonResponse */ public function joinRoom($id) { $room = Room::where('name', $id)->first(); // Room does not exist, or the owner is deleted - if (!$room || !$room->owner) { + if (!$room || !$room->owner || $room->owner->isDegraded(true)) { return $this->errorResponse(404, \trans('meet.room-not-found')); } // Check if there's still a valid meet entitlement for the room owner if (!$room->owner->hasSku('meet')) { return $this->errorResponse(404, \trans('meet.room-not-found')); } $user = Auth::guard()->user(); $isOwner = $user && $user->id == $room->user_id; $init = !empty(request()->input('init')); // There's no existing session if (!$room->hasSession()) { // Participants can't join the room until the session is created by the owner if (!$isOwner) { return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 323]); } // The room owner can create the session on request if (!$init) { return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 324]); } $session = $room->createSession(); if (empty($session)) { return $this->errorResponse(500, \trans('meet.session-create-error')); } } $settings = $room->getSettings(['locked', 'nomedia', 'password']); $password = (string) $settings['password']; $config = [ 'locked' => $settings['locked'] === 'true', 'nomedia' => $settings['nomedia'] === 'true', 'password' => $isOwner ? $password : '', 'requires_password' => !$isOwner && strlen($password), ]; $response = ['config' => $config]; // Validate room password if (!$isOwner && strlen($password)) { $request_password = request()->input('password'); if ($request_password !== $password) { return $this->errorResponse(422, \trans('meet.session-password-error'), $response + ['code' => 325]); } } // Handle locked room if (!$isOwner && $config['locked']) { $nickname = request()->input('nickname'); $picture = request()->input('picture'); $requestId = request()->input('requestId'); $request = $requestId ? $room->requestGet($requestId) : null; $error = \trans('meet.session-room-locked-error'); // Request already has been processed (not accepted yet, but it could be denied) if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) { if (!$request) { if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) { return $this->errorResponse(422, $error, $response + ['code' => 326]); } if (empty($picture)) { $svg = file_get_contents(resource_path('images/user.svg')); $picture = 'data:image/svg+xml;base64,' . base64_encode($svg); } elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) { return $this->errorResponse(422, $error, $response + ['code' => 326]); } // TODO: Resize when big/make safe the user picture? $request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture]; if (!$room->requestSave($requestId, $request)) { // FIXME: should we use error code 500? return $this->errorResponse(422, $error, $response + ['code' => 326]); } // Send the request (signal) to the owner $result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR); } return $this->errorResponse(422, $error, $response + ['code' => 327]); } } // Initialize connection tokens if ($init) { // Choose the connection role $canPublish = !empty(request()->input('canPublish')) && (empty($config['nomedia']) || $isOwner); $role = $canPublish ? Room::ROLE_PUBLISHER : Room::ROLE_SUBSCRIBER; if ($isOwner) { $role |= Room::ROLE_MODERATOR; $role |= Room::ROLE_OWNER; } // Create session token for the current user/connection $response = $room->getSessionToken($role); if (empty($response)) { return $this->errorResponse(500, \trans('meet.session-join-error')); } // Get up-to-date connections metadata $response['connections'] = $room->getSessionConnections(); $response_code = 200; $response['role'] = $role; $response['config'] = $config; } else { $response_code = 422; $response['code'] = 322; } return response()->json($response, $response_code); } /** * Set the domain configuration. * * @param string $id Room identifier (name) * * @return \Illuminate\Http\JsonResponse|void */ public function setRoomConfig($id) { $room = Room::where('name', $id)->first(); // Room does not exist, or the owner is deleted - if (!$room || !$room->owner) { + if (!$room || !$room->owner || $room->owner->isDegraded(true)) { return $this->errorResponse(404); } $user = Auth::guard()->user(); // Only room owner can configure the room if ($user->id != $room->user_id) { return $this->errorResponse(403); } $input = request()->input(); $errors = []; foreach ($input as $key => $value) { switch ($key) { case 'password': if ($value === null || $value === '') { $input[$key] = null; } else { // TODO: Do we have to validate the password in any way? } break; case 'locked': $input[$key] = $value ? 'true' : null; break; case 'nomedia': $input[$key] = $value ? 'true' : null; break; default: $errors[$key] = \trans('meet.room-unsupported-option-error'); } } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } if (!empty($input)) { $room->setSettings($input); } return response()->json([ 'status' => 'success', 'message' => \trans('meet.room-setconfig-success'), ]); } /** * Update the participant/connection parameters (e.g. role). * * @param string $id Room identifier (name) * @param string $conn Connection identifier * * @return \Illuminate\Http\JsonResponse */ public function updateConnection($id, $conn) { $connection = Connection::where('id', $conn)->first(); // There's no such connection, bye bye if (!$connection || $connection->room->name != $id) { return $this->errorResponse(404, \trans('meet.connection-not-found')); } foreach (request()->input() as $key => $value) { switch ($key) { case 'hand': // Only possible on user's own connection(s) if (!$this->isSelfConnection($connection)) { return $this->errorResponse(403); } if ($value) { // Store current time, so we know the order in the queue $connection->metadata = ['hand' => time()] + $connection->metadata; } else { $connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]); } break; case 'language': // Only the moderator can do it if (!$this->isModerator($connection->room)) { return $this->errorResponse(403); } if ($value) { if (preg_match('/^[a-z]{2}$/', $value)) { $connection->metadata = ['language' => $value] + $connection->metadata; } } else { $connection->metadata = array_diff_key($connection->metadata, ['language' => 0]); } break; case 'role': // Only the moderator can do it if (!$this->isModerator($connection->room)) { return $this->errorResponse(403); } // The 'owner' role is not assignable if ($value & Room::ROLE_OWNER && !($connection->role & Room::ROLE_OWNER)) { return $this->errorResponse(403); } elseif (!($value & Room::ROLE_OWNER) && ($connection->role & Room::ROLE_OWNER)) { return $this->errorResponse(403); } // The room owner has always a 'moderator' role if (!($value & Room::ROLE_MODERATOR) && $connection->role & Room::ROLE_OWNER) { $value |= Room::ROLE_MODERATOR; } // Promotion to publisher? Put the user hand down if ($value & Room::ROLE_PUBLISHER && !($connection->role & Room::ROLE_PUBLISHER)) { $connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]); } // Non-publisher cannot be a language interpreter if (!($value & Room::ROLE_PUBLISHER)) { $connection->metadata = array_diff_key($connection->metadata, ['language' => 0]); } $connection->{$key} = $value; break; } } // The connection observer will send a signal to everyone when needed $connection->save(); return response()->json(['status' => 'success']); } /** * Webhook as triggered from OpenVidu server * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function webhook(Request $request) { \Log::debug($request->getContent()); switch ((string) $request->input('event')) { case 'sessionDestroyed': // When all participants left the room OpenVidu dispatches sessionDestroyed // event. We'll remove the session reference from the database. $sessionId = $request->input('sessionId'); $room = Room::where('session_id', $sessionId)->first(); if ($room) { $room->session_id = null; $room->save(); } // Remove all connections // Note: We could remove connections one-by-one via the 'participantLeft' event // but that could create many INSERTs when the session (with many participants) ends // So, it is better to remove them all in a single INSERT. Connection::where('session_id', $sessionId)->delete(); break; } return response('Success', 200); } /** * Check if current user is a moderator for the specified room. * * @param \App\OpenVidu\Room $room The room * * @return bool True if the current user is the room moderator */ protected function isModerator(Room $room): bool { $user = Auth::guard()->user(); // The room owner is a moderator if ($user && $user->id == $room->user_id) { return true; } // Moderator's authentication via the extra request header if ( ($connection = $this->getConnectionFromRequest()) && $connection->session_id === $room->session_id && $connection->role & Room::ROLE_MODERATOR ) { return true; } return false; } /** * Check if current user "owns" the specified connection. * * @param \App\OpenVidu\Connection $connection The connection * * @return bool */ protected function isSelfConnection(Connection $connection): bool { return ($conn = $this->getConnectionFromRequest()) && $conn->id === $connection->id; } /** * Get the connection object for the token in current request headers. * It will also validate the token. * * @return \App\OpenVidu\Connection|null Connection (if exists and the token is valid) */ protected function getConnectionFromRequest() { // Authenticate the user via the extra request header if ($token = request()->header(self::AUTH_HEADER)) { list($connId, ) = explode(':', base64_decode($token), 2); if ( ($connection = Connection::find($connId)) && $connection->metadata['authToken'] === $token ) { return $connection; } } return null; } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index ddd66ce5..6669cde8 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,728 +1,730 @@ guard()->user(); $search = trim(request()->input('search')); $page = intval(request()->input('page')) ?: 1; $pageSize = 20; $hasMore = false; $result = $user->users(); // Search by user email, alias or name if (strlen($search) > 0) { // thanks to cloning we skip some extra queries in $user->users() $allUsers1 = clone $result; $allUsers2 = clone $result; $result->whereLike('email', $search) ->union( $allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id') ->whereLike('alias', $search) ) ->union( $allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id') ->whereLike('value', $search) ->whereIn('key', ['first_name', 'last_name']) ); } $result = $result->orderBy('email') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } // Process the result $result = $result->map( function ($user) { return $this->objectToClient($user); } ); $result = [ 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, ]; return response()->json($result); } /** * Display information on the user account specified by $id. * * @param string $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (!$this->checkTenant($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); $response['skus'] = \App\Entitlement::objectEntitlementsSummary($user); $response['config'] = $user->getConfig(); return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo($user): array { $process = self::processStateInfo( $user, [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ] ); // Check if the user is a controller of his wallet $isController = $user->canDelete($user); $hasCustomDomain = $user->wallet()->entitlements() ->where('entitleable_type', Domain::class) ->count() > 0; // Get user's entitlements titles $skus = $user->entitlements()->select('skus.title') ->join('skus', 'skus.id', '=', 'entitlements.sku_id') ->get() ->pluck('title') ->sort() ->unique() ->values() ->all(); $result = [ 'skus' => $skus, // TODO: This will change when we enable all users to create domains 'enableDomains' => $isController && $hasCustomDomain, // TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners 'enableDistlists' => $isController && $hasCustomDomain && in_array('beta-distlists', $skus), // TODO: Make 'enableFolders' working for wallet controllers that aren't account owners 'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus), // TODO: Make 'enableResources' working for wallet controllers that aren't account owners 'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus), 'enableUsers' => $isController, 'enableWallets' => $isController, ]; return array_merge($process, $result); } /** * Create a new user record. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $current_user = $this->guard()->user(); $owner = $current_user->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } $this->deleteBeforeCreate = null; if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) { $errors = ['package' => \trans('validation.packagerequired')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } if ($package->isDomain()) { $errors = ['package' => \trans('validation.packageinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } DB::beginTransaction(); // @phpstan-ignore-next-line if ($this->deleteBeforeCreate) { $this->deleteBeforeCreate->forceDelete(); } // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $owner->assignPackage($package, $user); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->aliases)) { $user->setAliases($request->aliases); } DB::commit(); return response()->json([ 'status' => 'success', 'message' => \trans('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @param string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::withEnvTenantContext()->find($id); if (empty($user)) { return $this->errorResponse(404); } $current_user = $this->guard()->user(); // TODO: Decide what attributes a user can change on his own profile if (!$current_user->canUpdate($user)) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, $user, $settings)) { return $error_response; } // Entitlements, only controller can do that if ($request->skus !== null && !$current_user->canDelete($user)) { return $this->errorResponse(422, "You have no permission to change entitlements"); } DB::beginTransaction(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } if (isset($request->aliases)) { $user->setAliases($request->aliases); } // TODO: Make sure that UserUpdate job is created in case of entitlements update // and no password change. So, for example quota change is applied to LDAP // TODO: Review use of $user->save() in the above context DB::commit(); $response = [ 'status' => 'success', 'message' => \trans('app.user-update-success'), ]; // For self-update refresh the statusInfo in the UI if ($user->id == $current_user->id) { $response['statusInfo'] = self::statusInfo($user); } return response()->json($response); } /** * Update user entitlements. * * @param \App\User $user The user * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] */ protected function updateEntitlements(User $user, $rSkus) { if (!is_array($rSkus)) { return; } // list of skus, [id=>obj] $skus = Sku::withEnvTenantContext()->get()->mapWithKeys( function ($sku) { return [$sku->id => $sku]; } ); // existing entitlement's SKUs $eSkus = []; $user->entitlements()->groupBy('sku_id') ->selectRaw('count(*) as total, sku_id')->each( function ($e) use (&$eSkus) { $eSkus[$e->sku_id] = $e->total; } ); foreach ($skus as $skuID => $sku) { $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; if ($sku->handler_class == \App\Handlers\Mailbox::class) { if ($r != 1) { throw new \Exception("Invalid quantity of mailboxes"); } } if ($e > $r) { // remove those entitled more than existing $user->removeSku($sku, ($e - $r)); } elseif ($e < $r) { // add those requested more than entitled $user->assignSku($sku, ($r - $e)); } } } /** * Create a response data array for specified user. * * @param \App\User $user User object * * @return array Response data */ public static function userResponse(User $user): array { $response = array_merge($user->toArray(), self::objectState($user)); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Aliases $response['aliases'] = []; foreach ($user->aliases as $item) { $response['aliases'][] = $item->alias; } // Status info $response['statusInfo'] = self::statusInfo($user); // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); if ($wallet->discount) { $result['discount'] = $wallet->discount->discount; $result['discount_description'] = $wallet->discount->description; } if ($wallet->user_id != $user->id) { $result['user_email'] = $wallet->owner->email; } $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); return $result; }; // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); $response['wallet'] = $map_func($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function objectState($user): array { return [ 'isImapReady' => $user->isImapReady(), 'isLdapReady' => $user->isLdapReady(), 'isSuspended' => $user->isSuspended(), 'isActive' => $user->isActive(), 'isDeleted' => $user->isDeleted() || $user->trashed(), + 'isDegraded' => $user->isDegraded(), + 'isAccountDegraded' => $user->isDegraded(true), ]; } /** * Validate user input * * @param \Illuminate\Http\Request $request The API request. * @param \App\User|null $user User identifier * @param array $settings User settings (from the request) * * @return \Illuminate\Http\JsonResponse|null The error response on error */ protected function validateUserRequest(Request $request, $user, &$settings = []) { $rules = [ 'external_email' => 'nullable|email', 'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/', 'first_name' => 'string|nullable|max:128', 'last_name' => 'string|nullable|max:128', 'organization' => 'string|nullable|max:512', 'billing_address' => 'string|nullable|max:1024', 'country' => 'string|nullable|alpha|size:2', 'currency' => 'string|nullable|alpha|size:3', 'aliases' => 'array|nullable', ]; if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { $rules['password'] = 'required|min:4|max:2048|confirmed'; } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } $controller = $user ? $user->wallet()->owner : $this->guard()->user(); // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) { $errors['email'] = $error; } } // Validate aliases input if (isset($request->aliases)) { $aliases = []; $existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : []; foreach ($request->aliases as $idx => $alias) { if (is_string($alias) && !empty($alias)) { // Alias cannot be the same as the email address (new user) if (!empty($email) && Str::lower($alias) == Str::lower($email)) { continue; } // validate new aliases if ( !in_array($alias, $existing_aliases) && ($error = self::validateAlias($alias, $controller)) ) { if (!isset($errors['aliases'])) { $errors['aliases'] = []; } $errors['aliases'][$idx] = $error; continue; } $aliases[] = $alias; } } $request->aliases = $aliases; } if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Update user settings $settings = $request->only(array_keys($rules)); unset($settings['password'], $settings['aliases'], $settings['email']); return null; } /** * Execute (synchronously) specified step in a user setup process. * * @param \App\User $user User object * @param string $step Step identifier (as in self::statusInfo()) * * @return bool|null True if the execution succeeded, False if not, Null when * the job has been sent to the worker (result unknown) */ public static function execProcessStep(User $user, string $step): ?bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\User\CreateJob($user->id); $job->handle(); $user->refresh(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again // Do it synchronously if the imap admin credentials are available // otherwise let the worker do the job if (!\config('imap.admin_password')) { \App\Jobs\User\VerifyJob::dispatch($user->id); return null; } $job = new \App\Jobs\User\VerifyJob($user->id); $job->handle(); $user->refresh(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } /** * Email address validation for use as a user mailbox (login). * * @param string $email Email address * @param \App\User $user The account owner * @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group * with the specified email address, if exists * * @return ?string Error message on validation error */ public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string { $deleted = null; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'email']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['email' => $login], ['email' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['email'][0]; } // Check if it is one of domains available to the user if (!$user->domains()->where('namespace', $domain->namespace)->exists()) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // If this is a deleted user in the same custom domain // we'll force delete him before if (!$domain->isPublic() && $existing_user->trashed()) { $deleted = $existing_user; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } // Check if an alias with specified address already exists. if (User::aliasExists($email)) { return \trans('validation.entryexists', ['attribute' => 'email']); } // Check if a group or resource with specified address already exists if ( ($existing = Group::emailExists($email, true)) || ($existing = \App\Resource::emailExists($email, true)) ) { // If this is a deleted group/resource in the same custom domain // we'll force delete it before if (!$domain->isPublic() && $existing->trashed()) { $deleted = $existing; } else { return \trans('validation.entryexists', ['attribute' => 'email']); } } return null; } /** * Email address validation for use as an alias. * * @param string $email Email address * @param \App\User $user The account owner * * @return ?string Error message on validation error */ public static function validateAlias(string $email, \App\User $user): ?string { if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } list($login, $domain) = explode('@', Str::lower($email)); if (strlen($login) === 0 || strlen($domain) === 0) { return \trans('validation.entryinvalid', ['attribute' => 'alias']); } // Check if domain exists $domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( ['alias' => $login], ['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()['alias'][0]; } // Check if it is one of domains available to the user if (!$user->domains()->where('namespace', $domain->namespace)->exists()) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if a user with specified address already exists if ($existing_user = User::emailExists($email, true)) { // Allow an alias in a custom domain to an address that was a user before if ($domain->isPublic() || !$existing_user->trashed()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if an alias with specified address already exists if (User::aliasExists($email)) { // Allow assigning the same alias to a user in the same group account, // but only for non-public domains if ($domain->isPublic()) { return \trans('validation.entryexists', ['attribute' => 'alias']); } } // Check if a group with specified address already exists if (Group::emailExists($email)) { return \trans('validation.entryexists', ['attribute' => 'alias']); } return null; } } diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/WalletCheck.php index 9876bfe2..c1d60421 100644 --- a/src/app/Jobs/WalletCheck.php +++ b/src/app/Jobs/WalletCheck.php @@ -1,311 +1,405 @@ wallet = $wallet; } /** * Execute the job. * * @return ?string Executed action (THRESHOLD_*) */ public function handle() { if ($this->wallet->balance >= 0) { return null; } $now = Carbon::now(); - - // Delete the account - if (self::threshold($this->wallet, self::THRESHOLD_DELETE) < $now) { - $this->deleteAccount(); - return self::THRESHOLD_DELETE; - } - - // Warn about the upcomming account deletion - if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_DELETE) < $now) { - $this->warnBeforeDelete(); - return self::THRESHOLD_BEFORE_DELETE; +/* + // Steps for old "first suspend then delete" approach + $steps = [ + // Send the initial reminder + self::THRESHOLD_INITIAL => 'initialReminder', + // Try to top-up the wallet before the second reminder + self::THRESHOLD_BEFORE_REMINDER => 'topUpWallet', + // Send the second reminder + self::THRESHOLD_REMINDER => 'secondReminder', + // Try to top-up the wallet before suspending the account + self::THRESHOLD_BEFORE_SUSPEND => 'topUpWallet', + // Suspend the account + self::THRESHOLD_SUSPEND => 'suspendAccount', + // Warn about the upcomming account deletion + self::THRESHOLD_BEFORE_DELETE => 'warnBeforeDelete', + // Delete the account + self::THRESHOLD_DELETE => 'deleteAccount', + ]; +*/ + // Steps for "demote instead of suspend+delete" approach + $steps = [ + // Send the initial reminder + self::THRESHOLD_INITIAL => 'initialReminderForDegrade', + // Try to top-up the wallet before the second reminder + self::THRESHOLD_BEFORE_REMINDER => 'topUpWallet', + // Send the second reminder + self::THRESHOLD_REMINDER => 'secondReminderForDegrade', + // Try to top-up the wallet before the account degradation + self::THRESHOLD_BEFORE_DEGRADE => 'topUpWallet', + // Degrade the account + self::THRESHOLD_DEGRADE => 'degradeAccount', + ]; + + if ($this->wallet->owner && $this->wallet->owner->isDegraded()) { + $this->degradedReminder(); + return self::THRESHOLD_DEGRADE_REMINDER; } - // Suspend the account - if (self::threshold($this->wallet, self::THRESHOLD_SUSPEND) < $now) { - $this->suspendAccount(); - return self::THRESHOLD_SUSPEND; + foreach (array_reverse($steps, true) as $type => $method) { + if (self::threshold($this->wallet, $type) < $now) { + $this->{$method}(); + return $type; + } } - // Try to top-up the wallet before suspending the account - if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_SUSPEND) < $now) { - PaymentsController::topUpWallet($this->wallet); - return self::THRESHOLD_BEFORE_SUSPEND; - } + return null; + } - // Send the second reminder - if (self::threshold($this->wallet, self::THRESHOLD_REMINDER) < $now) { - $this->secondReminder(); - return self::THRESHOLD_REMINDER; + /** + * Send the initial reminder (for the suspend+delete process) + */ + protected function initialReminder() + { + if ($this->wallet->getSetting('balance_warning_initial')) { + return; } - // Try to top-up the wallet before the second reminder - if (self::threshold($this->wallet, self::THRESHOLD_BEFORE_REMINDER) < $now) { - PaymentsController::topUpWallet($this->wallet); - return self::THRESHOLD_BEFORE_REMINDER; - } + // TODO: Should we check if the account is already suspended? - // Send the initial reminder - if (self::threshold($this->wallet, self::THRESHOLD_INITIAL) < $now) { - $this->initialReminder(); - return self::THRESHOLD_INITIAL; - } + $this->sendMail(\App\Mail\NegativeBalance::class, false); - return null; + $now = \Carbon\Carbon::now()->toDateTimeString(); + $this->wallet->setSetting('balance_warning_initial', $now); } /** - * Send the initial reminder + * Send the initial reminder (for the process of degrading a account) */ - protected function initialReminder() + protected function initialReminderForDegrade() { if ($this->wallet->getSetting('balance_warning_initial')) { return; } - // TODO: Should we check if the account is already suspended? + if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { + return; + } $this->sendMail(\App\Mail\NegativeBalance::class, false); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_initial', $now); } /** - * Send the second reminder + * Send the second reminder (for the suspend+delete process) */ protected function secondReminder() { if ($this->wallet->getSetting('balance_warning_reminder')) { return; } // TODO: Should we check if the account is already suspended? $this->sendMail(\App\Mail\NegativeBalanceReminder::class, false); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_reminder', $now); } + /** + * Send the second reminder (for the process of degrading a account) + */ + protected function secondReminderForDegrade() + { + if ($this->wallet->getSetting('balance_warning_reminder')) { + return; + } + + if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { + return; + } + + $this->sendMail(\App\Mail\NegativeBalanceReminderDegrade::class, true); + + $now = \Carbon\Carbon::now()->toDateTimeString(); + $this->wallet->setSetting('balance_warning_reminder', $now); + } + /** * Suspend the account (and send the warning) */ protected function suspendAccount() { if ($this->wallet->getSetting('balance_warning_suspended')) { return; } // Sanity check, already deleted if (!$this->wallet->owner) { return; } // Suspend the account $this->wallet->owner->suspend(); foreach ($this->wallet->entitlements as $entitlement) { if ( $entitlement->entitleable_type == \App\Domain::class || $entitlement->entitleable_type == \App\User::class ) { $entitlement->entitleable->suspend(); } } $this->sendMail(\App\Mail\NegativeBalanceSuspended::class, true); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_suspended', $now); } /** * Send the last warning before delete */ protected function warnBeforeDelete() { if ($this->wallet->getSetting('balance_warning_before_delete')) { return; } // Sanity check, already deleted if (!$this->wallet->owner) { return; } $this->sendMail(\App\Mail\NegativeBalanceBeforeDelete::class, true); $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_before_delete', $now); } + /** + * Send the periodic reminder to the degraded account owners + */ + protected function degradedReminder() + { + // Sanity check + if (!$this->wallet->owner || !$this->wallet->owner->isDegraded()) { + return; + } + + $now = \Carbon\Carbon::now(); + $last = $this->wallet->getSetting('degraded_last_reminder'); + + if ($last) { + $last = new Carbon($last); + $period = 14; + + if ($last->addDays($period) > $now) { + return; + } + + $this->sendMail(\App\Mail\DegradedAccountReminder::class, true); + } + + $this->wallet->setSetting('degraded_last_reminder', $now->toDateTimeString()); + } + + /** + * Degrade the account + */ + protected function degradeAccount() + { + // The account may be already deleted, or degraded + if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { + return; + } + + $email = $this->wallet->owner->email; + + // The dirty work will be done by UserObserver + $this->wallet->owner->degrade(); + + \Log::info( + sprintf( + "[WalletCheck] Account degraded %s (%s)", + $this->wallet->id, + $email + ) + ); + + $this->sendMail(\App\Mail\NegativeBalanceDegraded::class, true); + } + /** * Delete the account */ protected function deleteAccount() { // TODO: This will not work when we actually allow multiple-wallets per account // but in this case we anyway have to change the whole thing // and calculate summarized balance from all wallets. // The dirty work will be done by UserObserver if ($this->wallet->owner) { $email = $this->wallet->owner->email; $this->wallet->owner->delete(); \Log::info( sprintf( "[WalletCheck] Account deleted %s (%s)", $this->wallet->id, $email ) ); } } /** * Send the email * * @param string $class Mailable class name * @param bool $with_external Use users's external email */ protected function sendMail($class, $with_external = false): void { // TODO: Send the email to all wallet controllers? $mail = new $class($this->wallet, $this->wallet->owner); list($to, $cc) = \App\Mail\Helper::userEmails($this->wallet->owner, $with_external); if (!empty($to) || !empty($cc)) { $params = [ 'to' => $to, 'cc' => $cc, 'add' => " for {$this->wallet->id}", ]; \App\Mail\Helper::sendMail($mail, $this->wallet->owner->tenant_id, $params); } } /** * Get the date-time for an action threshold. Calculated using * the date when a wallet balance turned negative. * * @param \App\Wallet $wallet A wallet * @param string $type Action type (one of self::THRESHOLD_*) * * @return \Carbon\Carbon The threshold date-time object */ public static function threshold(Wallet $wallet, string $type): ?Carbon { $negative_since = $wallet->getSetting('balance_negative_since'); // Migration scenario: balance<0, but no balance_negative_since set if (!$negative_since) { // 2h back from now, so first run can sent the initial notification $negative_since = Carbon::now()->subHours(2); $wallet->setSetting('balance_negative_since', $negative_since->toDateTimeString()); } else { $negative_since = new Carbon($negative_since); } - $remind = 7; // remind after first X days - $suspend = 14; // suspend after next X days - $delete = 21; // delete after next X days - $warn = 3; // warn about delete on X days before delete - - // Acount deletion - if ($type == self::THRESHOLD_DELETE) { - return $negative_since->addDays($delete + $suspend + $remind); - } - - // Warning about the upcomming account deletion - if ($type == self::THRESHOLD_BEFORE_DELETE) { - return $negative_since->addDays($delete + $suspend + $remind - $warn); - } - - // Account suspension - if ($type == self::THRESHOLD_SUSPEND) { - return $negative_since->addDays($suspend + $remind); - } - - // A day before account suspension - if ($type == self::THRESHOLD_BEFORE_SUSPEND) { - return $negative_since->addDays($suspend + $remind - 1); - } - - // Second notification - if ($type == self::THRESHOLD_REMINDER) { - return $negative_since->addDays($remind); - } - - // A day before the second reminder - if ($type == self::THRESHOLD_BEFORE_REMINDER) { - return $negative_since->addDays($remind - 1); - } - // Initial notification // Give it an hour so the async recurring payment has a chance to be finished if ($type == self::THRESHOLD_INITIAL) { return $negative_since->addHours(1); } + $thresholds = [ + // A day before the second reminder + self::THRESHOLD_BEFORE_REMINDER => 7 - 1, + // Second notification + self::THRESHOLD_REMINDER => 7, + + // A day before account suspension + self::THRESHOLD_BEFORE_SUSPEND => 14 + 7 - 1, + // Account suspension + self::THRESHOLD_SUSPEND => 14 + 7, + // Warning about the upcomming account deletion + self::THRESHOLD_BEFORE_DELETE => 21 + 14 + 7 - 3, + // Acount deletion + self::THRESHOLD_DELETE => 21 + 14 + 7, + + // Last chance to top-up the wallet + self::THRESHOLD_BEFORE_DEGRADE => 13, + // Account degradation + self::THRESHOLD_DEGRADE => 14, + ]; + + if (!empty($thresholds[$type])) { + return $negative_since->addDays($thresholds[$type]); + } + return null; } + + /** + * Try to automatically top-up the wallet + */ + protected function topUpWallet(): void + { + PaymentsController::topUpWallet($this->wallet); + } } diff --git a/src/app/Mail/DegradedAccountReminder.php b/src/app/Mail/DegradedAccountReminder.php new file mode 100644 index 00000000..bed6247a --- /dev/null +++ b/src/app/Mail/DegradedAccountReminder.php @@ -0,0 +1,82 @@ +wallet = $wallet; + $this->user = $user; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); + $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); + + $subject = \trans('mail.degradedaccountreminder-subject', ['site' => $appName]); + + $this->view('emails.html.degraded_account_reminder') + ->text('emails.plain.degraded_account_reminder') + ->subject($subject) + ->with([ + 'site' => $appName, + 'subject' => $subject, + 'username' => $this->user->name(true), + 'supportUrl' => $supportUrl, + 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), + 'dashboardUrl' => Utils::serviceUrl('/dashboard', $this->user->tenant_id), + ]); + + return $this; + } + + /** + * Render the mail template with fake data + * + * @param string $type Output format ('html' or 'text') + * + * @return string HTML or Plain Text output + */ + public static function fakeRender(string $type = 'html'): string + { + $wallet = new Wallet(); + $user = new User(); + + $mail = new self($wallet, $user); + + return Helper::render($mail, $type); + } +} diff --git a/src/app/Mail/NegativeBalanceDegraded.php b/src/app/Mail/NegativeBalanceDegraded.php new file mode 100644 index 00000000..4caf562c --- /dev/null +++ b/src/app/Mail/NegativeBalanceDegraded.php @@ -0,0 +1,82 @@ +wallet = $wallet; + $this->user = $user; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); + $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); + + $subject = \trans('mail.negativebalancedegraded-subject', ['site' => $appName]); + + $this->view('emails.html.negative_balance_degraded') + ->text('emails.plain.negative_balance_degraded') + ->subject($subject) + ->with([ + 'site' => $appName, + 'subject' => $subject, + 'username' => $this->user->name(true), + 'supportUrl' => $supportUrl, + 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), + ]); + + return $this; + } + + /** + * Render the mail template with fake data + * + * @param string $type Output format ('html' or 'text') + * + * @return string HTML or Plain Text output + */ + public static function fakeRender(string $type = 'html'): string + { + $wallet = new Wallet(); + $user = new User(); + + $mail = new self($wallet, $user); + + return Helper::render($mail, $type); + } +} diff --git a/src/app/Mail/NegativeBalanceReminderDegrade.php b/src/app/Mail/NegativeBalanceReminderDegrade.php new file mode 100644 index 00000000..e2a9487e --- /dev/null +++ b/src/app/Mail/NegativeBalanceReminderDegrade.php @@ -0,0 +1,84 @@ +wallet = $wallet; + $this->user = $user; + } + + /** + * Build the message. + * + * @return $this + */ + public function build() + { + $appName = Tenant::getConfig($this->user->tenant_id, 'app.name'); + $supportUrl = Tenant::getConfig($this->user->tenant_id, 'app.support_url'); + $threshold = WalletCheck::threshold($this->wallet, WalletCheck::THRESHOLD_DEGRADE); + + $subject = \trans('mail.negativebalancereminder-subject', ['site' => $appName]); + + $this->view('emails.html.negative_balance_reminder_degrade') + ->text('emails.plain.negative_balance_reminder_degrade') + ->subject($subject) + ->with([ + 'site' => $appName, + 'subject' => $subject, + 'username' => $this->user->name(true), + 'supportUrl' => $supportUrl, + 'walletUrl' => Utils::serviceUrl('/wallet', $this->user->tenant_id), + 'date' => $threshold->toDateString(), + ]); + + return $this; + } + + /** + * Render the mail template with fake data + * + * @param string $type Output format ('html' or 'text') + * + * @return string HTML or Plain Text output + */ + public static function fakeRender(string $type = 'html'): string + { + $wallet = new Wallet(); + $user = new User(); + + $mail = new self($wallet, $user); + + return Helper::render($mail, $type); + } +} diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php index c1ba762c..0e2a64ec 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,169 +1,173 @@ wallet_id); if (!$wallet || !$wallet->owner) { return false; } $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } $result = $sku->handler_class::preReq($entitlement, $wallet->owner); if (!$result) { return false; } return true; } /** * Handle the entitlement "created" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function created(Entitlement $entitlement) { $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_CREATED); } /** * Handle the entitlement "deleted" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleted(Entitlement $entitlement) { // Remove all configured 2FA methods from Roundcube database if ($entitlement->sku->title == '2fa') { // FIXME: Should that be an async job? $sf = new \App\Auth\SecondFactor($entitlement->entitleable); $sf->removeFactors(); } - if ($entitlement->entitleable && !$entitlement->entitleable->trashed()) { + if (!$entitlement->entitleable->trashed()) { $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); } } /** * Handle the entitlement "deleting" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleting(Entitlement $entitlement) { if ($entitlement->trashed()) { return; } // Start calculating the costs for the consumption of this entitlement if the // existing consumption spans >= 14 days. // // Effect is that anything's free for the first 14 days if ($entitlement->created_at >= Carbon::now()->subDays(14)) { return; } $owner = $entitlement->wallet->owner; + if ($owner->isDegraded()) { + return; + } + // Determine if we're still within the free first month $freeMonthEnds = $owner->created_at->copy()->addMonthsWithoutOverflow(1); if ($freeMonthEnds >= Carbon::now()) { return; } $now = Carbon::now(); // get the discount rate applied to the wallet. $discount = $entitlement->wallet->getDiscountRate(); // just in case this had not been billed yet, ever $diffInMonths = $entitlement->updated_at->diffInMonths($now); $cost = (int) ($entitlement->cost * $discount * $diffInMonths); $fee = (int) ($entitlement->fee * $diffInMonths); // this moves the hypothetical updated at forward to however many months past the original $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); // now we have the diff in days since the last "billed" period end. // This may be an entitlement paid up until February 28th, 2020, with today being March // 12th 2020. Calculating the costs for the entitlement is based on the daily price // the price per day is based on the number of days in the last month // or the current month if the period does not overlap with the previous month // FIXME: This really should be simplified to $daysInMonth=30 $diffInDays = $updatedAt->diffInDays($now); if ($now->day >= $diffInDays) { $daysInMonth = $now->daysInMonth; } else { $daysInMonth = \App\Utils::daysInLastMonth(); } $pricePerDay = $entitlement->cost / $daysInMonth; $feePerDay = $entitlement->fee / $daysInMonth; $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); $fee += (int) (round($feePerDay * $diffInDays, 0)); $profit = $cost - $fee; if ($profit != 0 && $owner->tenant && ($wallet = $owner->tenant->wallet())) { $desc = "Charged user {$owner->email}"; $method = $profit > 0 ? 'credit' : 'debit'; $wallet->{$method}(abs($profit), $desc); } if ($cost == 0) { return; } $entitlement->wallet->debit($cost); } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php index 825a7b66..3dd9ca60 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,241 +1,275 @@ email = \strtolower($user->email); // only users that are not imported get the benefit of the doubt. $user->status |= User::STATUS_NEW | User::STATUS_ACTIVE; } /** * Handle the "created" event. * * Ensures the user has at least one wallet. * * Should ensure some basic settings are available as well. * * @param \App\User $user The user created. * * @return void */ public function created(User $user) { $settings = [ 'country' => \App\Utils::countryForRequest(), 'currency' => \config('app.currency'), /* 'first_name' => '', 'last_name' => '', 'billing_address' => '', 'organization' => '', 'phone' => '', 'external_email' => '', */ ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'user_id' => $user->id, ]; } // Note: Don't use setSettings() here to bypass UserSetting observers // Note: This is a single multi-insert query $user->settings()->insert(array_values($settings)); $user->wallets()->create(); // Create user record in LDAP, then check if the account is created in IMAP $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email); } } /** * Handle the "deleted" event. * * @param \App\User $user The user deleted. * * @return void */ public function deleted(User $user) { // Remove the user from existing groups $wallet = $user->wallet(); if ($wallet && $wallet->owner) { $wallet->owner->groups()->each(function ($group) use ($user) { if (in_array($user->email, $group->members)) { $group->members = array_diff($group->members, [$user->email]); $group->save(); } }); } } /** * Handle the "deleting" event. * * @param User $user The user that is being deleted. * * @return void */ public function deleting(User $user) { // Remove owned users/domains/groups/resources/etc self::removeRelatedObjects($user, $user->isForceDeleting()); // TODO: Especially in tests we're doing delete() on a already deleted user. // Should we escape here - for performance reasons? if (!$user->isForceDeleting()) { \App\Jobs\User\DeleteJob::dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyDeleteJob::dispatch($user->id, $user->email); } // Debit the reseller's wallet with the user negative balance $balance = 0; foreach ($user->wallets as $wallet) { // Note: here we assume all user wallets are using the same currency. // It might get changed in the future $balance += $wallet->balance; } if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) { $wallet->debit($balance * -1, "Deleted user {$user->email}"); } } } /** * Handle the user "restoring" event. * * @param \App\User $user The user * * @return void */ public function restoring(User $user) { // Make sure it's not DELETED/LDAP_READY/IMAP_READY/SUSPENDED anymore if ($user->isDeleted()) { $user->status ^= User::STATUS_DELETED; } if ($user->isLdapReady()) { $user->status ^= User::STATUS_LDAP_READY; } if ($user->isImapReady()) { $user->status ^= User::STATUS_IMAP_READY; } if ($user->isSuspended()) { $user->status ^= User::STATUS_SUSPENDED; } $user->status |= User::STATUS_ACTIVE; // Note: $user->save() is invoked between 'restoring' and 'restored' events } /** * Handle the user "restored" event. * * @param \App\User $user The user * * @return void */ public function restored(User $user) { // We need at least the user domain so it can be created in ldap. // FIXME: What if the domain is owned by someone else? $domain = $user->domain(); if ($domain->trashed() && !$domain->isPublic()) { // Note: Domain entitlements will be restored by the DomainObserver $domain->restore(); } // FIXME: Should we reset user aliases? or re-validate them in any way? // Create user record in LDAP, then run the verification process $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); } /** - * Handle the "updating" event. + * Handle the "updated" event. * - * @param User $user The user that is being updated. + * @param \App\User $user The user that is being updated. * * @return void */ - public function updating(User $user) + public function updated(User $user) { \App\Jobs\User\UpdateJob::dispatch($user->id); + + $oldStatus = $user->getOriginal('status'); + $newStatus = $user->status; + + if (($oldStatus & User::STATUS_DEGRADED) !== ($newStatus & User::STATUS_DEGRADED)) { + $wallets = []; + $isDegraded = $user->isDegraded(); + + // Charge all entitlements as if they were being deleted, + // but don't delete them. Just debit the wallet and update + // entitlements' updated_at timestamp. On un-degrade we still + // update updated_at, but with no debit (the cost is 0 on a degraded account). + foreach ($user->wallets as $wallet) { + $wallet->updateEntitlements($isDegraded); + + // Remember time of the degradation for sending periodic reminders + // and reset it on un-degradation + $val = $isDegraded ? \Carbon\Carbon::now()->toDateTimeString() : null; + $wallet->setSetting('degraded_last_reminder', $val); + + $wallets[] = $wallet->id; + } + + // (Un-)degrade users by invoking an update job. + // LDAP backend will read the wallet owner's degraded status and + // set LDAP attributes accordingly. + // We do not change their status as their wallets have its own state + \App\Entitlement::whereIn('wallet_id', $wallets) + ->where('entitleable_id', '!=', $user->id) + ->where('entitleable_type', User::class) + ->pluck('entitleable_id') + ->unique() + ->each(function ($user_id) { + \App\Jobs\User\UpdateJob::dispatch($user_id); + }); + } } /** * Remove entitleables/transactions related to the user (in user's wallets) * * @param \App\User $user The user * @param bool $force Force-delete mode */ private static function removeRelatedObjects(User $user, $force = false): void { $wallets = $user->wallets->pluck('id')->all(); \App\Entitlement::withTrashed() ->select('entitleable_id', 'entitleable_type') ->distinct() ->whereIn('wallet_id', $wallets) ->get() ->each(function ($entitlement) use ($user, $force) { // Skip the current user (infinite recursion loop) if ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id == $user->id) { return; } // Objects need to be deleted one by one to make sure observers can do the proper cleanup - if ($entitlement->entitleable) { - if ($force) { - $entitlement->entitleable->forceDelete(); - } elseif (!$entitlement->entitleable->trashed()) { - $entitlement->entitleable->delete(); - } + if ($force) { + $entitlement->entitleable->forceDelete(); + } elseif (!$entitlement->entitleable->trashed()) { + $entitlement->entitleable->delete(); } }); if ($force) { // Remove "wallet" transactions, they have no foreign key constraint \App\Transaction::where('object_type', Wallet::class) ->whereIn('object_id', $wallets) ->delete(); } } } diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php index 8414de01..009c22e5 100644 --- a/src/app/Observers/WalletObserver.php +++ b/src/app/Observers/WalletObserver.php @@ -1,106 +1,111 @@ currency = \config('app.currency'); } /** * Handle the wallet "deleting" event. * * Ensures that a wallet with a non-zero balance can not be deleted. * * Ensures that the wallet being deleted is not the last wallet for the user. * * Ensures that no entitlements are being billed to the wallet currently. * * @param Wallet $wallet The wallet being deleted. * * @return bool */ public function deleting(Wallet $wallet): bool { // can't delete a wallet that has any balance on it (positive and negative). if ($wallet->balance != 0.00) { return false; } if (!$wallet->owner) { throw new \Exception("Wallet: " . var_export($wallet, true)); } // can't remove the last wallet for the owner. if ($wallet->owner->wallets()->count() <= 1) { return false; } // can't remove a wallet that has billable entitlements attached. if ($wallet->entitlements()->count() > 0) { return false; } /* // can't remove a wallet that has payments attached. if ($wallet->payments()->count() > 0) { return false; } */ return true; } /** * Handle the wallet "updated" event. * * @param \App\Wallet $wallet The wallet. * * @return void */ public function updated(Wallet $wallet) { $negative_since = $wallet->getSetting('balance_negative_since'); if ($wallet->balance < 0) { if (!$negative_since) { $now = \Carbon\Carbon::now()->toDateTimeString(); $wallet->setSetting('balance_negative_since', $now); } } elseif ($negative_since) { $wallet->setSettings([ 'balance_negative_since' => null, 'balance_warning_initial' => null, 'balance_warning_reminder' => null, 'balance_warning_suspended' => null, 'balance_warning_before_delete' => null, ]); - // Unsuspend the account/domains/users + // FIXME: Since we use account degradation, should we leave suspended state untouched? + + // Un-suspend and un-degrade the account owner if ($wallet->owner) { $wallet->owner->unsuspend(); + $wallet->owner->undegrade(); } + + // Un-suspend domains/users foreach ($wallet->entitlements as $entitlement) { if ( $entitlement->entitleable_type == \App\Domain::class || $entitlement->entitleable_type == \App\User::class ) { $entitlement->entitleable->unsuspend(); } } } } } diff --git a/src/app/User.php b/src/app/User.php index c3546cc5..56a6b9e0 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,782 +1,834 @@ belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } return $user->assignPackageAndWallet($package, $this->wallets()->first()); } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Check if current user can delete another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet)); } /** * Check if current user can read data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if ($this->role == 'admin') { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } if ($object instanceof Wallet) { return $object->user_id == $this->id || $object->controllers->contains($this); } if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet)); } /** * Check if current user can update data of another object. * * @param mixed $object A user|domain|wallet|group object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if ($object instanceof User && $this->id == $object->id) { return true; } if ($this->role == 'admin') { return true; } if ($this->role == 'reseller') { if ($object instanceof User && $object->role == 'admin') { return false; } if ($object instanceof Wallet && !empty($object->owner)) { $object = $object->owner; } return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id; } return $this->canDelete($object); } + /** + * Degrade the user + * + * @return void + */ + public function degrade(): void + { + if ($this->isDegraded()) { + return; + } + + $this->status |= User::STATUS_DEGRADED; + $this->save(); + } + /** * Return the \App\Domain for this user. * * @return \App\Domain|null */ public function domain() { list($local, $domainName) = explode('@', $this->email); $domain = \App\Domain::withTrashed()->where('namespace', $domainName)->first(); return $domain; } /** * List the domains to which this user is entitled. * * @param bool $with_accounts Include domains assigned to wallets * the current user controls but not owns. * @param bool $with_public Include active public domains (for the user tenant). * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function domains($with_accounts = true, $with_public = true) { $domains = $this->entitleables(Domain::class, $with_accounts); if ($with_public) { $domains->orWhere(function ($query) { if (!$this->tenant_id) { $query->where('tenant_id', $this->tenant_id); } else { $query->withEnvTenantContext(); } $query->whereRaw(sprintf('(domains.type & %s)', Domain::TYPE_PUBLIC)) ->whereRaw(sprintf('(domains.status & %s)', Domain::STATUS_ACTIVE)); }); } return $domains; } /** * Find whether an email address exists as a user (including deleted users). * * @param string $email Email address * @param bool $return_user Return User instance instead of boolean * * @return \App\User|bool True or User model object if found, False otherwise */ public static function emailExists(string $email, bool $return_user = false) { if (strpos($email, '@') === false) { return false; } $email = \strtolower($email); $user = self::withTrashed()->where('email', $email)->first(); if ($user) { return $return_user ? $user : true; } return false; } /** * Return entitleable objects of a specified type controlled by the current user. * * @param string $class Object class * @param bool $with_accounts Include objects assigned to wallets * the current user controls, but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ private function entitleables(string $class, bool $with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } $object = new $class(); $table = $object->getTable(); return $object->select("{$table}.*") ->whereExists(function ($query) use ($table, $wallets, $class) { $query->select(DB::raw(1)) ->from('entitlements') ->whereColumn('entitleable_id', "{$table}.id") ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', $class); }); } /** * Helper to find user by email address, whether it is * main email address, alias or an external email. * * If there's more than one alias NULL will be returned. * * @param string $email Email address * @param bool $external Search also for an external email * * @return \App\User|null User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $aliases = UserAlias::where('alias', $email)->get(); if (count($aliases) == 1) { return $aliases->first()->user; } // TODO: External email return null; } /** * Return groups controlled by the current user. * * @param bool $with_accounts Include groups assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function groups($with_accounts = true) { return $this->entitleables(Group::class, $with_accounts); } /** - * Returns whether this domain is active. + * Returns whether this user is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** - * Returns whether this domain is deleted. + * Returns whether this user (or its wallet owner) is degraded. + * + * @param bool $owner Check also the wallet owner instead just the user himself + * + * @return bool + */ + public function isDegraded(bool $owner = false): bool + { + if ($this->status & self::STATUS_DEGRADED) { + return true; + } + + if ($owner && ($wallet = $this->wallet())) { + return $wallet->owner && $wallet->owner->isDegraded(); + } + + return false; + } + + /** + * Returns whether this user is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** - * Returns whether this (external) domain has been verified - * to exist in DNS. + * Returns whether this user is registered in IMAP. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** - * Returns whether this domain is suspended. + * Returns whether this user is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $settings = $this->getSettings(['first_name', 'last_name']); $name = trim($settings['first_name'] . ' ' . $settings['last_name']); if (empty($name) && $fallback) { return trim(\trans('app.siteuser', ['site' => \App\Tenant::getConfig($this->tenant_id, 'app.name')])); } return $name; } /** * Return resources controlled by the current user. * * @param bool $with_accounts Include resources assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function resources($with_accounts = true) { return $this->entitleables(\App\Resource::class, $with_accounts); } /** * Return shared folders controlled by the current user. * * @param bool $with_accounts Include folders assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function sharedFolders($with_accounts = true) { return $this->entitleables(\App\SharedFolder::class, $with_accounts); } public function senderPolicyFrameworkWhitelist($clientName) { $setting = $this->getSetting('spf_whitelist'); if (!$setting) { return false; } $whitelist = json_decode($setting); $matchFound = false; foreach ($whitelist as $entry) { if (substr($entry, 0, 1) == '/') { $match = preg_match($entry, $clientName); if ($match) { $matchFound = true; } continue; } if (substr($entry, 0, 1) == '.') { if (substr($clientName, (-1 * strlen($entry))) == $entry) { $matchFound = true; } continue; } if ($entry == $clientName) { $matchFound = true; continue; } } return $matchFound; } /** - * Suspend this domain. + * Suspend this user. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= User::STATUS_SUSPENDED; $this->save(); } /** - * Unsuspend this domain. + * Un-degrade this user. + * + * @return void + */ + public function undegrade(): void + { + if (!$this->isDegraded()) { + return; + } + + $this->status ^= User::STATUS_DEGRADED; + $this->save(); + } + + /** + * Unsuspend this user. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= User::STATUS_SUSPENDED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { return $this->entitleables(User::class, $with_accounts); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { $this->setPasswordAttribute($password); } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, + self::STATUS_DEGRADED, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Validate the user credentials * * @param string $username The username. * @param string $password The password in plain text. * @param bool $updatePassword Store the password if currently empty * * @return bool true on success */ public function validateCredentials(string $username, string $password, bool $updatePassword = true): bool { $authenticated = false; if ($this->email === \strtolower($username)) { if (!empty($this->password)) { if (Hash::check($password, $this->password)) { $authenticated = true; } } elseif (!empty($this->password_ldap)) { if (substr($this->password_ldap, 0, 6) == "{SSHA}") { $salt = substr(base64_decode(substr($this->password_ldap, 6)), 20); $hash = '{SSHA}' . base64_encode( sha1($password . $salt, true) . $salt ); if ($hash == $this->password_ldap) { $authenticated = true; } } elseif (substr($this->password_ldap, 0, 9) == "{SSHA512}") { $salt = substr(base64_decode(substr($this->password_ldap, 9)), 64); $hash = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password . $salt)) . $salt ); if ($hash == $this->password_ldap) { $authenticated = true; } } } else { \Log::error("Incomplete credentials for {$this->email}"); } } if ($authenticated) { \Log::info("Successful authentication for {$this->email}"); // TODO: update last login time if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) { $this->password = $password; $this->save(); } } else { // TODO: Try actual LDAP? \Log::info("Authentication failed for {$this->email}"); } return $authenticated; } /** * Retrieve and authenticate a user * * @param string $username The username. * @param string $password The password in plain text. * @param string $secondFactor The second factor (secondfactor from current request is used as fallback). * * @return array ['user', 'reason', 'errorMessage'] */ public static function findAndAuthenticate($username, $password, $secondFactor = null): ?array { $user = User::where('email', $username)->first(); if (!$user) { return ['reason' => 'notfound', 'errorMessage' => "User not found."]; } if (!$user->validateCredentials($username, $password)) { return ['reason' => 'credentials', 'errorMessage' => "Invalid password."]; } if (!$secondFactor) { // Check the request if there is a second factor provided // as fallback. $secondFactor = request()->secondfactor; } try { (new \App\Auth\SecondFactor($user))->validate($secondFactor); } catch (\Exception $e) { return ['reason' => 'secondfactor', 'errorMessage' => $e->getMessage()]; } return ['user' => $user]; } /** * Hook for passport * * @throws \Throwable * * @return \App\User User model object if found */ public function findAndValidateForPassport($username, $password): User { $result = self::findAndAuthenticate($username, $password); if (isset($result['reason'])) { if ($result['reason'] == 'secondfactor') { // This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'} throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401); } throw OAuthServerException::invalidCredentials(); } return $result['user']; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index 9a241264..66e71504 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,426 +1,508 @@ 0, ]; /** * The attributes that are mass assignable. * * @var array */ protected $fillable = [ 'currency', 'description' ]; /** * The attributes that can be not set. * * @var array */ protected $nullable = [ 'description', ]; /** * The types of attributes to which its values will be cast * * @var array */ protected $casts = [ 'balance' => 'integer', ]; /** * Add a controller to this wallet. * * @param \App\User $user The user to add as a controller to this wallet. * * @return void */ public function addController(User $user) { if (!$this->controllers->contains($user)) { $this->controllers()->save($user); } } - public function chargeEntitlements($apply = true) + /** + * Charge entitlements in the wallet + * + * @param bool $apply Set to false for a dry-run mode + * + * @return int Charged amount in cents + */ + public function chargeEntitlements($apply = true): int { // This wallet has been created less than a month ago, this is the trial period if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) { // Move all the current entitlement's updated_at timestamps forward to one month after // this wallet was created. $freeMonthEnds = $this->owner->created_at->copy()->addMonthsWithoutOverflow(1); foreach ($this->entitlements()->get()->fresh() as $entitlement) { if ($entitlement->updated_at < $freeMonthEnds) { $entitlement->updated_at = $freeMonthEnds; $entitlement->save(); } } return 0; } $profit = 0; $charges = 0; $discount = $this->getDiscountRate(); + $isDegraded = $this->owner->isDegraded(); - DB::beginTransaction(); + if ($apply) { + DB::beginTransaction(); + } // used to parent individual entitlement billings to the wallet debit. $entitlementTransactions = []; - foreach ($this->entitlements()->get()->fresh() as $entitlement) { + foreach ($this->entitlements()->get() as $entitlement) { // This entitlement has been created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). if ($entitlement->created_at > Carbon::now()->subDays(14)) { continue; } // This entitlement was created, or billed last, less than a month ago. if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) { continue; } // updated last more than a month ago -- was it billed? if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); $cost = (int) ($entitlement->cost * $discount * $diff); $fee = (int) ($entitlement->fee * $diff); + if ($isDegraded) { + $cost = 0; + } + $charges += $cost; $profit += $cost - $fee; // if we're in dry-run, you know... if (!$apply) { continue; } $entitlement->updated_at = $entitlement->updated_at->copy() ->addMonthsWithoutOverflow($diff); $entitlement->save(); if ($cost == 0) { continue; } $entitlementTransactions[] = $entitlement->createTransaction( \App\Transaction::ENTITLEMENT_BILLED, $cost ); } } if ($apply) { $this->debit($charges, '', $entitlementTransactions); // Credit/debit the reseller if ($profit != 0 && $this->owner->tenant) { // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) if ($wallet = $this->owner->tenant->wallet()) { $desc = "Charged user {$this->owner->email}"; $method = $profit > 0 ? 'credit' : 'debit'; $wallet->{$method}(abs($profit), $desc); } } - } - DB::commit(); + DB::commit(); + } return $charges; } /** * Calculate for how long the current balance will last. * * Returns NULL for balance < 0 or discount = 100% or on a fresh account * * @return \Carbon\Carbon|null Date */ public function balanceLastsUntil() { if ($this->balance < 0 || $this->getDiscount() == 100) { return null; } // retrieve any expected charges $expectedCharge = $this->expectedCharges(); // get the costs per day for all entitlements billed against this wallet $costsPerDay = $this->costsPerDay(); if (!$costsPerDay) { return null; } // the number of days this balance, minus the expected charges, would last $daysDelta = ($this->balance - $expectedCharge) / $costsPerDay; // calculate from the last entitlement billed $entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first(); $until = $entitlement->updated_at->copy()->addDays($daysDelta); // Don't return dates from the past if ($until < Carbon::now() && !$until->isToday()) { return null; } return $until; } /** * Controllers of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function controllers() { return $this->belongsToMany( 'App\User', // The foreign object definition 'user_accounts', // The table name 'wallet_id', // The local foreign key 'user_id' // The remote foreign key ); } /** * Retrieve the costs per day of everything charged to this wallet. * * @return float */ public function costsPerDay() { $costs = (float) 0; foreach ($this->entitlements as $entitlement) { $costs += $entitlement->costsPerDay(); } return $costs; } /** * Add an amount of pecunia to this wallet's balance. * * @param int $amount The amount of pecunia to add (in cents). * @param string $description The transaction description * * @return Wallet Self */ public function credit(int $amount, string $description = ''): Wallet { $this->balance += $amount; $this->save(); \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_CREDIT, 'amount' => $amount, 'description' => $description ] ); return $this; } /** * Deduct an amount of pecunia from this wallet's balance. * * @param int $amount The amount of pecunia to deduct (in cents). * @param string $description The transaction description * @param array $eTIDs List of transaction IDs for the individual entitlements * that make up this debit record, if any. * @return Wallet Self */ public function debit(int $amount, string $description = '', array $eTIDs = []): Wallet { if ($amount == 0) { return $this; } $this->balance -= $amount; $this->save(); $transaction = \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_DEBIT, 'amount' => $amount * -1, 'description' => $description ] ); if (!empty($eTIDs)) { \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); } return $this; } /** * The discount assigned to the wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function discount() { return $this->belongsTo('App\Discount', 'discount_id', 'id'); } /** * Entitlements billed to this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement'); } /** * Calculate the expected charges to this wallet. * * @return int */ public function expectedCharges() { return $this->chargeEntitlements(false); } /** * Return the exact, numeric version of the discount to be applied. * * Ranges from 0 - 100. * * @return int */ public function getDiscount() { return $this->discount ? $this->discount->discount : 0; } /** * The actual discount rate for use in multiplication * * Ranges from 0.00 to 1.00. */ public function getDiscountRate() { return (100 - $this->getDiscount()) / 100; } /** * A helper to display human-readable amount of money using * the wallet currency and specified locale. * * @param int $amount A amount of money (in cents) * @param string $locale A locale for the output * * @return string String representation, e.g. "9.99 CHF" */ public function money(int $amount, $locale = 'de_DE') { $amount = round($amount / 100, 2); $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); $result = $nf->formatCurrency($amount, $this->currency); // Replace non-breaking space return str_replace("\xC2\xA0", " ", $result); } /** * The owner of the wallet -- the wallet is in his/her back pocket. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo('App\User', 'user_id', 'id'); } /** * Payments on this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function payments() { return $this->hasMany('App\Payment'); } /** * Remove a controller from this wallet. * * @param \App\User $user The user to remove as a controller from this wallet. * * @return void */ public function removeController(User $user) { if ($this->controllers->contains($user)) { $this->controllers()->detach($user); } } /** * Retrieve the transactions against this wallet. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function transactions() { return \App\Transaction::where( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class ] ); } + + /** + * Force-update entitlements' updated_at, charge if needed. + * + * @param bool $withCost When enabled the cost will be charged + * + * @return int Charged amount in cents + */ + public function updateEntitlements($withCost = true): int + { + $charges = 0; + $discount = $this->getDiscountRate(); + $now = Carbon::now(); + + DB::beginTransaction(); + + // used to parent individual entitlement billings to the wallet debit. + $entitlementTransactions = []; + + foreach ($this->entitlements()->get() as $entitlement) { + $cost = 0; + $diffInDays = $entitlement->updated_at->diffInDays($now); + + // This entitlement has been created less than or equal to 14 days ago (this is at + // maximum the fourteenth 24-hour period). + if ($entitlement->created_at > Carbon::now()->subDays(14)) { + // $cost=0 + } elseif ($withCost && $diffInDays > 0) { + // The price per day is based on the number of days in the last month + // or the current month if the period does not overlap with the previous month + // FIXME: This really should be simplified to constant $daysInMonth=30 + if ($now->day >= $diffInDays && $now->month == $entitlement->updated_at->month) { + $daysInMonth = $now->daysInMonth; + } else { + $daysInMonth = \App\Utils::daysInLastMonth(); + } + + $pricePerDay = $entitlement->cost / $daysInMonth; + + $cost = (int) (round($pricePerDay * $discount * $diffInDays, 0)); + } + + if ($diffInDays > 0) { + $entitlement->updated_at = $entitlement->updated_at->setDateFrom($now); + $entitlement->save(); + } + + if ($cost == 0) { + continue; + } + + $charges += $cost; + + // FIXME: Shouldn't we store also cost=0 transactions (to have the full history)? + $entitlementTransactions[] = $entitlement->createTransaction( + \App\Transaction::ENTITLEMENT_BILLED, + $cost + ); + } + + if ($charges > 0) { + $this->debit($charges, '', $entitlementTransactions); + } + + DB::commit(); + + return $charges; + } } diff --git a/src/config/app.php b/src/config/app.php index 8927641b..41755949 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,287 +1,291 @@ env('APP_NAME', 'Laravel'), /* |-------------------------------------------------------------------------- | Application Environment |-------------------------------------------------------------------------- | | This value determines the "environment" your application is currently | running in. This may determine how you prefer to configure various | services the application utilizes. Set this in your ".env" file. | */ 'env' => env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | your application so that it is used when running Artisan tasks. */ 'url' => env('APP_URL', 'http://localhost'), 'passphrase' => env('APP_PASSPHRASE', null), 'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')), 'asset_url' => env('ASSET_URL', null), 'support_url' => env('SUPPORT_URL', null), 'support_email' => env('SUPPORT_EMAIL', null), 'webmail_url' => env('WEBMAIL_URL', null), 'theme' => env('APP_THEME', 'default'), 'tenant_id' => env('APP_TENANT_ID', null), 'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')), /* |-------------------------------------------------------------------------- | Application Domain |-------------------------------------------------------------------------- | | System domain used for user signup (kolab identity) */ 'domain' => env('APP_DOMAIN', 'domain.tld'), 'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | */ 'timezone' => 'UTC', /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by the translation service provider. You are free to set this value | to any of the locales which will be supported by the application. | */ 'locale' => env('APP_LOCALE', 'en'), /* |-------------------------------------------------------------------------- | Application Fallback Locale |-------------------------------------------------------------------------- | | The fallback locale determines the locale to use when the current one | is not available. You may change the value to correspond to any of | the language folders that are provided through your application. | */ 'fallback_locale' => 'en', /* |-------------------------------------------------------------------------- | Faker Locale |-------------------------------------------------------------------------- | | This locale will be used by the Faker PHP library when generating fake | data for your database seeds. For example, this will be used to get | localized telephone numbers, street address information and more. | */ 'faker_locale' => 'en_US', /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is used by the Illuminate encrypter service and should be set | to a random, 32 character string, otherwise these encrypted strings | will not be safe. Please do this before deploying an application! | */ 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', /* |-------------------------------------------------------------------------- | Autoloaded Service Providers |-------------------------------------------------------------------------- | | The service providers listed here will be automatically loaded on the | request to your application. Feel free to add your own services to | this array to grant expanded functionality to your applications. | */ 'providers' => [ /* * Laravel Framework Service Providers... */ Illuminate\Auth\AuthServiceProvider::class, Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, Illuminate\Cache\CacheServiceProvider::class, Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, Illuminate\Cookie\CookieServiceProvider::class, Illuminate\Database\DatabaseServiceProvider::class, Illuminate\Encryption\EncryptionServiceProvider::class, Illuminate\Filesystem\FilesystemServiceProvider::class, Illuminate\Foundation\Providers\FoundationServiceProvider::class, Illuminate\Hashing\HashServiceProvider::class, Illuminate\Mail\MailServiceProvider::class, Illuminate\Notifications\NotificationServiceProvider::class, Illuminate\Pagination\PaginationServiceProvider::class, Illuminate\Pipeline\PipelineServiceProvider::class, Illuminate\Queue\QueueServiceProvider::class, Illuminate\Redis\RedisServiceProvider::class, Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, Illuminate\Session\SessionServiceProvider::class, Illuminate\Translation\TranslationServiceProvider::class, Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, /* * Package Service Providers... */ Barryvdh\DomPDF\ServiceProvider::class, /* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\HorizonServiceProvider::class, App\Providers\PassportServiceProvider::class, App\Providers\RouteServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Class Aliases |-------------------------------------------------------------------------- | | This array of class aliases will be registered when this application | is started. However, feel free to register as many as you wish as | the aliases are "lazy" loaded so they don't hinder performance. | */ 'aliases' => [ 'App' => Illuminate\Support\Facades\App::class, 'Arr' => Illuminate\Support\Arr::class, 'Artisan' => Illuminate\Support\Facades\Artisan::class, 'Auth' => Illuminate\Support\Facades\Auth::class, 'Blade' => Illuminate\Support\Facades\Blade::class, 'Broadcast' => Illuminate\Support\Facades\Broadcast::class, 'Bus' => Illuminate\Support\Facades\Bus::class, 'Cache' => Illuminate\Support\Facades\Cache::class, 'Config' => Illuminate\Support\Facades\Config::class, 'Cookie' => Illuminate\Support\Facades\Cookie::class, 'Crypt' => Illuminate\Support\Facades\Crypt::class, 'DB' => Illuminate\Support\Facades\DB::class, 'Eloquent' => Illuminate\Database\Eloquent\Model::class, 'Event' => Illuminate\Support\Facades\Event::class, 'File' => Illuminate\Support\Facades\File::class, 'Gate' => Illuminate\Support\Facades\Gate::class, 'Hash' => Illuminate\Support\Facades\Hash::class, 'Lang' => Illuminate\Support\Facades\Lang::class, 'Log' => Illuminate\Support\Facades\Log::class, 'Mail' => Illuminate\Support\Facades\Mail::class, 'Notification' => Illuminate\Support\Facades\Notification::class, 'Password' => Illuminate\Support\Facades\Password::class, 'PDF' => Barryvdh\DomPDF\Facade::class, 'Queue' => Illuminate\Support\Facades\Queue::class, 'Redirect' => Illuminate\Support\Facades\Redirect::class, 'Redis' => Illuminate\Support\Facades\Redis::class, 'Request' => Illuminate\Support\Facades\Request::class, 'Response' => Illuminate\Support\Facades\Response::class, 'Route' => Illuminate\Support\Facades\Route::class, 'Schema' => Illuminate\Support\Facades\Schema::class, 'Session' => Illuminate\Support\Facades\Session::class, 'Storage' => Illuminate\Support\Facades\Storage::class, 'Str' => Illuminate\Support\Str::class, 'URL' => Illuminate\Support\Facades\URL::class, 'Validator' => Illuminate\Support\Facades\Validator::class, 'View' => Illuminate\Support\Facades\View::class, ], // Locations of knowledge base articles 'kb' => [ // An article about suspended accounts 'account_suspended' => env('KB_ACCOUNT_SUSPENDED'), // An article about a way to delete an owned account 'account_delete' => env('KB_ACCOUNT_DELETE'), ], 'company' => [ 'name' => env('COMPANY_NAME'), 'address' => env('COMPANY_ADDRESS'), 'details' => env('COMPANY_DETAILS'), 'email' => env('COMPANY_EMAIL'), 'logo' => env('COMPANY_LOGO'), 'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')), ], + 'storage' => [ + 'min_qty' => (int) env('STORAGE_MIN_QTY', 5), // in GB + ], + 'vat' => [ 'countries' => env('VAT_COUNTRIES'), 'rate' => (float) env('VAT_RATE'), ], 'payment' => [ 'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', "creditcard,paypal,banktransfer"), 'methods_recurring' => env('PAYMENT_METHODS_RECURRING', "creditcard"), ], 'with_admin' => (bool) env('APP_WITH_ADMIN', false), 'with_reseller' => (bool) env('APP_WITH_RESELLER', false), 'with_services' => (bool) env('APP_WITH_SERVICES', false), ]; diff --git a/src/resources/js/app.js b/src/resources/js/app.js index c6e4e30e..a35b5f5d 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,571 +1,578 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import SupportForm from '../vue/Widgets/SupportForm' import store from './store' import { Tab } from 'bootstrap' import { loadLangAsync, i18n } from './locale' const loader = '
Loading
' let isLoading = 0 // Lock the UI with the 'loading...' element const startLoading = () => { isLoading++ let loading = $('#app > .app-loader').removeClass('fadeOut') if (!loading.length) { $('#app').append($(loader)) } } // Hide "loading" overlay const stopLoading = () => { if (isLoading > 0) { $('#app > .app-loader').addClass('fadeOut') isLoading--; } } let loadingRoute // Note: This has to be before the app is created // Note: You cannot use app inside of the function window.router.beforeEach((to, from, next) => { // check if the route requires authentication and user is not logged in if (to.meta.requiresAuth && !store.state.isLoggedIn) { // remember the original request, to use after login store.state.afterLogin = to; // redirect to login page next({ name: 'login' }) return } if (to.meta.loading) { startLoading() loadingRoute = to.name } next() }) window.router.afterEach((to, from) => { if (to.name && loadingRoute === to.name) { stopLoading() loadingRoute = null } // When changing a page remove old: // - error page // - modal backdrop $('#error-page,.modal-backdrop.show').remove() $('body').css('padding', 0) // remove padding added by unclosed modal }) const app = new Vue({ components: { AppComponent, MenuComponent, }, i18n, store, router: window.router, data() { return { isUser: !window.isAdmin && !window.isReseller, appName: window.config['app.name'], appUrl: window.config['app.url'], themeDir: '/themes/' + window.config['app.theme'] } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, hasPermission(type) { const authInfo = store.state.authInfo const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1) return !!(authInfo && authInfo.statusInfo[key]) }, hasRoute(name) { return this.$router.resolve({ name: name }).resolved.matched.length > 0 }, hasSKU(name) { const authInfo = store.state.authInfo return authInfo.statusInfo.skus && authInfo.statusInfo.skus.indexOf(name) != -1 }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(response, dashboard, update) { if (!update) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') } localStorage.setItem('token', response.access_token) localStorage.setItem('refreshToken', response.refresh_token) axios.defaults.headers.common.Authorization = 'Bearer ' + response.access_token if (response.email) { store.state.authInfo = response } if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null // Refresh the token before it expires let timeout = response.expires_in || 0 // We'll refresh 60 seconds before the token expires if (timeout > 60) { timeout -= 60 } // TODO: We probably should try a few times in case of an error // TODO: We probably should prevent axios from doing any requests // while the token is being refreshed this.refreshTimeout = setTimeout(() => { axios.post('/api/auth/refresh', {'refresh_token': response.refresh_token}).then(response => { this.loginUser(response.data, false, true) }) }, timeout * 1000) }, // Set user state to "not logged in" logoutUser(redirect) { store.commit('logoutUser') localStorage.setItem('token', '') localStorage.setItem('refreshToken', '') delete axios.defaults.headers.common.Authorization if (redirect !== false) { this.$router.push({ name: 'login' }) } clearTimeout(this.refreshTimeout) }, logo(mode) { let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png' return `${this.appName}` }, // Display "loading" overlay inside of the specified element addLoader(elem, small = true, style = null) { if (style) { $(elem).css(style) } else { $(elem).css('position', 'relative') } $(elem).append(small ? $(loader).addClass('small') : $(loader)) }, // Create an object copy with specified properties only pick(obj, properties) { let result = {} properties.forEach(prop => { if (prop in obj) { result[prop] = obj[prop] } }) return result }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading, stopLoading, isLoading() { return isLoading > 0 }, tab(e) { e.preventDefault() new Tab(e.target).show() }, errorPage(code, msg, hint) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown') if (!hint) hint = '' const error_page = '
' + `
${code}
${msg}
${hint}
` + '
' $('#error-page').remove() $('#app').append(error_page) app.updateBodyClass('error') }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { // Remember requested route to come back to it after log in if (this.$route.meta.requiresAuth) { store.state.afterLogin = this.$route this.logoutUser() } else { this.logoutUser(false) } } else { this.errorPage(error.response.status, error.response.statusText) } }, downloadFile(url) { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser // but it could be implemented in the UI, axios has 'progress' property axios.get(url, { responseType: 'blob' }) .then(response => { const link = document.createElement('a') const contentDisposition = response.headers['content-disposition'] let filename = 'unknown' if (contentDisposition) { const match = contentDisposition.match(/filename="(.+)"/); if (match.length === 2) { filename = match[1]; } } link.href = window.URL.createObjectURL(response.data) link.download = filename link.click() }) }, price(price, currency) { // TODO: Set locale argument according to the currently used locale return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, discount, currency) { let index = '' if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost, currency) + '/' + this.$t('wallet.month') + index }, clickRecord(event) { if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) { $(event.target).closest('tr').find('a').trigger('click') } }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return this.$t('status.deleted') } if (domain.isSuspended) { return this.$t('status.suspended') } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return this.$t('status.notready') } return this.$t('status.active') }, distlistStatusClass(list) { if (list.isDeleted) { return 'text-muted' } if (list.isSuspended) { return 'text-warning' } if (!list.isLdapReady) { return 'text-danger' } return 'text-success' }, distlistStatusText(list) { if (list.isDeleted) { return this.$t('status.deleted') } if (list.isSuspended) { return this.$t('status.suspended') } if (!list.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, folderStatusClass(folder) { return this.userStatusClass(folder) }, folderStatusText(folder) { return this.userStatusText(folder) }, + isDegraded() { + return store.state.authInfo.isAccountDegraded + }, pageName(path) { let page = this.$route.path // check if it is a "menu page", find the page name // otherwise we'll use the real path as page name window.config.menu.every(item => { if (item.location == page && item.page) { page = item.page return false } }) page = page.replace(/^\//, '') return page ? page : '404' }, resourceStatusClass(resource) { return this.userStatusClass(resource) }, resourceStatusText(resource) { return this.userStatusText(resource) }, supportDialog(container) { let dialog = $('#support-dialog')[0] if (!dialog) { // FIXME: Find a nicer way of doing this SupportForm.i18n = i18n let form = new Vue(SupportForm) form.$mount($('
').appendTo(container)[0]) form.$root = this form.$toast = this.$toast dialog = form.$el } dialog.__vue__.showDialog() }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } - if (user.isSuspended) { + if (user.isDegraded || user.isAccountDegraded || user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return this.$t('status.deleted') } + if (user.isDegraded || user.isAccountDegraded) { + return this.$t('status.degraded') + } + if (user.isSuspended) { return this.$t('status.suspended') } if (!user.isImapReady || !user.isLdapReady) { return this.$t('status.notready') } return this.$t('status.active') }, // Append some wallet properties to the object userWalletProps(object) { let wallet = store.state.authInfo.accounts[0] if (!wallet) { wallet = store.state.authInfo.wallets[0] } if (wallet) { object.currency = wallet.currency if (wallet.discount) { object.discount = wallet.discount object.discount_description = wallet.discount_description } } }, updateBodyClass(name) { // Add 'class' attribute to the body, different for each page // so, we can apply page-specific styles document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '') } } }) // Fetch the locale file and the start the app loadLangAsync().then(() => app.$mount('#app')) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { if (response.config.onFinish) { response.config.onFinish() } return response }, error => { // Do not display the error in a toast message, pass the error as-is if (error.config.ignoreErrors) { return Promise.reject(error) } if (error.config.onFinish) { error.config.onFinish() } let error_msg const status = error.response ? error.response.status : 200 const data = error.response ? error.response.data : {} if (status == 422 && data.errors) { error_msg = app.$t('error.form') const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(data.errors, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message // API responses can use a string, array or object let msg_text = '' if (typeof(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget let controls = input.children(':not(:first-child)') if (!controls.length && typeof msg == 'string') { // this is an empty list (the main input only) // and the error message is not an array input.find('.main-input').addClass('is-invalid') } else { controls.each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) } input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // a special case, e.g. the invitation policy widget if (input.is('select') && input.parent().is('.input-group-select.selected')) { input = input.next() } // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').first().focus() }) } else if (data.status == 'error') { error_msg = data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || app.$t('error.server')) // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/lang/en/mail.php b/src/resources/lang/en/mail.php index 187b2a2c..1412d590 100644 --- a/src/resources/lang/en/mail.php +++ b/src/resources/lang/en/mail.php @@ -1,89 +1,107 @@ "Dear :name,", 'footer1' => "Best regards,", 'footer2' => "Your :site Team", 'more-info-html' => "See here for more information.", 'more-info-text' => "See :href for more information.", + 'degradedaccountreminder-subject' => ":site Reminder: Your account is free", + 'degradedaccountreminder-body1' => "Thanks for sticking around, we remind you your account is a free " + . "account and restricted to receiving email, and use of the web client and cockpit only.", + 'degradedaccountreminder-body2' => "This leaves you with an ideal account to use for account registration with third parties " + . "and password resets, notifications or even just subscriptions to newsletters and the like.", + 'degradedaccountreminder-body3' => "To regain functionality such as sending email, calendars, address books, phone synchronization " + . "and voice & video conferencing, log on to the cockpit and make sure you have a positive account balance.", + 'degradedaccountreminder-body4' => "You can also delete your account there, making sure your data disappears from our systems.", + 'degradedaccountreminder-body5' => "Thank you for your consideration!", + 'negativebalance-subject' => ":site Payment Required", 'negativebalance-body' => "This is a notification to let you know that your :site account balance has run into the negative and requires your attention. " . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalance-body-ext' => "Settle up to keep your account running:", 'negativebalancereminder-subject' => ":site Payment Reminder", 'negativebalancereminder-body' => "It has probably skipped your attention that you are behind on paying for your :site account. " . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalancereminder-body-ext' => "Settle up to keep your account running:", 'negativebalancereminder-body-warning' => "Please, be aware that your account will be suspended " . "if your account balance is not settled by :date.", + 'negativebalancereminderdegrade-body-warning' => "Please, be aware that your account will be degraded " + . "if your account balance is not settled by :date.", + + 'negativebalancedegraded-subject' => ":site Account Degraded", + 'negativebalancedegraded-body' => "Your :site account has been degraded for having a negative balance for too long. " + . "Consider setting up an automatic payment to avoid messages like this in the future.", + 'negativebalancedegraded-body-ext' => "Settle up now to undegrade your account:", + 'negativebalancesuspended-subject' => ":site Account Suspended", 'negativebalancesuspended-body' => "Your :site account has been suspended for having a negative balance for too long. " . "Consider setting up an automatic payment to avoid messages like this in the future.", 'negativebalancesuspended-body-ext' => "Settle up now to unsuspend your account:", 'negativebalancesuspended-body-warning' => "Please, be aware that your account and all its data will be deleted " . "if your account balance is not settled by :date.", 'negativebalancebeforedelete-subject' => ":site Final Warning", 'negativebalancebeforedelete-body' => "This is a final reminder to settle your :site account balance. " . "Your account and all its data will be deleted if your account balance is not settled by :date.", 'negativebalancebeforedelete-body-ext' => "Settle up now to keep your account:", 'passwordreset-subject' => ":site Password Reset", 'passwordreset-body1' => "Someone recently asked to change your :site password.", 'passwordreset-body2' => "If this was you, use this verification code to complete the process:", 'passwordreset-body3' => "You can also click the link below:", 'passwordreset-body4' => "If you did not make such a request, you can either ignore this message or get in touch with us about this incident.", 'paymentmandatedisabled-subject' => ":site Auto-payment Problem", 'paymentmandatedisabled-body' => "Your :site account balance is negative " . "and the configured amount for automatically topping up the balance does not cover " . "the costs of subscriptions consumed.", 'paymentmandatedisabled-body-ext' => "Charging you multiple times for the same amount in short succession " . "could lead to issues with the payment provider. " . "In order to not cause any problems, we suspended auto-payment for your account. " . "To resolve this issue, login to your account settings and adjust your auto-payment amount.", 'paymentfailure-subject' => ":site Payment Failed", 'paymentfailure-body' => "Something went wrong with auto-payment for your :site account.\n" . "We tried to charge you via your preferred payment method, but the charge did not go through.", 'paymentfailure-body-ext' => "In order to not cause any further issues, we suspended auto-payment for your account. " . "To resolve this issue, login to your account settings at", 'paymentfailure-body-rest' => "There you can pay manually for your account and " . "change your auto-payment settings.", 'paymentsuccess-subject' => ":site Payment Succeeded", 'paymentsuccess-body' => "The auto-payment for your :site account went through without issues. " . "You can check your new account balance and more details here:", 'support' => "Special circumstances? Something is wrong with a charge?\n" . ":site Support is here to help.", 'signupcode-subject' => ":site Registration", 'signupcode-body1' => "This is your verification code for the :site registration process:", 'signupcode-body2' => "You can also click the link below to continue the registration process:", 'signupinvitation-subject' => ":site Invitation", 'signupinvitation-header' => "Hi,", 'signupinvitation-body1' => "You have been invited to join :site. Click the link below to sign up.", 'signupinvitation-body2' => "", 'suspendeddebtor-subject' => ":site Account Suspended", 'suspendeddebtor-body' => "You have been behind on paying for your :site account " ."for over :days days. Your account has been suspended.", 'suspendeddebtor-middle' => "Settle up now to reactivate your account.", 'suspendeddebtor-cancel' => "Don't want to be our customer anymore? " . "Here is how you can cancel your account:", ]; diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 795ac2c8..ca9b0169 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,480 +1,483 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'save' => "Save", 'search' => "Search", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", 'resources' => "Resources", 'shared-folders' => "Shared folders", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'name' => "Name", 'new' => "New distribution list", 'recipients' => "Recipients", 'sender-policy' => "Sender Access List", 'sender-policy-text' => "With this list you can specify who can send mail to the distribution list." . " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to." . " If the list is empty, mail from anyone is allowed.", ], 'domain' => [ 'delete' => "Delete domain", 'delete-domain' => "Delete {domain}", 'delete-text' => "Do you really want to delete this domain permanently?" . " This is only possible if there are no users, aliases or other objects in this domain." . " Please note that this action cannot be undone.", 'dns-verify' => "Domain DNS verification sample:", 'dns-config' => "Domain DNS configuration sample:", 'namespace' => "Namespace", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", 'verify' => "Domain verification", 'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.", 'verify-dns' => "The domain must have one of the following entries in DNS:", 'verify-dns-txt' => "TXT entry with value:", 'verify-dns-cname' => "or CNAME entry:", 'verify-outro' => "When this is done press the button below to start the verification.", 'verify-sample' => "Here's a sample zone file for your domain:", 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", 'create' => "Create domain", 'new' => "New domain", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", 'form' => "Form validation error", ], 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", 'acl-read-only' => "Read-only", 'acl-read-write' => "Read-write", 'amount' => "Amount", 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", 'name' => "Name", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", 'shared-folder' => "Shared Folder", 'status' => "Status", 'surname' => "Surname", 'type' => "Type", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'empty-list' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'webmail' => "Webmail" ], 'meet' => [ 'title' => "Voice & Video Conferencing", 'welcome' => "Welcome to our beta program for Voice & Video Conferencing.", 'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.", 'notice' => "This is a work in progress and more features will be added over time. Current features include:", 'sharing' => "Screen Sharing", 'sharing-text' => "Share your screen for presentations or show-and-tell.", 'security' => "Room Security", 'security-text' => "Increase the room security by setting a password that attendees will need to know" . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.", 'qa' => "Raise Hand (Q&A)", 'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.", 'moderation' => "Moderator Delegation", 'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly" . " interrupted with attendees knocking and other moderator duties.", 'eject' => "Eject Attendees", 'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy" . " violations. Click the user icon for effective dismissal.", 'silent' => "Silent Audience Members", 'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.", 'interpreters' => "Language Specific Audio Channels", 'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions" . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.", 'beta-notice' => "Keep in mind that this is still in beta and might come with some issues." . " Should you encounter any on your way, let us know by contacting support.", // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'resource' => [ 'create' => "Create resource", 'delete' => "Delete resource", 'invitation-policy' => "Invitation policy", 'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically" . " if there is no conflicting event on the requested time slot. Invitation policy allows" . " for rejecting such requests or to require a manual acceptance from a specified user.", 'ipolicy-manual' => "Manual (tentative)", 'ipolicy-accept' => "Accept", 'ipolicy-reject' => "Reject", 'list-title' => "Resource | Resources", 'list-empty' => "There are no resources in this account.", 'new' => "New resource", ], 'shf' => [ 'create' => "Create folder", 'delete' => "Delete folder", 'acl-text' => "Defines user permissions to access the shared folder.", 'list-title' => "Shared folder | Shared folders", 'list-empty' => "There are no shared folders in this account.", 'new' => "New shared folder", 'type-mail' => "Mail", 'type-event' => "Calendar", 'type-contact' => "Address Book", 'type-task' => "Tasks", 'type-note' => "Notes", 'type-file' => "Files", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your Kolab identity (you can choose additional addresses later).", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-resource' => "We are preparing the resource.", 'prepare-shared-folder' => "We are preparing the shared folder.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-resource' => "The resource is almost ready.", 'ready-shared-folder' => "The shared-folder is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", + 'degraded' => "Degraded", 'deleted' => "Deleted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or john@kolab.org", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'add-beta' => "Enable beta program", 'address' => "Address", 'aliases' => "Aliases", 'aliases-email' => "Email Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", + 'degraded-warning' => "The account is degraded. Some features have been disabled.", + 'degraded-hint' => "Please, make a payment.", 'delete' => "Delete user", 'delete-account' => "Delete this account?", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'domains' => "Domains", 'domains-none' => "There are no domains in this account.", 'ext-email' => "External Email", 'finances' => "Finances", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'list-title' => "User accounts", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'price' => "Price", 'profile-title' => "Your profile", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'resources' => "Resources", 'title' => "User account", 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions' => "Subscriptions", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", 'users-none' => "There are no users in this account.", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/lang/fr/mail.php b/src/resources/lang/fr/mail.php index 268eba96..bafbb111 100644 --- a/src/resources/lang/fr/mail.php +++ b/src/resources/lang/fr/mail.php @@ -1,90 +1,108 @@ "Salut :name,", 'footer1' => "Meilleures salutations,", 'footer2' => "Votre :site Équipe", 'more-info-html' => "Cliquez ici pour plus d'information.", 'more-info-text' => "Cliquez :href pour plus d'information.", + 'degradedaccountreminder-subject' => "Rappel du :site: Votre compte est gratuit", + 'degradedaccountreminder-body1' => "Merci de ne pas quitter le site, nous vous rappelons que votre compte est gratuit." + . " et limité à la réception d'emails, et à l'utilisation du client web et du cockpit uniquement.", + 'degradedaccountreminder-body2' => "Vous disposez ainsi d'un compte idéal à employer pour l'enregistrement de comptes auprès de tiers" + . " et les réinitialisations de mot de passe, les notifications ou même simplement les souscriptions aux newsletters et autres.", + 'degradedaccountreminder-body3' => "Pour récupérer les fonctionnalités telles que l'envoi de e-mail, les calendriers, les carnets d'adresses et la synchronisation des téléphones" + . " et les voix et vidéoconférences, connectez-vous au cockpit et assurez-vous que le solde de votre compte est positif.", + 'degradedaccountreminder-body4' => "Vous pouvez également y supprimer votre compte, afin que vos données disparaissent de nos systèmes.", + 'degradedaccountreminder-body5' => "Nous apprécions votre collaboration!", + 'negativebalance-subject' => ":site Paiement Requis", 'negativebalance-body' => "C'est une notification pour vous informer que votre :site le solde du compte est en négatif et nécessite votre attention." . " Veillez à mettre en place un auto-paiement pour éviter de tel avertissement comme celui-ci dans le future.", 'negativebalance-body-ext' => "Régler votre compte pour le maintenir en fontion:", 'negativebalancereminder-subject' => ":site Rappel de Paiement", 'negativebalancereminder-body' => "Vous n'avez peut-être pas rendu compte que vous êtes en retard avec votre paiement pour :site compte." . " Veillez à mettre en place un auto-paiement pour éviter de tel avertissement comme celui-ci dans le future.", 'negativebalancereminder-body-ext' => "Régler votre compte pour le maintenir en fontion:", 'negativebalancereminder-body-warning' => "Soyez conscient que votre compte sera suspendu si le" . " solde de votre compte n'est réglé avant le :date.", 'negativebalancesuspended-subject' => ":site Compte Suspendu", 'negativebalancesuspended-body' => "Votre :site compte a été suspendu à la suite d'un solde négatif pendant trop longtemps." . " Veillez nvisager de mettre en place un auto-paiement pour éviter de tel avertissement comme celui-ci dans le future.", 'negativebalancesuspended-body-ext' => "Régler votre compte pour le maintenir en fontion:", 'negativebalancesuspended-body-warning' => "Veuillez vous assurer que votre compte et toutes ses données seront supprimés" . " si le solde de votre compte n'est pas réglé avant le :date.", 'negativebalancebeforedelete-subject' => ":site Dernier Avertissement", 'negativebalancebeforedelete-body' => "Ceci-ci est le dernier rappel pour régler votre :site solde de compte." . " votre compte et toutes ses données seront supprimés si le solde de votre compte nest pas régler avant le :date.", 'negativebalancebeforedelete-body-ext' => "Régler votre compte immédiatement:", + 'negativebalancereminderdegrade-body-warning' => "Veuillez noter que votre compte sera dégradé" + . " si le solde de votre compte n'est pas réglé avant le :date.", + + 'negativebalancedegraded-subject' => ":site de Compte Dégradé", + 'negativebalancedegraded-body' => "Votre compte :site a été dégradé pour avoir un solde négatif depuis trop longtemps." + . " Envisagez de mettre en place un paiement automatique pour éviter des messages comme celui-ci à l'avenir.", + 'negativebalancedegraded-body-ext' => "Réglez maintenant pour rétablir votre compte:", + 'passwordreset-subject' => ":site Réinitialisation du mot de passe", 'passwordreset-body1' => "Quelqu'un a récemment demandé de changer votre :site mot de passe.", 'passwordreset-body2' => "Si vous êtes dans ce cas, veuillez utiliser ce code de vérification pour terminer le processus:", 'passwordreset-body3' => "Vous pourrez également cliquer sur le lien ci-dessous:", 'passwordreset-body4' => "si vous n'avez pas fait une telle demande, vous pouvez soit ignorer ce message, soit prendre contact avec nous au sujet de cet incident.", 'paymentmandatedisabled-subject' => ":site Problème d'auto-paiement", 'paymentmandatedisabled-body' => "Votre :site solde du compte est négatif" . " et le montant configuré pour le rechargement automatique du solde ne suffit pas" . " le coût des abonnements consommés.", 'paymentmandatedisabled-body-ext' => "En vous facturant plusieurs fois le même monant dans un court laps de temps" . " peut entraîner des problêmes avec le fournisseur du service de paiement." . " Pour éviter tout problème, nous avons suspendu l'auto-paiement pour votre compte." . " Pour resourdre le problème,veuillez vous connecter aux paramètres de votre compte et modifier le montant d'auto-paiement.", 'paymentfailure-subject' => ":site Paiement Echoué", 'paymentfailure-body' => "Un problème est survenu avec l'auto-paiement pour votre :site account.\n" . "Nous avons tenté de vous facturer via votre méthode de paiement choisie, mais le chargement n'a pas été effectué.", 'paymentfailure-body-ext' => "Pour éviter tout problème supplémentaire, nous avons suspendu l'auto-paiement sur votre compte." . " Pour resourdre le problème,veuillez vous connecter aux paramètres de votre compte au", 'paymentfailure-body-rest' => "Vous y trouverez la possibilité de payer manuellement votre compte et" . " de modifier vos paramètres d'auto-paiement.", 'paymentsuccess-subject' => ":site Paiement Effectué", 'paymentsuccess-body' => "L'auto-paiement pour votre :site le compte s'est exécuté sans problème. " . "Vous pouvez contrôler le solde de votre nouveau compte et obtenir plus de détails ici:", 'support' => "Cas particulier? Il y a un probléme avec une charge?\n" . ":site Le support reste à votre disposition.", 'signupcode-subject' => ":site Enregistrement", 'signupcode-body1' => "Voici votre code de vérification pour le :site registration process:", 'signupcode-body2' => "Vous pouvez également continuer avec le processus d'enregistrement en cliquant sur le lien ci-dessous:", 'signupinvitation-subject' => ":site Invitation", 'signupinvitation-header' => "Salut,", 'signupinvitation-body1' => "Vous êtes invité à joindre :site. Cliquez sur le lien ci-dessous pour vous inscrire.", 'signupinvitation-body2' => "", 'suspendeddebtor-subject' => ":site Compte Suspendu", 'suspendeddebtor-body' => "Vous êtes en retard avec le paiement de votre :site compte" . " pour plus de :days jours. Votre compte est suspendu.", 'suspendeddebtor-middle' => "Réglez immédiatement pour réactiver votre compte.", 'suspendeddebtor-cancel' => "Vous ne souhaitez plus être notre client?" . " Voici la démarche à suivre pour annuler votre compte:", ]; diff --git a/src/resources/lang/fr/ui.php b/src/resources/lang/fr/ui.php index ef7ba2b7..ebd539e8 100644 --- a/src/resources/lang/fr/ui.php +++ b/src/resources/lang/fr/ui.php @@ -1,435 +1,438 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Ajouter", 'accept' => "Accepter", 'back' => "Back", 'cancel' => "Annuler", 'close' => "Fermer", 'continue' => "Continuer", 'delete' => "Supprimer", 'deny' => "Refuser", 'download' => "Télécharger", 'edit' => "Modifier", 'file' => "Choisir le ficher...", 'moreinfo' => "Plus d'information", 'refresh' => "Actualiser", 'reset' => "Réinitialiser", 'resend' => "Envoyer à nouveau", 'save' => "Sauvegarder", 'search' => "Chercher", 'signup' => "S'inscrire", 'submit' => "Soumettre", 'suspend' => "Suspendre", 'unsuspend' => "Débloquer", 'verify' => "Vérifier", ], 'dashboard' => [ 'beta' => "bêta", 'distlists' => "Listes de distribution", 'chat' => "Chat Vidéo", 'domains' => "Domaines", 'invitations' => "Invitations", 'profile' => "Votre profil", 'users' => "D'utilisateurs", 'wallet' => "Portefeuille", 'webmail' => "Webmail", 'stats' => "Statistiques", ], 'distlist' => [ 'list-title' => "Liste de distribution | Listes de Distribution", 'create' => "Créer une liste", 'delete' => "Suprimmer une list", 'email' => "Courriel", 'list-empty' => "il n'y a pas de listes de distribution dans ce compte.", 'new' => "Nouvelle liste de distribution", 'recipients' => "Destinataires", 'sender-policy' => "Liste d'Accès d'Expéditeur", 'sender-policy-text' => "Cette liste vous permet de spécifier qui peut envoyer du courrier à la liste de distribution." . " Vous pouvez mettre une adresse e-mail complète (jane@kolab.org), un domaine (kolab.org) ou un suffixe (.org)" . " auquel l'adresse électronique de l'expéditeur est assimilée." . " Si la liste est vide, le courriels de quiconque est autorisé." ], 'domain' => [ 'dns-verify' => "Exemple de vérification du DNS d'un domaine:", 'dns-config' => "Exemple de configuration du DNS d'un domaine:", 'namespace' => "Espace de noms", 'verify' => "Vérification du domaine", 'verify-intro' => "Afin de confirmer que vous êtes bien le titulaire du domaine, nous devons exécuter un processus de vérification avant de l'activer définitivement pour la livraison d'e-mails.", 'verify-dns' => "Le domaine doit avoir l'une des entrées suivantes dans le DNS:", 'verify-dns-txt' => "Entrée TXT avec valeur:", 'verify-dns-cname' => "ou entrée CNAME:", 'verify-outro' => "Lorsque cela est fait, appuyez sur le bouton ci-dessous pour lancer la vérification.", 'verify-sample' => "Voici un fichier de zone simple pour votre domaine:", 'config' => "Configuration du domaine", 'config-intro' => "Afin de permettre à {app} de recevoir le trafic de messagerie pour votre domaine, vous devez ajuster les paramètres DNS, plus précisément les entrées MX, en conséquence.", 'config-sample' => "Modifiez le fichier de zone de votre domaine et remplacez les entrées MX existantes par les valeurs suivantes:", 'config-hint' => "Si vous ne savez pas comment définir les entrées DNS pour votre domaine, veuillez contacter le service d'enregistrement auprès duquel vous avez enregistré le domaine ou votre fournisseur d'hébergement Web.", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "Le Sender Policy Framework permet à un domaine expéditeur de dévoiler, par le biais de DNS," . " quels systèmes sont autorisés à envoyer des e-mails avec une adresse d'expéditeur d'enveloppe dans le domaine en question.", 'spf-whitelist-ex' => "Vous pouvez ici spécifier une liste de serveurs autorisés, par exemple: .ess.barracuda.com.", 'create' => "Créer domaine", 'new' => "Nouveau domaine", 'delete' => "Supprimer domaine", 'delete-domain' => "Supprimer {domain}", 'delete-text' => "Voulez-vous vraiment supprimer ce domaine de façon permanente?" . " Ceci n'est possible que s'il n'y a pas d'utilisateurs, d'alias ou d'autres objets dans ce domaine." . " Veuillez noter que cette action ne peut pas être inversée.", ], 'error' => [ '400' => "Mauvaide demande", '401' => "Non autorisé", '403' => "Accès refusé", '404' => "Pas trouvé", '405' => "Méthode non autorisée", '500' => "Erreur de serveur interne", 'unknown' => "Erreur inconnu", 'server' => "Erreur de serveur", 'form' => "Erreur de validation du formulaire", ], 'form' => [ 'amount' => "Montant", 'code' => "Le code de confirmation", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Détails", 'domain' => "Domaine", 'email' => "Adresse e-mail", 'firstname' => "Prénom", 'lastname' => "Nom de famille", 'none' => "aucun", 'or' => "ou", 'password' => "Mot de passe", 'password-confirm' => "Confirmer le mot de passe", 'phone' => "Téléphone", 'status' => "État", 'surname' => "Nom de famille", 'user' => "Utilisateur", 'primary-email' => "Email principal", 'id' => "ID", 'created' => "Créé", 'deleted' => "Supprimé", 'disabled' => "Désactivé", 'enabled' => "Activé", 'general' => "Général", 'settings' => "Paramètres", ], 'invitation' => [ 'create' => "Créez des invitation(s)", 'create-title' => "Invitation à une inscription", 'create-email' => "Saisissez l'adresse électronique de la personne que vous souhaitez inviter.", 'create-csv' => "Pour envoyer plusieurs invitations à la fois, fournissez un fichier CSV (séparé par des virgules) ou un fichier en texte brut, contenant une adresse e-mail par ligne.", 'empty-list' => "Il y a aucune invitation dans la mémoire de données.", 'title' => "Invitation d'inscription", 'search' => "Adresse E-mail ou domaine", 'send' => "Envoyer invitation(s)", 'status-completed' => "Utilisateur s'est inscrit", 'status-failed' => "L'envoi a échoué", 'status-sent' => "Envoyé", 'status-new' => "Pas encore envoyé", ], 'lang' => [ 'en' => "Anglais", 'de' => "Allemand", 'fr' => "Français", 'it' => "Italien", ], 'login' => [ '2fa' => "Code du 2ème facteur", '2fa_desc' => "Le code du 2ème facteur est facultatif pour les utilisateurs qui n'ont pas configuré l'authentification à deux facteurs.", 'forgot_password' => "Mot de passe oublié?", 'header' => "Veuillez vous connecter", 'sign_in' => "Se connecter", 'webmail' => "Webmail" ], 'meet' => [ 'title' => "Voix et vidéo-conférence", 'welcome' => "Bienvenue dans notre programme bêta pour les conférences vocales et vidéo.", 'url' => "Vous disposez d'une salle avec l'URL ci-dessous. Cette salle ouvre uniquement quand vous y êtes vous-même. Utilisez cette URL pour inviter des personnes à vous rejoindre.", 'notice' => "Il s'agit d'un travail en évolution et d'autres fonctions seront ajoutées au fil du temps. Les fonctions actuelles sont les suivantes:", 'sharing' => "Partage d'écran", 'sharing-text' => "Partagez votre écran pour des présentations ou des exposés.", 'security' => "sécurité de chambre", 'security-text' => "Renforcez la sécurité de la salle en définissant un mot de passe que les participants devront connaître." . " avant de pouvoir entrer, ou verrouiller la porte afin que les participants doivent frapper, et un modérateur peut accepter ou refuser ces demandes.", 'qa' => "Lever la main (Q&A)", 'qa-text' => "Les membres du public silencieux peuvent lever la main pour animer une séance de questions-réponses avec les membres du panel.", 'moderation' => "Délégation des Modérateurs", 'moderation-text' => "Déléguer l'autorité du modérateur pour la séance, afin qu'un orateur ne soit pas inutilement" . " interrompu par l'arrivée des participants et d'autres tâches du modérateur.", 'eject' => "Éjecter les participants", 'eject-text' => "Éjectez les participants de la session afin de les obliger à se reconnecter ou de remédier aux violations des règles." . " Cliquez sur l'icône de l'utilisateur pour un renvoi effectif.", 'silent' => "Membres du Public en Silence", 'silent-text' => "Pour une séance de type webinaire, configurez la salle pour obliger tous les nouveaux participants à être des spectateurs silencieux.", 'interpreters' => "Canaux d'Audio Spécifiques de Langues", 'interpreters-text' => "Désignez un participant pour interpréter l'audio original dans une langue cible, pour les sessions avec des participants multilingues." . " L'interprète doit être capable de relayer l'audio original et de le remplacer.", 'beta-notice' => "Rappelez-vous qu'il s'agit d'une version bêta et pourrait entraîner des problèmes." . " Au cas où vous rencontreriez des problèmes, n'hésitez pas à nous en faire part en contactant le support.", // Room options dialog 'options' => "Options de salle", 'password' => "Mot de passe", 'password-none' => "aucun", 'password-clear' => "Effacer mot de passe", 'password-set' => "Définir le mot de passe", 'password-text' => "Vous pouvez ajouter un mot de passe à votre session. Les participants devront fournir le mot de passe avant d'être autorisés à rejoindre la session.", 'lock' => "Salle verrouillée", 'lock-text' => "Lorsque la salle est verrouillée, les participants doivent être approuvés par un modérateur avant de pouvoir rejoindre la réunion.", 'nomedia' => "Réservé aux abonnés", 'nomedia-text' => "Force tous les participants à se joindre en tant qu'abonnés (avec caméra et microphone désactivés)" . "Les modérateurs pourront les promouvoir en tant qu'éditeurs tout au long de la session.", // Room menu 'partcnt' => "Nombres de participants", 'menu-audio-mute' => "Désactiver le son", 'menu-audio-unmute' => "Activer le son", 'menu-video-mute' => "Désactiver la vidéo", 'menu-video-unmute' => "Activer la vidéo", 'menu-screen' => "Partager l'écran", 'menu-hand-lower' => "Baisser la main", 'menu-hand-raise' => "Lever la main", 'menu-channel' => "Canal de langue interprétée", 'menu-chat' => "Le Chat", 'menu-fullscreen' => "Plein écran", 'menu-fullscreen-exit' => "Sortir en plein écran", 'menu-leave' => "Quitter la session", // Room setup screen 'setup-title' => "Préparez votre session", 'mic' => "Microphone", 'cam' => "Caméra", 'nick' => "Surnom", 'nick-placeholder' => "Votre nom", 'join' => "JOINDRE", 'joinnow' => "JOINDRE MAINTENANT", 'imaowner' => "Je suis le propriétaire", // Room 'qa' => "Q & A", 'leave-title' => "Salle fermée", 'leave-body' => "La session a été fermée par le propriétaire de la salle.", 'media-title' => "Configuration des médias", 'join-request' => "Demande de rejoindre", 'join-requested' => "{user} demandé à rejoindre.", // Status messages 'status-init' => "Vérification de la salle...", 'status-323' => "La salle est fermée. Veuillez attendre le démarrage de la session par le propriétaire.", 'status-324' => "La salle est fermée. Elle sera ouverte aux autres participants après votre adhésion.", 'status-325' => "La salle est prête. Veuillez entrer un mot de passe valide.", 'status-326' => "La salle est fermée. Veuillez entrer votre nom et réessayer.", 'status-327' => "En attendant la permission de joindre la salle.", 'status-404' => "La salle n'existe pas.", 'status-429' => "Trop de demande. Veuillez, patienter.", 'status-500' => "La connexion à la salle a échoué. Erreur de serveur.", // Other menus 'media-setup' => "configuration des médias", 'perm' => "Permissions", 'perm-av' => "Publication d'audio et vidéo", 'perm-mod' => "Modération", 'lang-int' => "Interprète de langue", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Connecter", 'logout' => "Deconnecter", 'signup' => "S'inscrire", 'toggle' => "Basculer la navigation", ], 'msg' => [ 'initializing' => "Initialisation...", 'loading' => "Chargement...", 'loading-failed' => "Échec du chargement des données.", 'notfound' => "Resource introuvable.", 'info' => "Information", 'error' => "Erreur", 'warning' => "Avertissement", 'success' => "Succès", ], 'nav' => [ 'more' => "Charger plus", 'step' => "Étape {i}/{n}", ], 'password' => [ 'reset' => "Réinitialiser le mot de passe", 'reset-step1' => "Entrez votre adresse e-mail pour réinitialiser votre mot de passe.", 'reset-step1-hint' => "Veuillez vérifier votre dossier de spam ou débloquer {email}.", 'reset-step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail externe." . " Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.", ], 'signup' => [ 'email' => "Adresse e-mail actuelle", 'login' => "connecter", 'title' => "S'inscrire", 'step1' => "Inscrivez-vous pour commencer votre mois gratuit.", 'step2' => "Nous avons envoyé un code de confirmation à votre adresse e-mail. Entrez le code que nous vous avons envoyé, ou cliquez sur le lien dans le message.", 'step3' => "Créez votre identité Kolab (vous pourrez choisir des adresses supplémentaires plus tard).", 'voucher' => "Coupon Code", ], 'status' => [ 'prepare-account' => "Votre compte est en cours de préparation.", 'prepare-domain' => "Le domain est en cours de préparation.", 'prepare-distlist' => "La liste de distribution est en cours de préparation.", 'prepare-user' => "Le compte d'utilisateur est en cours de préparation.", 'prepare-hint' => "Certaines fonctionnalités peuvent être manquantes ou en lecture seule pour le moment.", 'prepare-refresh' => "Le processus ne se termine jamais? Appuyez sur le bouton \"Refresh\", s'il vous plaît.", 'ready-account' => "Votre compte est presque prêt.", 'ready-domain' => "Le domaine est presque prêt.", 'ready-distlist' => "La liste de distribution est presque prête.", 'ready-user' => "Le compte d'utilisateur est presque prêt.", 'verify' => "Veuillez vérifier votre domaine pour terminer le processus de configuration.", 'verify-domain' => "Vérifier domaine", + 'degraded' => "Dégradé", 'deleted' => "Supprimé", 'suspended' => "Suspendu", 'notready' => "Pas Prêt", 'active' => "Actif", ], 'support' => [ 'title' => "Contacter Support", 'id' => "Numéro de client ou adresse é-mail que vous avez chez nous.", 'id-pl' => "e.g. 12345678 ou john@kolab.org", 'id-hint' => "Laissez vide si vous n'êtes pas encore client", 'name' => "Nom", 'name-pl' => "comment nous devons vous adresser dans notre réponse", 'email' => "adresse e-mail qui fonctionne", 'email-pl' => "assurez-vous que nous pouvons vous atteindre à cette adresse", 'summary' => "Résumé du problème", 'summary-pl' => "une phrase qui résume votre situation", 'expl' => "Analyse du problème", ], 'user' => [ '2fa-hint1' => "Cela éliminera le droit à l'authentification à 2-Facteurs ainsi que les éléments configurés par l'utilisateur.", '2fa-hint2' => "Veuillez vous assurer que l'identité de l'utilisateur est correctement confirmée.", 'add-beta' => "Activer le programme bêta", 'address' => "Adresse", 'aliases' => "Alias", 'aliases-email' => "Alias E-mail", 'aliases-none' => "Cet utilisateur n'aucune alias e-mail.", 'add-bonus' => "Ajouter un bonus", 'add-bonus-title' => "Ajouter un bonus au portefeuille", 'add-penalty' => "Ajouter une pénalité", 'add-penalty-title' => "Ajouter une pénalité au portefeuille", 'auto-payment' => "Auto-paiement", 'auto-payment-text' => "Recharger par {amount} quand le montant est inférieur à {balance} utilisant {method}", 'country' => "Pays", 'create' => "Créer un utilisateur", 'custno' => "No. de Client.", + 'degraded-warning' => "Le compte est dégradé. Certaines fonctionnalités ont été désactivées.", + 'degraded-hint' => "Veuillez effectuer un paiement.", 'delete' => "Supprimer Utilisateur", 'delete-email' => "Supprimer {email}", 'delete-text' => "Voulez-vous vraiment supprimer cet utilisateur de façon permanente?" . " Cela supprimera toutes les données du compte et retirera la permission d'accéder au compte d'e-email." . " Veuillez noter que cette action ne peut pas être révoquée.", 'discount' => "Rabais", 'discount-hint' => "rabais appliqué", 'discount-title' => "Rabais de compte", 'distlists' => "Listes de Distribution", 'domains' => "Domaines", 'domains-none' => "Il y a pas de domaines dans ce compte.", 'ext-email' => "E-mail externe", 'finances' => "Finances", 'greylisting' => "Greylisting", 'greylisting-text' => "La greylisting est une méthode de défense des utilisateurs contre le spam." . " Tout e-mail entrant provenant d'un expéditeur non reconnu est temporairement rejeté." . " Le serveur d'origine doit réessayer après un délai cette fois-ci, le mail sera accepté." . " Les spammeurs ne réessayent généralement pas de remettre le mail.", 'list-title' => "Comptes d'utilisateur", 'managed-by' => "Géré par", 'new' => "Nouveau compte d'utilisateur", 'org' => "Organisation", 'package' => "Paquet", 'price' => "Prix", 'profile-title' => "Votre profile", 'profile-delete' => "Supprimer compte", 'profile-delete-title' => "Supprimer ce compte?", 'profile-delete-text1' => "Cela supprimera le compte ainsi que tous les domaines, utilisateurs et alias associés à ce compte.", 'profile-delete-warning' => "Cette opération est irrévocable", 'profile-delete-text2' => "Comme vous ne pourrez plus rien récupérer après ce point, assurez-vous d'avoir migré toutes les données avant de poursuivre.", 'profile-delete-support' => "Étant donné que nous nous attachons à toujours nous améliorer, nous aimerions vous demander 2 minutes de votre temps. " . "Le meilleur moyen de nous améliorer est le feedback des utilisateurs, et nous voudrions vous demander" . "quelques mots sur les raisons pour lesquelles vous avez quitté notre service. Veuillez envoyer vos commentaires au {email}.", 'profile-delete-contact' => "Par ailleurs, n'hésitez pas à contacter le support de {app} pour toute question ou souci que vous pourriez avoir dans ce contexte.", 'reset-2fa' => "Réinitialiser l'authentification à 2-Facteurs.", 'reset-2fa-title' => "Réinitialisation de l'Authentification à 2-Facteurs", 'title' => "Compte d'utilisateur", 'search' => "Adresse e-mail ou nom de l'utilisateur", 'search-pl' => "ID utilisateur, e-mail ou domamine", 'skureq' => "{sku} demande {list}.", 'subscription' => "Subscription", 'subscriptions' => "Subscriptions", 'subscriptions-none' => "Cet utilisateur n'a pas de subscriptions.", 'users' => "Utilisateurs", 'users-none' => "Il n'y a aucun utilisateur dans ce compte.", ], 'wallet' => [ 'add-credit' => "Ajouter un crédit", 'auto-payment-cancel' => "Annuler l'auto-paiement", 'auto-payment-change' => "Changer l'auto-paiement", 'auto-payment-failed' => "La configuration des paiements automatiques a échoué. Redémarrer le processus pour activer les top-ups automatiques.", 'auto-payment-hint' => "Cela fonctionne de la manière suivante: Chaque fois que votre compte est épuisé, nous débiterons votre méthode de paiement préférée d'un montant que vous aurez défini." . " Vous pouvez annuler ou modifier l'option de paiement automatique à tout moment.", 'auto-payment-setup' => "configurer l'auto-paiement", 'auto-payment-disabled' => "L'auto-paiement configuré a été désactivé. Rechargez votre porte-monnaie ou augmentez le montant d'auto-paiement.", 'auto-payment-info' => "L'auto-paiement est set pour recharger votre compte par {amount} lorsque le solde de votre compte devient inférieur à {balance}.", 'auto-payment-inprogress' => "La configuration d'auto-paiement est toujours en cours.", 'auto-payment-next' => "Ensuite, vous serez redirigé vers la page de paiement, où vous pourrez fournir les coordonnées de votre carte de crédit.", 'auto-payment-disabled-next' => "L'auto-paiement est désactivé. Dès que vous aurez soumis de nouveaux paramètres, nous l'activerons et essaierons de recharger votre portefeuille.", 'auto-payment-update' => "Mise à jour de l'auto-paiement.", 'banktransfer-hint' => "Veuillez noter qu'un virement bancaire peut nécessiter plusieurs jours avant d'être effectué.", 'currency-conv' => "Le principe est le suivant: Vous spécifiez le montant dont vous voulez recharger votre portefeuille en {wc}." . " Nous convertirons ensuite ce montant en {pc}, et sur la page suivante, vous obtiendrez les coordonnées bancaires pour transférer le montant en {pc}.", 'fill-up' => "Recharger par", 'history' => "Histoire", 'month' => "mois", 'noperm' => "Seuls les propriétaires de compte peuvent accéder à un portefeuille.", 'payment-amount-hint' => "Choisissez le montant dont vous voulez recharger votre portefeuille.", 'payment-method' => "Mode de paiement: {method}", 'payment-warning' => "Vous serez facturé pour {price}.", 'pending-payments' => "Paiements en attente", 'pending-payments-warning' => "Vous avez des paiements qui sont encore en cours. Voir l'onglet \"Paiements en attente\" ci-dessous.", 'pending-payments-none' => "Il y a aucun paiement en attente pour ce compte.", 'receipts' => "Reçus", 'receipts-hint' => "Vous pouvez télécharger ici les reçus (au format PDF) pour les paiements de la période spécifiée. Sélectionnez la période et appuyez sur le bouton Télécharger.", 'receipts-none' => "Il y a aucun reçu pour les paiements de ce compte. Veuillez noter que vous pouvez télécharger les reçus après la fin du mois.", 'title' => "Solde du compte", 'top-up' => "Rechargez votre portefeuille", 'transactions' => "Transactions", 'transactions-none' => "Il y a aucun transaction pour ce compte.", 'when-below' => "lorsque le solde du compte est inférieur à", ], ]; diff --git a/src/resources/views/emails/html/degraded_account_reminder.blade.php b/src/resources/views/emails/html/degraded_account_reminder.blade.php new file mode 100644 index 00000000..a6cab720 --- /dev/null +++ b/src/resources/views/emails/html/degraded_account_reminder.blade.php @@ -0,0 +1,19 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.degradedaccountreminder-body1', ['site' => $site]) }}

+

{{ __('mail.degradedaccountreminder-body2', ['site' => $site]) }}

+

{{ __('mail.degradedaccountreminder-body3', ['site' => $site]) }}

+

{{ $dashboardUrl }}

+

{{ __('mail.degradedaccountreminder-body4', ['site' => $site]) }}

+

{{ __('mail.degradedaccountreminder-body5', ['site' => $site]) }}

+ +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/html/negative_balance_degraded.blade.php b/src/resources/views/emails/html/negative_balance_degraded.blade.php new file mode 100644 index 00000000..5dcf8835 --- /dev/null +++ b/src/resources/views/emails/html/negative_balance_degraded.blade.php @@ -0,0 +1,21 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.negativebalancedegraded-body', ['site' => $site]) }}

+

{{ __('mail.negativebalancedegraded-body-ext', ['site' => $site]) }}

+

{{ $walletUrl }}

+ +@if ($supportUrl) +

{{ __('mail.support', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/html/negative_balance_reminder_degrade.blade.php b/src/resources/views/emails/html/negative_balance_reminder_degrade.blade.php new file mode 100644 index 00000000..80f7cb04 --- /dev/null +++ b/src/resources/views/emails/html/negative_balance_reminder_degrade.blade.php @@ -0,0 +1,22 @@ + + + + + + +

{{ __('mail.header', ['name' => $username]) }}

+ +

{{ __('mail.negativebalancereminder-body', ['site' => $site]) }}

+

{{ __('mail.negativebalancereminder-body-ext', ['site' => $site]) }}

+

{{ $walletUrl }}

+

{{ __('mail.negativebalancereminderdegrade-body-warning', ['site' => $site, 'date' => $date]) }}

+ +@if ($supportUrl) +

{{ __('mail.support', ['site' => $site]) }}

+

{{ $supportUrl }}

+@endif + +

{{ __('mail.footer1') }}

+

{{ __('mail.footer2', ['site' => $site]) }}

+ + diff --git a/src/resources/views/emails/plain/degraded_account_reminder.blade.php b/src/resources/views/emails/plain/degraded_account_reminder.blade.php new file mode 100644 index 00000000..8b253137 --- /dev/null +++ b/src/resources/views/emails/plain/degraded_account_reminder.blade.php @@ -0,0 +1,17 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.degradedaccountreminder-body1', ['site' => $site]) !!} + +{!! __('mail.degradedaccountreminder-body2', ['site' => $site]) !!} + +{!! __('mail.degradedaccountreminder-body3', ['site' => $site]) !!} + +{!! $dashboardUrl !!} + +{!! __('mail.degradedaccountreminder-body4', ['site' => $site]) !!} + +{!! __('mail.degradedaccountreminder-body5', ['site' => $site]) !!} + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/views/emails/plain/negative_balance_degraded.blade.php b/src/resources/views/emails/plain/negative_balance_degraded.blade.php new file mode 100644 index 00000000..9a1f01b6 --- /dev/null +++ b/src/resources/views/emails/plain/negative_balance_degraded.blade.php @@ -0,0 +1,17 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.negativebalancedegraded-body', ['site' => $site]) !!} + +{!! __('mail.negativebalancedegraded-body-ext', ['site' => $site]) !!} + +{!! $walletUrl !!} + +@if ($supportUrl) +{!! __('mail.support', ['site' => $site]) !!} + +{!! $supportUrl !!} +@endif + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/views/emails/plain/negative_balance_reminder_degrade.blade.php b/src/resources/views/emails/plain/negative_balance_reminder_degrade.blade.php new file mode 100644 index 00000000..01a9d312 --- /dev/null +++ b/src/resources/views/emails/plain/negative_balance_reminder_degrade.blade.php @@ -0,0 +1,19 @@ +{!! __('mail.header', ['name' => $username]) !!} + +{!! __('mail.negativebalancereminder-body', ['site' => $site]) !!} + +{!! __('mail.negativebalancereminder-body-ext', ['site' => $site]) !!} + +{!! $walletUrl !!} + +{!! __('mail.negativebalancereminderdegrade-body-warning', ['site' => $site, 'date' => $date]) !!} + +@if ($supportUrl) +{!! __('mail.support', ['site' => $site]) !!} + +{!! $supportUrl !!} +@endif + +-- +{!! __('mail.footer1') !!} +{!! __('mail.footer2', ['site' => $site]) !!} diff --git a/src/resources/vue/App.vue b/src/resources/vue/App.vue index f2790fca..0ccb02a2 100644 --- a/src/resources/vue/App.vue +++ b/src/resources/vue/App.vue @@ -1,113 +1,129 @@ diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue index 9ac9c1a5..e38d02a7 100644 --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -1,74 +1,74 @@ diff --git a/src/resources/vue/Distlist/List.vue b/src/resources/vue/Distlist/List.vue index c6fd582f..837edb6d 100644 --- a/src/resources/vue/Distlist/List.vue +++ b/src/resources/vue/Distlist/List.vue @@ -1,60 +1,60 @@ diff --git a/src/resources/vue/Domain/List.vue b/src/resources/vue/Domain/List.vue index 3f7ac0ab..3443669f 100644 --- a/src/resources/vue/Domain/List.vue +++ b/src/resources/vue/Domain/List.vue @@ -1,58 +1,58 @@ diff --git a/src/resources/vue/Resource/List.vue b/src/resources/vue/Resource/List.vue index e7707f09..3b1a19eb 100644 --- a/src/resources/vue/Resource/List.vue +++ b/src/resources/vue/Resource/List.vue @@ -1,60 +1,60 @@ diff --git a/src/resources/vue/Rooms.vue b/src/resources/vue/Rooms.vue index c0a32f5e..72db5364 100644 --- a/src/resources/vue/Rooms.vue +++ b/src/resources/vue/Rooms.vue @@ -1,64 +1,64 @@ diff --git a/src/resources/vue/SharedFolder/List.vue b/src/resources/vue/SharedFolder/List.vue index 8553210f..484696f3 100644 --- a/src/resources/vue/SharedFolder/List.vue +++ b/src/resources/vue/SharedFolder/List.vue @@ -1,60 +1,60 @@ diff --git a/src/resources/vue/User/List.vue b/src/resources/vue/User/List.vue index a0f2f131..d067928b 100644 --- a/src/resources/vue/User/List.vue +++ b/src/resources/vue/User/List.vue @@ -1,62 +1,62 @@ diff --git a/src/tests/Browser/DegradedAccountTest.php b/src/tests/Browser/DegradedAccountTest.php new file mode 100644 index 00000000..8d449619 --- /dev/null +++ b/src/tests/Browser/DegradedAccountTest.php @@ -0,0 +1,125 @@ +getTestUser('john@kolab.org'); + + if (!$john->isDegraded()) { + $john->status |= User::STATUS_DEGRADED; + User::where('id', $john->id)->update(['status' => $john->status]); + } + + $this->clearBetaEntitlements(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $john = $this->getTestUser('john@kolab.org'); + + if ($john->isDegraded()) { + $john->status ^= User::STATUS_DEGRADED; + User::where('id', $john->id)->update(['status' => $john->status]); + } + + $this->clearBetaEntitlements(); + + parent::tearDown(); + } + + /** + * Test acting as an owner of a degraded account + */ + public function testDegradedAccountOwner(): void + { + // Add beta+distlist entitlements + $john = $this->getTestUser('john@kolab.org'); + $this->addBetaEntitlement($john, ['beta-distlists', 'beta-resources', 'beta-shared-folders']); + + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('john@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment'); + + // Goto /users and assert that the warning is also displayed there + $browser->visit(new UserList()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') + ->whenAvailable('@table', function (Browser $browser) { + $browser->waitFor('tbody tr') + ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-warning') // Jack + ->assertText('tbody tr:nth-child(2) td:first-child svg.text-warning title', 'Degraded') + ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-warning') // John + ->assertText('tbody tr:nth-child(3) td:first-child svg.text-warning title', 'Degraded'); + }) + ->assertMissing('button.create-user'); + + // Goto /domains and assert that the warning is also displayed there + $browser->visit(new DomainList()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') + ->assertMissing('button.create-domain'); + + // Goto /distlists and assert that the warning is also displayed there + $browser->visit(new DistlistList()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') + ->assertMissing('button.create-list'); + + // Goto /resources and assert that the warning is also displayed there + $browser->visit(new ResourceList()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') + ->assertMissing('button.create-resource'); + + // Goto /shared-folders and assert that the warning is also displayed there + $browser->visit(new SharedFolderList()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertSeeIn('#status-degraded p.alert', 'Please, make a payment') + ->assertMissing('button.create-resource'); + + // Test that /rooms is not accessible + $browser->visit('/rooms') + ->waitFor('#app > #error-page') + ->assertSeeIn('#error-page .code', '403'); + }); + } + + /** + * Test acting as non-owner of a degraded account + */ + public function testDegradedAccountUser(): void + { + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('jack@kolab.org', 'simple123', true) + ->on(new Dashboard()) + ->assertSeeIn('#status-degraded p.alert', 'The account is degraded') + ->assertDontSeeIn('#status-degraded p.alert', 'Please, make a payment'); + }); + } +} diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php index 5b0c49b9..79fd532b 100644 --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -1,642 +1,668 @@ ldap_config = [ 'ldap.hosts' => \config('ldap.hosts'), ]; $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); $this->deleteTestResource('test-resource@kolab.org'); $this->deleteTestSharedFolder('test-folder@kolab.org'); // TODO: Remove group members } /** * {@inheritDoc} */ public function tearDown(): void { \config($this->ldap_config); $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); $this->deleteTestDomain('testldap.com'); $this->deleteTestGroup('group@kolab.org'); $this->deleteTestResource('test-resource@kolab.org'); $this->deleteTestSharedFolder('test-folder@kolab.org'); // TODO: Remove group members parent::tearDown(); } /** * Test handling connection errors * * @group ldap */ public function testConnectException(): void { \config(['ldap.hosts' => 'non-existing.host']); $this->expectException(\Exception::class); LDAP::connect(); } /** * Test creating/updating/deleting a domain record * * @group ldap */ public function testDomain(): void { Queue::fake(); $domain = $this->getTestDomain('testldap.com', [ 'type' => Domain::TYPE_EXTERNAL, 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, ]); // Create the domain LDAP::createDomain($domain); $ldap_domain = LDAP::getDomain($domain->namespace); $expected = [ 'associateddomain' => $domain->namespace, 'inetdomainstatus' => $domain->status, 'objectclass' => [ 'top', 'domainrelatedobject', 'inetdomain' ], ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_domain[$attr]) ? $ldap_domain[$attr] : null); } // TODO: Test other attributes, aci, roles/ous // Update the domain $domain->status |= User::STATUS_LDAP_READY; LDAP::updateDomain($domain); $expected['inetdomainstatus'] = $domain->status; $ldap_domain = LDAP::getDomain($domain->namespace); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_domain[$attr]) ? $ldap_domain[$attr] : null); } // Delete the domain LDAP::deleteDomain($domain); $this->assertSame(null, LDAP::getDomain($domain->namespace)); } /** * Test creating/updating/deleting a group record * * @group ldap */ public function testGroup(): void { Queue::fake(); $root_dn = \config('ldap.hosted.root_dn'); $group = $this->getTestGroup('group@kolab.org', [ 'members' => ['member1@testldap.com', 'member2@testldap.com'] ]); $group->setSetting('sender_policy', '["test.com"]'); // Create the group LDAP::createGroup($group); $ldap_group = LDAP::getGroup($group->email); $expected = [ 'cn' => 'group', 'dn' => 'cn=group,ou=Groups,ou=kolab.org,' . $root_dn, 'mail' => $group->email, 'objectclass' => [ 'top', 'groupofuniquenames', 'kolabgroupofuniquenames' ], 'kolaballowsmtpsender' => 'test.com', 'uniquemember' => [ 'uid=member1@testldap.com,ou=People,ou=kolab.org,' . $root_dn, 'uid=member2@testldap.com,ou=People,ou=kolab.org,' . $root_dn, ], ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } // Update members $group->members = ['member3@testldap.com']; $group->save(); $group->setSetting('sender_policy', '["test.com","-"]'); LDAP::updateGroup($group); // TODO: Should we force this to be always an array? $expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn; $expected['kolaballowsmtpsender'] = ['test.com', '-']; $ldap_group = LDAP::getGroup($group->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } $this->assertSame(['member3@testldap.com'], $group->fresh()->members); // Update members (add non-existing local member, expect it to be aot-removed from the group) // Update group name and sender_policy $group->members = ['member3@testldap.com', 'member-local@kolab.org']; $group->name = 'Te(=ść)1'; $group->save(); $group->setSetting('sender_policy', null); LDAP::updateGroup($group); // TODO: Should we force this to be always an array? $expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn; $expected['kolaballowsmtpsender'] = null; $expected['dn'] = 'cn=Te(\\3dść)1,ou=Groups,ou=kolab.org,' . $root_dn; $expected['cn'] = 'Te(=ść)1'; $ldap_group = LDAP::getGroup($group->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute"); } $this->assertSame(['member3@testldap.com'], $group->fresh()->members); // We called save() twice, and setSettings() three times, // this is making sure that there's no job executed by the LDAP backend Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 5); // Delete the group LDAP::deleteGroup($group); $this->assertSame(null, LDAP::getGroup($group->email)); } /** * Test creating/updating/deleting a resource record * * @group ldap */ public function testResource(): void { Queue::fake(); $root_dn = \config('ldap.hosted.root_dn'); $resource = $this->getTestResource('test-resource@kolab.org', ['name' => 'Test1']); $resource->setSetting('invitation_policy', null); // Make sure the resource does not exist // LDAP::deleteResource($resource); // Create the resource LDAP::createResource($resource); $ldap_resource = LDAP::getResource($resource->email); $expected = [ 'cn' => 'Test1', 'dn' => 'cn=Test1,ou=Resources,ou=kolab.org,' . $root_dn, 'mail' => $resource->email, 'objectclass' => [ 'top', 'kolabresource', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', 'kolabtargetfolder' => 'shared/Resources/Test1@kolab.org', 'kolabinvitationpolicy' => null, 'owner' => null, 'acl' => null, ]; foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_resource[$attr]) ? $ldap_resource[$attr] : null; $this->assertEquals($value, $ldap_value, "Resource $attr attribute"); } // Update resource name and invitation_policy $resource->name = 'Te(=ść)1'; $resource->save(); $resource->setSetting('invitation_policy', 'manual:john@kolab.org'); LDAP::updateResource($resource); $expected['kolabtargetfolder'] = 'shared/Resources/Te(=ść)1@kolab.org'; $expected['kolabinvitationpolicy'] = 'ACT_MANUAL'; $expected['owner'] = 'uid=john@kolab.org,ou=People,ou=kolab.org,' . $root_dn; $expected['dn'] = 'cn=Te(\\3dść)1,ou=Resources,ou=kolab.org,' . $root_dn; $expected['cn'] = 'Te(=ść)1'; $expected['acl'] = 'john@kolab.org, full'; $ldap_resource = LDAP::getResource($resource->email); foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_resource[$attr]) ? $ldap_resource[$attr] : null; $this->assertEquals($value, $ldap_value, "Resource $attr attribute"); } // Remove the invitation policy $resource->setSetting('invitation_policy', '[]'); LDAP::updateResource($resource); $expected['acl'] = null; $expected['kolabinvitationpolicy'] = null; $expected['owner'] = null; $ldap_resource = LDAP::getResource($resource->email); foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_resource[$attr]) ? $ldap_resource[$attr] : null; $this->assertEquals($value, $ldap_value, "Resource $attr attribute"); } // Delete the resource LDAP::deleteResource($resource); $this->assertSame(null, LDAP::getResource($resource->email)); } /** * Test creating/updating/deleting a shared folder record * * @group ldap */ public function testSharedFolder(): void { Queue::fake(); $root_dn = \config('ldap.hosted.root_dn'); $folder = $this->getTestSharedFolder('test-folder@kolab.org', ['type' => 'event']); $folder->setSetting('acl', null); // Make sure the shared folder does not exist // LDAP::deleteSharedFolder($folder); // Create the shared folder LDAP::createSharedFolder($folder); $ldap_folder = LDAP::getSharedFolder($folder->email); $expected = [ 'cn' => 'test-folder', 'dn' => 'cn=test-folder,ou=Shared Folders,ou=kolab.org,' . $root_dn, 'mail' => $folder->email, 'objectclass' => [ 'top', 'kolabsharedfolder', 'mailrecipient', ], 'kolabfoldertype' => 'event', 'kolabtargetfolder' => 'shared/test-folder@kolab.org', 'acl' => null, ]; foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null; $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute"); } // Update folder name and acl $folder->name = 'Te(=ść)1'; $folder->save(); $folder->setSetting('acl', '["john@kolab.org, read-write","anyone, read-only"]'); LDAP::updateSharedFolder($folder); $expected['kolabtargetfolder'] = 'shared/Te(=ść)1@kolab.org'; $expected['acl'] = ['john@kolab.org, read-write', 'anyone, read-only']; $expected['dn'] = 'cn=Te(\\3dść)1,ou=Shared Folders,ou=kolab.org,' . $root_dn; $expected['cn'] = 'Te(=ść)1'; $ldap_folder = LDAP::getSharedFolder($folder->email); foreach ($expected as $attr => $value) { $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null; $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute"); } // Delete the resource LDAP::deleteSharedFolder($folder); $this->assertSame(null, LDAP::getSharedFolder($folder->email)); } /** * Test creating/editing/deleting a user record * * @group ldap */ public function testUser(): void { Queue::fake(); $user = $this->getTestUser('user-ldap-test@' . \config('app.domain')); LDAP::createUser($user); $ldap_user = LDAP::getUser($user->email); $expected = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person', 'organizationalPerson', ], 'mail' => $user->email, 'uid' => $user->email, 'nsroledn' => [ 'cn=imap-user,' . \config('ldap.hosted.root_dn') ], 'cn' => 'unknown', 'displayname' => '', 'givenname' => '', 'sn' => 'unknown', 'inetuserstatus' => $user->status, 'mailquota' => null, 'o' => '', 'alias' => null, ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // Add aliases, and change some user settings, and entitlements $user->setSettings([ 'first_name' => 'Firstname', 'last_name' => 'Lastname', 'organization' => 'Org', 'country' => 'PL', ]); $user->status |= User::STATUS_IMAP_READY; $user->save(); $aliases = ['t1-' . $user->email, 't2-' . $user->email]; $user->setAliases($aliases); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package_kolab); LDAP::updateUser($user->fresh()); $expected['alias'] = $aliases; $expected['o'] = 'Org'; $expected['displayname'] = 'Lastname, Firstname'; $expected['givenname'] = 'Firstname'; $expected['cn'] = 'Firstname Lastname'; $expected['sn'] = 'Lastname'; $expected['inetuserstatus'] = $user->status; $expected['mailquota'] = 5242880; $expected['nsroledn'] = null; $ldap_user = LDAP::getUser($user->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // Update entitlements $sku_activesync = \App\Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $sku_groupware = \App\Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $user->assignSku($sku_activesync, 1); Entitlement::where(['sku_id' => $sku_groupware->id, 'entitleable_id' => $user->id])->delete(); LDAP::updateUser($user->fresh()); $expected_roles = [ 'activesync-user', 'imap-user' ]; $ldap_user = LDAP::getUser($user->email); $this->assertCount(2, $ldap_user['nsroledn']); $ldap_roles = array_map( function ($role) { if (preg_match('/^cn=([a-z0-9-]+)/', $role, $m)) { return $m[1]; } else { return $role; } }, $ldap_user['nsroledn'] ); $this->assertSame($expected_roles, $ldap_roles); + // Test degraded user + + $sku_storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); + $sku_2fa = \App\Sku::withEnvTenantContext()->where('title', '2fa')->first(); + $user->status |= User::STATUS_DEGRADED; + $user->update(['status' => $user->status]); + $user->assignSku($sku_storage, 2); + $user->assignSku($sku_2fa, 1); + + LDAP::updateUser($user->fresh()); + + $expected['inetuserstatus'] = $user->status; + $expected['mailquota'] = \config('app.storage.min_qty') * 1048576; + $expected['nsroledn'] = [ + 'cn=2fa-user,' . \config('ldap.hosted.root_dn'), + 'cn=degraded-user,' . \config('ldap.hosted.root_dn') + ]; + + $ldap_user = LDAP::getUser($user->email); + + foreach ($expected as $attr => $value) { + $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); + } + + // TODO: Test user who's owner is degraded + // Delete the user LDAP::deleteUser($user); $this->assertSame(null, LDAP::getUser($user->email)); } /** * Test handling errors on a resource creation * * @group ldap */ public function testCreateResourceException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create resource/'); $resource = new Resource([ 'email' => 'test-non-existing-ldap@non-existing.org', 'name' => 'Test', 'status' => Resource::STATUS_ACTIVE, ]); LDAP::createResource($resource); } /** * Test handling errors on a group creation * * @group ldap */ public function testCreateGroupException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create group/'); $group = new Group([ 'name' => 'test', 'email' => 'test@testldap.com', 'status' => Group::STATUS_NEW | Group::STATUS_ACTIVE, ]); LDAP::createGroup($group); } /** * Test handling errors on a shared folder creation * * @group ldap */ public function testCreateSharedFolderException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create shared folder/'); $folder = new SharedFolder([ 'email' => 'test-non-existing-ldap@non-existing.org', 'name' => 'Test', 'status' => SharedFolder::STATUS_ACTIVE, ]); LDAP::createSharedFolder($folder); } /** * Test handling errors on user creation * * @group ldap */ public function testCreateUserException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/Failed to create user/'); $user = new User([ 'email' => 'test-non-existing-ldap@non-existing.org', 'status' => User::STATUS_ACTIVE, ]); LDAP::createUser($user); } /** * Test handling update of a non-existing domain * * @group ldap */ public function testUpdateDomainException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/domain not found/'); $domain = new Domain([ 'namespace' => 'testldap.com', 'type' => Domain::TYPE_EXTERNAL, 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, ]); LDAP::updateDomain($domain); } /** * Test handling update of a non-existing group * * @group ldap */ public function testUpdateGroupException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/group not found/'); $group = new Group([ 'name' => 'test', 'email' => 'test@testldap.com', 'status' => Group::STATUS_NEW | Group::STATUS_ACTIVE, ]); LDAP::updateGroup($group); } /** * Test handling update of a non-existing resource * * @group ldap */ public function testUpdateResourceException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/resource not found/'); $resource = new Resource([ 'email' => 'test-resource@kolab.org', ]); LDAP::updateResource($resource); } /** * Test handling update of a non-existing shared folder * * @group ldap */ public function testUpdateSharedFolderException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/folder not found/'); $folder = new SharedFolder([ 'email' => 'test-folder-unknown@kolab.org', ]); LDAP::updateSharedFolder($folder); } /** * Test handling update of a non-existing user * * @group ldap */ public function testUpdateUserException(): void { $this->expectException(\Exception::class); $this->expectExceptionMessageMatches('/user not found/'); $user = new User([ 'email' => 'test-non-existing-ldap@kolab.org', 'status' => User::STATUS_ACTIVE, ]); LDAP::updateUser($user); } } diff --git a/src/tests/Feature/Console/User/DegradeTest.php b/src/tests/Feature/Console/User/DegradeTest.php new file mode 100644 index 00000000..96e32e61 --- /dev/null +++ b/src/tests/Feature/Console/User/DegradeTest.php @@ -0,0 +1,56 @@ +deleteTestUser('user-degrade-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user-degrade-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing user + $code = \Artisan::call("user:degrade unknown@unknown.org"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + // Create a user account for degrade + $user = $this->getTestUser('user-degrade-test@kolabnow.com'); + + $code = \Artisan::call("user:degrade {$user->email}"); + $output = trim(\Artisan::output()); + + $user->refresh(); + + $this->assertTrue($user->isDegraded()); + $this->assertSame('', $output); + $this->assertSame(0, $code); + } +} diff --git a/src/tests/Feature/Console/User/UndegradeTest.php b/src/tests/Feature/Console/User/UndegradeTest.php new file mode 100644 index 00000000..d953dabe --- /dev/null +++ b/src/tests/Feature/Console/User/UndegradeTest.php @@ -0,0 +1,58 @@ +deleteTestUser('user-degrade-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('user-degrade-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test the command + */ + public function testHandle(): void + { + Queue::fake(); + + // Non-existing user + $code = \Artisan::call("user:undegrade unknown@unknown.org"); + $output = trim(\Artisan::output()); + + $this->assertSame(1, $code); + $this->assertSame("User not found.", $output); + + // Create a user account for degrade/undegrade + $user = $this->getTestUser('user-degrade-test@kolabnow.com', ['status' => \App\User::STATUS_DEGRADED]); + + $this->assertTrue($user->isDegraded()); + + $code = \Artisan::call("user:undegrade {$user->email}"); + $output = trim(\Artisan::output()); + + $user->refresh(); + + $this->assertFalse($user->isDegraded()); + $this->assertSame('', $output); + $this->assertSame(0, $code); + } +} diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index 459213f6..7989aeaf 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,1398 +1,1402 @@ deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); $this->deleteTestUser('UserEntitlement2A@UserEntitlement.com'); $this->deleteTestUser('john2.doe2@kolab.org'); $this->deleteTestUser('deleted@kolab.org'); $this->deleteTestUser('deleted@kolabnow.com'); $this->deleteTestDomain('userscontroller.com'); $this->deleteTestGroup('group-test@kolabnow.com'); $this->deleteTestGroup('group-test@kolab.org'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->settings()->whereIn('key', ['greylist_enabled'])->delete(); $user->status |= User::STATUS_IMAP_READY; $user->save(); parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroy(): void { // First create some users/accounts to delete $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $john = $this->getTestUser('john@kolab.org'); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); // Test unauth access $response = $this->delete("api/v4/users/{$user2->id}"); $response->assertStatus(401); // Test access to other user/account $response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}"); $response->assertStatus(403); $response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(403); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test that non-controller cannot remove himself $response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(403); // Test removing a non-controller user $response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); // Test removing self (an account with users) $response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('User deleted successfully.', $json['message']); } /** * Test user deleting (DELETE /api/v4/users/) */ public function testDestroyByController(): void { // Create an account with additional controller - $user2 $package_kolab = \App\Package::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user1->assignPackage($package_kolab); $domain->assignPackage($package_domain, $user1); $user1->assignPackage($package_kolab, $user2); $user1->assignPackage($package_kolab, $user3); $user1->wallets()->first()->addController($user2); // TODO/FIXME: // For now controller can delete himself, as well as // the whole account he has control to, including the owner // Probably he should not be able to do none of those // However, this is not 0-regression scenario as we // do not fully support additional controllers. //$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}"); //$response->assertStatus(403); $response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}"); $response->assertStatus(200); $response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}"); $response->assertStatus(200); // Note: More detailed assertions in testDestroy() above $this->assertTrue($user1->fresh()->trashed()); $this->assertTrue($user2->fresh()->trashed()); $this->assertTrue($user3->fresh()->trashed()); } /** * Test user listing (GET /api/v4/users) */ public function testIndex(): void { // Test unauth access $response = $this->get("api/v4/users"); $response->assertStatus(401); $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $response = $this->actingAs($jack)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); $response = $this->actingAs($john)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(4, $json['count']); $this->assertCount(4, $json['list']); $this->assertSame($jack->email, $json['list'][0]['email']); $this->assertSame($joe->email, $json['list'][1]['email']); $this->assertSame($john->email, $json['list'][2]['email']); $this->assertSame($ned->email, $json['list'][3]['email']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json['list'][0]); + $this->assertArrayHasKey('isDegraded', $json['list'][0]); + $this->assertArrayHasKey('isAccountDegraded', $json['list'][0]); $this->assertArrayHasKey('isSuspended', $json['list'][0]); $this->assertArrayHasKey('isActive', $json['list'][0]); $this->assertArrayHasKey('isLdapReady', $json['list'][0]); $this->assertArrayHasKey('isImapReady', $json['list'][0]); $response = $this->actingAs($ned)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(4, $json['count']); $this->assertCount(4, $json['list']); $this->assertSame($jack->email, $json['list'][0]['email']); $this->assertSame($joe->email, $json['list'][1]['email']); $this->assertSame($john->email, $json['list'][2]['email']); $this->assertSame($ned->email, $json['list'][3]['email']); // Search by user email $response = $this->actingAs($john)->get("/api/v4/users?search=jack@k"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($jack->email, $json['list'][0]['email']); // Search by alias $response = $this->actingAs($john)->get("/api/v4/users?search=monster"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($joe->email, $json['list'][0]['email']); // Search by name $response = $this->actingAs($john)->get("/api/v4/users?search=land"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasMore']); $this->assertSame(1, $json['count']); $this->assertCount(1, $json['list']); $this->assertSame($ned->email, $json['list'][0]['email']); // TODO: Test paging } /** * Test fetching user data/profile (GET /api/v4/users/) */ public function testShow(): void { $userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com'); // Test getting profile of self $response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}"); $json = $response->json(); $response->assertStatus(200); $this->assertEquals($userA->id, $json['id']); $this->assertEquals($userA->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(is_array($json['aliases'])); $this->assertTrue($json['config']['greylist_enabled']); $this->assertSame([], $json['skus']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json); + $this->assertArrayHasKey('isDegraded', $json); + $this->assertArrayHasKey('isAccountDegraded', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $json); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); // Test unauthorized access to a profile of other user $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}"); $response->assertStatus(403); // Test authorized access to a profile of other user // Ned: Additional account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}"); $response->assertStatus(200); $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); // John: Account owner $response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}"); $response->assertStatus(200); $response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}"); $response->assertStatus(200); $json = $response->json(); $storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $groupware_sku = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $secondfactor_sku = Sku::withEnvTenantContext()->where('title', '2fa')->first(); $this->assertCount(5, $json['skus']); $this->assertSame(5, $json['skus'][$storage_sku->id]['count']); $this->assertSame([0,0,0,0,0], $json['skus'][$storage_sku->id]['costs']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame([490], $json['skus'][$groupware_sku->id]['costs']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); $this->assertSame([500], $json['skus'][$mailbox_sku->id]['costs']); $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']); $this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']); } /** * Test fetching user status (GET /api/v4/users//status) * and forcing setup process update (?refresh=1) * * @group imap * @group dns */ public function testStatus(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); // Test unauthorized access $response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(403); if ($john->isImapReady()) { $john->status ^= User::STATUS_IMAP_READY; $john->save(); } // Get user status $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Make sure the domain is confirmed (other test might unset that status) $domain = $this->getTestDomain('kolab.org'); $domain->status |= Domain::STATUS_CONFIRMED; $domain->save(); // Now "reboot" the process and verify the user in imap synchronously $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isImapReady']); $this->assertTrue($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); $this->assertSame('success', $json['status']); $this->assertSame('Setup process finished successfully.', $json['message']); Queue::size(1); // Test case for when the verify job is dispatched to the worker $john->refresh(); $john->status ^= User::STATUS_IMAP_READY; $john->save(); \config(['imap.admin_password' => null]); $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertSame('success', $json['status']); $this->assertSame('waiting', $json['processState']); $this->assertSame('Setup process has been pushed. Please wait.', $json['message']); Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); } /** * Test UsersController::statusInfo() */ public function testStatusInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $user->created_at = Carbon::now(); $user->status = User::STATUS_NEW; $user->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(false, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(false, $result['process'][2]['state']); $this->assertSame('running', $result['processState']); $user->created_at = Carbon::now()->subSeconds(181); $user->save(); $result = UsersController::statusInfo($user); $this->assertSame('failed', $result['processState']); $user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY; $user->save(); $result = UsersController::statusInfo($user); $this->assertTrue($result['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('done', $result['processState']); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertSame([], $result['skus']); $this->assertCount(7, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $this->assertSame('user-ldap-ready', $result['process'][1]['label']); $this->assertSame(true, $result['process'][1]['state']); $this->assertSame('user-imap-ready', $result['process'][2]['label']); $this->assertSame(true, $result['process'][2]['state']); $this->assertSame('domain-new', $result['process'][3]['label']); $this->assertSame(true, $result['process'][3]['state']); $this->assertSame('domain-ldap-ready', $result['process'][4]['label']); $this->assertSame(false, $result['process'][4]['state']); $this->assertSame('domain-verified', $result['process'][5]['label']); $this->assertSame(true, $result['process'][5]['state']); $this->assertSame('domain-confirmed', $result['process'][6]['label']); $this->assertSame(false, $result['process'][6]['state']); // Test 'skus' property $user->assignSku(Sku::withEnvTenantContext()->where('title', 'beta')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta'], $result['skus']); $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta', 'meet'], $result['skus']); $user->assignSku(Sku::withEnvTenantContext()->where('title', 'meet')->first()); $result = UsersController::statusInfo($user); $this->assertSame(['beta', 'meet'], $result['skus']); } /** * Test user config update (POST /api/v4/users//config) */ public function testSetConfig(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); // Test unknown user id $post = ['greylist_enabled' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/123/config", $post); $json = $response->json(); $response->assertStatus(404); // Test access by user not being a wallet controller $post = ['greylist_enabled' => 1]; $response = $this->actingAs($jack)->post("/api/v4/users/{$john->id}/config", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['grey' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertCount(1, $json['errors']); $this->assertSame('The requested configuration parameter is not supported.', $json['errors']['grey']); $this->assertNull($john->fresh()->getSetting('greylist_enabled')); // Test some valid data $post = ['greylist_enabled' => 1]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('true', $john->fresh()->getSetting('greylist_enabled')); // Test some valid data $post = ['greylist_enabled' => 0]; $response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('success', $json['status']); $this->assertSame('User settings updated successfully.', $json['message']); $this->assertSame('false', $john->fresh()->getSetting('greylist_enabled')); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { Queue::fake(); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); // Test empty request $response = $this->actingAs($john)->post("/api/v4/users", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame("The email field is required.", $json['errors']['email']); $this->assertSame("The password field is required.", $json['errors']['password'][0]); $this->assertCount(2, $json); // Test access by user not being a wallet controller $post = ['first_name' => 'Test']; $response = $this->actingAs($jack)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(403); $this->assertSame('error', $json['status']); $this->assertSame("Access denied", $json['message']); $this->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', 'email' => 'invalid']; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The specified email is invalid.', $json['errors']['email']); // Test existing user email $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'jack.daniels@kolab.org', ]; $response = $this->actingAs($john)->post("/api/v4/users", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The specified email is not available.', $json['errors']['email']); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'john2.doe2@kolab.org', 'organization' => 'TestOrg', 'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'], ]; // Missing package $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Package is required.", $json['errors']['package']); $this->assertCount(2, $json); // Invalid package $post['package'] = $package_domain->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertSame("Invalid package selected.", $json['errors']['package']); $this->assertCount(2, $json); // Test full and valid data $post['package'] = $package_kolab->id; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('deleted@kolab.org', $aliases[0]->alias); $this->assertSame('useralias1@kolab.org', $aliases[1]->alias); // Assert the new user entitlements $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Assert the wallet to which the new user should be assigned to $wallet = $user->wallet(); $this->assertSame($john->wallets()->first()->id, $wallet->id); // Attempt to create a user previously deleted $user->delete(); $post['package'] = $package_kolab->id; $post['aliases'] = []; $response = $this->actingAs($john)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User created successfully.", $json['message']); $this->assertCount(2, $json); $user = User::where('email', 'john2.doe2@kolab.org')->first(); $this->assertInstanceOf(User::class, $user); $this->assertSame('John2', $user->getSetting('first_name')); $this->assertSame('Doe2', $user->getSetting('last_name')); $this->assertSame('TestOrg', $user->getSetting('organization')); $this->assertCount(0, $user->aliases()->get()); $this->assertEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']); // Test acting as account controller (not owner) $this->markTestIncomplete(); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $domain = $this->getTestDomain( 'userscontroller.com', ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL] ); // Test unauthorized update of other user profile $response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []); $response->assertStatus(403); // Test authorized update of account owner by account controller $response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []); $response->assertStatus(200); // Test updating of self (empty request) $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); // Test some invalid data $post = ['password' => '12345678', 'currency' => 'invalid']; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json); $this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]); $this->assertSame('The currency must be 3 characters.', $json['errors']['currency'][0]); // Test full profile update including password $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'organization' => 'TestOrg', 'phone' => '+123 123 123', 'external_email' => 'external@gmail.com', 'billing_address' => 'billing', 'country' => 'CH', 'currency' => 'CHF', 'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); $this->assertTrue($userA->password != $userA->fresh()->password); unset($post['password'], $post['password_confirmation'], $post['aliases']); foreach ($post as $key => $value) { $this->assertSame($value, $userA->getSetting($key)); } $aliases = $userA->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias); // Test unsetting values $post = [ 'first_name' => '', 'last_name' => '', 'organization' => '', 'phone' => '', 'external_email' => '', 'billing_address' => '', 'country' => '', 'currency' => '', 'aliases' => ['useralias2@' . \config('app.domain')] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); $this->assertSame("User data updated successfully.", $json['message']); $this->assertTrue(!empty($json['statusInfo'])); $this->assertCount(3, $json); unset($post['aliases']); foreach ($post as $key => $value) { $this->assertNull($userA->getSetting($key)); } $aliases = $userA->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias); // Test error on some invalid aliases missing password confirmation $post = [ 'password' => 'simple123', 'aliases' => [ 'useralias2@' . \config('app.domain'), 'useralias1@kolab.org', '@kolab.org', ] ]; $response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post); $json = $response->json(); $response->assertStatus(422); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertCount(2, $json['errors']['aliases']); $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]); $this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); // Test authorized update of other user $response = $this->actingAs($ned)->put("/api/v4/users/{$jack->id}", []); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(empty($json['statusInfo'])); // TODO: Test error on aliases with invalid/non-existing/other-user's domain // Create entitlements and additional user for following tests $owner = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $user = $this->getTestUser('UsersControllerTest2@userscontroller.com'); $package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $package_kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_lite = Package::withEnvTenantContext()->where('title', 'lite')->first(); $sku_mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $sku_storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $sku_groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $domain = $this->getTestDomain( 'userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($package_domain, $owner); $owner->assignPackage($package_kolab); $owner->assignPackage($package_lite, $user); // Non-controller cannot update his own entitlements $post = ['skus' => []]; $response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(422); // Test updating entitlements $post = [ 'skus' => [ $sku_mailbox->id => 1, $sku_storage->id => 6, $sku_groupware->id => 1, ], ]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $json = $response->json(); $storage_cost = $user->entitlements() ->where('sku_id', $sku_storage->id) ->orderBy('cost') ->pluck('cost')->all(); $this->assertEntitlements( $user, ['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage'] ); $this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost); $this->assertTrue(empty($json['statusInfo'])); } /** * Test UsersController::updateEntitlements() */ public function testUpdateEntitlements(): void { $jane = $this->getTestUser('jane@kolabnow.com'); $kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $activesync = Sku::withEnvTenantContext()->where('title', 'activesync')->first(); $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); // standard package, 1 mailbox, 1 groupware, 2 storage $jane->assignPackage($kolab); // add 2 storage, 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 7, $activesync->id => 1 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'activesync', 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add 2 storage, remove 1 activesync $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // add mailbox $post = [ 'skus' => [ $mailbox->id => 2, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // remove mailbox $post = [ 'skus' => [ $mailbox->id => 0, $groupware->id => 1, $storage->id => 9, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(500); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); // less than free storage $post = [ 'skus' => [ $mailbox->id => 1, $groupware->id => 1, $storage->id => 1, $activesync->id => 0 ] ]; $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); $response->assertStatus(200); $this->assertEntitlements( $jane, [ 'groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage' ] ); } /** * Test user data response used in show and info actions */ public function testUserResponse(): void { $provider = \config('services.payment_provider') ?: 'mollie'; $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertEquals($user->email, $result['email']); $this->assertEquals($user->status, $result['status']); $this->assertTrue(is_array($result['statusInfo'])); $this->assertTrue(is_array($result['aliases'])); $this->assertCount(1, $result['aliases']); $this->assertSame('john.doe@kolab.org', $result['aliases'][0]); $this->assertTrue(is_array($result['settings'])); $this->assertSame('US', $result['settings']['country']); $this->assertSame('USD', $result['settings']['currency']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(0, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertArrayNotHasKey('discount', $result['wallet']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); // Ned is John's wallet controller $ned = $this->getTestUser('ned@kolab.org'); $ned_wallet = $ned->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]); $this->assertEquals($ned->id, $result['id']); $this->assertEquals($ned->email, $result['email']); $this->assertTrue(is_array($result['accounts'])); $this->assertTrue(is_array($result['wallets'])); $this->assertCount(1, $result['accounts']); $this->assertCount(1, $result['wallets']); $this->assertSame($wallet->id, $result['wallet']['id']); $this->assertSame($wallet->id, $result['accounts'][0]['id']); $this->assertSame($ned_wallet->id, $result['wallets'][0]['id']); $this->assertSame($provider, $result['wallet']['provider']); $this->assertSame($provider, $result['wallets'][0]['provider']); $this->assertTrue($result['statusInfo']['enableDomains']); $this->assertTrue($result['statusInfo']['enableWallets']); $this->assertTrue($result['statusInfo']['enableUsers']); // Test discount in a response $discount = Discount::where('code', 'TEST')->first(); $wallet->discount()->associate($discount); $wallet->save(); $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie'; $wallet->setSetting($mod_provider . '_id', 123); $user->refresh(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertSame($discount->id, $result['wallet']['discount_id']); $this->assertSame($discount->discount, $result['wallet']['discount']); $this->assertSame($discount->description, $result['wallet']['discount_description']); $this->assertSame($mod_provider, $result['wallet']['provider']); $this->assertSame($discount->id, $result['wallets'][0]['discount_id']); $this->assertSame($discount->discount, $result['wallets'][0]['discount']); $this->assertSame($discount->description, $result['wallets'][0]['discount_description']); $this->assertSame($mod_provider, $result['wallets'][0]['provider']); // Jack is not a John's wallet controller $jack = $this->getTestUser('jack@kolab.org'); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]); $this->assertFalse($result['statusInfo']['enableDomains']); $this->assertFalse($result['statusInfo']['enableWallets']); $this->assertFalse($result['statusInfo']['enableUsers']); } /** * List of email address validation cases for testValidateEmail() * * @return array Arguments for testValidateEmail() */ public function dataValidateEmail(): array { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); return [ // Invalid format ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified email is invalid.'], [".@$domain", $john, 'The specified email is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified email is not available.'], ["administrator@$domain", $john, 'The specified email is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], // existing alias of other user, to be a user email ["jack.daniels@kolab.org", $john, 'The specified email is not available.'], // valid (user domain) ["admin@kolab.org", $john, null], // valid (public domain) ["test.test@$domain", $john, null], ]; } /** * User email address validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateEmail */ public function testValidateEmail($email, $user, $expected_result): void { $result = UsersController::validateEmail($email, $user); $this->assertSame($expected_result, $result); } /** * User email validation - tests for $deleted argument * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateEmailDeleted(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->delete(); $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->delete(); $result = UsersController::validateEmail('deleted@kolab.org', $john, $deleted); $this->assertSame(null, $result); $this->assertSame($deleted_priv->id, $deleted->id); $result = UsersController::validateEmail('deleted@kolabnow.com', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); $result = UsersController::validateEmail('jack@kolab.org', $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertSame(null, $deleted); } /** * User email validation - tests for an address being a group email address * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateEmailGroup(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $pub_group = $this->getTestGroup('group-test@kolabnow.com'); $priv_group = $this->getTestGroup('group-test@kolab.org'); // A group in a public domain, existing $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $pub_group->delete(); // A group in a public domain, deleted $result = UsersController::validateEmail($pub_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); // A group in a private domain, existing $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame('The specified email is not available.', $result); $this->assertNull($deleted); $priv_group->delete(); // A group in a private domain, deleted $result = UsersController::validateEmail($priv_group->email, $john, $deleted); $this->assertSame(null, $result); $this->assertSame($priv_group->id, $deleted->id); } /** * List of alias validation cases for testValidateAlias() * * @return array Arguments for testValidateAlias() */ public function dataValidateAlias(): array { $this->refreshApplication(); $public_domains = Domain::getPublicDomains(); $domain = reset($public_domains); $john = $this->getTestUser('john@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); return [ // Invalid format ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], ["test123456@localhost", $john, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'], ["$domain", $john, 'The specified alias is invalid.'], [".@$domain", $john, 'The specified alias is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, 'The specified alias is not available.'], ["administrator@$domain", $john, 'The specified alias is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, 'The specified domain is not available.'], // existing alias of other user, to be an alias, user in the same group account ["jack.daniels@kolab.org", $john, null], // existing user ["jack@kolab.org", $john, 'The specified alias is not available.'], // valid (user domain) ["admin@kolab.org", $john, null], // valid (public domain) ["test.test@$domain", $john, null], ]; } /** * User email alias validation. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? * * @dataProvider dataValidateAlias */ public function testValidateAlias($alias, $user, $expected_result): void { $result = UsersController::validateAlias($alias, $user); $this->assertSame($expected_result, $result); } /** * User alias validation - more cases. * * Note: Technically these include unit tests, but let's keep it here for now. * FIXME: Shall we do a http request for each case? */ public function testValidateAlias2(): void { Queue::fake(); $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $deleted_priv = $this->getTestUser('deleted@kolab.org'); $deleted_priv->setAliases(['deleted-alias@kolab.org']); $deleted_priv->delete(); $deleted_pub = $this->getTestUser('deleted@kolabnow.com'); $deleted_pub->setAliases(['deleted-alias@kolabnow.com']); $deleted_pub->delete(); $group = $this->getTestGroup('group-test@kolabnow.com'); // An alias that was a user email before is allowed, but only for custom domains $result = UsersController::validateAlias('deleted@kolab.org', $john); $this->assertSame(null, $result); $result = UsersController::validateAlias('deleted-alias@kolab.org', $john); $this->assertSame(null, $result); $result = UsersController::validateAlias('deleted@kolabnow.com', $john); $this->assertSame('The specified alias is not available.', $result); $result = UsersController::validateAlias('deleted-alias@kolabnow.com', $john); $this->assertSame('The specified alias is not available.', $result); // A grpoup with the same email address exists $result = UsersController::validateAlias($group->email, $john); $this->assertSame('The specified alias is not available.', $result); } } diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index ff2be6d2..15c4684b 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,160 +1,225 @@ deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestGroup('test-group@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestGroup('test-group@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); parent::tearDown(); } /** * Test for Entitlement::costsPerDay() */ public function testCostsPerDay(): void { // 500 // 28 days: 17.86 // 31 days: 16.129 $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $user->assignPackage($package); $entitlement = $user->entitlements->where('sku_id', $mailbox->id)->first(); $costsPerDay = $entitlement->costsPerDay(); $this->assertTrue($costsPerDay < 17.86); $this->assertTrue($costsPerDay > 16.12); } /** * Tests for entitlements * @todo This really should be in User or Wallet tests file */ public function testEntitlements(): void { $packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $skuDomain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $owner = $this->getTestUser('entitlement-test@kolabnow.com'); $user = $this->getTestUser('entitled-user@custom-domain.com'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($packageDomain, $owner); $owner->assignPackage($packageKolab); $owner->assignPackage($packageKolab, $user); $wallet = $owner->wallets->first(); $this->assertCount(7, $owner->entitlements()->get()); $this->assertCount(1, $skuDomain->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(2, $skuMailbox->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(15, $wallet->entitlements); $this->backdateEntitlements( $owner->entitlements, Carbon::now()->subMonthsWithoutOverflow(1) ); $wallet->chargeEntitlements(); $this->assertTrue($wallet->fresh()->balance < 0); } /** - * Test Entitlement::entitleableTitle() + * @todo This really should be in User or Wallet tests file + */ + public function testBillDeletedEntitlement(): void + { + $user = $this->getTestUser('entitlement-test@kolabnow.com'); + $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); + $storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); + + $user->assignPackage($package); + // some additional SKUs so we have something to delete. + $user->assignSku($storage, 4); + + // the mailbox, the groupware, the 5 original storage and the additional 4 + $this->assertCount(11, $user->fresh()->entitlements); + + $wallet = $user->wallets()->first(); + + $backdate = Carbon::now()->subWeeks(7); + $this->backdateEntitlements($user->entitlements, $backdate); + + $charge = $wallet->chargeEntitlements(); + + $this->assertSame(-1090, $wallet->balance); + + $balance = $wallet->balance; + $discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first(); + $wallet->discount()->associate($discount); + $wallet->save(); + + $user->removeSku($storage, 4); + + // we expect the wallet to have been charged for ~3 weeks of use of + // 4 deleted storage entitlements, it should also take discount into account + $backdate->addMonthsWithoutOverflow(1); + $diffInDays = $backdate->diffInDays(Carbon::now()); + + // entitlements-num * cost * discount * days-in-month + $max = intval(4 * 25 * 0.7 * $diffInDays / 28); + $min = intval(4 * 25 * 0.7 * $diffInDays / 31); + + $wallet->refresh(); + $this->assertTrue($wallet->balance >= $balance - $max); + $this->assertTrue($wallet->balance <= $balance - $min); + + $transactions = \App\Transaction::where('object_id', $wallet->id) + ->where('object_type', \App\Wallet::class)->get(); + + // one round of the monthly invoicing, four sku deletions getting invoiced + $this->assertCount(5, $transactions); + + // Test that deleting an entitlement on a degraded account costs nothing + $balance = $wallet->balance; + User::where('id', $user->id)->update(['status' => $user->status | User::STATUS_DEGRADED]); + + $backdate = Carbon::now()->subWeeks(7); + $this->backdateEntitlements($user->entitlements()->get(), $backdate); + + $groupware = \App\Sku::withEnvTenantContext()->where('title', 'groupware')->first(); + $entitlement = $wallet->entitlements()->where('sku_id', $groupware->id)->first(); + $entitlement->delete(); + + $this->assertSame($wallet->refresh()->balance, $balance); + } + + /** + * Test Entitlement::entitlementTitle() */ public function testEntitleableTitle(): void { Queue::fake(); $packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user = $this->getTestUser('entitled-user@custom-domain.com'); $group = $this->getTestGroup('test-group@custom-domain.com'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $wallet = $user->wallets->first(); $domain->assignPackage($packageDomain, $user); $user->assignPackage($packageKolab); $group->assignToWallet($wallet); $sku_mailbox = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $sku_group = \App\Sku::withEnvTenantContext()->where('title', 'group')->first(); $sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_mailbox->id)->first(); $this->assertSame($user->email, $entitlement->entitleableTitle()); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_group->id)->first(); $this->assertSame($group->email, $entitlement->entitleableTitle()); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_domain->id)->first(); $this->assertSame($domain->namespace, $entitlement->entitleableTitle()); // Make sure it still works if the entitleable is deleted $domain->delete(); $entitlement->refresh(); $this->assertSame($domain->namespace, $entitlement->entitleableTitle()); $this->assertNotNull($entitlement->entitleable); } } diff --git a/src/tests/Feature/Jobs/WalletCheckTest.php b/src/tests/Feature/Jobs/WalletCheckTest.php index fde45f2d..db8bebee 100644 --- a/src/tests/Feature/Jobs/WalletCheckTest.php +++ b/src/tests/Feature/Jobs/WalletCheckTest.php @@ -1,328 +1,399 @@ getTestUser('ned@kolab.org'); - if ($ned->isSuspended()) { - $ned->status -= User::STATUS_SUSPENDED; - $ned->save(); - } - $this->deleteTestUser('wallet-check@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { - $ned = $this->getTestUser('ned@kolab.org'); - if ($ned->isSuspended()) { - $ned->status -= User::STATUS_SUSPENDED; - $ned->save(); - } - $this->deleteTestUser('wallet-check@kolabnow.com'); parent::tearDown(); } /** * Test job handle, initial negative-balance notification */ public function testHandleInitial(): void { Mail::fake(); - $user = $this->getTestUser('ned@kolab.org'); - $user->setSetting('external_email', 'external@test.com'); - $wallet = $user->wallets()->first(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); - // Balance is not negative, double-update+save for proper resetting of the state - $wallet->balance = -100; - $wallet->save(); $wallet->balance = 0; $wallet->save(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); // Balance is negative now $wallet->balance = -100; $wallet->save(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); // Balance turned negative 2 hours ago, expect mail sent $wallet->setSetting('balance_negative_since', $now->subHours(2)->toDateTimeString()); $wallet->setSetting('balance_warning_initial', null); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, but not to his external email Mail::assertSent(\App\Mail\NegativeBalance::class, 1); Mail::assertSent(\App\Mail\NegativeBalance::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); }); // Run the job again to make sure the notification is not sent again Mail::fake(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); // Test the migration scenario where a negative wallet has no balance_negative_since set yet Mail::fake(); $wallet->setSetting('balance_negative_since', null); $wallet->setSetting('balance_warning_initial', null); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, but not to his external email Mail::assertSent(\App\Mail\NegativeBalance::class, 1); Mail::assertSent(\App\Mail\NegativeBalance::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); }); $wallet->refresh(); $today_regexp = '/' . Carbon::now()->toDateString() . ' [0-9]{2}:[0-9]{2}:[0-9]{2}/'; $this->assertMatchesRegularExpression($today_regexp, $wallet->getSetting('balance_negative_since')); $this->assertMatchesRegularExpression($today_regexp, $wallet->getSetting('balance_warning_initial')); } /** * Test job handle, top-up before reminder notification * * @depends testHandleInitial */ public function testHandleBeforeReminder(): void { Mail::fake(); - $user = $this->getTestUser('ned@kolab.org'); - $wallet = $user->wallets()->first(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7-1 days ago $wallet->setSetting('balance_negative_since', $now->subDays(7 - 1)->toDateTimeString()); $job = new WalletCheck($wallet); $res = $job->handle(); Mail::assertNothingSent(); // TODO: Test that it actually executed the topUpWallet() $this->assertSame(WalletCheck::THRESHOLD_BEFORE_REMINDER, $res); $this->assertFalse($user->fresh()->isSuspended()); } /** * Test job handle, reminder notification * * @depends testHandleBeforeReminder */ public function testHandleReminder(): void { Mail::fake(); - $user = $this->getTestUser('ned@kolab.org'); - $user->setSetting('external_email', 'external@test.com'); - $wallet = $user->wallets()->first(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7+1 days ago, expect mail sent $wallet->setSetting('balance_negative_since', $now->subDays(7 + 1)->toDateTimeString()); $job = new WalletCheck($wallet); $job->handle(); - // Assert the mail was sent to the user's email, but not to his external email - Mail::assertSent(\App\Mail\NegativeBalanceReminder::class, 1); - Mail::assertSent(\App\Mail\NegativeBalanceReminder::class, function ($mail) use ($user) { - return $mail->hasTo($user->email) && !$mail->hasCc('external@test.com'); + // Assert the mail was sent to the user's email and to his external email + Mail::assertSent(\App\Mail\NegativeBalanceReminderDegrade::class, 1); + Mail::assertSent(\App\Mail\NegativeBalanceReminderDegrade::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); }); // Run the job again to make sure the notification is not sent again Mail::fake(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); } /** * Test job handle, top-up wallet before account suspending * * @depends testHandleReminder */ +/* public function testHandleBeforeSuspended(): void { Mail::fake(); - $user = $this->getTestUser('ned@kolab.org'); - $wallet = $user->wallets()->first(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7+14-1 days ago $days = 7 + 14 - 1; $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); $job = new WalletCheck($wallet); $res = $job->handle(); Mail::assertNothingSent(); // TODO: Test that it actually executed the topUpWallet() $this->assertSame(WalletCheck::THRESHOLD_BEFORE_SUSPEND, $res); $this->assertFalse($user->fresh()->isSuspended()); } - +*/ /** * Test job handle, account suspending * * @depends testHandleBeforeSuspended */ +/* public function testHandleSuspended(): void { Mail::fake(); - $user = $this->getTestUser('ned@kolab.org'); - $user->setSetting('external_email', 'external@test.com'); - $wallet = $user->wallets()->first(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7+14+1 days ago, expect mail sent $days = 7 + 14 + 1; $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, but not to his external email Mail::assertSent(\App\Mail\NegativeBalanceSuspended::class, 1); Mail::assertSent(\App\Mail\NegativeBalanceSuspended::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); }); // Check that it has been suspended $this->assertTrue($user->fresh()->isSuspended()); // TODO: Test that group account members/domain are also being suspended - /* - foreach ($wallet->entitlements()->fresh()->get() as $entitlement) { - if ( - $entitlement->entitleable_type == \App\Domain::class - || $entitlement->entitleable_type == \App\User::class - ) { - $this->assertTrue($entitlement->entitleable->isSuspended()); - } - } - */ // Run the job again to make sure the notification is not sent again Mail::fake(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); } - +*/ /** * Test job handle, final warning before delete * * @depends testHandleSuspended */ +/* public function testHandleBeforeDelete(): void { Mail::fake(); - $user = $this->getTestUser('ned@kolab.org'); - $user->setSetting('external_email', 'external@test.com'); - $wallet = $user->wallets()->first(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); // Balance turned negative 7+14+21-3+1 days ago, expect mail sent $days = 7 + 14 + 21 - 3 + 1; $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); $job = new WalletCheck($wallet); $job->handle(); // Assert the mail was sent to the user's email, and his external email Mail::assertSent(\App\Mail\NegativeBalanceBeforeDelete::class, 1); Mail::assertSent(\App\Mail\NegativeBalanceBeforeDelete::class, function ($mail) use ($user) { return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); }); // Check that it has not been deleted yet $this->assertFalse($user->fresh()->isDeleted()); // Run the job again to make sure the notification is not sent again Mail::fake(); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); } - +*/ /** * Test job handle, account delete * * @depends testHandleBeforeDelete */ +/* public function testHandleDelete(): void { Mail::fake(); - $user = $this->getTestUser('wallet-check@kolabnow.com'); - $wallet = $user->wallets()->first(); - $wallet->balance = -100; - $wallet->save(); + $user = $this->prepareTestUser($wallet); $now = Carbon::now(); - $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); - $user->assignPackage($package); - $this->assertFalse($user->isDeleted()); $this->assertCount(7, $user->entitlements()->get()); // Balance turned negative 7+14+21+1 days ago, expect mail sent $days = 7 + 14 + 21 + 1; $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); $job = new WalletCheck($wallet); $job->handle(); Mail::assertNothingSent(); // Check that it has not been deleted $this->assertTrue($user->fresh()->trashed()); $this->assertCount(0, $user->entitlements()->get()); // TODO: Test it deletes all members of the group account } +*/ + + /** + * Test job handle, account degrade + * + * @depends testHandleReminder + */ + public function testHandleDegrade(): void + { + Mail::fake(); + + $user = $this->prepareTestUser($wallet); + $now = Carbon::now(); + + $this->assertFalse($user->isDegraded()); + + // Balance turned negative 7+7+1 days ago, expect mail sent + $days = 7 + 7 + 1; + $wallet->setSetting('balance_negative_since', $now->subDays($days)->toDateTimeString()); + + $job = new WalletCheck($wallet); + $job->handle(); + + // Assert the mail was sent to the user's email, and his external email + Mail::assertSent(\App\Mail\NegativeBalanceDegraded::class, 1); + Mail::assertSent(\App\Mail\NegativeBalanceDegraded::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); + }); + + // Check that it has been degraded + $this->assertTrue($user->fresh()->isDegraded()); + } + + /** + * Test job handle, periodic reminder to a degraded account + * + * @depends testHandleDegrade + */ + public function testHandleDegradeReminder(): void + { + Mail::fake(); + + $user = $this->prepareTestUser($wallet); + $user->update(['status' => $user->status | User::STATUS_DEGRADED]); + $now = Carbon::now(); + + $this->assertTrue($user->isDegraded()); + + // Test degraded_last_reminder not set + $wallet->setSetting('degraded_last_reminder', null); + + $job = new WalletCheck($wallet); + $res = $job->handle(); + + Mail::assertNothingSent(); + + $_last = Wallet::find($wallet->id)->getSetting('degraded_last_reminder'); + $this->assertSame(Carbon::now()->toDateTimeString(), $_last); + $this->assertSame(WalletCheck::THRESHOLD_DEGRADE_REMINDER, $res); + + // Test degraded_last_reminder set, but 14 days didn't pass yet + $last = $now->copy()->subDays(10); + $wallet->setSetting('degraded_last_reminder', $last->toDateTimeString()); + + $job = new WalletCheck($wallet); + $res = $job->handle(); + + Mail::assertNothingSent(); + + $_last = $wallet->fresh()->getSetting('degraded_last_reminder'); + $this->assertSame(WalletCheck::THRESHOLD_DEGRADE_REMINDER, $res); + $this->assertSame($last->toDateTimeString(), $_last); + + // Test degraded_last_reminder set, and 14 days passed + $wallet->setSetting('degraded_last_reminder', $now->copy()->subDays(14)->setSeconds(0)); + + $job = new WalletCheck($wallet); + $res = $job->handle(); + + // Assert the mail was sent to the user's email, and his external email + Mail::assertSent(\App\Mail\DegradedAccountReminder::class, 1); + Mail::assertSent(\App\Mail\DegradedAccountReminder::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) && $mail->hasCc('external@test.com'); + }); + + $_last = $wallet->fresh()->getSetting('degraded_last_reminder'); + $this->assertSame(Carbon::now()->toDateTimeString(), $_last); + $this->assertSame(WalletCheck::THRESHOLD_DEGRADE_REMINDER, $res); + } + + /** + * A helper to prepare a user for tests + */ + private function prepareTestUser(&$wallet) + { + $user = $this->getTestUser('wallet-check@kolabnow.com'); + $user->setSetting('external_email', 'external@test.com'); + $wallet = $user->wallets()->first(); + + $package = \App\Package::withObjectTenantContext($user)->where('title', 'kolab')->first(); + $user->assignPackage($package); + + $wallet->balance = -100; + $wallet->save(); + + return $user; + } } diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php index a4c52dac..98538b3b 100644 --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -1,1106 +1,1186 @@ deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); } public function tearDown(): void { \App\TenantSetting::truncate(); $this->deleteTestUser('user-test@' . \config('app.domain')); $this->deleteTestUser('UserAccountA@UserAccount.com'); $this->deleteTestUser('UserAccountB@UserAccount.com'); $this->deleteTestUser('UserAccountC@UserAccount.com'); $this->deleteTestGroup('test-group@UserAccount.com'); $this->deleteTestResource('test-resource@UserAccount.com'); $this->deleteTestSharedFolder('test-folder@UserAccount.com'); $this->deleteTestDomain('UserAccount.com'); $this->deleteTestDomain('UserAccountAdd.com'); parent::tearDown(); } /** * Tests for User::assignPackage() */ public function testAssignPackage(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $sku = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $entitlement = \App\Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku->id)->first(); $this->assertNotNull($entitlement); $this->assertSame($sku->id, $entitlement->sku->id); $this->assertSame($wallet->id, $entitlement->wallet->id); $this->assertEquals($user->id, $entitlement->entitleable->id); $this->assertTrue($entitlement->entitleable instanceof \App\User); $this->assertCount(7, $user->entitlements()->get()); } /** * Tests for User::assignPlan() */ public function testAssignPlan(): void { $this->markTestIncomplete(); } /** * Tests for User::assignSku() */ public function testAssignSku(): void { $this->markTestIncomplete(); } /** * Verify a wallet assigned a controller is among the accounts of the assignee. */ public function testAccounts(): void { $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $this->assertTrue($userA->wallets()->count() == 1); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertTrue($userB->accounts()->get()[0]->id === $userA->wallets()->get()[0]->id); } public function testCanDelete(): void { $this->markTestIncomplete(); } /** * Test User::canRead() method */ public function testCanRead(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canRead($admin)); $this->assertTrue($admin->canRead($john)); $this->assertTrue($admin->canRead($jack)); $this->assertTrue($admin->canRead($reseller1)); $this->assertTrue($admin->canRead($reseller2)); $this->assertTrue($admin->canRead($domain)); $this->assertTrue($admin->canRead($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canRead($john)); $this->assertTrue($reseller1->canRead($jack)); $this->assertTrue($reseller1->canRead($reseller1)); $this->assertTrue($reseller1->canRead($domain)); $this->assertTrue($reseller1->canRead($domain->wallet())); $this->assertFalse($reseller1->canRead($reseller2)); $this->assertFalse($reseller1->canRead($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canRead($reseller2)); $this->assertFalse($reseller2->canRead($john)); $this->assertFalse($reseller2->canRead($jack)); $this->assertFalse($reseller2->canRead($reseller1)); $this->assertFalse($reseller2->canRead($domain)); $this->assertFalse($reseller2->canRead($domain->wallet())); $this->assertFalse($reseller2->canRead($admin)); // Normal user - account owner $this->assertTrue($john->canRead($john)); $this->assertTrue($john->canRead($ned)); $this->assertTrue($john->canRead($jack)); $this->assertTrue($john->canRead($domain)); $this->assertTrue($john->canRead($domain->wallet())); $this->assertFalse($john->canRead($reseller1)); $this->assertFalse($john->canRead($reseller2)); $this->assertFalse($john->canRead($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canRead($jack)); $this->assertFalse($jack->canRead($john)); $this->assertFalse($jack->canRead($domain)); $this->assertFalse($jack->canRead($domain->wallet())); $this->assertFalse($jack->canRead($reseller1)); $this->assertFalse($jack->canRead($reseller2)); $this->assertFalse($jack->canRead($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canRead($ned)); $this->assertTrue($ned->canRead($john)); $this->assertTrue($ned->canRead($jack)); $this->assertTrue($ned->canRead($domain)); $this->assertTrue($ned->canRead($domain->wallet())); $this->assertFalse($ned->canRead($reseller1)); $this->assertFalse($ned->canRead($reseller2)); $this->assertFalse($ned->canRead($admin)); } /** * Test User::canUpdate() method */ public function testCanUpdate(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $reseller1 = $this->getTestUser('reseller@' . \config('app.domain')); $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $domain = $this->getTestDomain('kolab.org'); // Admin $this->assertTrue($admin->canUpdate($admin)); $this->assertTrue($admin->canUpdate($john)); $this->assertTrue($admin->canUpdate($jack)); $this->assertTrue($admin->canUpdate($reseller1)); $this->assertTrue($admin->canUpdate($reseller2)); $this->assertTrue($admin->canUpdate($domain)); $this->assertTrue($admin->canUpdate($domain->wallet())); // Reseller - kolabnow $this->assertTrue($reseller1->canUpdate($john)); $this->assertTrue($reseller1->canUpdate($jack)); $this->assertTrue($reseller1->canUpdate($reseller1)); $this->assertTrue($reseller1->canUpdate($domain)); $this->assertTrue($reseller1->canUpdate($domain->wallet())); $this->assertFalse($reseller1->canUpdate($reseller2)); $this->assertFalse($reseller1->canUpdate($admin)); // Reseller - different tenant $this->assertTrue($reseller2->canUpdate($reseller2)); $this->assertFalse($reseller2->canUpdate($john)); $this->assertFalse($reseller2->canUpdate($jack)); $this->assertFalse($reseller2->canUpdate($reseller1)); $this->assertFalse($reseller2->canUpdate($domain)); $this->assertFalse($reseller2->canUpdate($domain->wallet())); $this->assertFalse($reseller2->canUpdate($admin)); // Normal user - account owner $this->assertTrue($john->canUpdate($john)); $this->assertTrue($john->canUpdate($ned)); $this->assertTrue($john->canUpdate($jack)); $this->assertTrue($john->canUpdate($domain)); $this->assertFalse($john->canUpdate($domain->wallet())); $this->assertFalse($john->canUpdate($reseller1)); $this->assertFalse($john->canUpdate($reseller2)); $this->assertFalse($john->canUpdate($admin)); // Normal user - a non-owner and non-controller $this->assertTrue($jack->canUpdate($jack)); $this->assertFalse($jack->canUpdate($john)); $this->assertFalse($jack->canUpdate($domain)); $this->assertFalse($jack->canUpdate($domain->wallet())); $this->assertFalse($jack->canUpdate($reseller1)); $this->assertFalse($jack->canUpdate($reseller2)); $this->assertFalse($jack->canUpdate($admin)); // Normal user - John's wallet controller $this->assertTrue($ned->canUpdate($ned)); $this->assertTrue($ned->canUpdate($john)); $this->assertTrue($ned->canUpdate($jack)); $this->assertTrue($ned->canUpdate($domain)); $this->assertFalse($ned->canUpdate($domain->wallet())); $this->assertFalse($ned->canUpdate($reseller1)); $this->assertFalse($ned->canUpdate($reseller2)); $this->assertFalse($ned->canUpdate($admin)); } /** * Test user create/creating observer */ public function testCreate(): void { Queue::fake(); $domain = \config('app.domain'); $user = User::create(['email' => 'USER-test@' . \strtoupper($domain)]); $result = User::where('email', 'user-test@' . $domain)->first(); $this->assertSame('user-test@' . $domain, $result->email); $this->assertSame($user->id, $result->id); $this->assertSame(User::STATUS_NEW | User::STATUS_ACTIVE, $result->status); } /** * Verify user creation process */ public function testCreateJobs(): void { Queue::fake(); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 0); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); /* FIXME: Looks like we can't really do detailed assertions on chained jobs Another thing to consider is if we maybe should run these jobs independently (not chained) and make sure there's no race-condition in status update Queue::assertPushed(\App\Jobs\User\VerifyJob::class, 1); Queue::assertPushed(\App\Jobs\User\VerifyJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; }); */ } /** * Verify user creation process invokes the PGP keys creation job (if configured) */ public function testCreatePGPJob(): void { Queue::fake(); \App\Tenant::find(\config('app.tenant_id'))->setSetting('pgp.enable', 1); $user = User::create([ 'email' => 'user-test@' . \config('app.domain') ]); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyCreateJob::class, function ($job) use ($user) { $userEmail = TestCase::getObjectProperty($job, 'userEmail'); $userId = TestCase::getObjectProperty($job, 'userId'); return $userEmail === $user->email && $userId === $user->id; } ); } /** * Tests for User::domains() */ public function testDomains(): void { $user = $this->getTestUser('john@kolab.org'); $domain = $this->getTestDomain('useraccount.com', [ 'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE, 'type' => Domain::TYPE_PUBLIC, ]); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertContains('kolab.org', $domains); // Jack is not the wallet controller, so for him the list should not // include John's domains, kolab.org specifically $user = $this->getTestUser('jack@kolab.org'); $domains = $user->domains()->pluck('namespace')->all(); $this->assertContains($domain->namespace, $domains); $this->assertNotContains('kolab.org', $domains); // Public domains of other tenants should not be returned $tenant = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->first(); $domain->tenant_id = $tenant->id; $domain->save(); $domains = $user->domains()->pluck('namespace')->all(); $this->assertNotContains($domain->namespace, $domains); } /** * Test User::getConfig() and setConfig() methods */ public function testConfigTrait(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSetting('greylist_enabled', null); $this->assertSame(['greylist_enabled' => true], $john->getConfig()); $result = $john->setConfig(['greylist_enabled' => false, 'unknown' => false]); $this->assertSame(['greylist_enabled' => false], $john->getConfig()); $this->assertSame('false', $john->getSetting('greylist_enabled')); $result = $john->setConfig(['greylist_enabled' => true]); $this->assertSame(['greylist_enabled' => true], $john->getConfig()); $this->assertSame('true', $john->getSetting('greylist_enabled')); } /** - * Test User::hasSku() method + * Test user account degradation and un-degradation */ - public function testHasSku(): void + public function testDegradeAndUndegrade(): void { - $john = $this->getTestUser('john@kolab.org'); + Queue::fake(); - $this->assertTrue($john->hasSku('mailbox')); - $this->assertTrue($john->hasSku('storage')); - $this->assertFalse($john->hasSku('beta')); - $this->assertFalse($john->hasSku('unknown')); - } + // Test an account with users, domain + $userA = $this->getTestUser('UserAccountA@UserAccount.com'); + $userB = $this->getTestUser('UserAccountB@UserAccount.com'); + $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); + $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); + $domain = $this->getTestDomain('UserAccount.com', [ + 'status' => Domain::STATUS_NEW, + 'type' => Domain::TYPE_HOSTED, + ]); + $userA->assignPackage($package_kolab); + $domain->assignPackage($package_domain, $userA); + $userA->assignPackage($package_kolab, $userB); - public function testUserQuota(): void - { - // TODO: This test does not test much, probably could be removed - // or moved to somewhere else, or extended with - // other entitlements() related cases. + $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); + $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); + $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); - $user = $this->getTestUser('john@kolab.org'); - $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); + $yesterday = Carbon::now()->subDays(1); - $count = 0; + $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); + $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); - foreach ($user->entitlements()->get() as $entitlement) { - if ($entitlement->sku_id == $storage_sku->id) { - $count += 1; - } - } + $wallet = $userA->wallets->first(); + + $this->assertSame(7, $entitlementsA->count()); + $this->assertSame(7, $entitlementsB->count()); + $this->assertSame(7, $entitlementsA->whereDate('updated_at', $yesterday->toDateString())->count()); + $this->assertSame(7, $entitlementsB->whereDate('updated_at', $yesterday->toDateString())->count()); + $this->assertSame(0, $wallet->balance); + + Queue::fake(); // reset queue state - $this->assertTrue($count == 5); + // Degrade the account/wallet owner + $userA->degrade(); + + $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); + $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); + + $this->assertTrue($userA->fresh()->isDegraded()); + $this->assertTrue($userA->fresh()->isDegraded(true)); + $this->assertFalse($userB->fresh()->isDegraded()); + $this->assertTrue($userB->fresh()->isDegraded(true)); + + $balance = $wallet->fresh()->balance; + $this->assertTrue($balance <= -64); + $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); + $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); + + // Expect one update job for every user + // @phpstan-ignore-next-line + $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { + return TestCase::getObjectProperty($job, 'userId'); + })->all(); + + $this->assertSame([$userA->id, $userB->id], $userIds); + + // Un-Degrade the account/wallet owner + + $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); + $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); + + $yesterday = Carbon::now()->subDays(1); + + $this->backdateEntitlements($entitlementsA->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); + $this->backdateEntitlements($entitlementsB->get(), $yesterday, Carbon::now()->subMonthsWithoutOverflow(1)); + + Queue::fake(); // reset queue state + + $userA->undegrade(); + + $this->assertFalse($userA->fresh()->isDegraded()); + $this->assertFalse($userA->fresh()->isDegraded(true)); + $this->assertFalse($userB->fresh()->isDegraded()); + $this->assertFalse($userB->fresh()->isDegraded(true)); + + // Expect no balance change, degraded account entitlements are free + $this->assertSame($balance, $wallet->fresh()->balance); + $this->assertSame(7, $entitlementsA->whereDate('updated_at', Carbon::now()->toDateString())->count()); + $this->assertSame(7, $entitlementsB->whereDate('updated_at', Carbon::now()->toDateString())->count()); + + // Expect one update job for every user + // @phpstan-ignore-next-line + $userIds = Queue::pushed(\App\Jobs\User\UpdateJob::class)->map(function ($job) { + return TestCase::getObjectProperty($job, 'userId'); + })->all(); + + $this->assertSame([$userA->id, $userB->id], $userIds); } /** * Test user deletion */ public function testDelete(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $id = $user->id; $this->assertCount(7, $user->entitlements()->get()); $user->delete(); $this->assertCount(0, $user->entitlements()->get()); $this->assertTrue($user->fresh()->trashed()); $this->assertFalse($user->fresh()->isDeleted()); // Delete the user for real $job = new \App\Jobs\User\DeleteJob($id); $job->handle(); $this->assertTrue(User::withTrashed()->where('id', $id)->first()->isDeleted()); $user->forceDelete(); $this->assertCount(0, User::withTrashed()->where('id', $id)->get()); // Test an account with users, domain, and group, and resource $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userC = $this->getTestUser('UserAccountC@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domain->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $userA->assignPackage($package_kolab, $userC); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->assignToWallet($userA->wallets->first()); $resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']); $resource->assignToWallet($userA->wallets->first()); $folder = $this->getTestSharedFolder('test-folder@UserAccount.com', ['name' => 'test']); $folder->assignToWallet($userA->wallets->first()); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id); $entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id); $entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id); $entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id); $this->assertSame(7, $entitlementsA->count()); $this->assertSame(7, $entitlementsB->count()); $this->assertSame(7, $entitlementsC->count()); $this->assertSame(1, $entitlementsDomain->count()); $this->assertSame(1, $entitlementsGroup->count()); $this->assertSame(1, $entitlementsResource->count()); $this->assertSame(1, $entitlementsFolder->count()); // Delete non-controller user $userC->delete(); $this->assertTrue($userC->fresh()->trashed()); $this->assertFalse($userC->fresh()->isDeleted()); $this->assertSame(0, $entitlementsC->count()); // Delete the controller (and expect "sub"-users to be deleted too) $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertSame(0, $entitlementsGroup->count()); $this->assertSame(0, $entitlementsResource->count()); $this->assertSame(0, $entitlementsFolder->count()); $this->assertSame(7, $entitlementsA->withTrashed()->count()); $this->assertSame(7, $entitlementsB->withTrashed()->count()); $this->assertSame(7, $entitlementsC->withTrashed()->count()); $this->assertSame(1, $entitlementsDomain->withTrashed()->count()); $this->assertSame(1, $entitlementsGroup->withTrashed()->count()); $this->assertSame(1, $entitlementsResource->withTrashed()->count()); $this->assertSame(1, $entitlementsFolder->withTrashed()->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domain->fresh()->trashed()); $this->assertTrue($group->fresh()->trashed()); $this->assertTrue($resource->fresh()->trashed()); $this->assertTrue($folder->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domain->isDeleted()); $this->assertFalse($group->isDeleted()); $this->assertFalse($resource->isDeleted()); $this->assertFalse($folder->isDeleted()); $userA->forceDelete(); $all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id); $transactions = \App\Transaction::where('object_id', $userA->wallets->first()->id); $this->assertSame(0, $all_entitlements->withTrashed()->count()); $this->assertSame(0, $transactions->count()); $this->assertCount(0, User::withTrashed()->where('id', $userA->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userB->id)->get()); $this->assertCount(0, User::withTrashed()->where('id', $userC->id)->get()); $this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get()); $this->assertCount(0, Group::withTrashed()->where('id', $group->id)->get()); $this->assertCount(0, \App\Resource::withTrashed()->where('id', $resource->id)->get()); $this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get()); } /** * Test user deletion vs. group membership */ public function testDeleteAndGroups(): void { Queue::fake(); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $userA = $this->getTestUser('UserAccountA@UserAccount.com'); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $userA->assignPackage($package_kolab, $userB); $group = $this->getTestGroup('test-group@UserAccount.com'); $group->members = ['test@gmail.com', $userB->email]; $group->assignToWallet($userA->wallets->first()); $group->save(); Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1); $userGroups = $userA->groups()->get(); $this->assertSame(1, $userGroups->count()); $this->assertSame($group->id, $userGroups->first()->id); $userB->delete(); $this->assertSame(['test@gmail.com'], $group->fresh()->members); // Twice, one for save() and one for delete() above Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); } /** * Test handling negative balance on user deletion */ public function testDeleteWithNegativeBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = -1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); \App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); $user->delete(); $reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertSame(-1000, $reseller_wallet->fresh()->balance); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Deleted user {$user->email}", $trans->description); $this->assertSame(-1000, $trans->amount); $this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type); } /** * Test handling positive balance on user deletion */ public function testDeleteWithPositiveBalance(): void { $user = $this->getTestUser('user-test@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->balance = 1000; $wallet->save(); $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); $user->delete(); $this->assertSame(0, $reseller_wallet->fresh()->balance); } /** * Test user deletion with PGP/WOAT enabled */ public function testDeleteWithPGP(): void { Queue::fake(); // Test with PGP disabled $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 0); $user->delete(); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 0); // Test with PGP enabled $this->deleteTestUser('user-test@' . \config('app.domain')); $user = $this->getTestUser('user-test@' . \config('app.domain')); $user->tenant->setSetting('pgp.enable', 1); $user->delete(); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === $user->email; } ); } /** * Tests for User::aliasExists() */ public function testAliasExists(): void { $this->assertTrue(User::aliasExists('jack.daniels@kolab.org')); $this->assertFalse(User::aliasExists('j.daniels@kolab.org')); $this->assertFalse(User::aliasExists('john@kolab.org')); } /** * Tests for User::emailExists() */ public function testEmailExists(): void { $this->assertFalse(User::emailExists('jack.daniels@kolab.org')); $this->assertFalse(User::emailExists('j.daniels@kolab.org')); $this->assertTrue(User::emailExists('john@kolab.org')); $user = User::emailExists('john@kolab.org', true); $this->assertSame('john@kolab.org', $user->email); } /** * Tests for User::findByEmail() */ public function testFindByEmail(): void { $user = $this->getTestUser('john@kolab.org'); $result = User::findByEmail('john'); $this->assertNull($result); $result = User::findByEmail('non-existing@email.com'); $this->assertNull($result); $result = User::findByEmail('john@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); // Use an alias $result = User::findByEmail('john.doe@kolab.org'); $this->assertInstanceOf(User::class, $result); $this->assertSame($user->id, $result->id); Queue::fake(); // A case where two users have the same alias $ned = $this->getTestUser('ned@kolab.org'); $ned->setAliases(['joe.monster@kolab.org']); $result = User::findByEmail('joe.monster@kolab.org'); $this->assertNull($result); $ned->setAliases([]); // TODO: searching by external email (setting) $this->markTestIncomplete(); } + /** + * Test User::hasSku() method + */ + public function testHasSku(): void + { + $john = $this->getTestUser('john@kolab.org'); + + $this->assertTrue($john->hasSku('mailbox')); + $this->assertTrue($john->hasSku('storage')); + $this->assertFalse($john->hasSku('beta')); + $this->assertFalse($john->hasSku('unknown')); + } + /** * Test User::name() */ public function testName(): void { Queue::fake(); $user = $this->getTestUser('user-test@' . \config('app.domain')); $this->assertSame('', $user->name()); $this->assertSame($user->tenant->title . ' User', $user->name(true)); $user->setSetting('first_name', 'First'); $this->assertSame('First', $user->name()); $this->assertSame('First', $user->name(true)); $user->setSetting('last_name', 'Last'); $this->assertSame('First Last', $user->name()); $this->assertSame('First Last', $user->name(true)); } /** * Test resources() method */ public function testResources(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $resources = $john->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $ned->resources()->orderBy('email')->get(); $this->assertSame(2, $resources->count()); $this->assertSame('resource-test1@kolab.org', $resources[0]->email); $this->assertSame('resource-test2@kolab.org', $resources[1]->email); $resources = $jack->resources()->get(); $this->assertSame(0, $resources->count()); } /** * Test sharedFolders() method */ public function testSharedFolders(): void { $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $folders = $john->sharedFolders()->orderBy('email')->get(); $this->assertSame(2, $folders->count()); $this->assertSame('folder-contact@kolab.org', $folders[0]->email); $this->assertSame('folder-event@kolab.org', $folders[1]->email); $folders = $ned->sharedFolders()->orderBy('email')->get(); $this->assertSame(2, $folders->count()); $this->assertSame('folder-contact@kolab.org', $folders[0]->email); $this->assertSame('folder-event@kolab.org', $folders[1]->email); $folders = $jack->sharedFolders()->get(); $this->assertSame(0, $folders->count()); } /** * Test user restoring */ public function testRestore(): void { Queue::fake(); // Test an account with users and domain $userA = $this->getTestUser('UserAccountA@UserAccount.com', [ 'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED, ]); $userB = $this->getTestUser('UserAccountB@UserAccount.com'); $package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); $package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $domainA = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $domainB = $this->getTestDomain('UserAccountAdd.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $userA->assignPackage($package_kolab); $domainA->assignPackage($package_domain, $userA); $domainB->assignPackage($package_domain, $userA); $userA->assignPackage($package_kolab, $userB); $storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $now = \Carbon\Carbon::now(); $wallet_id = $userA->wallets->first()->id; // add an extra storage entitlement $ent1 = \App\Entitlement::create([ 'wallet_id' => $wallet_id, 'sku_id' => $storage_sku->id, 'cost' => 0, 'entitleable_id' => $userA->id, 'entitleable_type' => User::class, ]); $entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id); $entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id); $entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id); // First delete the user $userA->delete(); $this->assertSame(0, $entitlementsA->count()); $this->assertSame(0, $entitlementsB->count()); $this->assertSame(0, $entitlementsDomain->count()); $this->assertTrue($userA->fresh()->trashed()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainA->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userB->isDeleted()); $this->assertFalse($domainA->isDeleted()); // Backdate one storage entitlement (it's not expected to be restored) \App\Entitlement::withTrashed()->where('id', $ent1->id) ->update(['deleted_at' => $now->copy()->subMinutes(2)]); // Backdate entitlements to assert that they were restored with proper updated_at timestamp \App\Entitlement::withTrashed()->where('wallet_id', $wallet_id) ->update(['updated_at' => $now->subMinutes(10)]); Queue::fake(); // Then restore it $userA->restore(); $userA->refresh(); $this->assertFalse($userA->trashed()); $this->assertFalse($userA->isDeleted()); $this->assertFalse($userA->isSuspended()); $this->assertFalse($userA->isLdapReady()); $this->assertFalse($userA->isImapReady()); $this->assertTrue($userA->isActive()); $this->assertTrue($userB->fresh()->trashed()); $this->assertTrue($domainB->fresh()->trashed()); $this->assertFalse($domainA->fresh()->trashed()); // Assert entitlements $this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage $this->assertTrue($ent1->fresh()->trashed()); $entitlementsA->get()->each(function ($ent) { $this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5))); }); // We expect only CreateJob + UpdateJob pair for both user and domain. // Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method // is implemented we cannot skip the UpdateJob in any way. // I don't want to overwrite this method, the extra job shouldn't do any harm. $this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1); Queue::assertPushed( \App\Jobs\User\CreateJob::class, function ($job) use ($userA) { return $userA->id === TestCase::getObjectProperty($job, 'userId'); } ); Queue::assertPushedWithChain( \App\Jobs\User\CreateJob::class, [ \App\Jobs\User\VerifyJob::class, ] ); } /** * Tests for UserAliasesTrait::setAliases() */ public function testSetAliases(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); $domain = $this->getTestDomain('UserAccount.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_HOSTED, ]); $this->assertCount(0, $user->aliases->all()); $user->tenant->setSetting('pgp.enable', 1); // Add an alias $user->setAliases(['UserAlias1@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $user->tenant->setSetting('pgp.enable', 0); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Add another alias $user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]->alias); $this->assertSame('useralias2@useraccount.com', $aliases[1]->alias); $user->tenant->setSetting('pgp.enable', 1); // Remove an alias $user->setAliases(['UserAlias1@UserAccount.com']); $user->tenant->setSetting('pgp.enable', 0); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1); Queue::assertPushed( \App\Jobs\PGP\KeyDeleteJob::class, function ($job) use ($user) { $userId = TestCase::getObjectProperty($job, 'userId'); $userEmail = TestCase::getObjectProperty($job, 'userEmail'); return $userId == $user->id && $userEmail === 'useralias2@useraccount.com'; } ); $aliases = $user->aliases()->get(); $this->assertCount(1, $aliases); $this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']); // Remove all aliases $user->setAliases([]); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4); $this->assertCount(0, $user->aliases()->get()); } /** * Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings() */ public function testUserSettings(): void { Queue::fake(); Queue::assertNothingPushed(); $user = $this->getTestUser('UserAccountA@UserAccount.com'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); // Test default settings // Note: Technicly this tests UserObserver::created() behavior $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(2, $all_settings); $this->assertSame('country', $all_settings[0]->key); $this->assertSame('CH', $all_settings[0]->value); $this->assertSame('currency', $all_settings[1]->key); $this->assertSame('CHF', $all_settings[1]->value); // Add a setting $user->setSetting('first_name', 'Firstname'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname', $user->getSetting('first_name')); $this->assertSame('Firstname', $user->fresh()->getSetting('first_name')); // Update a setting $user->setSetting('first_name', 'Firstname1'); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname1', $user->getSetting('first_name')); $this->assertSame('Firstname1', $user->fresh()->getSetting('first_name')); // Delete a setting (null) $user->setSetting('first_name', null); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Delete a setting (empty string) $user->setSetting('first_name', 'Firstname1'); $user->setSetting('first_name', ''); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame(null, $user->getSetting('first_name')); $this->assertSame(null, $user->fresh()->getSetting('first_name')); // Set multiple settings at once $user->setSettings([ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'country' => null, ]); // TODO: This really should create a single UserUpdate job, not 3 Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7); // Note: We test both current user as well as fresh user object // to make sure cache works as expected $this->assertSame('Firstname2', $user->getSetting('first_name')); $this->assertSame('Firstname2', $user->fresh()->getSetting('first_name')); $this->assertSame('Lastname2', $user->getSetting('last_name')); $this->assertSame('Lastname2', $user->fresh()->getSetting('last_name')); $this->assertSame(null, $user->getSetting('country')); $this->assertSame(null, $user->fresh()->getSetting('country')); $all_settings = $user->settings()->orderBy('key')->get(); $this->assertCount(3, $all_settings); // Test getSettings() method $this->assertSame( [ 'first_name' => 'Firstname2', 'last_name' => 'Lastname2', 'unknown' => null, ], $user->getSettings(['first_name', 'last_name', 'unknown']) ); } /** * Tests for User::users() */ public function testUsers(): void { $jack = $this->getTestUser('jack@kolab.org'); $joe = $this->getTestUser('joe@kolab.org'); $john = $this->getTestUser('john@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $john->wallets()->first(); $users = $john->users()->orderBy('email')->get(); $this->assertCount(4, $users); $this->assertEquals($jack->id, $users[0]->id); $this->assertEquals($joe->id, $users[1]->id); $this->assertEquals($john->id, $users[2]->id); $this->assertEquals($ned->id, $users[3]->id); $users = $jack->users()->orderBy('email')->get(); $this->assertCount(0, $users); $users = $ned->users()->orderBy('email')->get(); $this->assertCount(4, $users); } + /** + * Tests for User::wallets() + */ public function testWallets(): void { $this->markTestIncomplete(); } } diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php index f77ec8cc..5a3f2542 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,415 +1,431 @@ users as $user) { $this->deleteTestUser($user); } } public function tearDown(): void { foreach ($this->users as $user) { $this->deleteTestUser($user); } Sku::select()->update(['fee' => 0]); parent::tearDown(); } /** * Test that turning wallet balance from negative to positive - * unsuspends the account + * unsuspends and undegrades the account */ - public function testBalancePositiveUnsuspend(): void + public function testBalanceTurnsPositive(): void { + Queue::fake(); + $user = $this->getTestUser('UserWallet1@UserWallet.com'); $user->suspend(); + $user->degrade(); $wallet = $user->wallets()->first(); $wallet->balance = -100; $wallet->save(); $this->assertTrue($user->isSuspended()); + $this->assertTrue($user->isDegraded()); $this->assertNotNull($wallet->getSetting('balance_negative_since')); $wallet->balance = 100; $wallet->save(); - $this->assertFalse($user->fresh()->isSuspended()); + $user->refresh(); + + $this->assertFalse($user->isSuspended()); + $this->assertFalse($user->isDegraded()); $this->assertNull($wallet->getSetting('balance_negative_since')); - // TODO: Test group account and unsuspending domain/members + // TODO: Test group account and unsuspending domain/members/groups } /** * Test for Wallet::balanceLastsUntil() */ public function testBalanceLastsUntil(): void { // Monthly cost of all entitlements: 990 // 28 days: 35.36 per day // 31 days: 31.93 per day $user = $this->getTestUser('jane@kolabnow.com'); $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); // User/entitlements created today, balance=0 $until = $wallet->balanceLastsUntil(); $this->assertSame( Carbon::now()->addMonthsWithoutOverflow(1)->toDateString(), $until->toDateString() ); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $until = $wallet->balanceLastsUntil(); $this->assertSame(null, $until); // User/entitlements created today, balance=-9,99 CHF (monthly cost) $wallet->balance = 990; $until = $wallet->balanceLastsUntil(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $delta = Carbon::now()->addMonthsWithoutOverflow(1)->addDays($daysInLastMonth)->diff($until)->days; $this->assertTrue($delta <= 1); $this->assertTrue($delta >= -1); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::withEnvTenantContext()->where('discount', 100)->first(); $wallet->discount()->associate($discount); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); // User with no entitlements $wallet->discount()->dissociate($discount); $wallet->entitlements()->delete(); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); } /** * Test for Wallet::costsPerDay() */ public function testCostsPerDay(): void { // 990 // 28 days: 35.36 // 31 days: 31.93 $user = $this->getTestUser('jane@kolabnow.com'); $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $costsPerDay = $wallet->costsPerDay(); $this->assertTrue($costsPerDay < 35.38); $this->assertTrue($costsPerDay > 31.93); } /** * Verify a wallet is created, when a user is created. */ public function testCreateUserCreatesWallet(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $this->assertCount(1, $user->wallets); $this->assertSame(\config('app.currency'), $user->wallets[0]->currency); $this->assertSame(0, $user->wallets[0]->balance); } /** * Verify a user can haz more wallets. */ public function testAddWallet(): void { $user = $this->getTestUser('UserWallet2@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $this->assertCount(2, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertEquals(0, $wallet->balance); } ); // For now all wallets use system currency $this->assertFalse($user->wallets()->where('currency', 'USD')->exists()); } /** * Verify we can not delete a user wallet that holds balance. */ public function testDeleteWalletWithCredit(): void { $user = $this->getTestUser('UserWallet3@UserWallet.com'); $user->wallets()->each( function ($wallet) { $wallet->credit(100)->save(); } ); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can not delete a wallet that is the last wallet. */ public function testDeleteLastWallet(): void { $user = $this->getTestUser('UserWallet4@UserWallet.com'); $this->assertCount(1, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can remove a wallet that is an additional wallet. */ public function testDeleteAddtWallet(): void { $user = $this->getTestUser('UserWallet5@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); // For now additional wallets with a different currency is not allowed $this->assertFalse($user->wallets()->where('currency', 'USD')->exists()); /* $user->wallets()->each( function ($wallet) { if ($wallet->currency == 'USD') { $this->assertNotFalse($wallet->delete()); } } ); */ } /** * Verify a wallet can be assigned a controller. */ public function testAddWalletController(): void { $userA = $this->getTestUser('WalletControllerA@WalletController.com'); $userB = $this->getTestUser('WalletControllerB@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertCount(1, $userB->accounts); $aWallet = $userA->wallets()->first(); $bAccount = $userB->accounts()->first(); $this->assertTrue($bAccount->id === $aWallet->id); } /** * Verify controllers can also be removed from wallets. */ public function testRemoveWalletController(): void { $userA = $this->getTestUser('WalletController2A@WalletController.com'); $userB = $this->getTestUser('WalletController2B@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $userB->refresh(); $userB->accounts()->each( function ($wallet) use ($userB) { $wallet->removeController($userB); } ); $this->assertCount(0, $userB->accounts); } /** * Test for charging and removing entitlements (including tenant commission calculations) */ public function testChargeAndDeleteEntitlements(): void { $user = $this->getTestUser('jane@kolabnow.com'); $wallet = $user->wallets()->first(); $discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first(); $wallet->discount()->associate($discount); $wallet->save(); // Add 40% fee to all SKUs Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]); $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $user->assignPackage($package); $user->assignSku($storage, 5); $user->refresh(); // Reset reseller's wallet balance and transactions $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); // ------------------------------------ // Test normal charging of entitlements // ------------------------------------ // Backdate and charge entitlements, we're expecting one month to be charged // Set fake NOW date to make simpler asserting results that depend on number of days in current/last month Carbon::setTestNow(Carbon::create(2021, 5, 21, 12)); $backdate = Carbon::now()->subWeeks(7); $this->backdateEntitlements($user->entitlements, $backdate); $charge = $wallet->chargeEntitlements(); $wallet->refresh(); $reseller_wallet->refresh(); // TODO: Update these comments with what is actually being used to calculate these numbers // 388 + 310 + 17 + 17 = 732 $this->assertSame(-778, $wallet->balance); // 388 - 555 x 40% + 310 - 444 x 40% + 34 - 50 x 40% = 312 $this->assertSame(332, $reseller_wallet->balance); $transactions = Transaction::where('object_id', $wallet->id) ->where('object_type', \App\Wallet::class)->get(); $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(332, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); $this->assertCount(1, $transactions); $trans = $transactions[0]; $this->assertSame('', $trans->description); $this->assertSame(-778, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); // TODO: Test entitlement transaction records // ----------------------------------- // Test charging on entitlement delete // ----------------------------------- $reseller_wallet->balance = 0; $reseller_wallet->save(); $transactions = Transaction::where('object_id', $wallet->id) ->where('object_type', \App\Wallet::class)->delete(); $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->delete(); $user->removeSku($storage, 2); // we expect the wallet to have been charged for 19 days of use of // 2 deleted storage entitlements $wallet->refresh(); $reseller_wallet->refresh(); // 2 x round(25 / 31 * 19 * 0.7) = 22 $this->assertSame(-(778 + 22), $wallet->balance); // 22 - 2 x round(25 * 0.4 / 31 * 19) = 10 $this->assertSame(10, $reseller_wallet->balance); $transactions = Transaction::where('object_id', $wallet->id) ->where('object_type', \App\Wallet::class)->get(); $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertCount(2, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(5, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); $trans = $reseller_transactions[1]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(5, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); $this->assertCount(2, $transactions); $trans = $transactions[0]; $this->assertSame('', $trans->description); $this->assertSame(-11, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); $trans = $transactions[1]; $this->assertSame('', $trans->description); $this->assertSame(-11, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); // TODO: Test entitlement transaction records } + + /** + * Tests for updateEntitlements() + */ + public function testUpdateEntitlements(): void + { + $this->markTestIncomplete(); + } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index e1dda81a..70cda30f 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,599 +1,606 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Test Domain Owner', ]; /** * Some users for the hosted domain, ultimately including the owner. * * @var \App\User[] */ protected $domainUsers = []; /** * A specific user that is a regular user in the hosted domain. * * @var ?\App\User */ protected $jack; /** * A specific user that is a controller on the wallet to which the hosted domain is charged. * * @var ?\App\User */ protected $jane; /** * A specific user that has a second factor configured. * * @var ?\App\User */ protected $joe; /** * One of the domains that is available for public registration. * * @var ?\App\Domain */ protected $publicDomain; /** * A newly generated user in a public domain. * * @var ?\App\User */ protected $publicDomainUser; /** * A placeholder for a password that can be generated. * * Should be generated with `\App\Utils::generatePassphrase()`. * * @var ?string */ protected $userPassword; /** * Register the beta entitlement for a user */ - protected function addBetaEntitlement($user, $title): void + protected function addBetaEntitlement($user, $titles = []): void { // Add beta + $title entitlements $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); - $sku = Sku::withEnvTenantContext()->where('title', $title)->first(); $user->assignSku($beta_sku); - $user->assignSku($sku); + + if (!empty($titles)) { + Sku::withEnvTenantContext()->whereIn('title', (array) $titles)->get() + ->each(function ($sku) use ($user) { + $user->assignSku($sku); + }); + } } /** * Assert that the entitlements for the user match the expected list of entitlements. * * @param \App\User|\App\Domain $object The object for which the entitlements need to be pulled. * @param array $expected An array of expected \App\Sku titles. */ protected function assertEntitlements($object, $expected) { // Assert the user entitlements $skus = $object->entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } - protected function backdateEntitlements($entitlements, $targetDate) + protected function backdateEntitlements($entitlements, $targetDate, $targetCreatedDate = null) { $wallets = []; $ids = []; foreach ($entitlements as $entitlement) { $ids[] = $entitlement->id; $wallets[] = $entitlement->wallet_id; } \App\Entitlement::whereIn('id', $ids)->update([ - 'created_at' => $targetDate, + 'created_at' => $targetCreatedDate ?: $targetDate, 'updated_at' => $targetDate, ]); if (!empty($wallets)) { $wallets = array_unique($wallets); $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); - \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]); + \App\User::whereIn('id', $owners)->update([ + 'created_at' => $targetCreatedDate ?: $targetDate + ]); } } /** * Removes all beta entitlements from the database */ protected function clearBetaEntitlements(): void { $beta_handlers = [ 'App\Handlers\Beta', 'App\Handlers\Beta\Distlists', 'App\Handlers\Beta\Resources', 'App\Handlers\Beta\SharedFolders', ]; $betas = Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } /** * Create a set of transaction log entries for a wallet */ protected function createTestTransactions($wallet) { $result = []; $date = Carbon::now(); $debit = 0; $entitlementTransactions = []; foreach ($wallet->entitlements as $entitlement) { if ($entitlement->cost) { $debit += $entitlement->cost; $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $entitlement->cost ); } } $transaction = Transaction::create( [ 'user_email' => 'jeroen@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $debit * -1, 'description' => 'Payment', ] ); $result[] = $transaction; Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); $transaction = Transaction::create( [ 'user_email' => null, 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 2000, 'description' => 'Payment', ] ); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; $types = [ Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY, ]; // The page size is 10, so we generate so many to have at least two pages $loops = 10; while ($loops-- > 0) { $type = $types[count($result) % count($types)]; $transaction = Transaction::create([ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => $type, 'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1), 'description' => 'TRANS' . $loops, ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; } return $result; } /** * Delete a test domain whatever it takes. * * @coversNothing */ protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $domain->forceDelete(); } /** * Delete a test group whatever it takes. * * @coversNothing */ protected function deleteTestGroup($email) { Queue::fake(); $group = Group::withTrashed()->where('email', $email)->first(); if (!$group) { return; } LDAP::deleteGroup($group); $group->forceDelete(); } /** * Delete a test resource whatever it takes. * * @coversNothing */ protected function deleteTestResource($email) { Queue::fake(); $resource = Resource::withTrashed()->where('email', $email)->first(); if (!$resource) { return; } LDAP::deleteResource($resource); $resource->forceDelete(); } /** * Delete a test shared folder whatever it takes. * * @coversNothing */ protected function deleteTestSharedFolder($email) { Queue::fake(); $folder = SharedFolder::withTrashed()->where('email', $email)->first(); if (!$folder) { return; } LDAP::deleteSharedFolder($folder); $folder->forceDelete(); } /** * Delete a test user whatever it takes. * * @coversNothing */ protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } LDAP::deleteUser($user); $user->forceDelete(); } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get Group object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestGroup($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Group::firstOrCreate(['email' => $email], $attrib); } /** * Get Resource object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestResource($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $resource = Resource::where('email', $email)->first(); if (!$resource) { list($local, $domain) = explode('@', $email, 2); $resource = new Resource(); $resource->email = $email; $resource->domain = $domain; if (!isset($attrib['name'])) { $resource->name = $local; } } foreach ($attrib as $key => $val) { $resource->{$key} = $val; } $resource->save(); return $resource; } /** * Get SharedFolder object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestSharedFolder($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $folder = SharedFolder::where('email', $email)->first(); if (!$folder) { list($local, $domain) = explode('@', $email, 2); $folder = new SharedFolder(); $folder->email = $email; $folder->domain = $domain; if (!isset($attrib['name'])) { $folder->name = $local; } } foreach ($attrib as $key => $val) { $folder->{$key} = $val; } $folder->save(); return $folder; } /** * Get User object by email, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $user = User::firstOrCreate(['email' => $email], $attrib); if ($user->trashed()) { // Note: we do not want to use user restore here User::where('id', $user->id)->forceDelete(); $user = User::create(['email' => $email] + $attrib); } return $user; } /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ protected function invokeMethod($object, $methodName, array $parameters = array()) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } protected function setUpTest() { $this->userPassword = \App\Utils::generatePassphrase(); $this->domainHosted = $this->getTestDomain( 'test.domain', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $this->getTestDomain( 'test2.domain2', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $packageKolab = \App\Package::where('title', 'kolab')->first(); $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]); $this->domainOwner->assignPackage($packageKolab); $this->domainOwner->setSettings($this->domainOwnerSettings); $this->domainOwner->setAliases(['alias1@test2.domain2']); // separate for regular user $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]); // separate for wallet controller $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]); $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]); $this->domainUsers[] = $this->jack; $this->domainUsers[] = $this->jane; $this->domainUsers[] = $this->joe; $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]); foreach ($this->domainUsers as $user) { $this->domainOwner->assignPackage($packageKolab, $user); } $this->domainUsers[] = $this->domainOwner; // assign second factor to joe $this->joe->assignSku(Sku::where('title', '2fa')->first()); \App\Auth\SecondFactor::seed($this->joe->email); usort( $this->domainUsers, function ($a, $b) { return $a->email > $b->email; } ); $this->domainHosted->assignPackage( \App\Package::where('title', 'domain-hosting')->first(), $this->domainOwner ); $wallet = $this->domainOwner->wallets()->first(); $wallet->addController($this->jane); $this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first(); $this->publicDomainUser = $this->getTestUser( 'john@' . $this->publicDomain->namespace, ['password' => $this->userPassword] ); $this->publicDomainUser->assignPackage($packageKolab); } public function tearDown(): void { foreach ($this->domainUsers as $user) { if ($user == $this->domainOwner) { continue; } $this->deleteTestUser($user->email); } if ($this->domainOwner) { $this->deleteTestUser($this->domainOwner->email); } if ($this->domainHosted) { $this->deleteTestDomain($this->domainHosted->namespace); } if ($this->publicDomainUser) { $this->deleteTestUser($this->publicDomainUser->email); } parent::tearDown(); } } diff --git a/src/tests/Unit/Mail/DegradedAccountReminderTest.php b/src/tests/Unit/Mail/DegradedAccountReminderTest.php new file mode 100644 index 00000000..3244f9ef --- /dev/null +++ b/src/tests/Unit/Mail/DegradedAccountReminderTest.php @@ -0,0 +1,45 @@ +getTestUser('ned@kolab.org'); + $wallet = $user->wallets->first(); + + $mail = $this->fakeMail(new DegradedAccountReminder($wallet, $user)); + + $html = $mail['html']; + $plain = $mail['plain']; + + $dashboardUrl = \App\Utils::serviceUrl('/dashboard'); + $dashboardLink = sprintf('%s', $dashboardUrl, $dashboardUrl); + $appName = $user->tenant->title; + + $this->assertMailSubject("$appName Reminder: Your account is free", $mail['message']); + + $this->assertStringStartsWith('', $html); + $this->assertTrue(strpos($html, $user->name(true)) > 0); + $this->assertTrue(strpos($html, $dashboardLink) > 0); + $this->assertTrue(strpos($html, "your account is a free account") > 0); + $this->assertTrue(strpos($html, "$appName Team") > 0); + + $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); + $this->assertTrue(strpos($plain, $dashboardUrl) > 0); + $this->assertTrue(strpos($plain, "your account is a free account") > 0); + $this->assertTrue(strpos($plain, "$appName Team") > 0); + } +} diff --git a/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php b/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php new file mode 100644 index 00000000..1fd471ed --- /dev/null +++ b/src/tests/Unit/Mail/NegativeBalanceDegradedTest.php @@ -0,0 +1,58 @@ +getTestUser('ned@kolab.org'); + $wallet = $user->wallets->first(); + $wallet->balance = -100; + $wallet->save(); + + \config([ + 'app.support_url' => 'https://kolab.org/support', + ]); + + $mail = $this->fakeMail(new NegativeBalanceDegraded($wallet, $user)); + + $html = $mail['html']; + $plain = $mail['plain']; + + $walletUrl = \App\Utils::serviceUrl('/wallet'); + $walletLink = sprintf('%s', $walletUrl, $walletUrl); + $supportUrl = \config('app.support_url'); + $supportLink = sprintf('%s', $supportUrl, $supportUrl); + $appName = $user->tenant->title; + + $this->assertMailSubject("$appName Account Degraded", $mail['message']); + + $this->assertStringStartsWith('', $html); + $this->assertTrue(strpos($html, $user->name(true)) > 0); + $this->assertTrue(strpos($html, $walletLink) > 0); + $this->assertTrue(strpos($html, $supportLink) > 0); + $this->assertTrue(strpos($html, "Your $appName account has been degraded") > 0); + $this->assertTrue(strpos($html, "$appName Support") > 0); + $this->assertTrue(strpos($html, "$appName Team") > 0); + + $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); + $this->assertTrue(strpos($plain, $walletUrl) > 0); + $this->assertTrue(strpos($plain, $supportUrl) > 0); + $this->assertTrue(strpos($plain, "Your $appName account has been degraded") > 0); + $this->assertTrue(strpos($plain, "$appName Support") > 0); + $this->assertTrue(strpos($plain, "$appName Team") > 0); + } +} diff --git a/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php b/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php new file mode 100644 index 00000000..c4d9fd8f --- /dev/null +++ b/src/tests/Unit/Mail/NegativeBalanceReminderDegradeTest.php @@ -0,0 +1,64 @@ +getTestUser('ned@kolab.org'); + $wallet = $user->wallets->first(); + $wallet->balance = -100; + $wallet->save(); + + $threshold = WalletCheck::threshold($wallet, WalletCheck::THRESHOLD_DEGRADE); + + \config([ + 'app.support_url' => 'https://kolab.org/support', + ]); + + $mail = $this->fakeMail(new NegativeBalanceReminderDegrade($wallet, $user)); + + $html = $mail['html']; + $plain = $mail['plain']; + + $walletUrl = \App\Utils::serviceUrl('/wallet'); + $walletLink = sprintf('%s', $walletUrl, $walletUrl); + $supportUrl = \config('app.support_url'); + $supportLink = sprintf('%s', $supportUrl, $supportUrl); + $appName = $user->tenant->title; + + $this->assertMailSubject("$appName Payment Reminder", $mail['message']); + + $this->assertStringStartsWith('', $html); + $this->assertTrue(strpos($html, $user->name(true)) > 0); + $this->assertTrue(strpos($html, $walletLink) > 0); + $this->assertTrue(strpos($html, $supportLink) > 0); + $this->assertTrue(strpos($html, "you are behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($html, "your account will be degraded") > 0); + $this->assertTrue(strpos($html, $threshold->toDateString()) > 0); + $this->assertTrue(strpos($html, "$appName Support") > 0); + $this->assertTrue(strpos($html, "$appName Team") > 0); + + $this->assertStringStartsWith('Dear ' . $user->name(true), $plain); + $this->assertTrue(strpos($plain, $walletUrl) > 0); + $this->assertTrue(strpos($plain, $supportUrl) > 0); + $this->assertTrue(strpos($plain, "you are behind on paying for your $appName account") > 0); + $this->assertTrue(strpos($plain, "your account will be degraded") > 0); + $this->assertTrue(strpos($plain, $threshold->toDateString()) > 0); + $this->assertTrue(strpos($plain, "$appName Support") > 0); + $this->assertTrue(strpos($plain, "$appName Team") > 0); + } +} diff --git a/src/tests/Unit/UserTest.php b/src/tests/Unit/UserTest.php index 1eeda627..cd733643 100644 --- a/src/tests/Unit/UserTest.php +++ b/src/tests/Unit/UserTest.php @@ -1,114 +1,117 @@ 'user@email.com']); $user->password = 'test'; $ssh512 = "{SSHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ" . "6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="; $this->assertMatchesRegularExpression('/^\$2y\$12\$[0-9a-zA-Z\/.]{53}$/', $user->password); $this->assertSame($ssh512, $user->password_ldap); } /** * Test User password mutator */ public function testSetPasswordLdapAttribute(): void { $user = new User(['email' => 'user@email.com']); $user->password_ldap = 'test'; $ssh512 = "{SSHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ" . "6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="; $this->assertMatchesRegularExpression('/^\$2y\$12\$[0-9a-zA-Z\/.]{53}$/', $user->password); $this->assertSame($ssh512, $user->password_ldap); } /** * Test User password validation */ public function testPasswordValidation(): void { $user = new User(['email' => 'user@email.com']); $user->password = 'test'; $this->assertSame(true, $user->validateCredentials('user@email.com', 'test')); $this->assertSame(false, $user->validateCredentials('user@email.com', 'wrong')); $this->assertSame(true, $user->validateCredentials('User@Email.Com', 'test')); $this->assertSame(false, $user->validateCredentials('wrong', 'test')); // Ensure the fallback to the ldap_password works if the current password is empty $ssh512 = "{SSHA512}7iaw3Ur350mqGo7jwQrpkj9hiYB3Lkc/iBml1JQODbJ" . "6wYX4oOHV+E+IvIh/1nsUNzLDBMxfqa2Ob1f1ACio/w=="; $ldapUser = new User(['email' => 'user2@email.com']); $ldapUser->setRawAttributes(['password' => '', 'password_ldap' => $ssh512, 'email' => 'user2@email.com']); $this->assertSame($ldapUser->password, ''); $this->assertSame($ldapUser->password_ldap, $ssh512); $this->assertSame(true, $ldapUser->validateCredentials('user2@email.com', 'test', false)); $ldapUser->delete(); } /** * Test basic User funtionality */ public function testStatus(): void { $statuses = [ User::STATUS_NEW, User::STATUS_ACTIVE, User::STATUS_SUSPENDED, User::STATUS_DELETED, User::STATUS_IMAP_READY, User::STATUS_LDAP_READY, + User::STATUS_DEGRADED, ]; $users = \App\Utils::powerSet($statuses); foreach ($users as $user_statuses) { $user = new User( [ 'email' => 'user@email.com', 'status' => \array_sum($user_statuses), ] ); $this->assertTrue($user->isNew() === in_array(User::STATUS_NEW, $user_statuses)); $this->assertTrue($user->isActive() === in_array(User::STATUS_ACTIVE, $user_statuses)); $this->assertTrue($user->isSuspended() === in_array(User::STATUS_SUSPENDED, $user_statuses)); $this->assertTrue($user->isDeleted() === in_array(User::STATUS_DELETED, $user_statuses)); $this->assertTrue($user->isLdapReady() === in_array(User::STATUS_LDAP_READY, $user_statuses)); $this->assertTrue($user->isImapReady() === in_array(User::STATUS_IMAP_READY, $user_statuses)); + $this->assertTrue($user->isDegraded() === in_array(User::STATUS_DEGRADED, $user_statuses)); } } /** * Test setStatusAttribute exception */ public function testStatusInvalid(): void { $this->expectException(\Exception::class); $user = new User( [ 'email' => 'user@email.com', 'status' => 1234567, ] ); } }