Page MenuHomePhorge

No OneTemporary

Size
891 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/app/Backends/IMAP.php b/src/app/Backends/IMAP.php
index cdca97ce..41d4bfe0 100644
--- a/src/app/Backends/IMAP.php
+++ b/src/app/Backends/IMAP.php
@@ -1,111 +1,148 @@
<?php
namespace App\Backends;
use App\Domain;
use App\User;
class IMAP
{
/**
* Check if an account is set up
*
* @param string $username User login (email address)
*
* @return bool True if an account exists and is set up, False otherwise
*/
public static function verifyAccount(string $username): bool
{
$config = self::getConfig();
$imap = self::initIMAP($config, $username);
$folders = $imap->listMailboxes('', '*');
+ $imap->closeConnection();
+
if (!is_array($folders)) {
throw new \Exception("Failed to get IMAP folders");
}
+ return count($folders) > 0;
+ }
+
+ /**
+ * Check if a shared folder is set up.
+ *
+ * @param string $folder Folder name, eg. shared/Resources/Name@domain.tld
+ *
+ * @return bool True if a folder exists and is set up, False otherwise
+ */
+ public static function verifySharedFolder(string $folder): bool
+ {
+ $config = self::getConfig();
+ $imap = self::initIMAP($config);
+
+ // Convert the folder from UTF8 to UTF7-IMAP
+ if (\preg_match('|^(shared/Resources/)(.*)(@[^@]+)$|', $folder, $matches)) {
+ $folderName = \mb_convert_encoding($matches[2], 'UTF7-IMAP', 'UTF8');
+ $folder = $matches[1] . $folderName . $matches[3];
+ }
+
+ // FIXME: just listMailboxes() does not return shared folders at all
+
+ $metadata = $imap->getMetadata($folder, ['/shared/vendor/kolab/folder-type']);
+
$imap->closeConnection();
- return count($folders) > 0;
+ // Note: We have to use error code to distinguish an error from "no mailbox" response
+
+ if ($imap->errornum === \rcube_imap_generic::ERROR_NO) {
+ return false;
+ }
+
+ if ($imap->errornum !== \rcube_imap_generic::ERROR_OK) {
+ throw new \Exception("Failed to get folder metadata from IMAP");
+ }
+
+ return true;
}
/**
* Initialize connection to IMAP
*/
private static function initIMAP(array $config, string $login_as = null)
{
$imap = new \rcube_imap_generic();
if (\config('app.debug')) {
$imap->setDebug(true, 'App\Backends\IMAP::logDebug');
}
if ($login_as) {
$config['options']['auth_cid'] = $config['user'];
$config['options']['auth_pw'] = $config['password'];
$config['options']['auth_type'] = 'PLAIN';
$config['user'] = $login_as;
}
$imap->connect($config['host'], $config['user'], $config['password'], $config['options']);
if (!$imap->connected()) {
$message = sprintf("Login failed for %s against %s. %s", $config['user'], $config['host'], $imap->error);
\Log::error($message);
throw new \Exception("Connection to IMAP failed");
}
return $imap;
}
/**
* Get LDAP configuration for specified access level
*/
private static function getConfig()
{
$uri = \parse_url(\config('imap.uri'));
$default_port = 143;
$ssl_mode = null;
if (isset($uri['scheme'])) {
if (preg_match('/^(ssl|imaps)/', $uri['scheme'])) {
$default_port = 993;
$ssl_mode = 'ssl';
} elseif ($uri['scheme'] === 'tls') {
$ssl_mode = 'tls';
}
}
$config = [
'host' => $uri['host'],
'user' => \config('imap.admin_login'),
'password' => \config('imap.admin_password'),
'options' => [
'port' => !empty($uri['port']) ? $uri['port'] : $default_port,
'ssl_mode' => $ssl_mode,
'socket_options' => [
'ssl' => [
'verify_peer' => \config('imap.verify_peer'),
'verify_peer_name' => \config('imap.verify_peer'),
'verify_host' => \config('imap.verify_host')
],
],
],
];
return $config;
}
/**
* Debug logging callback
*/
public static function logDebug($conn, $msg): void
{
$msg = '[IMAP] ' . $msg;
\Log::debug($msg);
}
}
diff --git a/src/app/Backends/LDAP.php b/src/app/Backends/LDAP.php
index 6f54b930..24392fb3 100644
--- a/src/app/Backends/LDAP.php
+++ b/src/app/Backends/LDAP.php
@@ -1,990 +1,1211 @@
<?php
namespace App\Backends;
use App\Domain;
use App\Group;
+use App\Resource;
use App\User;
class LDAP
{
/** @const array Group settings used by the backend */
public const GROUP_SETTINGS = [
'sender_policy',
];
+ /** @const array Resource settings used by the backend */
+ public const RESOURCE_SETTINGS = [
+ 'folder',
+ 'invitation_policy',
+ ];
+
/** @const array User settings used by the backend */
public const USER_SETTINGS = [
'first_name',
'last_name',
'organization',
];
/** @var ?\Net_LDAP3 LDAP connection object */
protected static $ldap;
/**
* Starts a new LDAP connection that will be used by all methods
* until you call self::disconnect() explicitely. Normally every
* method uses a separate connection.
*
* @throws \Exception
*/
public static function connect(): void
{
if (empty(self::$ldap)) {
$config = self::getConfig('admin');
self::$ldap = self::initLDAP($config);
}
}
/**
* Close the connection created by self::connect()
*/
public static function disconnect(): void
{
if (!empty(self::$ldap)) {
self::$ldap->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 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 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 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 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;
+
+ $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['kolabinvitationpolicy'] = 'ACT_MANUAL';
+ } else {
+ $entry['kolabinvitationpolicy'] = 'ACT_ACCEPT';
+ }
+
+ // TODO: Set folder ACL so the owner can write to it
+ // TODO: Do we need to add lrs for anyone?
+ }
+ }
+ }
+
/**
* Set common user attributes
*/
private static function setUserAttributes(User $user, array &$entry)
{
$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 (!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 false|null|array Group entry, False on error, NULL if not found
+ * @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.
- $result = $ldap->search($base_dn, "(mail=$email)", "sub", $attrs);
+ return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn);
+ }
- if ($result && $result->count() == 1) {
- $entries = $result->entries(true);
- $dn = key($entries);
- $entry = $entries[$dn];
- $entry['dn'] = $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');
- return $entry;
- }
+ $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder',
+ 'kolabfoldertype', 'kolabinvitationpolicy', 'owner'];
- return null;
+ // 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 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/Command.php b/src/app/Console/Command.php
index ffad402d..97d972b3 100644
--- a/src/app/Console/Command.php
+++ b/src/app/Console/Command.php
@@ -1,221 +1,235 @@
<?php
namespace App\Console;
use Illuminate\Support\Facades\DB;
abstract class Command extends \Illuminate\Console\Command
{
/**
* This needs to be here to be used.
*
* @var null
*/
protected $commandPrefix = null;
/**
* Annotate this command as being dangerous for any potential unintended consequences.
*
* Commands are considered dangerous if;
*
* * observers are deliberately not triggered, meaning that the deletion of an object model that requires the
* associated observer to clean some things up, or charge a wallet or something, are deliberately not triggered,
*
* * deletion of objects and their relations rely on database foreign keys with obscure cascading,
*
* * a command will result in the permanent, irrecoverable loss of data.
*
* @var boolean
*/
protected $dangerous = false;
/**
* Find the domain.
*
* @param string $domain Domain ID or namespace
* @param bool $withDeleted Include deleted
*
* @return \App\Domain|null
*/
public function getDomain($domain, $withDeleted = false)
{
return $this->getObject(\App\Domain::class, $domain, 'namespace', $withDeleted);
}
/**
* Find a group.
*
* @param string $group Group ID or email
* @param bool $withDeleted Include deleted
*
* @return \App\Group|null
*/
public function getGroup($group, $withDeleted = false)
{
return $this->getObject(\App\Group::class, $group, 'email', $withDeleted);
}
/**
* Find an object.
*
* @param string $objectClass The name of the class
* @param string $objectIdOrTitle The name of a database field to match.
* @param string|null $objectTitle An additional database field to match.
* @param bool $withDeleted Act as if --with-deleted was used
*
* @return mixed
*/
public function getObject($objectClass, $objectIdOrTitle, $objectTitle = null, $withDeleted = false)
{
if (!$withDeleted) {
$withDeleted = $this->hasOption('with-deleted') && $this->option('with-deleted');
}
$object = $this->getObjectModel($objectClass, $withDeleted)->find($objectIdOrTitle);
if (!$object && !empty($objectTitle)) {
$object = $this->getObjectModel($objectClass, $withDeleted)
->where($objectTitle, $objectIdOrTitle)->first();
}
return $object;
}
/**
* Returns a preconfigured Model object for a specified class.
*
* @param string $objectClass The name of the class
* @param bool $withDeleted Include withTrashed() query
*
* @return mixed
*/
protected function getObjectModel($objectClass, $withDeleted = false)
{
if ($withDeleted) {
$model = $objectClass::withTrashed();
} else {
$model = new $objectClass();
}
if ($this->commandPrefix == 'scalpel') {
return $model;
}
$modelsWithTenant = [
\App\Discount::class,
\App\Domain::class,
\App\Group::class,
\App\Package::class,
\App\Plan::class,
+ \App\Resource::class,
\App\Sku::class,
\App\User::class,
];
$modelsWithOwner = [
\App\Wallet::class,
];
$tenantId = \config('app.tenant_id');
// Add tenant filter
if (in_array($objectClass, $modelsWithTenant)) {
$model = $model->withEnvTenantContext();
} elseif (in_array($objectClass, $modelsWithOwner)) {
$model = $model->whereExists(function ($query) use ($tenantId) {
$query->select(DB::raw(1))
->from('users')
->whereRaw('wallets.user_id = users.id')
->whereRaw('users.tenant_id ' . ($tenantId ? "= $tenantId" : 'is null'));
});
}
return $model;
}
+ /**
+ * Find a resource.
+ *
+ * @param string $resource Resource ID or email
+ * @param bool $withDeleted Include deleted
+ *
+ * @return \App\Resource|null
+ */
+ public function getResource($resource, $withDeleted = false)
+ {
+ return $this->getObject(\App\Resource::class, $resource, 'email', $withDeleted);
+ }
+
/**
* Find the user.
*
* @param string $user User ID or email
* @param bool $withDeleted Include deleted
*
* @return \App\User|null
*/
public function getUser($user, $withDeleted = false)
{
return $this->getObject(\App\User::class, $user, 'email', $withDeleted);
}
/**
* Find the wallet.
*
* @param string $wallet Wallet ID
*
* @return \App\Wallet|null
*/
public function getWallet($wallet)
{
return $this->getObject(\App\Wallet::class, $wallet, null);
}
public function handle()
{
if ($this->dangerous) {
$this->warn(
"This command is a dangerous scalpel command with potentially significant unintended consequences"
);
$confirmation = $this->confirm("Are you sure you understand what's about to happen?");
if (!$confirmation) {
$this->info("Better safe than sorry.");
return false;
}
$this->info("Vámonos!");
}
return true;
}
/**
* Return a string for output, with any additional attributes specified as well.
*
* @param mixed $entry An object
*
* @return string
*/
protected function toString($entry)
{
/**
* Haven't figured out yet, how to test if this command implements an option for additional
* attributes.
if (!in_array('attr', $this->options())) {
return $entry->{$entry->getKeyName()};
}
*/
$str = [
$entry->{$entry->getKeyName()}
];
foreach ($this->option('attr') as $attr) {
if ($attr == $entry->getKeyName()) {
$this->warn("Specifying {$attr} is not useful.");
continue;
}
if (!array_key_exists($attr, $entry->toArray())) {
$this->error("Attribute {$attr} isn't available");
continue;
}
if (is_numeric($entry->{$attr})) {
$str[] = $entry->{$attr};
} else {
$str[] = !empty($entry->{$attr}) ? $entry->{$attr} : "null";
}
}
return implode(" ", $str);
}
}
diff --git a/src/app/Console/Commands/Resource/VerifyCommand.php b/src/app/Console/Commands/Resource/VerifyCommand.php
new file mode 100644
index 00000000..d25dcd07
--- /dev/null
+++ b/src/app/Console/Commands/Resource/VerifyCommand.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Console\Commands\Resource;
+
+use App\Console\Command;
+
+class VerifyCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'resource:verify {resource}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Verify the state of a resource';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $resource = $this->getResource($this->argument('resource'));
+
+ if (!$resource) {
+ $this->error("Resource not found.");
+ return 1;
+ }
+
+ $job = new \App\Jobs\Resource\VerifyJob($resource->id);
+ $job->handle();
+
+ // TODO: We should check the job result and print an error on failure
+ }
+}
diff --git a/src/app/Console/Commands/ResourcesCommand.php b/src/app/Console/Commands/ResourcesCommand.php
new file mode 100644
index 00000000..aee91eb7
--- /dev/null
+++ b/src/app/Console/Commands/ResourcesCommand.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Console\ObjectListCommand;
+
+class ResourcesCommand extends ObjectListCommand
+{
+ protected $objectClass = \App\Resource::class;
+ protected $objectName = 'resource';
+ protected $objectTitle = 'name';
+}
diff --git a/src/app/Domain.php b/src/app/Domain.php
index 11cdf093..0d300ae1 100644
--- a/src/app/Domain.php
+++ b/src/app/Domain.php
@@ -1,500 +1,501 @@
<?php
namespace App;
use App\Wallet;
use App\Traits\BelongsToTenantTrait;
use App\Traits\DomainConfigTrait;
use App\Traits\EntitleableTrait;
use App\Traits\SettingsTrait;
use App\Traits\UuidIntKeyTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* The eloquent definition of a Domain.
*
* @property string $namespace
* @property int $status
* @property int $tenant_id
* @property int $type
*/
class Domain extends Model
{
use BelongsToTenantTrait;
use DomainConfigTrait;
use EntitleableTrait;
use SettingsTrait;
use SoftDeletes;
use UuidIntKeyTrait;
// we've simply never heard of this domain
public const STATUS_NEW = 1 << 0;
// it's been activated
public const STATUS_ACTIVE = 1 << 1;
// domain has been suspended.
public const STATUS_SUSPENDED = 1 << 2;
// domain has been deleted
public const STATUS_DELETED = 1 << 3;
// ownership of the domain has been confirmed
public const STATUS_CONFIRMED = 1 << 4;
// domain has been verified that it exists in DNS
public const STATUS_VERIFIED = 1 << 5;
// domain has been created in LDAP
public const STATUS_LDAP_READY = 1 << 6;
// open for public registration
public const TYPE_PUBLIC = 1 << 0;
// zone hosted with us
public const TYPE_HOSTED = 1 << 1;
// zone registered externally
public const TYPE_EXTERNAL = 1 << 2;
public const HASH_CODE = 1;
public const HASH_TEXT = 2;
public const HASH_CNAME = 3;
protected $fillable = [
'namespace',
'status',
'type'
];
/**
* Assign a package to a domain. The domain should not belong to any existing entitlements.
*
* @param \App\Package $package The package to assign.
* @param \App\User $user The wallet owner.
*
* @return \App\Domain Self
*/
public function assignPackage($package, $user)
{
// If this domain is public it can not be assigned to a user.
if ($this->isPublic()) {
return $this;
}
// See if this domain is already owned by another user.
$wallet = $this->wallet();
if ($wallet) {
\Log::error(
"Domain {$this->namespace} is already assigned to {$wallet->owner->email}"
);
return $this;
}
$wallet_id = $user->wallets()->first()->id;
foreach ($package->skus as $sku) {
for ($i = $sku->pivot->qty; $i > 0; $i--) {
\App\Entitlement::create(
[
'wallet_id' => $wallet_id,
'sku_id' => $sku->id,
'cost' => $sku->pivot->cost(),
'fee' => $sku->pivot->fee(),
'entitleable_id' => $this->id,
'entitleable_type' => Domain::class
]
);
}
}
return $this;
}
/**
* Return list of public+active domain names (for current tenant)
*/
public static function getPublicDomains(): array
{
return self::withEnvTenantContext()
->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC))
->get(['namespace'])->pluck('namespace')->toArray();
}
/**
* Returns whether this domain is active.
*
* @return bool
*/
public function isActive(): bool
{
return ($this->status & self::STATUS_ACTIVE) > 0;
}
/**
* Returns whether this domain is confirmed the ownership of.
*
* @return bool
*/
public function isConfirmed(): bool
{
return ($this->status & self::STATUS_CONFIRMED) > 0;
}
/**
* Returns whether this domain is deleted.
*
* @return bool
*/
public function isDeleted(): bool
{
return ($this->status & self::STATUS_DELETED) > 0;
}
/**
* Returns whether this domain is registered with us.
*
* @return bool
*/
public function isExternal(): bool
{
return ($this->type & self::TYPE_EXTERNAL) > 0;
}
/**
* Returns whether this domain is hosted with us.
*
* @return bool
*/
public function isHosted(): bool
{
return ($this->type & self::TYPE_HOSTED) > 0;
}
/**
* Returns whether this domain is new.
*
* @return bool
*/
public function isNew(): bool
{
return ($this->status & self::STATUS_NEW) > 0;
}
/**
* Returns whether this domain is public.
*
* @return bool
*/
public function isPublic(): bool
{
return ($this->type & self::TYPE_PUBLIC) > 0;
}
/**
* Returns whether this domain is registered in LDAP.
*
* @return bool
*/
public function isLdapReady(): bool
{
return ($this->status & self::STATUS_LDAP_READY) > 0;
}
/**
* Returns whether this domain is suspended.
*
* @return bool
*/
public function isSuspended(): bool
{
return ($this->status & self::STATUS_SUSPENDED) > 0;
}
/**
* Returns whether this (external) domain has been verified
* to exist in DNS.
*
* @return bool
*/
public function isVerified(): bool
{
return ($this->status & self::STATUS_VERIFIED) > 0;
}
/**
* Ensure the namespace is appropriately cased.
*/
public function setNamespaceAttribute($namespace)
{
$this->attributes['namespace'] = strtolower($namespace);
}
/**
* Domain 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_CONFIRMED,
self::STATUS_VERIFIED,
self::STATUS_LDAP_READY,
];
foreach ($allowed_values as $value) {
if ($status & $value) {
$new_status |= $value;
$status ^= $value;
}
}
if ($status > 0) {
throw new \Exception("Invalid domain status: {$status}");
}
if ($this->isPublic()) {
$this->attributes['status'] = $new_status;
return;
}
if ($new_status & self::STATUS_CONFIRMED) {
// if we have confirmed ownership of or management access to the domain, then we have
// also confirmed the domain exists in DNS.
$new_status |= self::STATUS_VERIFIED;
$new_status |= self::STATUS_ACTIVE;
}
if ($new_status & self::STATUS_DELETED && $new_status & self::STATUS_ACTIVE) {
$new_status ^= self::STATUS_ACTIVE;
}
if ($new_status & self::STATUS_SUSPENDED && $new_status & self::STATUS_ACTIVE) {
$new_status ^= self::STATUS_ACTIVE;
}
// if the domain is now active, it is not new anymore.
if ($new_status & self::STATUS_ACTIVE && $new_status & self::STATUS_NEW) {
$new_status ^= self::STATUS_NEW;
}
$this->attributes['status'] = $new_status;
}
/**
* Ownership verification by checking for a TXT (or CNAME) record
* in the domain's DNS (that matches the verification hash).
*
* @return bool True if verification was successful, false otherwise
* @throws \Exception Throws exception on DNS or DB errors
*/
public function confirm(): bool
{
if ($this->isConfirmed()) {
return true;
}
$hash = $this->hash(self::HASH_TEXT);
$confirmed = false;
// Get DNS records and find a matching TXT entry
$records = \dns_get_record($this->namespace, DNS_TXT);
if ($records === false) {
throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
foreach ($records as $record) {
if ($record['txt'] === $hash) {
$confirmed = true;
break;
}
}
// Get DNS records and find a matching CNAME entry
// Note: some servers resolve every non-existing name
// so we need to define left and right side of the CNAME record
// i.e.: kolab-verify IN CNAME <hash>.domain.tld.
if (!$confirmed) {
$cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace;
$records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME);
if ($records === false) {
throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
foreach ($records as $records) {
if ($records['target'] === $cname) {
$confirmed = true;
break;
}
}
}
if ($confirmed) {
$this->status |= Domain::STATUS_CONFIRMED;
$this->save();
}
return $confirmed;
}
/**
* Generate a verification hash for this domain
*
* @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT
*
* @return string Verification hash
*/
public function hash($mod = null): string
{
$cname = 'kolab-verify';
if ($mod === self::HASH_CNAME) {
return $cname;
}
$hash = \md5('hkccp-verify-' . $this->namespace);
return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash;
}
/**
* Checks if there are any objects (users/aliases/groups) in a domain.
* Note: Public domains are always reported not empty.
*
* @return bool True if there are no objects assigned, False otherwise
*/
public function isEmpty(): bool
{
if ($this->isPublic()) {
return false;
}
// FIXME: These queries will not use indexes, so maybe we should consider
// wallet/entitlements to search in objects that belong to this domain account?
$suffix = '@' . $this->namespace;
$suffixLen = strlen($suffix);
return !(
\App\User::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
|| \App\UserAlias::whereRaw('substr(alias, ?) = ?', [-$suffixLen, $suffix])->exists()
|| \App\Group::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
+ || \App\Resource::whereRaw('substr(email, ?) = ?', [-$suffixLen, $suffix])->exists()
);
}
/**
* Suspend this domain.
*
* @return void
*/
public function suspend(): void
{
if ($this->isSuspended()) {
return;
}
$this->status |= Domain::STATUS_SUSPENDED;
$this->save();
}
/**
* Unsuspend this domain.
*
* The domain is unsuspended through either of the following courses of actions;
*
* * The account balance has been topped up, or
* * a suspected spammer has resolved their issues, or
* * the command-line is triggered.
*
* Therefore, we can also confidently set the domain status to 'active' should the ownership of or management
* access to have been confirmed before.
*
* @return void
*/
public function unsuspend(): void
{
if (!$this->isSuspended()) {
return;
}
$this->status ^= Domain::STATUS_SUSPENDED;
if ($this->isConfirmed() && $this->isVerified()) {
$this->status |= Domain::STATUS_ACTIVE;
}
$this->save();
}
/**
* List the users of a domain, so long as the domain is not a public registration domain.
* Note: It returns only users with a mailbox.
*
* @return \App\User[] A list of users
*/
public function users(): array
{
if ($this->isPublic()) {
return [];
}
$wallet = $this->wallet();
if (!$wallet) {
return [];
}
$mailboxSKU = \App\Sku::withObjectTenantContext($this)->where('title', 'mailbox')->first();
if (!$mailboxSKU) {
\Log::error("No mailbox SKU available.");
return [];
}
$entitlements = $wallet->entitlements()
->where('entitleable_type', \App\User::class)
->where('sku_id', $mailboxSKU->id)->get();
$users = [];
foreach ($entitlements as $entitlement) {
$users[] = $entitlement->entitleable;
}
return $users;
}
/**
* Verify if a domain exists in DNS
*
* @return bool True if registered, False otherwise
* @throws \Exception Throws exception on DNS or DB errors
*/
public function verify(): bool
{
if ($this->isVerified()) {
return true;
}
$records = \dns_get_record($this->namespace, DNS_ANY);
if ($records === false) {
throw new \Exception("Failed to get DNS record for {$this->namespace}");
}
// It may happen that result contains other domains depending on the host DNS setup
// that's why in_array() and not just !empty()
if (in_array($this->namespace, array_column($records, 'host'))) {
$this->status |= Domain::STATUS_VERIFIED;
$this->save();
return true;
}
return false;
}
}
diff --git a/src/app/Handlers/Beta/Resources.php b/src/app/Handlers/Beta/Resources.php
new file mode 100644
index 00000000..127e3c91
--- /dev/null
+++ b/src/app/Handlers/Beta/Resources.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Handlers\Beta;
+
+class Resources extends Base
+{
+ /**
+ * The entitleable class for this handler.
+ *
+ * @return string
+ */
+ public static function entitleableClass(): string
+ {
+ return \App\User::class;
+ }
+
+ /**
+ * Check if the SKU is available to the user/domain.
+ *
+ * @param \App\Sku $sku The SKU object
+ * @param \App\User|\App\Domain $object The user or domain object
+ *
+ * @return bool
+ */
+ public static function isAvailable(\App\Sku $sku, $object): bool
+ {
+ // This SKU must be:
+ // - already assigned, or active and a 'beta' entitlement must exist
+ // - and this is a group account owner (custom domain)
+
+ if (parent::isAvailable($sku, $object)) {
+ return $object->wallet()->entitlements()
+ ->where('entitleable_type', \App\Domain::class)->count() > 0;
+ }
+
+ return false;
+ }
+
+ /**
+ * The priority that specifies the order of SKUs in UI.
+ * Higher number means higher on the list.
+ *
+ * @return int
+ */
+ public static function priority(): int
+ {
+ return 10;
+ }
+}
diff --git a/src/app/Handlers/Resource.php b/src/app/Handlers/Resource.php
index 4e6e3bd9..6963af62 100644
--- a/src/app/Handlers/Resource.php
+++ b/src/app/Handlers/Resource.php
@@ -1,17 +1,16 @@
<?php
namespace App\Handlers;
class Resource extends \App\Handlers\Base
{
/**
* The entitleable class for this handler.
*
* @return string
*/
public static function entitleableClass(): string
{
- // TODO
- return '';
+ return \App\Resource::class;
}
}
diff --git a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php
index 00f9a055..11051e5f 100644
--- a/src/app/Http/Controllers/API/V4/Admin/GroupsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/GroupsController.php
@@ -1,114 +1,107 @@
<?php
namespace App\Http\Controllers\API\V4\Admin;
use App\Group;
use App\User;
use Illuminate\Http\Request;
class GroupsController extends \App\Http\Controllers\API\V4\GroupsController
{
/**
* Search for groups
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$search = trim(request()->input('search'));
$owner = trim(request()->input('owner'));
$result = collect([]);
if ($owner) {
if ($owner = User::find($owner)) {
- foreach ($owner->wallets as $wallet) {
- $wallet->entitlements()->where('entitleable_type', Group::class)->get()
- ->each(function ($entitlement) use ($result) {
- $result->push($entitlement->entitleable);
- });
- }
-
- $result = $result->sortBy('name')->values();
+ $result = $owner->groups(false)->orderBy('name')->get();
}
} elseif (!empty($search)) {
if ($group = Group::where('email', $search)->first()) {
$result->push($group);
}
}
// Process the result
$result = $result->map(
function ($group) {
return $this->objectToClient($group);
}
);
$result = [
'list' => $result,
'count' => count($result),
'message' => \trans('app.search-foundxdistlists', ['x' => count($result)]),
];
return response()->json($result);
}
/**
* Create a new group.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
return $this->errorResponse(404);
}
/**
* Suspend a group
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id Group identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function suspend(Request $request, $id)
{
$group = Group::find($id);
if (!$this->checkTenant($group)) {
return $this->errorResponse(404);
}
$group->suspend();
return response()->json([
'status' => 'success',
'message' => \trans('app.distlist-suspend-success'),
]);
}
/**
* Un-Suspend a group
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id Group identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function unsuspend(Request $request, $id)
{
$group = Group::find($id);
if (!$this->checkTenant($group)) {
return $this->errorResponse(404);
}
$group->unsuspend();
return response()->json([
'status' => 'success',
'message' => \trans('app.distlist-unsuspend-success'),
]);
}
}
diff --git a/src/app/Http/Controllers/API/V4/Admin/ResourcesController.php b/src/app/Http/Controllers/API/V4/Admin/ResourcesController.php
new file mode 100644
index 00000000..481d7b7f
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Admin/ResourcesController.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Admin;
+
+use App\Resource;
+use App\User;
+use Illuminate\Http\Request;
+
+class ResourcesController extends \App\Http\Controllers\API\V4\ResourcesController
+{
+ /**
+ * Search for resources
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $search = trim(request()->input('search'));
+ $owner = trim(request()->input('owner'));
+ $result = collect([]);
+
+ if ($owner) {
+ if ($owner = User::find($owner)) {
+ $result = $owner->resources(false)->orderBy('name')->get();
+ }
+ } elseif (!empty($search)) {
+ if ($resource = Resource::where('email', $search)->first()) {
+ $result->push($resource);
+ }
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($resource) {
+ return $this->objectToClient($resource);
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxresources', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Create a new resource.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function store(Request $request)
+ {
+ return $this->errorResponse(404);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Admin/UsersController.php b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
index b702e09c..cf5664bd 100644
--- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
@@ -1,381 +1,380 @@
<?php
namespace App\Http\Controllers\API\V4\Admin;
use App\Domain;
-use App\Group;
use App\Sku;
use App\User;
-use App\UserAlias;
-use App\UserSetting;
use App\Wallet;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class UsersController extends \App\Http\Controllers\API\V4\UsersController
{
/**
* Delete a user.
*
* @param int $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function destroy($id)
{
return $this->errorResponse(404);
}
/**
* Searching of user accounts.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$search = trim(request()->input('search'));
$owner = trim(request()->input('owner'));
$result = collect([]);
if ($owner) {
$owner = User::find($owner);
if ($owner) {
$result = $owner->users(false)->orderBy('email')->get();
}
} elseif (strpos($search, '@')) {
// Search by email
$result = User::withTrashed()->where('email', $search)
->orderBy('email')
->get();
if ($result->isEmpty()) {
// Search by an alias
- $user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id');
+ $user_ids = \App\UserAlias::where('alias', $search)->get()->pluck('user_id');
// Search by an external email
- $ext_user_ids = UserSetting::where('key', 'external_email')
+ $ext_user_ids = \App\UserSetting::where('key', 'external_email')
->where('value', $search)
->get()
->pluck('user_id');
$user_ids = $user_ids->merge($ext_user_ids)->unique();
- // Search by a distribution list email
- if ($group = Group::withTrashed()->where('email', $search)->first()) {
+ // Search by a distribution list or resource email
+ if ($group = \App\Group::withTrashed()->where('email', $search)->first()) {
$user_ids = $user_ids->merge([$group->wallet()->user_id])->unique();
+ } elseif ($resource = \App\Resource::withTrashed()->where('email', $search)->first()) {
+ $user_ids = $user_ids->merge([$resource->wallet()->user_id])->unique();
}
if (!$user_ids->isEmpty()) {
$result = User::withTrashed()->whereIn('id', $user_ids)
->orderBy('email')
->get();
}
}
} elseif (is_numeric($search)) {
// Search by user ID
$user = User::withTrashed()->where('id', $search)
->first();
if ($user) {
$result->push($user);
}
} elseif (strpos($search, '.') !== false) {
// Search by domain
$domain = Domain::withTrashed()->where('namespace', $search)
->first();
if ($domain) {
if (($wallet = $domain->wallet()) && ($owner = $wallet->owner()->withTrashed()->first())) {
$result->push($owner);
}
}
// A mollie customer ID
} elseif (substr($search, 0, 4) == 'cst_') {
$setting = \App\WalletSetting::where(
[
'key' => 'mollie_id',
'value' => $search
]
)->first();
if ($setting) {
if ($wallet = $setting->wallet) {
if ($owner = $wallet->owner()->withTrashed()->first()) {
$result->push($owner);
}
}
}
// A mollie transaction ID
} elseif (substr($search, 0, 3) == 'tr_') {
$payment = \App\Payment::find($search);
if ($payment) {
if ($owner = $payment->wallet->owner()->withTrashed()->first()) {
$result->push($owner);
}
}
} elseif (!empty($search)) {
$wallet = Wallet::find($search);
if ($wallet) {
if ($owner = $wallet->owner()->withTrashed()->first()) {
$result->push($owner);
}
}
}
// Process the result
$result = $result->map(
function ($user) {
return $this->objectToClient($user, true);
}
);
$result = [
'list' => $result,
'count' => count($result),
'message' => \trans('app.search-foundxusers', ['x' => count($result)]),
];
return response()->json($result);
}
/**
* Reset 2-Factor Authentication for the user
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function reset2FA(Request $request, $id)
{
$user = User::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(403);
}
$sku = Sku::withObjectTenantContext($user)->where('title', '2fa')->first();
// Note: we do select first, so the observer can delete
// 2FA preferences from Roundcube database, so don't
// be tempted to replace first() with delete() below
$entitlement = $user->entitlements()->where('sku_id', $sku->id)->first();
$entitlement->delete();
return response()->json([
'status' => 'success',
'message' => \trans('app.user-reset-2fa-success'),
]);
}
/**
* Set/Add a SKU for the user
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
* @param string $sku SKU title
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function setSku(Request $request, $id, $sku)
{
// For now we allow adding the 'beta' SKU only
if ($sku != 'beta') {
return $this->errorResponse(404);
}
$user = User::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(403);
}
$sku = Sku::withObjectTenantContext($user)->where('title', $sku)->first();
if (!$sku) {
return $this->errorResponse(404);
}
if ($user->entitlements()->where('sku_id', $sku->id)->first()) {
return $this->errorResponse(422, \trans('app.user-set-sku-already-exists'));
}
$user->assignSku($sku);
$entitlement = $user->entitlements()->where('sku_id', $sku->id)->first();
return response()->json([
'status' => 'success',
'message' => \trans('app.user-set-sku-success'),
'sku' => [
'cost' => $entitlement->cost,
'name' => $sku->name,
'id' => $sku->id,
]
]);
}
/**
* Display information on the user account specified by $id.
*
* @param int $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);
// Simplified Entitlement/SKU information,
// TODO: I agree this format may need to be extended in future
$response['skus'] = [];
foreach ($user->entitlements as $ent) {
$sku = $ent->sku;
if (!isset($response['skus'][$sku->id])) {
$response['skus'][$sku->id] = ['costs' => [], 'count' => 0];
}
$response['skus'][$sku->id]['count']++;
$response['skus'][$sku->id]['costs'][] = $ent->cost;
}
$response['config'] = $user->getConfig();
return response()->json($response);
}
/**
* Create a new user record.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
return $this->errorResponse(404);
}
/**
* Suspend the user
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function suspend(Request $request, $id)
{
$user = User::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(403);
}
$user->suspend();
return response()->json([
'status' => 'success',
'message' => \trans('app.user-suspend-success'),
]);
}
/**
* Un-Suspend the user
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function unsuspend(Request $request, $id)
{
$user = User::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(403);
}
$user->unsuspend();
return response()->json([
'status' => 'success',
'message' => \trans('app.user-unsuspend-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::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(403);
}
// For now admins can change only user external email address
$rules = [];
if (array_key_exists('external_email', $request->input())) {
$rules['external_email'] = 'email';
}
// Validate input
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
// Update user settings
$settings = $request->only(array_keys($rules));
if (!empty($settings)) {
$user->setSettings($settings);
}
return response()->json([
'status' => 'success',
'message' => \trans('app.user-update-success'),
]);
}
}
diff --git a/src/app/Http/Controllers/API/V4/DomainsController.php b/src/app/Http/Controllers/API/V4/DomainsController.php
index 639e1bd6..b247fbda 100644
--- a/src/app/Http/Controllers/API/V4/DomainsController.php
+++ b/src/app/Http/Controllers/API/V4/DomainsController.php
@@ -1,463 +1,463 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Domain;
use App\Http\Controllers\Controller;
use App\Backends\LDAP;
use App\Rules\UserEmailDomain;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class DomainsController extends Controller
{
/** @var array Common object properties in the API response */
- protected static $objectProps = ['namespace', 'status', 'type'];
+ protected static $objectProps = ['namespace', 'type'];
/**
* Return a list of domains owned by the current user
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->guard()->user();
$list = \collect($user->domains())
->filter(function ($domain) {
return !$domain->isPublic();
})
->map(function ($domain) {
return $this->objectToClient($domain);
})
->sortBy('namespace')
->values()
->all();
return response()->json($list);
}
/**
* Show the form for creating a new domain.
*
* @return \Illuminate\Http\JsonResponse
*/
public function create()
{
return $this->errorResponse(404);
}
/**
* Confirm ownership of the specified domain (via DNS check).
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function confirm($id)
{
$domain = Domain::find($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
if (!$domain->confirm()) {
return response()->json([
'status' => 'error',
'message' => \trans('app.domain-verify-error'),
]);
}
return response()->json([
'status' => 'success',
'statusInfo' => self::statusInfo($domain),
'message' => \trans('app.domain-verify-success'),
]);
}
/**
* Remove the specified domain.
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function destroy($id)
{
$domain = Domain::withEnvTenantContext()->find($id);
if (empty($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canDelete($domain)) {
return $this->errorResponse(403);
}
// It is possible to delete domain only if there are no users/aliases/groups using it.
if (!$domain->isEmpty()) {
$response = ['status' => 'error', 'message' => \trans('app.domain-notempty-error')];
return response()->json($response, 422);
}
$domain->delete();
return response()->json([
'status' => 'success',
'message' => \trans('app.domain-delete-success'),
]);
}
/**
* Show the form for editing the specified domain.
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function edit($id)
{
return $this->errorResponse(404);
}
/**
* Set the domain configuration.
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function setConfig($id)
{
$domain = Domain::find($id);
if (empty($domain)) {
return $this->errorResponse(404);
}
// Only owner (or admin) has access to the domain
if (!$this->guard()->user()->canUpdate($domain)) {
return $this->errorResponse(403);
}
$errors = $domain->setConfig(request()->input());
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
return response()->json([
'status' => 'success',
'message' => \trans('app.domain-setconfig-success'),
]);
}
/**
* Create a domain.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\JsonResponse
*/
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);
}
// Validate the input
$v = Validator::make(
$request->all(),
[
'namespace' => ['required', 'string', new UserEmailDomain()]
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$namespace = \strtolower(request()->input('namespace'));
// Domain already exists
if ($domain = Domain::withTrashed()->where('namespace', $namespace)->first()) {
// Check if the domain is soft-deleted and belongs to the same user
$deleteBeforeCreate = $domain->trashed() && ($wallet = $domain->wallet())
&& $wallet->owner && $wallet->owner->id == $owner->id;
if (!$deleteBeforeCreate) {
$errors = ['namespace' => \trans('validation.domainnotavailable')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
}
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();
// Force-delete the existing domain if it is soft-deleted and belongs to the same user
if (!empty($deleteBeforeCreate)) {
$domain->forceDelete();
}
// Create the domain
$domain = Domain::create([
'namespace' => $namespace,
'type' => \App\Domain::TYPE_EXTERNAL,
]);
$domain->assignPackage($package, $owner);
DB::commit();
return response()->json([
'status' => 'success',
'message' => __('app.domain-create-success'),
]);
}
/**
* Get the information about the specified domain.
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function show($id)
{
$domain = Domain::find($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
$response = $this->objectToClient($domain, true);
// Add hash information to the response
$response['hash_text'] = $domain->hash(Domain::HASH_TEXT);
$response['hash_cname'] = $domain->hash(Domain::HASH_CNAME);
$response['hash_code'] = $domain->hash(Domain::HASH_CODE);
// Add DNS/MX configuration for the domain
$response['dns'] = self::getDNSConfig($domain);
$response['mx'] = self::getMXConfig($domain->namespace);
// Domain configuration, e.g. spf whitelist
$response['config'] = $domain->getConfig();
// Status info
$response['statusInfo'] = self::statusInfo($domain);
// Entitlements info
$response['skus'] = \App\Entitlement::objectEntitlementsSummary($domain);
// Some basic information about the domain wallet
$wallet = $domain->wallet();
$response['wallet'] = $wallet->toArray();
if ($wallet->discount) {
$response['wallet']['discount'] = $wallet->discount->discount;
$response['wallet']['discount_description'] = $wallet->discount->description;
}
return response()->json($response);
}
/**
* Fetch domain status (and reload setup process)
*
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function status($id)
{
$domain = Domain::find($id);
if (!$this->checkTenant($domain)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($domain)) {
return $this->errorResponse(403);
}
$response = $this->processStateUpdate($domain);
$response = array_merge($response, self::objectState($domain));
return response()->json($response);
}
/**
* Update the specified domain.
*
* @param \Illuminate\Http\Request $request
* @param int $id Domain identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function update(Request $request, $id)
{
return $this->errorResponse(404);
}
/**
* Provide DNS MX information to configure specified domain for
*/
protected static function getMXConfig(string $namespace): array
{
$entries = [];
// copy MX entries from an existing domain
if ($master = \config('dns.copyfrom')) {
// TODO: cache this lookup
foreach ((array) dns_get_record($master, DNS_MX) as $entry) {
$entries[] = sprintf(
"@\t%s\t%s\tMX\t%d %s.",
\config('dns.ttl', $entry['ttl']),
$entry['class'],
$entry['pri'],
$entry['target']
);
}
} elseif ($static = \config('dns.static')) {
$entries[] = strtr($static, array('\n' => "\n", '%s' => $namespace));
}
// display SPF settings
if ($spf = \config('dns.spf')) {
$entries[] = ';';
foreach (['TXT', 'SPF'] as $type) {
$entries[] = sprintf(
"@\t%s\tIN\t%s\t\"%s\"",
\config('dns.ttl'),
$type,
$spf
);
}
}
return $entries;
}
/**
* Provide sample DNS config for domain confirmation
*/
protected static function getDNSConfig(Domain $domain): array
{
$serial = date('Ymd01');
$hash_txt = $domain->hash(Domain::HASH_TEXT);
$hash_cname = $domain->hash(Domain::HASH_CNAME);
$hash = $domain->hash(Domain::HASH_CODE);
return [
"@ IN SOA ns1.dnsservice.com. hostmaster.{$domain->namespace}. (",
" {$serial} 10800 3600 604800 86400 )",
";",
"@ IN A <some-ip>",
"www IN A <some-ip>",
";",
"{$hash_cname}.{$domain->namespace}. IN CNAME {$hash}.{$domain->namespace}.",
"@ 3600 TXT \"{$hash_txt}\"",
];
}
/**
* Prepare domain statuses for the UI
*
* @param \App\Domain $domain Domain object
*
* @return array Statuses array
*/
protected static function objectState(Domain $domain): array
{
return [
'isLdapReady' => $domain->isLdapReady(),
'isConfirmed' => $domain->isConfirmed(),
'isVerified' => $domain->isVerified(),
'isSuspended' => $domain->isSuspended(),
'isActive' => $domain->isActive(),
'isDeleted' => $domain->isDeleted() || $domain->trashed(),
];
}
/**
* Domain status (extended) information.
*
* @param \App\Domain $domain Domain object
*
* @return array Status information
*/
public static function statusInfo(Domain $domain): array
{
// If that is not a public domain, add domain specific steps
return self::processStateInfo(
$domain,
[
'domain-new' => true,
'domain-ldap-ready' => $domain->isLdapReady(),
'domain-verified' => $domain->isVerified(),
'domain-confirmed' => [$domain->isConfirmed(), "/domain/{$domain->id}"],
]
);
}
/**
* Execute (synchronously) specified step in a domain setup process.
*
* @param \App\Domain $domain Domain object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool True if the execution succeeded, False otherwise
*/
public static function execProcessStep(Domain $domain, string $step): bool
{
try {
switch ($step) {
case 'domain-ldap-ready':
// Domain not in LDAP, create it
if (!$domain->isLdapReady()) {
LDAP::createDomain($domain);
$domain->status |= Domain::STATUS_LDAP_READY;
$domain->save();
}
return $domain->isLdapReady();
case 'domain-verified':
// Domain existence not verified
$domain->verify();
return $domain->isVerified();
case 'domain-confirmed':
// Domain ownership confirmation
$domain->confirm();
return $domain->isConfirmed();
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
}
diff --git a/src/app/Http/Controllers/API/V4/GroupsController.php b/src/app/Http/Controllers/API/V4/GroupsController.php
index d7a01ea9..e63d46d3 100644
--- a/src/app/Http/Controllers/API/V4/GroupsController.php
+++ b/src/app/Http/Controllers/API/V4/GroupsController.php
@@ -1,488 +1,488 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Domain;
use App\Group;
use App\Rules\GroupName;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class GroupsController extends Controller
{
/** @var array Common object properties in the API response */
- protected static $objectProps = ['email', 'name', 'status'];
+ protected static $objectProps = ['email', 'name'];
/**
* Show the form for creating a new group.
*
* @return \Illuminate\Http\JsonResponse
*/
public function create()
{
return $this->errorResponse(404);
}
/**
* Delete a group.
*
* @param int $id Group identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function destroy($id)
{
$group = Group::find($id);
if (!$this->checkTenant($group)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canDelete($group)) {
return $this->errorResponse(403);
}
$group->delete();
return response()->json([
'status' => 'success',
'message' => \trans('app.distlist-delete-success'),
]);
}
/**
* Show the form for editing the specified group.
*
* @param int $id Group identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function edit($id)
{
return $this->errorResponse(404);
}
/**
* Listing of groups belonging to the authenticated user.
*
* The group-entitlements billed to the current user wallet(s)
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->guard()->user();
$result = $user->groups()->orderBy('name')->orderBy('email')->get()
->map(function ($group) {
return $this->objectToClient($group);
});
return response()->json($result);
}
/**
* Set the group configuration.
*
* @param int $id Group identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function setConfig($id)
{
$group = Group::find($id);
if (!$this->checkTenant($group)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($group)) {
return $this->errorResponse(403);
}
$errors = $group->setConfig(request()->input());
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
return response()->json([
'status' => 'success',
'message' => \trans('app.distlist-setconfig-success'),
]);
}
/**
* Display information of a group specified by $id.
*
* @param int $id The group to show information for.
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$group = Group::find($id);
if (!$this->checkTenant($group)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($group)) {
return $this->errorResponse(403);
}
$response = $this->objectToClient($group, true);
$response['statusInfo'] = self::statusInfo($group);
// Group configuration, e.g. sender_policy
$response['config'] = $group->getConfig();
return response()->json($response);
}
/**
* Fetch group status (and reload setup process)
*
* @param int $id Group identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function status($id)
{
$group = Group::find($id);
if (!$this->checkTenant($group)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($group)) {
return $this->errorResponse(403);
}
$response = $this->processStateUpdate($group);
$response = array_merge($response, self::objectState($group));
return response()->json($response);
}
/**
* Group status (extended) information
*
* @param \App\Group $group Group object
*
* @return array Status information
*/
public static function statusInfo(Group $group): array
{
return self::processStateInfo(
$group,
[
'distlist-new' => true,
'distlist-ldap-ready' => $group->isLdapReady(),
]
);
}
/**
* Create a new group 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);
}
$email = $request->input('email');
$members = $request->input('members');
$errors = [];
$rules = [
'name' => 'required|string|max:191',
];
// Validate group address
if ($error = GroupsController::validateGroupEmail($email, $owner)) {
$errors['email'] = $error;
} else {
list(, $domainName) = explode('@', $email);
$rules['name'] = ['required', 'string', new GroupName($owner, $domainName)];
}
// Validate the group name
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = array_merge($errors, $v->errors()->toArray());
}
// Validate members' email addresses
if (empty($members) || !is_array($members)) {
$errors['members'] = \trans('validation.listmembersrequired');
} else {
foreach ($members as $i => $member) {
if (is_string($member) && !empty($member)) {
if ($error = GroupsController::validateMemberEmail($member, $owner)) {
$errors['members'][$i] = $error;
} elseif (\strtolower($member) === \strtolower($email)) {
$errors['members'][$i] = \trans('validation.memberislist');
}
} else {
unset($members[$i]);
}
}
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
DB::beginTransaction();
// Create the group
$group = new Group();
$group->name = $request->input('name');
$group->email = $email;
$group->members = $members;
$group->save();
$group->assignToWallet($owner->wallets->first());
DB::commit();
return response()->json([
'status' => 'success',
'message' => \trans('app.distlist-create-success'),
]);
}
/**
* Update a group.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id Group identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$group = Group::find($id);
if (!$this->checkTenant($group)) {
return $this->errorResponse(404);
}
$current_user = $this->guard()->user();
if (!$current_user->canUpdate($group)) {
return $this->errorResponse(403);
}
$owner = $group->wallet()->owner;
$name = $request->input('name');
$members = $request->input('members');
$errors = [];
// Validate the group name
if ($name !== null && $name != $group->name) {
list(, $domainName) = explode('@', $group->email);
$rules = ['name' => ['required', 'string', new GroupName($owner, $domainName)]];
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = array_merge($errors, $v->errors()->toArray());
} else {
$group->name = $name;
}
}
// Validate members' email addresses
if (empty($members) || !is_array($members)) {
$errors['members'] = \trans('validation.listmembersrequired');
} else {
foreach ((array) $members as $i => $member) {
if (is_string($member) && !empty($member)) {
if ($error = GroupsController::validateMemberEmail($member, $owner)) {
$errors['members'][$i] = $error;
} elseif (\strtolower($member) === $group->email) {
$errors['members'][$i] = \trans('validation.memberislist');
}
} else {
unset($members[$i]);
}
}
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$group->members = $members;
$group->save();
return response()->json([
'status' => 'success',
'message' => \trans('app.distlist-update-success'),
]);
}
/**
* Execute (synchronously) specified step in a group setup process.
*
* @param \App\Group $group Group 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(Group $group, string $step): ?bool
{
try {
if (strpos($step, 'domain-') === 0) {
return DomainsController::execProcessStep($group->domain(), $step);
}
switch ($step) {
case 'distlist-ldap-ready':
// Group not in LDAP, create it
$job = new \App\Jobs\Group\CreateJob($group->id);
$job->handle();
$group->refresh();
return $group->isLdapReady();
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
/**
* Prepare group statuses for the UI
*
* @param \App\Group $group Group object
*
* @return array Statuses array
*/
protected static function objectState(Group $group): array
{
return [
'isLdapReady' => $group->isLdapReady(),
'isSuspended' => $group->isSuspended(),
'isActive' => $group->isActive(),
'isDeleted' => $group->isDeleted() || $group->trashed(),
];
}
/**
* Validate an email address for use as a group email
*
* @param string $email Email address
* @param \App\User $user The group owner
*
* @return ?string Error message on validation error
*/
public static function validateGroupEmail($email, \App\User $user): ?string
{
if (empty($email)) {
return \trans('validation.required', ['attribute' => 'email']);
}
if (strpos($email, '@') === false) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
list($login, $domain) = explode('@', \strtolower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
// Check if domain exists
$domain = Domain::where('namespace', $domain)->first();
if (empty($domain)) {
return \trans('validation.domaininvalid');
}
$wallet = $domain->wallet();
// The domain must be owned by the user
if (!$wallet || !$user->wallets()->find($wallet->id)) {
return \trans('validation.domainnotavailable');
}
// Validate login part alone
$v = Validator::make(
['email' => $login],
['email' => [new \App\Rules\UserEmailLocal(true)]]
);
if ($v->fails()) {
return $v->errors()->toArray()['email'][0];
}
// Check if a user with specified address already exists
if (User::emailExists($email)) {
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']);
}
if (Group::emailExists($email)) {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
return null;
}
/**
* Validate an email address for use as a group member
*
* @param string $email Email address
* @param \App\User $user The group owner
*
* @return ?string Error message on validation error
*/
public static function validateMemberEmail($email, \App\User $user): ?string
{
$v = Validator::make(
['email' => $email],
['email' => [new \App\Rules\ExternalEmail()]]
);
if ($v->fails()) {
return $v->errors()->toArray()['email'][0];
}
// A local domain user must exist
if (!User::where('email', \strtolower($email))->first()) {
list($login, $domain) = explode('@', \strtolower($email));
$domain = Domain::where('namespace', $domain)->first();
// We return an error only if the domain belongs to the group owner
if ($domain && ($wallet = $domain->wallet()) && $user->wallets()->find($wallet->id)) {
return \trans('validation.notalocaluser');
}
}
return null;
}
}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php
index ee73ddf2..5f51beeb 100644
--- a/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php
+++ b/src/app/Http/Controllers/API/V4/Reseller/GroupsController.php
@@ -1,53 +1,46 @@
<?php
namespace App\Http\Controllers\API\V4\Reseller;
use App\Group;
use App\User;
class GroupsController extends \App\Http\Controllers\API\V4\Admin\GroupsController
{
/**
* Search for groups
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$search = trim(request()->input('search'));
$owner = trim(request()->input('owner'));
$result = collect([]);
if ($owner) {
if ($owner = User::withSubjectTenantContext()->find($owner)) {
- foreach ($owner->wallets as $wallet) {
- $wallet->entitlements()->where('entitleable_type', Group::class)->get()
- ->each(function ($entitlement) use ($result) {
- $result->push($entitlement->entitleable);
- });
- }
-
- $result = $result->sortBy('name')->values();
+ $result = $owner->groups(false)->orderBy('name')->get();
}
} elseif (!empty($search)) {
if ($group = Group::withSubjectTenantContext()->where('email', $search)->first()) {
$result->push($group);
}
}
// Process the result
$result = $result->map(
function ($group) {
return $this->objectToClient($group);
}
);
$result = [
'list' => $result,
'count' => count($result),
'message' => \trans('app.search-foundxdistlists', ['x' => count($result)]),
];
return response()->json($result);
}
}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/ResourcesController.php b/src/app/Http/Controllers/API/V4/Reseller/ResourcesController.php
new file mode 100644
index 00000000..7e860cf4
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/ResourcesController.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+use App\Resource;
+use App\User;
+
+class ResourcesController extends \App\Http\Controllers\API\V4\Admin\ResourcesController
+{
+ /**
+ * Search for resources
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $search = trim(request()->input('search'));
+ $owner = trim(request()->input('owner'));
+ $result = collect([]);
+
+ if ($owner) {
+ if ($owner = User::withSubjectTenantContext()->find($owner)) {
+ $result = $owner->resources(false)->orderBy('name')->get();
+ }
+ } elseif (!empty($search)) {
+ if ($resource = Resource::withSubjectTenantContext()->where('email', $search)->first()) {
+ $result->push($resource);
+ }
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($resource) {
+ return $this->objectToClient($resource);
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxresources', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php
new file mode 100644
index 00000000..b7c34d7b
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/ResourcesController.php
@@ -0,0 +1,353 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\Controller;
+use App\Resource;
+use App\Rules\ResourceName;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+
+class ResourcesController extends Controller
+{
+ /** @var array Common object properties in the API response */
+ protected static $objectProps = ['email', 'name'];
+
+ /**
+ * Show the form for creating a new resource.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function create()
+ {
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Delete a resource.
+ *
+ * @param int $id Resource identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function destroy($id)
+ {
+ $resource = Resource::find($id);
+
+ if (!$this->checkTenant($resource)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canDelete($resource)) {
+ return $this->errorResponse(403);
+ }
+
+ $resource->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.resource-delete-success'),
+ ]);
+ }
+
+ /**
+ * Show the form for editing the specified resource.
+ *
+ * @param int $id Resource identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function edit($id)
+ {
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Listing of resources belonging to the authenticated user.
+ *
+ * The resource-entitlements billed to the current user wallet(s)
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $user = $this->guard()->user();
+
+ $result = $user->resources()->orderBy('name')->get()
+ ->map(function (Resource $resource) {
+ return $this->objectToClient($resource);
+ });
+
+ return response()->json($result);
+ }
+
+ /**
+ * Set the resource configuration.
+ *
+ * @param int $id Resource identifier
+ *
+ * @return \Illuminate\Http\JsonResponse|void
+ */
+ public function setConfig($id)
+ {
+ $resource = Resource::find($id);
+
+ if (!$this->checkTenant($resource)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canUpdate($resource)) {
+ return $this->errorResponse(403);
+ }
+
+ $errors = $resource->setConfig(request()->input());
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.resource-setconfig-success'),
+ ]);
+ }
+
+ /**
+ * Display information of a resource specified by $id.
+ *
+ * @param int $id Resource identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function show($id)
+ {
+ $resource = Resource::find($id);
+
+ if (!$this->checkTenant($resource)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canRead($resource)) {
+ return $this->errorResponse(403);
+ }
+
+ $response = $this->objectToClient($resource, true);
+
+ $response['statusInfo'] = self::statusInfo($resource);
+
+ // Resource configuration, e.g. invitation_policy
+ $response['config'] = $resource->getConfig();
+
+ return response()->json($response);
+ }
+
+ /**
+ * Fetch resource status (and reload setup process)
+ *
+ * @param int $id Resource identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function status($id)
+ {
+ $resource = Resource::find($id);
+
+ if (!$this->checkTenant($resource)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canRead($resource)) {
+ return $this->errorResponse(403);
+ }
+
+ $response = $this->processStateUpdate($resource);
+ $response = array_merge($response, self::objectState($resource));
+
+ return response()->json($response);
+ }
+
+ /**
+ * Resource status (extended) information
+ *
+ * @param \App\Resource $resource Resource object
+ *
+ * @return array Status information
+ */
+ public static function statusInfo(Resource $resource): array
+ {
+ return self::processStateInfo(
+ $resource,
+ [
+ 'resource-new' => true,
+ 'resource-ldap-ready' => $resource->isLdapReady(),
+ 'resource-imap-ready' => $resource->isImapReady(),
+ ]
+ );
+ }
+
+ /**
+ * Create a new resource 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);
+ }
+
+ $domain = request()->input('domain');
+
+ $rules = ['name' => ['required', 'string', new ResourceName($owner, $domain)]];
+
+ $v = Validator::make($request->all(), $rules);
+
+ if ($v->fails()) {
+ $errors = $v->errors()->toArray();
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ DB::beginTransaction();
+
+ // Create the resource
+ $resource = new Resource();
+ $resource->name = request()->input('name');
+ $resource->domain = $domain;
+ $resource->save();
+
+ $resource->assignToWallet($owner->wallets->first());
+
+ DB::commit();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.resource-create-success'),
+ ]);
+ }
+
+ /**
+ * Update a resource.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @param string $id Resource identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function update(Request $request, $id)
+ {
+ $resource = Resource::find($id);
+
+ if (!$this->checkTenant($resource)) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+
+ if (!$current_user->canUpdate($resource)) {
+ return $this->errorResponse(403);
+ }
+
+ $owner = $resource->wallet()->owner;
+
+ $name = $request->input('name');
+ $errors = [];
+
+ // Validate the resource name
+ if ($name !== null && $name != $resource->name) {
+ $domainName = explode('@', $resource->email, 2)[1];
+ $rules = ['name' => ['required', 'string', new ResourceName($owner, $domainName)]];
+
+ $v = Validator::make($request->all(), $rules);
+
+ if ($v->fails()) {
+ $errors = $v->errors()->toArray();
+ } else {
+ $resource->name = $name;
+ }
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $resource->save();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.resource-update-success'),
+ ]);
+ }
+
+ /**
+ * Execute (synchronously) specified step in a resource setup process.
+ *
+ * @param \App\Resource $resource Resource 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(Resource $resource, string $step): ?bool
+ {
+ try {
+ if (strpos($step, 'domain-') === 0) {
+ return DomainsController::execProcessStep($resource->domain(), $step);
+ }
+
+ switch ($step) {
+ case 'resource-ldap-ready':
+ // Resource not in LDAP, create it
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
+ $job->handle();
+
+ $resource->refresh();
+
+ return $resource->isLdapReady();
+
+ case 'resource-imap-ready':
+ // Resource 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\Resource\VerifyJob::dispatch($resource->id);
+
+ return null;
+ }
+
+ $job = new \App\Jobs\Resource\VerifyJob($resource->id);
+ $job->handle();
+
+ $resource->refresh();
+
+ return $resource->isImapReady();
+ }
+ } catch (\Exception $e) {
+ \Log::error($e);
+ }
+
+ return false;
+ }
+
+ /**
+ * Prepare resource statuses for the UI
+ *
+ * @param \App\Resource $resource Resource object
+ *
+ * @return array Statuses array
+ */
+ protected static function objectState(Resource $resource): array
+ {
+ return [
+ 'isLdapReady' => $resource->isLdapReady(),
+ 'isImapReady' => $resource->isImapReady(),
+ 'isActive' => $resource->isActive(),
+ 'isDeleted' => $resource->isDeleted() || $resource->trashed(),
+ ];
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
index 789a5747..e427555c 100644
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -1,803 +1,808 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Domain;
use App\Group;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\Sku;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UsersController extends Controller
{
/** @const array List of user setting keys available for modification in UI */
public const USER_SETTINGS = [
'billing_address',
'country',
'currency',
'external_email',
'first_name',
'last_name',
'organization',
'phone',
];
/**
* On user create it is filled with a user or group object to force-delete
* before the creation of a new user record is possible.
*
* @var \App\User|\App\Group|null
*/
protected $deleteBeforeCreate;
/** @var array Common object properties in the API response */
- protected static $objectProps = ['email', 'status'];
+ protected static $objectProps = ['email'];
/**
* Delete a user.
*
* @param int $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function destroy($id)
{
$user = User::withEnvTenantContext()->find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
// User can't remove himself until he's the controller
if (!$this->guard()->user()->canDelete($user)) {
return $this->errorResponse(403);
}
$user->delete();
return response()->json([
'status' => 'success',
'message' => \trans('app.user-delete-success'),
]);
}
/**
* Listing of users.
*
* The user-entitlements billed to the current user wallet(s)
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->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);
}
/**
* Set user config.
*
* @param int $id The user
*
* @return \Illuminate\Http\JsonResponse
*/
public function setConfig($id)
{
$user = User::find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canUpdate($user)) {
return $this->errorResponse(403);
}
$errors = $user->setConfig(request()->input());
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
return response()->json([
'status' => 'success',
'message' => \trans('app.user-setconfig-success'),
]);
}
/**
* Display information on the user account specified by $id.
*
* @param int $id The account to show information for.
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$user = User::withEnvTenantContext()->find($id);
if (empty($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);
}
/**
* Fetch user status (and reload setup process)
*
* @param int $id User identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function status($id)
{
$user = User::withEnvTenantContext()->find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($user)) {
return $this->errorResponse(403);
}
$response = $this->processStateUpdate($user);
$response = array_merge($response, self::objectState($user));
return response()->json($response);
}
/**
* User status (extended) information
*
* @param \App\User $user User object
*
* @return array Status information
*/
public static function statusInfo(User $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('distlist', $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 = self::objectToClient($user, true);
// 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 $user): array
{
return [
'isImapReady' => $user->isImapReady(),
'isLdapReady' => $user->isLdapReady(),
'isSuspended' => $user->isSuspended(),
'isActive' => $user->isActive(),
'isDeleted' => $user->isDeleted() || $user->trashed(),
];
}
/**
* 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::withEnvTenantContext()->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
$domains = \collect($user->domains())->pluck('namespace')->all();
if (!in_array($domain->namespace, $domains)) {
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 with specified address already exists
- if ($existing_group = Group::emailExists($email, true)) {
- // If this is a deleted group in the same custom domain
+ // 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_group->trashed()) {
- $deleted = $existing_group;
+ 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::withEnvTenantContext()->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
$domains = \collect($user->domains())->pluck('namespace')->all();
if (!in_array($domain->namespace, $domains)) {
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/Resource/CreateJob.php b/src/app/Jobs/Resource/CreateJob.php
new file mode 100644
index 00000000..5cd2cc7e
--- /dev/null
+++ b/src/app/Jobs/Resource/CreateJob.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Jobs\Resource;
+
+use App\Jobs\ResourceJob;
+
+class CreateJob extends ResourceJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $resource = $this->getResource();
+
+ if (!$resource) {
+ return;
+ }
+
+ // sanity checks
+ if ($resource->isDeleted()) {
+ $this->fail(new \Exception("Resource {$this->resourceId} is marked as deleted."));
+ return;
+ }
+
+ if ($resource->trashed()) {
+ $this->fail(new \Exception("Resource {$this->resourceId} is actually deleted."));
+ return;
+ }
+
+ if ($resource->isLdapReady()) {
+ $this->fail(new \Exception("Resource {$this->resourceId} is already marked as ldap-ready."));
+ return;
+ }
+
+ // see if the domain is ready
+ $domain = $resource->domain();
+
+ if (!$domain) {
+ $this->fail(new \Exception("The domain for resource {$this->resourceId} does not exist."));
+ return;
+ }
+
+ if ($domain->isDeleted()) {
+ $this->fail(new \Exception("The domain for resource {$this->resourceId} is marked as deleted."));
+ return;
+ }
+
+ if (!$domain->isLdapReady()) {
+ $this->release(60);
+ return;
+ }
+
+ \App\Backends\LDAP::createResource($resource);
+
+ $resource->status |= \App\Resource::STATUS_LDAP_READY;
+ $resource->save();
+ }
+}
diff --git a/src/app/Jobs/Resource/DeleteJob.php b/src/app/Jobs/Resource/DeleteJob.php
new file mode 100644
index 00000000..c109e2ac
--- /dev/null
+++ b/src/app/Jobs/Resource/DeleteJob.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Jobs\Resource;
+
+use App\Jobs\ResourceJob;
+
+class DeleteJob extends ResourceJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $resource = $this->getResource();
+
+ if (!$resource) {
+ return;
+ }
+
+ // sanity checks
+ if ($resource->isDeleted()) {
+ $this->fail(new \Exception("Resource {$this->resourceId} is already marked as deleted."));
+ return;
+ }
+
+ \App\Backends\LDAP::deleteResource($resource);
+
+ $resource->status |= \App\Resource::STATUS_DELETED;
+
+ if ($resource->isLdapReady()) {
+ $resource->status ^= \App\Resource::STATUS_LDAP_READY;
+ }
+
+ if ($resource->isImapReady()) {
+ $resource->status ^= \App\Resource::STATUS_IMAP_READY;
+ }
+
+ $resource->save();
+ }
+}
diff --git a/src/app/Jobs/Resource/UpdateJob.php b/src/app/Jobs/Resource/UpdateJob.php
new file mode 100644
index 00000000..86b7d201
--- /dev/null
+++ b/src/app/Jobs/Resource/UpdateJob.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Jobs\Resource;
+
+use App\Jobs\ResourceJob;
+
+class UpdateJob extends ResourceJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $resource = $this->getResource();
+
+ if (!$resource) {
+ return;
+ }
+
+ // Cancel the update if the resource is deleted or not yet in LDAP
+ if (!$resource->isLdapReady() || $resource->isDeleted()) {
+ $this->delete();
+ return;
+ }
+
+ \App\Backends\LDAP::updateResource($resource);
+ }
+}
diff --git a/src/app/Jobs/Resource/VerifyJob.php b/src/app/Jobs/Resource/VerifyJob.php
new file mode 100644
index 00000000..44ae6e69
--- /dev/null
+++ b/src/app/Jobs/Resource/VerifyJob.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Jobs\Resource;
+
+use App\Jobs\ResourceJob;
+
+class VerifyJob extends ResourceJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $resource = $this->getResource();
+
+ if (!$resource) {
+ return;
+ }
+
+ // the user has a mailbox (or is marked as such)
+ if ($resource->isImapReady()) {
+ $this->fail(new \Exception("Resource {$this->resourceId} is already verified."));
+ return;
+ }
+
+ $folder = $resource->getSetting('folder');
+
+ if ($folder && \App\Backends\IMAP::verifySharedFolder($folder)) {
+ $resource->status |= \App\Resource::STATUS_IMAP_READY;
+ $resource->status |= \App\Resource::STATUS_ACTIVE;
+ $resource->save();
+ }
+ }
+}
diff --git a/src/app/Jobs/ResourceJob.php b/src/app/Jobs/ResourceJob.php
new file mode 100644
index 00000000..74cc2a3d
--- /dev/null
+++ b/src/app/Jobs/ResourceJob.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace App\Jobs;
+
+/**
+ * The abstract \App\Jobs\ResourceJob implements the logic needed for all dispatchable Jobs related to
+ * \App\Resource objects.
+ *
+ * ```php
+ * $job = new \App\Jobs\Resource\CreateJob($resourceId);
+ * $job->handle();
+ * ```
+ */
+abstract class ResourceJob extends CommonJob
+{
+ /**
+ * The ID for the \App\Resource. This is the shortest globally unique identifier and saves Redis space
+ * compared to a serialized version of the complete \App\Resource object.
+ *
+ * @var int
+ */
+ protected $resourceId;
+
+ /**
+ * The \App\Resource email property, for legibility in the queue management.
+ *
+ * @var string
+ */
+ protected $resourceEmail;
+
+ /**
+ * Create a new job instance.
+ *
+ * @param int $resourceId The ID for the resource to process.
+ *
+ * @return void
+ */
+ public function __construct(int $resourceId)
+ {
+ $this->resourceId = $resourceId;
+
+ $resource = $this->getResource();
+
+ if ($resource) {
+ $this->resourceEmail = $resource->email;
+ }
+ }
+
+ /**
+ * Get the \App\Resource entry associated with this job.
+ *
+ * @return \App\Resource|null
+ *
+ * @throws \Exception
+ */
+ protected function getResource()
+ {
+ $resource = \App\Resource::withTrashed()->find($this->resourceId);
+
+ if (!$resource) {
+ // The record might not exist yet in case of a db replication environment
+ // This will release the job and delay another attempt for 5 seconds
+ if ($this instanceof Resource\CreateJob) {
+ $this->release(5);
+ return null;
+ }
+
+ $this->fail(new \Exception("Resource {$this->resourceId} could not be found in the database."));
+ }
+
+ return $resource;
+ }
+}
diff --git a/src/app/Observers/ResourceObserver.php b/src/app/Observers/ResourceObserver.php
new file mode 100644
index 00000000..1e6f13e2
--- /dev/null
+++ b/src/app/Observers/ResourceObserver.php
@@ -0,0 +1,136 @@
+<?php
+
+namespace App\Observers;
+
+use App\Resource;
+
+class ResourceObserver
+{
+ /**
+ * Handle the resource "creating" event.
+ *
+ * @param \App\Resource $resource The resource
+ *
+ * @return void
+ */
+ public function creating(Resource $resource): void
+ {
+ if (empty($resource->email)) {
+ if (!isset($resource->name)) {
+ throw new \Exception("Missing 'domain' property for a new resource");
+ }
+
+ $domainName = \strtolower($resource->domain);
+
+ $resource->email = "resource-{$resource->id}@{$domainName}";
+ } else {
+ $resource->email = \strtolower($resource->email);
+ }
+
+ $resource->status |= Resource::STATUS_NEW | Resource::STATUS_ACTIVE;
+ }
+
+ /**
+ * Handle the resource "created" event.
+ *
+ * @param \App\Resource $resource The resource
+ *
+ * @return void
+ */
+ public function created(Resource $resource)
+ {
+ $domainName = explode('@', $resource->email, 2)[1];
+
+ $settings = [
+ 'folder' => "shared/Resources/{$resource->name}@{$domainName}",
+ ];
+
+ foreach ($settings as $key => $value) {
+ $settings[$key] = [
+ 'key' => $key,
+ 'value' => $value,
+ 'resource_id' => $resource->id,
+ ];
+ }
+
+ // Note: Don't use setSettings() here to bypass ResourceSetting observers
+ // Note: This is a single multi-insert query
+ $resource->settings()->insert(array_values($settings));
+
+ // Create resource record in LDAP, then check if it is created in IMAP
+ $chain = [
+ new \App\Jobs\Resource\VerifyJob($resource->id),
+ ];
+
+ \App\Jobs\Resource\CreateJob::withChain($chain)->dispatch($resource->id);
+ }
+
+ /**
+ * Handle the resource "deleting" event.
+ *
+ * @param \App\Resource $resource The resource
+ *
+ * @return void
+ */
+ public function deleting(Resource $resource)
+ {
+ // Entitlements do not have referential integrity on the entitled object, so this is our
+ // way of doing an onDelete('cascade') without the foreign key.
+ \App\Entitlement::where('entitleable_id', $resource->id)
+ ->where('entitleable_type', Resource::class)
+ ->delete();
+ }
+
+ /**
+ * Handle the resource "deleted" event.
+ *
+ * @param \App\Resource $resource The resource
+ *
+ * @return void
+ */
+ public function deleted(Resource $resource)
+ {
+ if ($resource->isForceDeleting()) {
+ return;
+ }
+
+ \App\Jobs\Resource\DeleteJob::dispatch($resource->id);
+ }
+
+ /**
+ * Handle the resource "updated" event.
+ *
+ * @param \App\Resource $resource The resource
+ *
+ * @return void
+ */
+ public function updated(Resource $resource)
+ {
+ \App\Jobs\Resource\UpdateJob::dispatch($resource->id);
+
+ // Update the folder property if name changed
+ if ($resource->name != $resource->getOriginal('name')) {
+ $domainName = explode('@', $resource->email, 2)[1];
+ $folder = "shared/Resources/{$resource->name}@{$domainName}";
+
+ // Note: This does not invoke ResourceSetting observer events, good.
+ $resource->settings()->where('key', 'folder')->update(['value' => $folder]);
+ }
+ }
+
+ /**
+ * Handle the resource "force deleted" event.
+ *
+ * @param \App\Resource $resource The resource
+ *
+ * @return void
+ */
+ public function forceDeleted(Resource $resource)
+ {
+ // A group can be force-deleted separately from the owner
+ // we have to force-delete entitlements
+ \App\Entitlement::where('entitleable_id', $resource->id)
+ ->where('entitleable_type', Resource::class)
+ ->forceDelete();
+ }
+}
diff --git a/src/app/Observers/ResourceSettingObserver.php b/src/app/Observers/ResourceSettingObserver.php
new file mode 100644
index 00000000..4af7ca81
--- /dev/null
+++ b/src/app/Observers/ResourceSettingObserver.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace App\Observers;
+
+use App\Backends\LDAP;
+use App\ResourceSetting;
+
+class ResourceSettingObserver
+{
+ /**
+ * Handle the resource setting "created" event.
+ *
+ * @param \App\ResourceSetting $resourceSetting Settings object
+ *
+ * @return void
+ */
+ public function created(ResourceSetting $resourceSetting)
+ {
+ if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) {
+ \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id);
+ }
+ }
+
+ /**
+ * Handle the resource setting "updated" event.
+ *
+ * @param \App\ResourceSetting $resourceSetting Settings object
+ *
+ * @return void
+ */
+ public function updated(ResourceSetting $resourceSetting)
+ {
+ if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) {
+ \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id);
+ }
+ }
+
+ /**
+ * Handle the resource setting "deleted" event.
+ *
+ * @param \App\ResourceSetting $resourceSetting Settings object
+ *
+ * @return void
+ */
+ public function deleted(ResourceSetting $resourceSetting)
+ {
+ if (in_array($resourceSetting->key, LDAP::RESOURCE_SETTINGS)) {
+ \App\Jobs\Resource\UpdateJob::dispatch($resourceSetting->resource_id);
+ }
+ }
+}
diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php
index 5b459fba..08ad8503 100644
--- a/src/app/Observers/UserObserver.php
+++ b/src/app/Observers/UserObserver.php
@@ -1,348 +1,366 @@
<?php
namespace App\Observers;
use App\Entitlement;
use App\Domain;
use App\Group;
+use App\Resource;
use App\Transaction;
use App\User;
use App\Wallet;
use Illuminate\Support\Facades\DB;
class UserObserver
{
/**
* Handle the "creating" event.
*
* Ensure that the user is created with a random, large integer.
*
* @param \App\User $user The user being created.
*
* @return void
*/
public function creating(User $user)
{
$user->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();
}
});
}
// 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 "deleting" event.
*
* @param User $user The user that is being deleted.
*
* @return void
*/
public function deleting(User $user)
{
if ($user->isForceDeleting()) {
$this->forceDeleting($user);
return;
}
// TODO: Especially in tests we're doing delete() on a already deleted user.
// Should we escape here - for performance reasons?
// TODO: I think all of this should use database transactions
// Entitlements do not have referential integrity on the entitled object, so this is our
// way of doing an onDelete('cascade') without the foreign key.
$entitlements = Entitlement::where('entitleable_id', $user->id)
->where('entitleable_type', User::class)->get();
foreach ($entitlements as $entitlement) {
$entitlement->delete();
}
// Remove owned users/domains
$wallets = $user->wallets()->pluck('id')->all();
$assignments = Entitlement::whereIn('wallet_id', $wallets)->get();
$users = [];
$domains = [];
$groups = [];
+ $resources = [];
$entitlements = [];
foreach ($assignments as $entitlement) {
if ($entitlement->entitleable_type == Domain::class) {
$domains[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id) {
$users[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Group::class) {
$groups[] = $entitlement->entitleable_id;
+ } elseif ($entitlement->entitleable_type == Resource::class) {
+ $resources[] = $entitlement->entitleable_id;
} else {
$entitlements[] = $entitlement;
}
}
// Domains/users/entitlements need to be deleted one by one to make sure
// events are fired and observers can do the proper cleanup.
if (!empty($users)) {
foreach (User::whereIn('id', array_unique($users))->get() as $_user) {
$_user->delete();
}
}
if (!empty($domains)) {
foreach (Domain::whereIn('id', array_unique($domains))->get() as $_domain) {
$_domain->delete();
}
}
if (!empty($groups)) {
foreach (Group::whereIn('id', array_unique($groups))->get() as $_group) {
$_group->delete();
}
}
+ if (!empty($resources)) {
+ foreach (Resource::whereIn('id', array_unique($resources))->get() as $_resource) {
+ $_resource->delete();
+ }
+ }
+
foreach ($entitlements as $entitlement) {
$entitlement->delete();
}
// FIXME: What do we do with user wallets?
\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);
}
}
/**
* Handle the "deleting" event on forceDelete() call.
*
* @param User $user The user that is being deleted.
*
* @return void
*/
public function forceDeleting(User $user)
{
// TODO: We assume that at this moment all belongings are already soft-deleted.
// Remove owned users/domains
$wallets = $user->wallets()->pluck('id')->all();
$assignments = Entitlement::withTrashed()->whereIn('wallet_id', $wallets)->get();
$entitlements = [];
$domains = [];
$groups = [];
+ $resources = [];
$users = [];
foreach ($assignments as $entitlement) {
$entitlements[] = $entitlement->id;
if ($entitlement->entitleable_type == Domain::class) {
$domains[] = $entitlement->entitleable_id;
} elseif (
$entitlement->entitleable_type == User::class
&& $entitlement->entitleable_id != $user->id
) {
$users[] = $entitlement->entitleable_id;
} elseif ($entitlement->entitleable_type == Group::class) {
$groups[] = $entitlement->entitleable_id;
+ } elseif ($entitlement->entitleable_type == Resource::class) {
+ $resources[] = $entitlement->entitleable_id;
}
}
// Remove the user "direct" entitlements explicitely, if they belong to another
// user's wallet they will not be removed by the wallets foreign key cascade
Entitlement::withTrashed()
->where('entitleable_id', $user->id)
->where('entitleable_type', User::class)
->forceDelete();
// Users need to be deleted one by one to make sure observers can do the proper cleanup.
if (!empty($users)) {
foreach (User::withTrashed()->whereIn('id', array_unique($users))->get() as $_user) {
$_user->forceDelete();
}
}
// Domains can be just removed
if (!empty($domains)) {
Domain::withTrashed()->whereIn('id', array_unique($domains))->forceDelete();
}
// Groups can be just removed
if (!empty($groups)) {
Group::withTrashed()->whereIn('id', array_unique($groups))->forceDelete();
}
+ // Resources can be just removed
+ if (!empty($resources)) {
+ Resource::withTrashed()->whereIn('id', array_unique($resources))->forceDelete();
+ }
+
// Remove transactions, they also have no foreign key constraint
Transaction::where('object_type', Entitlement::class)
->whereIn('object_id', $entitlements)
->delete();
Transaction::where('object_type', Wallet::class)
->whereIn('object_id', $wallets)
->delete();
}
/**
* 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)
{
// Restore user entitlements
\App\Entitlement::restoreEntitlementsFor($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 "retrieving" event.
*
* @param User $user The user that is being retrieved.
*
* @todo This is useful for audit.
*
* @return void
*/
public function retrieving(User $user)
{
// TODO \App\Jobs\User\ReadJob::dispatch($user->id);
}
/**
* Handle the "updating" event.
*
* @param User $user The user that is being updated.
*
* @return void
*/
public function updating(User $user)
{
\App\Jobs\User\UpdateJob::dispatch($user->id);
}
}
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
index 38b742a0..29904762 100644
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -1,162 +1,164 @@
<?php
namespace App\Providers;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
use Laravel\Passport\Passport;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
Passport::ignoreMigrations();
}
/**
* Serialize a bindings array to a string.
*
* @return string
*/
private static function serializeSQLBindings(array $array): string
{
$serialized = array_map(function ($entry) {
if ($entry instanceof \DateTime) {
return $entry->format('Y-m-d h:i:s');
}
return $entry;
}, $array);
return implode(', ', $serialized);
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
\App\Domain::observe(\App\Observers\DomainObserver::class);
\App\Entitlement::observe(\App\Observers\EntitlementObserver::class);
\App\Group::observe(\App\Observers\GroupObserver::class);
\App\GroupSetting::observe(\App\Observers\GroupSettingObserver::class);
\App\OpenVidu\Connection::observe(\App\Observers\OpenVidu\ConnectionObserver::class);
\App\PackageSku::observe(\App\Observers\PackageSkuObserver::class);
\App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class);
+ \App\Resource::observe(\App\Observers\ResourceObserver::class);
+ \App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class);
\App\SignupCode::observe(\App\Observers\SignupCodeObserver::class);
\App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class);
\App\Transaction::observe(\App\Observers\TransactionObserver::class);
\App\User::observe(\App\Observers\UserObserver::class);
\App\UserAlias::observe(\App\Observers\UserAliasObserver::class);
\App\UserSetting::observe(\App\Observers\UserSettingObserver::class);
\App\VerificationCode::observe(\App\Observers\VerificationCodeObserver::class);
\App\Wallet::observe(\App\Observers\WalletObserver::class);
\App\PowerDNS\Domain::observe(\App\Observers\PowerDNS\DomainObserver::class);
\App\PowerDNS\Record::observe(\App\Observers\PowerDNS\RecordObserver::class);
Schema::defaultStringLength(191);
// Log SQL queries in debug mode
if (\config('app.debug')) {
DB::listen(function ($query) {
\Log::debug(
sprintf(
'[SQL] %s [%s]: %.4f sec.',
$query->sql,
self::serializeSQLBindings($query->bindings),
$query->time / 1000
)
);
});
}
// Register some template helpers
Blade::directive(
'theme_asset',
function ($path) {
$path = trim($path, '/\'"');
return "<?php echo secure_asset('themes/' . \$env['app.theme'] . '/' . '$path'); ?>";
}
);
Builder::macro(
'withEnvTenantContext',
function (string $table = null) {
$tenantId = \config('app.tenant_id');
if ($tenantId) {
/** @var Builder $this */
return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId);
}
/** @var Builder $this */
return $this->whereNull(($table ? "$table." : "") . "tenant_id");
}
);
Builder::macro(
'withObjectTenantContext',
function (object $object, string $table = null) {
$tenantId = $object->tenant_id;
if ($tenantId) {
/** @var Builder $this */
return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId);
}
/** @var Builder $this */
return $this->whereNull(($table ? "$table." : "") . "tenant_id");
}
);
Builder::macro(
'withSubjectTenantContext',
function (string $table = null) {
if ($user = auth()->user()) {
$tenantId = $user->tenant_id;
} else {
$tenantId = \config('app.tenant_id');
}
if ($tenantId) {
/** @var Builder $this */
return $this->where(($table ? "$table." : "") . "tenant_id", $tenantId);
}
/** @var Builder $this */
return $this->whereNull(($table ? "$table." : "") . "tenant_id");
}
);
// Query builder 'whereLike' mocro
Builder::macro(
'whereLike',
function (string $column, string $search, int $mode = 0) {
$search = addcslashes($search, '%_');
switch ($mode) {
case 2:
$search .= '%';
break;
case 1:
$search = '%' . $search;
break;
default:
$search = '%' . $search . '%';
}
/** @var Builder $this */
return $this->where($column, 'like', $search);
}
);
}
}
diff --git a/src/app/Resource.php b/src/app/Resource.php
new file mode 100644
index 00000000..732d088b
--- /dev/null
+++ b/src/app/Resource.php
@@ -0,0 +1,210 @@
+<?php
+
+namespace App;
+
+use App\Traits\BelongsToTenantTrait;
+use App\Traits\EntitleableTrait;
+use App\Traits\ResourceConfigTrait;
+use App\Traits\SettingsTrait;
+use App\Traits\UuidIntKeyTrait;
+use App\Wallet;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
+
+/**
+ * The eloquent definition of a Resource.
+ *
+ * @property int $id The resource identifier
+ * @property string $email An email address
+ * @property string $name The resource name
+ * @property int $status The resource status
+ * @property int $tenant_id Tenant identifier
+ */
+class Resource extends Model
+{
+ use BelongsToTenantTrait;
+ use EntitleableTrait;
+ use ResourceConfigTrait;
+ use SettingsTrait;
+ use SoftDeletes;
+ use UuidIntKeyTrait;
+
+ // we've simply never heard of this resource
+ public const STATUS_NEW = 1 << 0;
+ // resource has been activated
+ public const STATUS_ACTIVE = 1 << 1;
+ // resource has been suspended.
+ // public const STATUS_SUSPENDED = 1 << 2;
+ // resource has been deleted
+ public const STATUS_DELETED = 1 << 3;
+ // resource has been created in LDAP
+ public const STATUS_LDAP_READY = 1 << 4;
+ // resource has been created in IMAP
+ public const STATUS_IMAP_READY = 1 << 8;
+
+ protected $fillable = [
+ 'email',
+ 'name',
+ 'status',
+ ];
+
+ /**
+ * @var ?string Domain name for a resource to be created */
+ public $domain;
+
+
+ /**
+ * Assign the resource to a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return \App\Resource Self
+ * @throws \Exception
+ */
+ public function assignToWallet(Wallet $wallet): Resource
+ {
+ if (empty($this->id)) {
+ throw new \Exception("Resource not yet exists");
+ }
+
+ if ($this->entitlements()->count()) {
+ throw new \Exception("Resource already assigned to a wallet");
+ }
+
+ $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'resource')->first();
+ $exists = $wallet->entitlements()->where('sku_id', $sku->id)->count();
+
+ \App\Entitlement::create([
+ 'wallet_id' => $wallet->id,
+ 'sku_id' => $sku->id,
+ 'cost' => $exists >= $sku->units_free ? $sku->cost : 0,
+ 'fee' => $exists >= $sku->units_free ? $sku->fee : 0,
+ 'entitleable_id' => $this->id,
+ 'entitleable_type' => Resource::class
+ ]);
+
+ return $this;
+ }
+
+ /**
+ * Returns the resource domain.
+ *
+ * @return ?\App\Domain The domain to which the resource belongs to, NULL if it does not exist
+ */
+ public function domain(): ?Domain
+ {
+ if (isset($this->domain)) {
+ $domainName = $this->domain;
+ } else {
+ list($local, $domainName) = explode('@', $this->email);
+ }
+
+ return Domain::where('namespace', $domainName)->first();
+ }
+
+ /**
+ * Find whether an email address exists as a resource (including deleted resources).
+ *
+ * @param string $email Email address
+ * @param bool $return_resource Return Resource instance instead of boolean
+ *
+ * @return \App\Resource|bool True or Resource model object if found, False otherwise
+ */
+ public static function emailExists(string $email, bool $return_resource = false)
+ {
+ if (strpos($email, '@') === false) {
+ return false;
+ }
+
+ $email = \strtolower($email);
+
+ $resource = self::withTrashed()->where('email', $email)->first();
+
+ if ($resource) {
+ return $return_resource ? $resource : true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns whether this resource is active.
+ *
+ * @return bool
+ */
+ public function isActive(): bool
+ {
+ return ($this->status & self::STATUS_ACTIVE) > 0;
+ }
+
+ /**
+ * Returns whether this resource is deleted.
+ *
+ * @return bool
+ */
+ public function isDeleted(): bool
+ {
+ return ($this->status & self::STATUS_DELETED) > 0;
+ }
+
+ /**
+ * Returns whether this resource's folder exists in IMAP.
+ *
+ * @return bool
+ */
+ public function isImapReady(): bool
+ {
+ return ($this->status & self::STATUS_IMAP_READY) > 0;
+ }
+
+ /**
+ * Returns whether this resource is registered in LDAP.
+ *
+ * @return bool
+ */
+ public function isLdapReady(): bool
+ {
+ return ($this->status & self::STATUS_LDAP_READY) > 0;
+ }
+
+ /**
+ * Returns whether this resource is new.
+ *
+ * @return bool
+ */
+ public function isNew(): bool
+ {
+ return ($this->status & self::STATUS_NEW) > 0;
+ }
+
+ /**
+ * Resource status mutator
+ *
+ * @throws \Exception
+ */
+ public function setStatusAttribute($status)
+ {
+ $new_status = 0;
+
+ $allowed_values = [
+ self::STATUS_NEW,
+ self::STATUS_ACTIVE,
+ self::STATUS_DELETED,
+ self::STATUS_IMAP_READY,
+ self::STATUS_LDAP_READY,
+ ];
+
+ foreach ($allowed_values as $value) {
+ if ($status & $value) {
+ $new_status |= $value;
+ $status ^= $value;
+ }
+ }
+
+ if ($status > 0) {
+ throw new \Exception("Invalid resource status: {$status}");
+ }
+
+ $this->attributes['status'] = $new_status;
+ }
+}
diff --git a/src/app/ResourceSetting.php b/src/app/ResourceSetting.php
new file mode 100644
index 00000000..15192a0a
--- /dev/null
+++ b/src/app/ResourceSetting.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A collection of settings for a Resource.
+ *
+ * @property int $id
+ * @property int $resource_id
+ * @property string $key
+ * @property string $value
+ */
+class ResourceSetting extends Model
+{
+ protected $fillable = [
+ 'resource_id', 'key', 'value'
+ ];
+
+ /**
+ * The resource to which this setting belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function resource()
+ {
+ return $this->belongsTo(\App\Resource::class, 'resource_id', 'id');
+ }
+}
diff --git a/src/app/Rules/GroupName.php b/src/app/Rules/GroupName.php
index 384e163a..ba4aec3a 100644
--- a/src/app/Rules/GroupName.php
+++ b/src/app/Rules/GroupName.php
@@ -1,72 +1,72 @@
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class GroupName implements Rule
{
private $message;
private $owner;
private $domain;
/**
* Class constructor.
*
* @param \App\User $owner The account owner
* @param string $domain The domain name of the group
*/
public function __construct($owner, $domain)
{
$this->owner = $owner;
$this->domain = Str::lower($domain);
}
/**
* Determine if the validation rule passes.
*
* @param string $attribute Attribute name
* @param mixed $name The value to validate
*
* @return bool
*/
public function passes($attribute, $name): bool
{
if (empty($name) || !is_string($name)) {
$this->message = \trans('validation.nameinvalid');
return false;
}
// Check the max length, according to the database column length
if (strlen($name) > 191) {
- $this->message = \trans('validation.nametoolong');
+ $this->message = \trans('validation.max.string', ['max' => 191]);
return false;
}
// Check if the name is unique in the domain
// FIXME: Maybe just using the whole groups table would be faster than groups()?
$exists = $this->owner->groups()
->where('groups.name', $name)
->where('groups.email', 'like', '%@' . $this->domain)
->exists();
if ($exists) {
$this->message = \trans('validation.nameexists');
return false;
}
return true;
}
/**
* Get the validation error message.
*
* @return string
*/
public function message(): ?string
{
return $this->message;
}
}
diff --git a/src/app/Rules/GroupName.php b/src/app/Rules/ResourceName.php
similarity index 65%
copy from src/app/Rules/GroupName.php
copy to src/app/Rules/ResourceName.php
index 384e163a..73ab9b1e 100644
--- a/src/app/Rules/GroupName.php
+++ b/src/app/Rules/ResourceName.php
@@ -1,72 +1,79 @@
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
-class GroupName implements Rule
+class ResourceName implements Rule
{
private $message;
private $owner;
private $domain;
/**
* Class constructor.
*
* @param \App\User $owner The account owner
* @param string $domain The domain name of the group
*/
public function __construct($owner, $domain)
{
$this->owner = $owner;
$this->domain = Str::lower($domain);
}
/**
* Determine if the validation rule passes.
*
* @param string $attribute Attribute name
- * @param mixed $name The value to validate
+ * @param mixed $name Resource name input
*
* @return bool
*/
public function passes($attribute, $name): bool
{
if (empty($name) || !is_string($name)) {
$this->message = \trans('validation.nameinvalid');
return false;
}
// Check the max length, according to the database column length
if (strlen($name) > 191) {
- $this->message = \trans('validation.nametoolong');
+ $this->message = \trans('validation.max.string', ['max' => 191]);
+ return false;
+ }
+
+ // Check if specified domain is belongs to the user
+ $domains = \collect($this->owner->domains(true, false))->pluck('namespace')->all();
+ if (!in_array($this->domain, $domains)) {
+ $this->message = \trans('validation.domaininvalid');
return false;
}
// Check if the name is unique in the domain
- // FIXME: Maybe just using the whole groups table would be faster than groups()?
- $exists = $this->owner->groups()
- ->where('groups.name', $name)
- ->where('groups.email', 'like', '%@' . $this->domain)
+ // FIXME: Maybe just using the whole resources table would be faster than resources()?
+ $exists = $this->owner->resources()
+ ->where('resources.name', $name)
+ ->where('resources.email', 'like', '%@' . $this->domain)
->exists();
if ($exists) {
$this->message = \trans('validation.nameexists');
return false;
}
return true;
}
/**
* Get the validation error message.
*
* @return string
*/
public function message(): ?string
{
return $this->message;
}
}
diff --git a/src/app/Traits/ResourceConfigTrait.php b/src/app/Traits/ResourceConfigTrait.php
new file mode 100644
index 00000000..015f98bd
--- /dev/null
+++ b/src/app/Traits/ResourceConfigTrait.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace App\Traits;
+
+use Illuminate\Support\Facades\Validator;
+
+trait ResourceConfigTrait
+{
+ /**
+ * A helper to get a resource configuration.
+ */
+ public function getConfig(): array
+ {
+ $config = [];
+
+ $config['invitation_policy'] = $this->getSetting('invitation_policy') ?: 'accept';
+
+ return $config;
+ }
+
+ /**
+ * A helper to update a resource configuration.
+ *
+ * @param array $config An array of configuration options
+ *
+ * @return array A list of input validation errors
+ */
+ public function setConfig(array $config): array
+ {
+ $errors = [];
+
+ foreach ($config as $key => $value) {
+ // validate and save the invitation policy
+ if ($key === 'invitation_policy') {
+ $value = (string) $value;
+ if ($value === 'accept' || $value === 'reject') {
+ // do nothing
+ } elseif (preg_match('/^manual:/', $value, $matches)) {
+ $email = trim(substr($value, 7));
+ if ($error = $this->validateInvitationPolicyUser($email)) {
+ $errors[$key] = $error;
+ } else {
+ $value = "manual:$email";
+ }
+ } else {
+ $errors[$key] = \trans('validation.ipolicy-invalid');
+ }
+
+ if (empty($errors[$key])) {
+ $this->setSetting($key, $value);
+ }
+ } else {
+ $errors[$key] = \trans('validation.invalid-config-parameter');
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Validate an email address for use as a resource owner (with invitation policy)
+ *
+ * @param string $email Email address
+ *
+ * @return ?string Error message on validation error
+ */
+ protected function validateInvitationPolicyUser($email): ?string
+ {
+ $v = Validator::make(['email' => $email], ['email' => 'required|email']);
+
+ if ($v->fails()) {
+ return \trans('validation.emailinvalid');
+ }
+
+ $user = \App\User::where('email', \strtolower($email))->first();
+
+ // The user and resource must be in the same wallet
+ if ($user && ($wallet = $user->wallet())) {
+ if ($wallet->user_id == $this->wallet()->user_id) {
+ return null;
+ }
+ }
+
+ return \trans('validation.notalocaluser');
+ }
+}
diff --git a/src/app/User.php b/src/app/User.php
index f3e28556..5679af44 100644
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -1,856 +1,886 @@
<?php
namespace App;
use App\Entitlement;
use App\UserAlias;
use App\Sku;
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
use App\Traits\UserAliasesTrait;
use App\Traits\UserConfigTrait;
use App\Traits\UuidIntKeyTrait;
use App\Traits\SettingsTrait;
use App\Wallet;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Iatstuti\Database\Support\NullableFields;
use Laravel\Passport\HasApiTokens;
use League\OAuth2\Server\Exception\OAuthServerException;
/**
* The eloquent definition of a User.
*
* @property string $email
* @property int $id
* @property string $password
* @property int $status
* @property int $tenant_id
*/
class User extends Authenticatable
{
use BelongsToTenantTrait;
use EntitleableTrait;
use HasApiTokens;
use NullableFields;
use UserConfigTrait;
use UserAliasesTrait;
use UuidIntKeyTrait;
use SettingsTrait;
use SoftDeletes;
// a new user, default on creation
public const STATUS_NEW = 1 << 0;
// it's been activated
public const STATUS_ACTIVE = 1 << 1;
// user has been suspended
public const STATUS_SUSPENDED = 1 << 2;
// user has been deleted
public const STATUS_DELETED = 1 << 3;
// user has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
// user mailbox has been created in IMAP
public const STATUS_IMAP_READY = 1 << 5;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'id',
'email',
'password',
'password_ldap',
'status',
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'password',
'password_ldap',
'role'
];
protected $nullable = [
'password',
'password_ldap'
];
/**
* Any wallets on which this user is a controller.
*
* This does not include wallets owned by the user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function accounts()
{
return $this->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;
}
$wallet_id = $this->wallets()->first()->id;
foreach ($package->skus as $sku) {
for ($i = $sku->pivot->qty; $i > 0; $i--) {
\App\Entitlement::create(
[
'wallet_id' => $wallet_id,
'sku_id' => $sku->id,
'cost' => $sku->pivot->cost(),
'fee' => $sku->pivot->fee(),
'entitleable_id' => $user->id,
'entitleable_type' => User::class
]
);
}
}
return $user;
}
/**
* 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;
}
/**
* Assign a Sku to a user.
*
* @param \App\Sku $sku The sku to assign.
* @param int $count Count of entitlements to add
*
* @return \App\User Self
* @throws \Exception
*/
public function assignSku(Sku $sku, int $count = 1): User
{
// TODO: I guess wallet could be parametrized in future
$wallet = $this->wallet();
$exists = $this->entitlements()->where('sku_id', $sku->id)->count();
while ($count > 0) {
\App\Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $sku->id,
'cost' => $exists >= $sku->units_free ? $sku->cost : 0,
'fee' => $exists >= $sku->units_free ? $sku->fee : 0,
'entitleable_id' => $this->id,
'entitleable_type' => User::class
]);
$exists++;
$count--;
}
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);
}
/**
* 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.
- * Note: Active public domains are also returned (for the user tenant).
+ *
+ * @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 Domain[] List of Domain objects
*/
- public function domains(): array
+ public function domains($with_accounts = true, $with_public = true): array
{
- if ($this->tenant_id) {
- $domains = Domain::where('tenant_id', $this->tenant_id);
- } else {
- $domains = Domain::withEnvTenantContext();
- }
+ $domains = [];
- $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC))
- ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE))
- ->get()
- ->all();
+ if ($with_public) {
+ if ($this->tenant_id) {
+ $domains = Domain::where('tenant_id', $this->tenant_id);
+ } else {
+ $domains = Domain::withEnvTenantContext();
+ }
+
+ $domains = $domains->whereRaw(sprintf('(type & %s)', Domain::TYPE_PUBLIC))
+ ->whereRaw(sprintf('(status & %s)', Domain::STATUS_ACTIVE))
+ ->get()
+ ->all();
+ }
foreach ($this->wallets as $wallet) {
$entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get();
foreach ($entitlements as $entitlement) {
$domains[] = $entitlement->entitleable;
}
}
- foreach ($this->accounts as $wallet) {
- $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get();
- foreach ($entitlements as $entitlement) {
- $domains[] = $entitlement->entitleable;
+ if ($with_accounts) {
+ foreach ($this->accounts as $wallet) {
+ $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get();
+ foreach ($entitlements as $entitlement) {
+ $domains[] = $entitlement->entitleable;
+ }
}
}
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;
}
/**
* 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)
{
$wallets = $this->wallets()->pluck('id')->all();
if ($with_accounts) {
$wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
}
return Group::select(['groups.*', 'entitlements.wallet_id'])
->distinct()
->join('entitlements', 'entitlements.entitleable_id', '=', 'groups.id')
->whereIn('entitlements.wallet_id', $wallets)
->where('entitlements.entitleable_type', Group::class);
}
/**
* Check if user has an entitlement for the specified SKU.
*
* @param string $title The SKU title
*
* @return bool True if specified SKU entitlement exists
*/
public function hasSku(string $title): bool
{
$sku = Sku::withObjectTenantContext($this)->where('title', $title)->first();
if (!$sku) {
return false;
}
return $this->entitlements()->where('sku_id', $sku->id)->count() > 0;
}
/**
* Returns whether this domain is active.
*
* @return bool
*/
public function isActive(): bool
{
return ($this->status & self::STATUS_ACTIVE) > 0;
}
/**
* Returns whether this domain 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.
*
* @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.
*
* @return bool
*/
public function isSuspended(): bool
{
return ($this->status & self::STATUS_SUSPENDED) > 0;
}
/**
* A shortcut to get the user name.
*
* @param bool $fallback Return "<aa.name> 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;
}
/**
* Remove a number of entitlements for the SKU.
*
* @param \App\Sku $sku The SKU
* @param int $count The number of entitlements to remove
*
* @return User Self
*/
public function removeSku(Sku $sku, int $count = 1): User
{
$entitlements = $this->entitlements()
->where('sku_id', $sku->id)
->orderBy('cost', 'desc')
->orderBy('created_at')
->get();
$entitlements_count = count($entitlements);
foreach ($entitlements as $entitlement) {
if ($entitlements_count <= $sku->units_free) {
continue;
}
if ($count > 0) {
$entitlement->delete();
$entitlements_count--;
$count--;
}
}
return $this;
}
+ /**
+ * 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)
+ {
+ $wallets = $this->wallets()->pluck('id')->all();
+
+ if ($with_accounts) {
+ $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
+ }
+
+ return \App\Resource::select(['resources.*', 'entitlements.wallet_id'])
+ ->distinct()
+ ->join('entitlements', 'entitlements.entitleable_id', '=', 'resources.id')
+ ->whereIn('entitlements.wallet_id', $wallets)
+ ->where('entitlements.entitleable_type', \App\Resource::class);
+ }
+
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.
*
* @return void
*/
public function suspend(): void
{
if ($this->isSuspended()) {
return;
}
$this->status |= User::STATUS_SUSPENDED;
$this->save();
}
/**
* Unsuspend this domain.
*
* @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)
{
$wallets = $this->wallets()->pluck('id')->all();
if ($with_accounts) {
$wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
}
return $this->select(['users.*', 'entitlements.wallet_id'])
->distinct()
->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id')
->whereIn('entitlements.wallet_id', $wallets)
->where('entitlements.entitleable_type', User::class);
}
/**
* 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,
];
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/composer.json b/src/composer.json
index fa399f1f..c232fed9 100644
--- a/src/composer.json
+++ b/src/composer.json
@@ -1,86 +1,86 @@
{
"name": "laravel/laravel",
"type": "project",
"description": "The Laravel Framework.",
"keywords": [
"framework",
"laravel"
],
"license": "MIT",
"repositories": [
{
"type": "vcs",
"url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git"
}
],
"require": {
"php": "^7.3",
"barryvdh/laravel-dompdf": "^0.8.6",
"doctrine/dbal": "^2.13",
"dyrynda/laravel-nullable-fields": "*",
"fideloper/proxy": "^4.0",
"guzzlehttp/guzzle": "^7.3",
"kolab/net_ldap3": "dev-master",
"laravel/framework": "6.*",
"laravel/horizon": "^3",
"laravel/passport": "^9",
"laravel/tinker": "^2.4",
"mlocati/spf-lib": "^3.0",
"mollie/laravel-mollie": "^2.9",
"moontoast/math": "^1.2",
"morrislaptop/laravel-queue-clear": "^1.2",
"pear/crypt_gpg": "dev-master",
"silviolleite/laravelpwa": "^2.0",
"spatie/laravel-translatable": "^4.2",
"spomky-labs/otphp": "~4.0.0",
"stripe/stripe-php": "^7.29",
"swooletw/laravel-swoole": "^2.6"
},
"require-dev": {
"beyondcode/laravel-er-diagram-generator": "^1.3",
"code-lts/doctum": "^5.1",
"kirschbaum-development/mail-intercept": "^0.2.4",
"laravel/dusk": "~6.15.0",
"nunomaduro/larastan": "^0.7",
"phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^9"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"autoload": {
"psr-4": {
"App\\": "app/"
},
"classmap": [
"database/seeds",
"include"
]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
- "minimum-stability": "dev",
+ "minimum-stability": "stable",
"prefer-stable": true,
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
]
}
}
diff --git a/src/database/migrations/2021_11_16_100000_create_resources_tables.php b/src/database/migrations/2021_11_16_100000_create_resources_tables.php
new file mode 100644
index 00000000..c6c47c22
--- /dev/null
+++ b/src/database/migrations/2021_11_16_100000_create_resources_tables.php
@@ -0,0 +1,80 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class CreateResourcesTables extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'resources',
+ function (Blueprint $table) {
+ $table->unsignedBigInteger('id');
+ $table->string('email')->unique();
+ $table->string('name');
+ $table->smallInteger('status');
+ $table->unsignedBigInteger('tenant_id')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->primary('id');
+ $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null');
+ }
+ );
+
+ Schema::create(
+ 'resource_settings',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->unsignedBigInteger('resource_id');
+ $table->string('key');
+ $table->text('value');
+ $table->timestamps();
+
+ $table->foreign('resource_id')->references('id')->on('resources')
+ ->onDelete('cascade')->onUpdate('cascade');
+
+ $table->unique(['resource_id', 'key']);
+ }
+ );
+
+ \App\Sku::where('title', 'resource')->update([
+ 'active' => true,
+ 'cost' => 0,
+ ]);
+
+ if (!\App\Sku::where('title', 'beta-resources')->first()) {
+ \App\Sku::create([
+ 'title' => 'beta-resources',
+ 'name' => 'Calendaring resources',
+ 'description' => 'Access to calendaring resources',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\Resources',
+ 'active' => true,
+ ]);
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('resource_settings');
+ Schema::dropIfExists('resources');
+
+ // there's no need to remove the SKU
+ }
+}
diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php
index 9b05c0b1..3c463ac9 100644
--- a/src/database/seeds/DatabaseSeeder.php
+++ b/src/database/seeds/DatabaseSeeder.php
@@ -1,42 +1,43 @@
<?php
use Illuminate\Database\Seeder;
// phpcs:ignore
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*
* @return void
*/
public function run()
{
// Define seeders order
$seeders = [
'IP4NetSeeder',
'TenantSeeder',
'DiscountSeeder',
'DomainSeeder',
'SkuSeeder',
'PackageSeeder',
'PlanSeeder',
'PowerDNSSeeder',
'UserSeeder',
'OpenViduRoomSeeder',
'OauthClientSeeder',
+ 'ResourceSeeder',
];
$env = ucfirst(App::environment());
// Check if the seeders exists
foreach ($seeders as $idx => $name) {
$class = "Database\\Seeds\\$env\\$name";
$seeders[$idx] = class_exists($class) ? $class : null;
}
$seeders = array_filter($seeders);
$this->call($seeders);
}
}
diff --git a/src/database/seeds/local/ResourceSeeder.php b/src/database/seeds/local/ResourceSeeder.php
new file mode 100644
index 00000000..36061c44
--- /dev/null
+++ b/src/database/seeds/local/ResourceSeeder.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Database\Seeds\Local;
+
+use App\Resource;
+use App\User;
+use Illuminate\Database\Seeder;
+
+class ResourceSeeder extends Seeder
+{
+ /**
+ * Run the database seeds.
+ *
+ * @return void
+ */
+ public function run()
+ {
+ $john = User::where('email', 'john@kolab.org')->first();
+ $wallet = $john->wallets()->first();
+
+ $resource = Resource::create([
+ 'name' => 'Conference Room #1',
+ 'email' => 'resource-test1@kolab.org',
+ ]);
+ $resource->assignToWallet($wallet);
+
+ $resource = Resource::create([
+ 'name' => 'Conference Room #2',
+ 'email' => 'resource-test2@kolab.org',
+ ]);
+ $resource->assignToWallet($wallet);
+ }
+}
diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php
index cc4b26ca..4e0ef730 100644
--- a/src/database/seeds/local/SkuSeeder.php
+++ b/src/database/seeds/local/SkuSeeder.php
@@ -1,332 +1,348 @@
<?php
namespace Database\Seeds\Local;
use App\Sku;
use Illuminate\Database\Seeder;
class SkuSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Sku::create(
[
'title' => 'mailbox',
'name' => 'User Mailbox',
'description' => 'Just a mailbox',
'cost' => 500,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Mailbox',
'active' => true,
]
);
Sku::create(
[
'title' => 'domain',
'name' => 'Hosted Domain',
'description' => 'Somewhere to place a mailbox',
'cost' => 100,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Domain',
'active' => false,
]
);
Sku::create(
[
'title' => 'domain-registration',
'name' => 'Domain Registration',
'description' => 'Register a domain with us',
'cost' => 101,
'period' => 'yearly',
'handler_class' => 'App\Handlers\DomainRegistration',
'active' => false,
]
);
Sku::create(
[
'title' => 'domain-hosting',
'name' => 'External Domain',
'description' => 'Host a domain that is externally registered',
'cost' => 100,
'units_free' => 1,
'period' => 'monthly',
'handler_class' => 'App\Handlers\DomainHosting',
'active' => true,
]
);
Sku::create(
[
'title' => 'domain-relay',
'name' => 'Domain Relay',
'description' => 'A domain you host at home, for which we relay email',
'cost' => 103,
'period' => 'monthly',
'handler_class' => 'App\Handlers\DomainRelay',
'active' => false,
]
);
Sku::create(
[
'title' => 'storage',
'name' => 'Storage Quota',
'description' => 'Some wiggle room',
'cost' => 25,
'units_free' => 5,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Storage',
'active' => true,
]
);
Sku::create(
[
'title' => 'groupware',
'name' => 'Groupware Features',
'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.',
'cost' => 490,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Groupware',
'active' => true,
]
);
Sku::create(
[
'title' => 'resource',
'name' => 'Resource',
'description' => 'Reservation taker',
'cost' => 101,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Resource',
- 'active' => false,
+ 'active' => true,
]
);
Sku::create(
[
'title' => 'shared_folder',
'name' => 'Shared Folder',
'description' => 'A shared folder',
'cost' => 89,
'period' => 'monthly',
'handler_class' => 'App\Handlers\SharedFolder',
'active' => false,
]
);
Sku::create(
[
'title' => '2fa',
'name' => '2-Factor Authentication',
'description' => 'Two factor authentication for webmail and administration panel',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Auth2F',
'active' => true,
]
);
Sku::create(
[
'title' => 'activesync',
'name' => 'Activesync',
'description' => 'Mobile synchronization',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Activesync',
'active' => true,
]
);
// Check existence because migration might have added this already
- $sku = \App\Sku::where(['title' => 'beta', 'tenant_id' => \config('app.tenant_id')])->first();
+ $sku = Sku::where(['title' => 'beta', 'tenant_id' => \config('app.tenant_id')])->first();
if (!$sku) {
Sku::create(
[
'title' => 'beta',
'name' => 'Private Beta (invitation only)',
'description' => 'Access to the private beta program subscriptions',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Beta',
'active' => false,
]
);
}
// Check existence because migration might have added this already
- $sku = \App\Sku::where(['title' => 'meet', 'tenant_id' => \config('app.tenant_id')])->first();
+ $sku = Sku::where(['title' => 'meet', 'tenant_id' => \config('app.tenant_id')])->first();
if (!$sku) {
Sku::create(
[
'title' => 'meet',
'name' => 'Voice & Video Conferencing (public beta)',
'description' => 'Video conferencing tool',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Meet',
'active' => true,
]
);
}
// Check existence because migration might have added this already
- $sku = \App\Sku::where(['title' => 'group', 'tenant_id' => \config('app.tenant_id')])->first();
+ $sku = Sku::where(['title' => 'group', 'tenant_id' => \config('app.tenant_id')])->first();
if (!$sku) {
Sku::create(
[
'title' => 'group',
'name' => 'Group',
'description' => 'Distribution list',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Group',
'active' => true,
]
);
}
// Check existence because migration might have added this already
- $sku = \App\Sku::where(['title' => 'distlist', 'tenant_id' => \config('app.tenant_id')])->first();
+ $sku = Sku::where(['title' => 'distlist', 'tenant_id' => \config('app.tenant_id')])->first();
if (!$sku) {
- \App\Sku::create(
+ Sku::create(
[
'title' => 'distlist',
'name' => 'Distribution lists',
'description' => 'Access to mail distribution lists',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Distlist',
'active' => true,
]
);
}
+ // Check existence because migration might have added this already
+ $sku = Sku::where(['title' => 'beta-resources', 'tenant_id' => \config('app.tenant_id')])->first();
+
+ if (!$sku) {
+ Sku::create([
+ 'title' => 'beta-resources',
+ 'name' => 'Calendaring resources',
+ 'description' => 'Access to calendaring resources',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\Resources',
+ 'active' => true,
+ ]);
+ }
+
// for tenants that are not the configured tenant id
$tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get();
foreach ($tenants as $tenant) {
$sku = Sku::create(
[
'title' => 'mailbox',
'name' => 'User Mailbox',
'description' => 'Just a mailbox',
'cost' => 500,
'fee' => 333,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Mailbox',
'active' => true,
]
);
$sku->tenant_id = $tenant->id;
$sku->save();
$sku = Sku::create(
[
'title' => 'storage',
'name' => 'Storage Quota',
'description' => 'Some wiggle room',
'cost' => 25,
'fee' => 16,
'units_free' => 5,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Storage',
'active' => true,
]
);
$sku->tenant_id = $tenant->id;
$sku->save();
$sku = Sku::create(
[
'title' => 'domain-hosting',
'name' => 'External Domain',
'description' => 'Host a domain that is externally registered',
'cost' => 100,
'fee' => 66,
'units_free' => 1,
'period' => 'monthly',
'handler_class' => 'App\Handlers\DomainHosting',
'active' => true,
]
);
$sku->tenant_id = $tenant->id;
$sku->save();
$sku = Sku::create(
[
'title' => 'groupware',
'name' => 'Groupware Features',
'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.',
'cost' => 490,
'fee' => 327,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Groupware',
'active' => true,
]
);
$sku->tenant_id = $tenant->id;
$sku->save();
$sku = Sku::create(
[
'title' => '2fa',
'name' => '2-Factor Authentication',
'description' => 'Two factor authentication for webmail and administration panel',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Auth2F',
'active' => true,
]
);
$sku->tenant_id = $tenant->id;
$sku->save();
$sku = Sku::create(
[
'title' => 'activesync',
'name' => 'Activesync',
'description' => 'Mobile synchronization',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Activesync',
'active' => true,
]
);
$sku->tenant_id = $tenant->id;
$sku->save();
}
}
}
diff --git a/src/database/seeds/production/SkuSeeder.php b/src/database/seeds/production/SkuSeeder.php
index 0d524b9c..128d9a1d 100644
--- a/src/database/seeds/production/SkuSeeder.php
+++ b/src/database/seeds/production/SkuSeeder.php
@@ -1,217 +1,231 @@
<?php
namespace Database\Seeds\Production;
use App\Sku;
use Illuminate\Database\Seeder;
class SkuSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Sku::create(
[
'title' => 'mailbox',
'name' => 'User Mailbox',
'description' => 'Just a mailbox',
'cost' => 444,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Mailbox',
'active' => true,
]
);
Sku::create(
[
'title' => 'domain',
'name' => 'Hosted Domain',
'description' => 'Somewhere to place a mailbox',
'cost' => 100,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Domain',
'active' => false,
]
);
Sku::create(
[
'title' => 'domain-registration',
'name' => 'Domain Registration',
'description' => 'Register a domain with us',
'cost' => 101,
'period' => 'yearly',
'handler_class' => 'App\Handlers\DomainRegistration',
'active' => false,
]
);
Sku::create(
[
'title' => 'domain-hosting',
'name' => 'External Domain',
'description' => 'Host a domain that is externally registered',
'cost' => 100,
'units_free' => 1,
'period' => 'monthly',
'handler_class' => 'App\Handlers\DomainHosting',
'active' => true,
]
);
Sku::create(
[
'title' => 'domain-relay',
'name' => 'Domain Relay',
'description' => 'A domain you host at home, for which we relay email',
'cost' => 103,
'period' => 'monthly',
'handler_class' => 'App\Handlers\DomainRelay',
'active' => false,
]
);
Sku::create(
[
'title' => 'storage',
'name' => 'Storage Quota',
'description' => 'Some wiggle room',
'cost' => 50,
'units_free' => 2,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Storage',
'active' => true,
]
);
Sku::create(
[
'title' => 'groupware',
'name' => 'Groupware Features',
'description' => 'Groupware functions like Calendar, Tasks, Notes, etc.',
'cost' => 555,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Groupware',
'active' => true,
]
);
Sku::create(
[
'title' => 'resource',
'name' => 'Resource',
'description' => 'Reservation taker',
- 'cost' => 101,
+ 'cost' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Resource',
- 'active' => false,
+ 'active' => true,
]
);
Sku::create(
[
'title' => 'shared_folder',
'name' => 'Shared Folder',
'description' => 'A shared folder',
'cost' => 89,
'period' => 'monthly',
'handler_class' => 'App\Handlers\SharedFolder',
'active' => false,
]
);
Sku::create(
[
'title' => '2fa',
'name' => '2-Factor Authentication',
'description' => 'Two factor authentication for webmail and administration panel',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Auth2F',
'active' => true,
]
);
Sku::create(
[
'title' => 'activesync',
'name' => 'Activesync',
'description' => 'Mobile synchronization',
'cost' => 100,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Activesync',
'active' => true,
]
);
// Check existence because migration might have added this already
- if (!\App\Sku::where('title', 'beta')->first()) {
+ if (!Sku::where('title', 'beta')->first()) {
Sku::create(
[
'title' => 'beta',
'name' => 'Private Beta (invitation only)',
'description' => 'Access to the private beta program subscriptions',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Beta',
'active' => false,
]
);
}
// Check existence because migration might have added this already
- if (!\App\Sku::where('title', 'meet')->first()) {
+ if (!Sku::where('title', 'meet')->first()) {
Sku::create(
[
'title' => 'meet',
'name' => 'Voice & Video Conferencing (public beta)',
'description' => 'Video conferencing tool',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Meet',
'active' => true,
]
);
}
// Check existence because migration might have added this already
- if (!\App\Sku::where('title', 'group')->first()) {
+ if (!Sku::where('title', 'group')->first()) {
Sku::create(
[
'title' => 'group',
'name' => 'Group',
'description' => 'Distribution list',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Group',
'active' => true,
]
);
}
// Check existence because migration might have added this already
- if (!\App\Sku::where('title', 'distlist')->first()) {
- \App\Sku::create([
+ if (!Sku::where('title', 'distlist')->first()) {
+ Sku::create([
'title' => 'distlist',
'name' => 'Distribution lists',
'description' => 'Access to mail distribution lists',
'cost' => 0,
'units_free' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Distlist',
'active' => true,
]);
}
+
+ // Check existence because migration might have added this already
+ if (!Sku::where('title', 'beta-resources')->first()) {
+ Sku::create([
+ 'title' => 'beta-resources',
+ 'name' => 'Calendaring resources',
+ 'description' => 'Access to calendaring resources',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\Resources',
+ 'active' => true,
+ ]);
+ }
}
}
diff --git a/src/include/rcube_imap_generic.php b/src/include/rcube_imap_generic.php
index 5dd2139e..127c4cfa 100644
--- a/src/include/rcube_imap_generic.php
+++ b/src/include/rcube_imap_generic.php
@@ -1,4106 +1,4106 @@
<?php
/**
+-----------------------------------------------------------------------+
| This file is part of the Roundcube Webmail client |
| |
| Copyright (C) The Roundcube Dev Team |
| Copyright (C) Kolab Systems AG |
| |
| Licensed under the GNU General Public License version 3 or |
| any later version with exceptions for skins & plugins. |
| See the README file for a full license statement. |
| |
| PURPOSE: |
| Provide alternative IMAP library that doesn't rely on the standard |
| C-Client based version. This allows to function regardless |
| of whether or not the PHP build it's running on has IMAP |
| functionality built-in. |
| |
| Based on Iloha IMAP Library. See http://ilohamail.org/ for details |
+-----------------------------------------------------------------------+
| Author: Aleksander Machniak <alec@alec.pl> |
| Author: Ryo Chijiiwa <Ryo@IlohaMail.org> |
+-----------------------------------------------------------------------+
*/
/**
* PHP based wrapper class to connect to an IMAP server
*
* @package Framework
* @subpackage Storage
*/
class rcube_imap_generic
{
public $error;
public $errornum;
public $result;
public $resultcode;
public $selected;
public $data = array();
public $flags = array(
'SEEN' => '\\Seen',
'DELETED' => '\\Deleted',
'ANSWERED' => '\\Answered',
'DRAFT' => '\\Draft',
'FLAGGED' => '\\Flagged',
'FORWARDED' => '$Forwarded',
'MDNSENT' => '$MDNSent',
'*' => '\\*',
);
protected $fp;
protected $host;
protected $cmd_tag;
protected $cmd_num = 0;
protected $resourceid;
protected $prefs = array();
protected $logged = false;
protected $capability = array();
protected $capability_readed = false;
protected $debug = false;
protected $debug_handler = false;
const ERROR_OK = 0;
const ERROR_NO = -1;
const ERROR_BAD = -2;
const ERROR_BYE = -3;
const ERROR_UNKNOWN = -4;
const ERROR_COMMAND = -5;
const ERROR_READONLY = -6;
const COMMAND_NORESPONSE = 1;
const COMMAND_CAPABILITY = 2;
const COMMAND_LASTLINE = 4;
const COMMAND_ANONYMIZED = 8;
const DEBUG_LINE_LENGTH = 4098; // 4KB + 2B for \r\n
/**
* Send simple (one line) command to the connection stream
*
* @param string $string Command string
* @param bool $endln True if CRLF need to be added at the end of command
* @param bool $anonymized Don't write the given data to log but a placeholder
*
* @param int Number of bytes sent, False on error
*/
protected function putLine($string, $endln = true, $anonymized = false)
{
if (!$this->fp) {
return false;
}
if ($this->debug) {
// anonymize the sent command for logging
$cut = $endln ? 2 : 0;
if ($anonymized && preg_match('/^(A\d+ (?:[A-Z]+ )+)(.+)/', $string, $m)) {
$log = $m[1] . sprintf('****** [%d]', strlen($m[2]) - $cut);
}
else if ($anonymized) {
$log = sprintf('****** [%d]', strlen($string) - $cut);
}
else {
$log = rtrim($string);
}
$this->debug('C: ' . $log);
}
if ($endln) {
$string .= "\r\n";
}
$res = fwrite($this->fp, $string);
if ($res === false) {
$this->closeSocket();
}
return $res;
}
/**
* Send command to the connection stream with Command Continuation
* Requests (RFC3501 7.5) and LITERAL+ (RFC2088) support
*
* @param string $string Command string
* @param bool $endln True if CRLF need to be added at the end of command
* @param bool $anonymized Don't write the given data to log but a placeholder
*
* @return int|bool Number of bytes sent, False on error
*/
protected function putLineC($string, $endln=true, $anonymized=false)
{
if (!$this->fp) {
return false;
}
if ($endln) {
$string .= "\r\n";
}
$res = 0;
if ($parts = preg_split('/(\{[0-9]+\}\r\n)/m', $string, -1, PREG_SPLIT_DELIM_CAPTURE)) {
for ($i=0, $cnt=count($parts); $i<$cnt; $i++) {
if ($i+1 < $cnt && preg_match('/^\{([0-9]+)\}\r\n$/', $parts[$i+1], $matches)) {
// LITERAL+ support
if ($this->prefs['literal+']) {
$parts[$i+1] = sprintf("{%d+}\r\n", $matches[1]);
}
$bytes = $this->putLine($parts[$i].$parts[$i+1], false, $anonymized);
if ($bytes === false) {
return false;
}
$res += $bytes;
// don't wait if server supports LITERAL+ capability
if (!$this->prefs['literal+']) {
$line = $this->readLine(1000);
// handle error in command
if ($line[0] != '+') {
return false;
}
}
$i++;
}
else {
$bytes = $this->putLine($parts[$i], false, $anonymized);
if ($bytes === false) {
return false;
}
$res += $bytes;
}
}
}
return $res;
}
/**
* Reads line from the connection stream
*
* @param int $size Buffer size
*
* @return string Line of text response
*/
protected function readLine($size = 1024)
{
$line = '';
if (!$size) {
$size = 1024;
}
do {
if ($this->eof()) {
return $line ?: null;
}
$buffer = fgets($this->fp, $size);
if ($buffer === false) {
$this->closeSocket();
break;
}
if ($this->debug) {
$this->debug('S: '. rtrim($buffer));
}
$line .= $buffer;
}
while (substr($buffer, -1) != "\n");
return $line;
}
/**
* Reads more data from the connection stream when provided
* data contain string literal
*
* @param string $line Response text
* @param bool $escape Enables escaping
*
* @return string Line of text response
*/
protected function multLine($line, $escape = false)
{
$line = rtrim($line);
if (preg_match('/\{([0-9]+)\}$/', $line, $m)) {
$out = '';
$str = substr($line, 0, -strlen($m[0]));
$bytes = $m[1];
while (strlen($out) < $bytes) {
$line = $this->readBytes($bytes);
if ($line === null) {
break;
}
$out .= $line;
}
$line = $str . ($escape ? $this->escape($out) : $out);
}
return $line;
}
/**
* Reads specified number of bytes from the connection stream
*
* @param int $bytes Number of bytes to get
*
* @return string Response text
*/
protected function readBytes($bytes)
{
$data = '';
$len = 0;
while ($len < $bytes && !$this->eof()) {
$d = fread($this->fp, $bytes-$len);
if ($this->debug) {
$this->debug('S: '. $d);
}
$data .= $d;
$data_len = strlen($data);
if ($len == $data_len) {
break; // nothing was read -> exit to avoid apache lockups
}
$len = $data_len;
}
return $data;
}
/**
* Reads complete response to the IMAP command
*
* @param array $untagged Will be filled with untagged response lines
*
* @return string Response text
*/
protected function readReply(&$untagged = null)
{
do {
$line = trim($this->readLine(1024));
// store untagged response lines
if ($line[0] == '*') {
$untagged[] = $line;
}
}
while ($line[0] == '*');
if ($untagged) {
$untagged = implode("\n", $untagged);
}
return $line;
}
/**
* Response parser.
*
* @param string $string Response text
* @param string $err_prefix Error message prefix
*
* @return int Response status
*/
protected function parseResult($string, $err_prefix = '')
{
if (preg_match('/^[a-z0-9*]+ (OK|NO|BAD|BYE)(.*)$/i', trim($string), $matches)) {
$res = strtoupper($matches[1]);
$str = trim($matches[2]);
if ($res == 'OK') {
$this->errornum = self::ERROR_OK;
}
else if ($res == 'NO') {
$this->errornum = self::ERROR_NO;
}
else if ($res == 'BAD') {
$this->errornum = self::ERROR_BAD;
}
else if ($res == 'BYE') {
$this->closeSocket();
$this->errornum = self::ERROR_BYE;
}
if ($str) {
$str = trim($str);
// get response string and code (RFC5530)
if (preg_match("/^\[([a-z-]+)\]/i", $str, $m)) {
$this->resultcode = strtoupper($m[1]);
$str = trim(substr($str, strlen($m[1]) + 2));
}
else {
$this->resultcode = null;
// parse response for [APPENDUID 1204196876 3456]
if (preg_match("/^\[APPENDUID [0-9]+ ([0-9]+)\]/i", $str, $m)) {
$this->data['APPENDUID'] = $m[1];
}
// parse response for [COPYUID 1204196876 3456:3457 123:124]
else if (preg_match("/^\[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $str, $m)) {
$this->data['COPYUID'] = array($m[1], $m[2]);
}
}
$this->result = $str;
if ($this->errornum != self::ERROR_OK) {
$this->error = $err_prefix ? $err_prefix.$str : $str;
}
}
return $this->errornum;
}
return self::ERROR_UNKNOWN;
}
/**
* Checks connection stream state.
*
* @return bool True if connection is closed
*/
protected function eof()
{
if (!is_resource($this->fp)) {
return true;
}
// If a connection opened by fsockopen() wasn't closed
// by the server, feof() will hang.
$start = microtime(true);
if (feof($this->fp) ||
($this->prefs['timeout'] && (microtime(true) - $start > $this->prefs['timeout']))
) {
$this->closeSocket();
return true;
}
return false;
}
/**
* Closes connection stream.
*/
protected function closeSocket()
{
@fclose($this->fp);
$this->fp = null;
}
/**
* Error code/message setter.
*/
protected function setError($code, $msg = '')
{
$this->errornum = $code;
$this->error = $msg;
return $code;
}
/**
* Checks response status.
* Checks if command response line starts with specified prefix (or * BYE/BAD)
*
* @param string $string Response text
* @param string $match Prefix to match with (case-sensitive)
* @param bool $error Enables BYE/BAD checking
* @param bool $nonempty Enables empty response checking
*
* @return bool True any check is true or connection is closed.
*/
protected function startsWith($string, $match, $error = false, $nonempty = false)
{
if (!$this->fp) {
return true;
}
if (strncmp($string, $match, strlen($match)) == 0) {
return true;
}
if ($error && preg_match('/^\* (BYE|BAD) /i', $string, $m)) {
if (strtoupper($m[1]) == 'BYE') {
$this->closeSocket();
}
return true;
}
if ($nonempty && !strlen($string)) {
return true;
}
return false;
}
/**
* Capabilities checker
*/
protected function hasCapability($name)
{
if (empty($this->capability) || $name == '') {
return false;
}
if (in_array($name, $this->capability)) {
return true;
}
else if (strpos($name, '=')) {
return false;
}
$result = array();
foreach ($this->capability as $cap) {
$entry = explode('=', $cap);
if ($entry[0] == $name) {
$result[] = $entry[1];
}
}
return $result ?: false;
}
/**
* Capabilities checker
*
* @param string $name Capability name
*
* @return mixed Capability values array for key=value pairs, true/false for others
*/
public function getCapability($name)
{
$result = $this->hasCapability($name);
if (!empty($result)) {
return $result;
}
else if ($this->capability_readed) {
return false;
}
// get capabilities (only once) because initial
// optional CAPABILITY response may differ
$result = $this->execute('CAPABILITY');
if ($result[0] == self::ERROR_OK) {
$this->parseCapability($result[1]);
}
$this->capability_readed = true;
return $this->hasCapability($name);
}
/**
* Clears detected server capabilities
*/
public function clearCapability()
{
$this->capability = array();
$this->capability_readed = false;
}
/**
* DIGEST-MD5/CRAM-MD5/PLAIN Authentication
*
* @param string $user Username
* @param string $pass Password
* @param string $type Authentication type (PLAIN/CRAM-MD5/DIGEST-MD5)
*
* @return resource Connection resourse on success, error code on error
*/
protected function authenticate($user, $pass, $type = 'PLAIN')
{
if ($type == 'CRAM-MD5' || $type == 'DIGEST-MD5') {
if ($type == 'DIGEST-MD5' && !class_exists('Auth_SASL')) {
return $this->setError(self::ERROR_BYE,
"The Auth_SASL package is required for DIGEST-MD5 authentication");
}
$this->putLine($this->nextTag() . " AUTHENTICATE $type");
$line = trim($this->readReply());
if ($line[0] == '+') {
$challenge = substr($line, 2);
}
else {
return $this->parseResult($line);
}
if ($type == 'CRAM-MD5') {
// RFC2195: CRAM-MD5
$ipad = '';
$opad = '';
$xor = function($str1, $str2) {
$result = '';
$size = strlen($str1);
for ($i=0; $i<$size; $i++) {
$result .= chr(ord($str1[$i]) ^ ord($str2[$i]));
}
return $result;
};
// initialize ipad, opad
for ($i=0; $i<64; $i++) {
$ipad .= chr(0x36);
$opad .= chr(0x5C);
}
// pad $pass so it's 64 bytes
$pass = str_pad($pass, 64, chr(0));
// generate hash
$hash = md5($xor($pass, $opad) . pack("H*",
md5($xor($pass, $ipad) . base64_decode($challenge))));
$reply = base64_encode($user . ' ' . $hash);
// send result
$this->putLine($reply, true, true);
}
else {
// RFC2831: DIGEST-MD5
// proxy authorization
if (!empty($this->prefs['auth_cid'])) {
$authc = $this->prefs['auth_cid'];
$pass = $this->prefs['auth_pw'];
}
else {
$authc = $user;
$user = '';
}
$auth_sasl = new Auth_SASL;
$auth_sasl = $auth_sasl->factory('digestmd5');
$reply = base64_encode($auth_sasl->getResponse($authc, $pass,
base64_decode($challenge), $this->host, 'imap', $user));
// send result
$this->putLine($reply, true, true);
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
// check response
$challenge = substr($line, 2);
$challenge = base64_decode($challenge);
if (strpos($challenge, 'rspauth=') === false) {
return $this->setError(self::ERROR_BAD,
"Unexpected response from server to DIGEST-MD5 response");
}
$this->putLine('');
}
$line = $this->readReply();
$result = $this->parseResult($line);
}
else if ($type == 'GSSAPI') {
if (!extension_loaded('krb5')) {
return $this->setError(self::ERROR_BYE,
"The krb5 extension is required for GSSAPI authentication");
}
if (empty($this->prefs['gssapi_cn'])) {
return $this->setError(self::ERROR_BYE,
"The gssapi_cn parameter is required for GSSAPI authentication");
}
if (empty($this->prefs['gssapi_context'])) {
return $this->setError(self::ERROR_BYE,
"The gssapi_context parameter is required for GSSAPI authentication");
}
putenv('KRB5CCNAME=' . $this->prefs['gssapi_cn']);
try {
$ccache = new KRB5CCache();
$ccache->open($this->prefs['gssapi_cn']);
$gssapicontext = new GSSAPIContext();
$gssapicontext->acquireCredentials($ccache);
$token = '';
$success = $gssapicontext->initSecContext($this->prefs['gssapi_context'], null, null, null, $token);
$token = base64_encode($token);
}
catch (Exception $e) {
trigger_error($e->getMessage(), E_USER_WARNING);
return $this->setError(self::ERROR_BYE, "GSSAPI authentication failed");
}
$this->putLine($this->nextTag() . " AUTHENTICATE GSSAPI " . $token);
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
try {
$itoken = base64_decode(substr($line, 2));
if (!$gssapicontext->unwrap($itoken, $itoken)) {
throw new Exception("GSSAPI SASL input token unwrap failed");
}
if (strlen($itoken) < 4) {
throw new Exception("GSSAPI SASL input token invalid");
}
// Integrity/encryption layers are not supported. The first bit
// indicates that the server supports "no security layers".
// 0x00 should not occur, but support broken implementations.
$server_layers = ord($itoken[0]);
if ($server_layers && ($server_layers & 0x1) != 0x1) {
throw new Exception("Server requires GSSAPI SASL integrity/encryption");
}
// Construct output token. 0x01 in the first octet = SASL layer "none",
// zero in the following three octets = no data follows.
// See https://github.com/cyrusimap/cyrus-sasl/blob/e41cfb986c1b1935770de554872247453fdbb079/plugins/gssapi.c#L1284
if (!$gssapicontext->wrap(pack("CCCC", 0x1, 0, 0, 0), $otoken, true)) {
throw new Exception("GSSAPI SASL output token wrap failed");
}
}
catch (Exception $e) {
trigger_error($e->getMessage(), E_USER_WARNING);
return $this->setError(self::ERROR_BYE, "GSSAPI authentication failed");
}
$this->putLine(base64_encode($otoken));
$line = $this->readReply();
$result = $this->parseResult($line);
}
else if ($type == 'PLAIN') {
// proxy authorization
if (!empty($this->prefs['auth_cid'])) {
$authc = $this->prefs['auth_cid'];
$pass = $this->prefs['auth_pw'];
}
else {
$authc = $user;
$user = '';
}
$reply = base64_encode($user . chr(0) . $authc . chr(0) . $pass);
// RFC 4959 (SASL-IR): save one round trip
if ($this->getCapability('SASL-IR')) {
list($result, $line) = $this->execute("AUTHENTICATE PLAIN", array($reply),
self::COMMAND_LASTLINE | self::COMMAND_CAPABILITY | self::COMMAND_ANONYMIZED);
}
else {
$this->putLine($this->nextTag() . " AUTHENTICATE PLAIN");
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
// send result, get reply and process it
$this->putLine($reply, true, true);
$line = $this->readReply();
$result = $this->parseResult($line);
}
}
else if ($type == 'LOGIN') {
$this->putLine($this->nextTag() . " AUTHENTICATE LOGIN");
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
$this->putLine(base64_encode($user), true, true);
$line = trim($this->readReply());
if ($line[0] != '+') {
return $this->parseResult($line);
}
// send result, get reply and process it
$this->putLine(base64_encode($pass), true, true);
$line = $this->readReply();
$result = $this->parseResult($line);
}
if ($result === self::ERROR_OK) {
// optional CAPABILITY response
if ($line && preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) {
$this->parseCapability($matches[1], true);
}
return $this->fp;
}
return $this->setError($result, "AUTHENTICATE $type: $line");
}
/**
* LOGIN Authentication
*
* @param string $user Username
* @param string $pass Password
*
* @return resource Connection resourse on success, error code on error
*/
protected function login($user, $password)
{
// Prevent from sending credentials in plain text when connection is not secure
if ($this->getCapability('LOGINDISABLED')) {
return $this->setError(self::ERROR_BAD, "Login disabled by IMAP server");
}
list($code, $response) = $this->execute('LOGIN', array(
$this->escape($user), $this->escape($password)), self::COMMAND_CAPABILITY | self::COMMAND_ANONYMIZED);
// re-set capabilities list if untagged CAPABILITY response provided
if (preg_match('/\* CAPABILITY (.+)/i', $response, $matches)) {
$this->parseCapability($matches[1], true);
}
if ($code == self::ERROR_OK) {
return $this->fp;
}
return $code;
}
/**
* Detects hierarchy delimiter
*
* @return string The delimiter
*/
public function getHierarchyDelimiter()
{
if (isset($this->prefs['delimiter'])) {
return $this->prefs['delimiter'];
}
// try (LIST "" ""), should return delimiter (RFC2060 Sec 6.3.8)
list($code, $response) = $this->execute('LIST',
array($this->escape(''), $this->escape('')));
if ($code == self::ERROR_OK) {
$args = $this->tokenizeResponse($response, 4);
$delimiter = $args[3];
if (strlen($delimiter) > 0) {
return ($this->prefs['delimiter'] = $delimiter);
}
}
}
/**
* NAMESPACE handler (RFC 2342)
*
* @return array Namespace data hash (personal, other, shared)
*/
public function getNamespace()
{
if (array_key_exists('namespace', $this->prefs)) {
return $this->prefs['namespace'];
}
if (!$this->getCapability('NAMESPACE')) {
return self::ERROR_BAD;
}
list($code, $response) = $this->execute('NAMESPACE');
if ($code == self::ERROR_OK && preg_match('/^\* NAMESPACE /', $response)) {
$response = substr($response, 11);
$data = $this->tokenizeResponse($response);
}
if (!is_array($data)) {
return $code;
}
$this->prefs['namespace'] = array(
'personal' => $data[0],
'other' => $data[1],
'shared' => $data[2],
);
return $this->prefs['namespace'];
}
/**
* Connects to IMAP server and authenticates.
*
* @param string $host Server hostname or IP
* @param string $user User name
* @param string $password Password
* @param array $options Connection and class options
*
* @return bool True on success, False on failure
*/
public function connect($host, $user, $password, $options = array())
{
// configure
$this->set_prefs($options);
$this->host = $host;
$this->user = $user;
$this->logged = false;
$this->selected = null;
// check input
if (empty($host)) {
$this->setError(self::ERROR_BAD, "Empty host");
return false;
}
if (empty($user)) {
$this->setError(self::ERROR_NO, "Empty user");
return false;
}
if (empty($password) && empty($options['gssapi_cn'])) {
$this->setError(self::ERROR_NO, "Empty password");
return false;
}
// Connect
if (!$this->_connect($host)) {
return false;
}
// Send ID info
if (!empty($this->prefs['ident']) && $this->getCapability('ID')) {
$this->data['ID'] = $this->id($this->prefs['ident']);
}
$auth_method = $this->prefs['auth_type'];
$auth_methods = array();
$result = null;
// check for supported auth methods
if (!$auth_method || $auth_method == 'CHECK') {
if ($auth_caps = $this->getCapability('AUTH')) {
$auth_methods = $auth_caps;
}
// Use best (for security) supported authentication method
$all_methods = array('DIGEST-MD5', 'CRAM-MD5', 'CRAM_MD5', 'PLAIN', 'LOGIN');
if (!empty($this->prefs['gssapi_cn'])) {
array_unshift($all_methods, 'GSSAPI');
}
foreach ($all_methods as $auth_method) {
if (in_array($auth_method, $auth_methods)) {
break;
}
}
// Prefer LOGIN over AUTHENTICATE LOGIN for performance reasons
if ($auth_method == 'LOGIN' && !$this->getCapability('LOGINDISABLED')) {
$auth_method = 'IMAP';
}
}
// pre-login capabilities can be not complete
$this->capability_readed = false;
// Authenticate
switch ($auth_method) {
case 'CRAM_MD5':
$auth_method = 'CRAM-MD5';
case 'CRAM-MD5':
case 'DIGEST-MD5':
case 'GSSAPI':
case 'PLAIN':
case 'LOGIN':
$result = $this->authenticate($user, $password, $auth_method);
break;
case 'IMAP':
$result = $this->login($user, $password);
break;
default:
$this->setError(self::ERROR_BAD, "Configuration error. Unknown auth method: $auth_method");
}
// Connected and authenticated
if (is_resource($result)) {
if (!empty($this->prefs['force_caps'])) {
$this->clearCapability();
}
$this->logged = true;
return true;
}
$this->closeConnection();
return false;
}
/**
* Connects to IMAP server.
*
* @param string $host Server hostname or IP
*
* @return bool True on success, False on failure
*/
protected function _connect($host)
{
// initialize connection
$this->error = '';
$this->errornum = self::ERROR_OK;
if (!$this->prefs['port']) {
$this->prefs['port'] = 143;
}
// check for SSL
if (!empty($this->prefs['ssl_mode']) && $this->prefs['ssl_mode'] != 'tls') {
$host = $this->prefs['ssl_mode'] . '://' . $host;
}
if (empty($this->prefs['timeout']) || $this->prefs['timeout'] < 0) {
$this->prefs['timeout'] = max(0, intval(ini_get('default_socket_timeout')));
}
if ($this->debug) {
// set connection identifier for debug output
$this->resourceid = strtoupper(substr(md5(microtime() . $host . $this->user), 0, 4));
$_host = ($this->prefs['ssl_mode'] == 'tls' ? 'tls://' : '') . $host . ':' . $this->prefs['port'];
$this->debug("Connecting to $_host...");
}
if (!empty($this->prefs['socket_options'])) {
$context = stream_context_create($this->prefs['socket_options']);
$this->fp = stream_socket_client($host . ':' . $this->prefs['port'], $errno, $errstr,
$this->prefs['timeout'], STREAM_CLIENT_CONNECT, $context);
}
else {
$this->fp = @fsockopen($host, $this->prefs['port'], $errno, $errstr, $this->prefs['timeout']);
}
if (!$this->fp) {
$this->setError(self::ERROR_BAD, sprintf("Could not connect to %s:%d: %s",
$host, $this->prefs['port'], $errstr ?: "Unknown reason"));
return false;
}
if ($this->prefs['timeout'] > 0) {
stream_set_timeout($this->fp, $this->prefs['timeout']);
}
$line = trim(fgets($this->fp, 8192));
if ($this->debug && $line) {
$this->debug('S: '. $line);
}
// Connected to wrong port or connection error?
if (!preg_match('/^\* (OK|PREAUTH)/i', $line)) {
if ($line)
$error = sprintf("Wrong startup greeting (%s:%d): %s", $host, $this->prefs['port'], $line);
else
$error = sprintf("Empty startup greeting (%s:%d)", $host, $this->prefs['port']);
$this->setError(self::ERROR_BAD, $error);
$this->closeConnection();
return false;
}
$this->data['GREETING'] = trim(preg_replace('/\[[^\]]+\]\s*/', '', $line));
// RFC3501 [7.1] optional CAPABILITY response
if (preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)) {
$this->parseCapability($matches[1], true);
}
// TLS connection
if ($this->prefs['ssl_mode'] == 'tls' && $this->getCapability('STARTTLS')) {
$res = $this->execute('STARTTLS');
if ($res[0] != self::ERROR_OK) {
$this->closeConnection();
return false;
}
if (isset($this->prefs['socket_options']['ssl']['crypto_method'])) {
$crypto_method = $this->prefs['socket_options']['ssl']['crypto_method'];
}
else {
// There is no flag to enable all TLS methods. Net_SMTP
// handles enabling TLS similarly.
$crypto_method = STREAM_CRYPTO_METHOD_TLS_CLIENT
| @STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT
| @STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
}
if (!stream_socket_enable_crypto($this->fp, true, $crypto_method)) {
$this->setError(self::ERROR_BAD, "Unable to negotiate TLS");
$this->closeConnection();
return false;
}
// Now we're secure, capabilities need to be reread
$this->clearCapability();
}
return true;
}
/**
* Initializes environment
*/
protected function set_prefs($prefs)
{
// set preferences
if (is_array($prefs)) {
$this->prefs = $prefs;
}
// set auth method
if (!empty($this->prefs['auth_type'])) {
$this->prefs['auth_type'] = strtoupper($this->prefs['auth_type']);
}
else {
$this->prefs['auth_type'] = 'CHECK';
}
// disabled capabilities
if (!empty($this->prefs['disabled_caps'])) {
$this->prefs['disabled_caps'] = array_map('strtoupper', (array)$this->prefs['disabled_caps']);
}
// additional message flags
if (!empty($this->prefs['message_flags'])) {
$this->flags = array_merge($this->flags, $this->prefs['message_flags']);
unset($this->prefs['message_flags']);
}
}
/**
* Checks connection status
*
* @return bool True if connection is active and user is logged in, False otherwise.
*/
public function connected()
{
return $this->fp && $this->logged;
}
/**
* Closes connection with logout.
*/
public function closeConnection()
{
if ($this->logged && $this->putLine($this->nextTag() . ' LOGOUT')) {
$this->readReply();
}
$this->closeSocket();
}
/**
* Executes SELECT command (if mailbox is already not in selected state)
*
* @param string $mailbox Mailbox name
* @param array $qresync_data QRESYNC data (RFC5162)
*
* @return boolean True on success, false on error
*/
public function select($mailbox, $qresync_data = null)
{
if (!strlen($mailbox)) {
return false;
}
if ($this->selected === $mailbox) {
return true;
}
$params = array($this->escape($mailbox));
// QRESYNC data items
// 0. the last known UIDVALIDITY,
// 1. the last known modification sequence,
// 2. the optional set of known UIDs, and
// 3. an optional parenthesized list of known sequence ranges and their
// corresponding UIDs.
if (!empty($qresync_data)) {
if (!empty($qresync_data[2])) {
$qresync_data[2] = self::compressMessageSet($qresync_data[2]);
}
$params[] = array('QRESYNC', $qresync_data);
}
list($code, $response) = $this->execute('SELECT', $params);
if ($code == self::ERROR_OK) {
$this->clear_mailbox_cache();
$response = explode("\r\n", $response);
foreach ($response as $line) {
if (preg_match('/^\* OK \[/i', $line)) {
$pos = strcspn($line, ' ]', 6);
$token = strtoupper(substr($line, 6, $pos));
$pos += 7;
switch ($token) {
case 'UIDNEXT':
case 'UIDVALIDITY':
case 'UNSEEN':
if ($len = strspn($line, '0123456789', $pos)) {
$this->data[$token] = (int) substr($line, $pos, $len);
}
break;
case 'HIGHESTMODSEQ':
if ($len = strspn($line, '0123456789', $pos)) {
$this->data[$token] = (string) substr($line, $pos, $len);
}
break;
case 'NOMODSEQ':
$this->data[$token] = true;
break;
case 'PERMANENTFLAGS':
$start = strpos($line, '(', $pos);
$end = strrpos($line, ')');
if ($start && $end) {
$flags = substr($line, $start + 1, $end - $start - 1);
$this->data[$token] = explode(' ', $flags);
}
break;
}
}
else if (preg_match('/^\* ([0-9]+) (EXISTS|RECENT|FETCH)/i', $line, $match)) {
$token = strtoupper($match[2]);
switch ($token) {
case 'EXISTS':
case 'RECENT':
$this->data[$token] = (int) $match[1];
break;
case 'FETCH':
// QRESYNC FETCH response (RFC5162)
$line = substr($line, strlen($match[0]));
$fetch_data = $this->tokenizeResponse($line, 1);
$data = array('id' => $match[1]);
for ($i=0, $size=count($fetch_data); $i<$size; $i+=2) {
$data[strtolower($fetch_data[$i])] = $fetch_data[$i+1];
}
$this->data['QRESYNC'][$data['uid']] = $data;
break;
}
}
// QRESYNC VANISHED response (RFC5162)
else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) {
$line = substr($line, strlen($match[0]));
$v_data = $this->tokenizeResponse($line, 1);
$this->data['VANISHED'] = $v_data;
}
}
$this->data['READ-WRITE'] = $this->resultcode != 'READ-ONLY';
$this->selected = $mailbox;
return true;
}
return false;
}
/**
* Executes STATUS command
*
* @param string $mailbox Mailbox name
* @param array $items Additional requested item names. By default
* MESSAGES and UNSEEN are requested. Other defined
* in RFC3501: UIDNEXT, UIDVALIDITY, RECENT
*
* @return array Status item-value hash
* @since 0.5-beta
*/
public function status($mailbox, $items = array())
{
if (!strlen($mailbox)) {
return false;
}
if (!in_array('MESSAGES', $items)) {
$items[] = 'MESSAGES';
}
if (!in_array('UNSEEN', $items)) {
$items[] = 'UNSEEN';
}
list($code, $response) = $this->execute('STATUS',
array($this->escape($mailbox), '(' . implode(' ', $items) . ')'), 0, '/^\* STATUS /i');
if ($code == self::ERROR_OK && $response) {
$result = array();
$response = substr($response, 9); // remove prefix "* STATUS "
list($mbox, $items) = $this->tokenizeResponse($response, 2);
// Fix for #1487859. Some buggy server returns not quoted
// folder name with spaces. Let's try to handle this situation
if (!is_array($items) && ($pos = strpos($response, '(')) !== false) {
$response = substr($response, $pos);
$items = $this->tokenizeResponse($response, 1);
}
if (!is_array($items)) {
return $result;
}
for ($i=0, $len=count($items); $i<$len; $i += 2) {
$result[$items[$i]] = $items[$i+1];
}
$this->data['STATUS:'.$mailbox] = $result;
return $result;
}
return false;
}
/**
* Executes EXPUNGE command
*
* @param string $mailbox Mailbox name
* @param string|array $messages Message UIDs to expunge
*
* @return boolean True on success, False on error
*/
public function expunge($mailbox, $messages = null)
{
if (!$this->select($mailbox)) {
return false;
}
if (!$this->data['READ-WRITE']) {
$this->setError(self::ERROR_READONLY, "Mailbox is read-only");
return false;
}
// Clear internal status cache
$this->clear_status_cache($mailbox);
if (!empty($messages) && $messages != '*' && $this->hasCapability('UIDPLUS')) {
$messages = self::compressMessageSet($messages);
$result = $this->execute('UID EXPUNGE', array($messages), self::COMMAND_NORESPONSE);
}
else {
$result = $this->execute('EXPUNGE', null, self::COMMAND_NORESPONSE);
}
if ($result == self::ERROR_OK) {
$this->selected = null; // state has changed, need to reselect
return true;
}
return false;
}
/**
* Executes CLOSE command
*
* @return boolean True on success, False on error
* @since 0.5
*/
public function close()
{
$result = $this->execute('CLOSE', null, self::COMMAND_NORESPONSE);
if ($result == self::ERROR_OK) {
$this->selected = null;
return true;
}
return false;
}
/**
* Folder subscription (SUBSCRIBE)
*
* @param string $mailbox Mailbox name
*
* @return boolean True on success, False on error
*/
public function subscribe($mailbox)
{
$result = $this->execute('SUBSCRIBE', array($this->escape($mailbox)),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Folder unsubscription (UNSUBSCRIBE)
*
* @param string $mailbox Mailbox name
*
* @return boolean True on success, False on error
*/
public function unsubscribe($mailbox)
{
$result = $this->execute('UNSUBSCRIBE', array($this->escape($mailbox)),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Folder creation (CREATE)
*
* @param string $mailbox Mailbox name
* @param array $types Optional folder types (RFC 6154)
*
* @return bool True on success, False on error
*/
public function createFolder($mailbox, $types = null)
{
$args = array($this->escape($mailbox));
// RFC 6154: CREATE-SPECIAL-USE
if (!empty($types) && $this->getCapability('CREATE-SPECIAL-USE')) {
$args[] = '(USE (' . implode(' ', $types) . '))';
}
$result = $this->execute('CREATE', $args, self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Folder renaming (RENAME)
*
* @param string $mailbox Mailbox name
*
* @return bool True on success, False on error
*/
public function renameFolder($from, $to)
{
$result = $this->execute('RENAME', array($this->escape($from), $this->escape($to)),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Executes DELETE command
*
* @param string $mailbox Mailbox name
*
* @return boolean True on success, False on error
*/
public function deleteFolder($mailbox)
{
$result = $this->execute('DELETE', array($this->escape($mailbox)),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Removes all messages in a folder
*
* @param string $mailbox Mailbox name
*
* @return boolean True on success, False on error
*/
public function clearFolder($mailbox)
{
$res = false;
if ($this->countMessages($mailbox) > 0) {
$res = $this->flag($mailbox, '1:*', 'DELETED');
}
if ($res) {
if ($this->selected === $mailbox) {
$res = $this->close();
}
else {
$res = $this->expunge($mailbox);
}
}
return $res;
}
/**
* Returns list of mailboxes
*
* @param string $ref Reference name
* @param string $mailbox Mailbox name
* @param array $return_opts (see self::_listMailboxes)
* @param array $select_opts (see self::_listMailboxes)
*
* @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response
* is requested, False on error.
*/
public function listMailboxes($ref, $mailbox, $return_opts = array(), $select_opts = array())
{
return $this->_listMailboxes($ref, $mailbox, false, $return_opts, $select_opts);
}
/**
* Returns list of subscribed mailboxes
*
* @param string $ref Reference name
* @param string $mailbox Mailbox name
* @param array $return_opts (see self::_listMailboxes)
*
* @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response
* is requested, False on error.
*/
public function listSubscribed($ref, $mailbox, $return_opts = array())
{
return $this->_listMailboxes($ref, $mailbox, true, $return_opts, null);
}
/**
* IMAP LIST/LSUB command
*
* @param string $ref Reference name
* @param string $mailbox Mailbox name
* @param bool $subscribed Enables returning subscribed mailboxes only
* @param array $return_opts List of RETURN options (RFC5819: LIST-STATUS, RFC5258: LIST-EXTENDED)
* Possible: MESSAGES, RECENT, UIDNEXT, UIDVALIDITY, UNSEEN,
* MYRIGHTS, SUBSCRIBED, CHILDREN
* @param array $select_opts List of selection options (RFC5258: LIST-EXTENDED)
* Possible: SUBSCRIBED, RECURSIVEMATCH, REMOTE,
* SPECIAL-USE (RFC6154)
*
* @return array|bool List of mailboxes or hash of options if STATUS/MYROGHTS response
* is requested, False on error.
*/
protected function _listMailboxes($ref, $mailbox, $subscribed=false,
$return_opts=array(), $select_opts=array())
{
if (!strlen($mailbox)) {
$mailbox = '*';
}
$args = array();
$rets = array();
if (!empty($select_opts) && $this->getCapability('LIST-EXTENDED')) {
$select_opts = (array) $select_opts;
$args[] = '(' . implode(' ', $select_opts) . ')';
}
$lstatus = false;
$args[] = $this->escape($ref);
$args[] = $this->escape($mailbox);
if (!empty($return_opts) && $this->getCapability('LIST-EXTENDED')) {
$ext_opts = array('SUBSCRIBED', 'CHILDREN');
$rets = array_intersect($return_opts, $ext_opts);
$return_opts = array_diff($return_opts, $rets);
}
if (!empty($return_opts) && $this->getCapability('LIST-STATUS')) {
$lstatus = true;
$status_opts = array('MESSAGES', 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN');
$opts = array_diff($return_opts, $status_opts);
$status_opts = array_diff($return_opts, $opts);
if (!empty($status_opts)) {
$rets[] = 'STATUS (' . implode(' ', $status_opts) . ')';
}
if (!empty($opts)) {
$rets = array_merge($rets, $opts);
}
}
if (!empty($rets)) {
$args[] = 'RETURN (' . implode(' ', $rets) . ')';
}
list($code, $response) = $this->execute($subscribed ? 'LSUB' : 'LIST', $args);
if ($code == self::ERROR_OK) {
$folders = array();
$last = 0;
$pos = 0;
$response .= "\r\n";
while ($pos = strpos($response, "\r\n", $pos+1)) {
// literal string, not real end-of-command-line
if ($response[$pos-1] == '}') {
continue;
}
$line = substr($response, $last, $pos - $last);
$last = $pos + 2;
if (!preg_match('/^\* (LIST|LSUB|STATUS|MYRIGHTS) /i', $line, $m)) {
continue;
}
$cmd = strtoupper($m[1]);
$line = substr($line, strlen($m[0]));
// * LIST (<options>) <delimiter> <mailbox>
if ($cmd == 'LIST' || $cmd == 'LSUB') {
list($opts, $delim, $mailbox) = $this->tokenizeResponse($line, 3);
// Remove redundant separator at the end of folder name, UW-IMAP bug? (#1488879)
if ($delim) {
$mailbox = rtrim($mailbox, $delim);
}
// Add to result array
if (!$lstatus) {
$folders[] = $mailbox;
}
else {
$folders[$mailbox] = array();
}
// store folder options
if ($cmd == 'LIST') {
// Add to options array
if (empty($this->data['LIST'][$mailbox])) {
$this->data['LIST'][$mailbox] = $opts;
}
else if (!empty($opts)) {
$this->data['LIST'][$mailbox] = array_unique(array_merge(
$this->data['LIST'][$mailbox], $opts));
}
}
}
else if ($lstatus) {
// * STATUS <mailbox> (<result>)
if ($cmd == 'STATUS') {
list($mailbox, $status) = $this->tokenizeResponse($line, 2);
for ($i=0, $len=count($status); $i<$len; $i += 2) {
list($name, $value) = $this->tokenizeResponse($status, 2);
$folders[$mailbox][$name] = $value;
}
}
// * MYRIGHTS <mailbox> <acl>
else if ($cmd == 'MYRIGHTS') {
list($mailbox, $acl) = $this->tokenizeResponse($line, 2);
$folders[$mailbox]['MYRIGHTS'] = $acl;
}
}
}
return $folders;
}
return false;
}
/**
* Returns count of all messages in a folder
*
* @param string $mailbox Mailbox name
*
* @return int Number of messages, False on error
*/
public function countMessages($mailbox)
{
if ($this->selected === $mailbox && isset($this->data['EXISTS'])) {
return $this->data['EXISTS'];
}
if (isset($this->data["STATUS:".$mailbox])) {
$cache = $this->data["STATUS:".$mailbox];
if (!empty($cache) && isset($cache['MESSAGES'])) {
return (int) $cache['MESSAGES'];
}
}
// Try STATUS (should be faster than SELECT)
$counts = $this->status($mailbox);
if (is_array($counts)) {
return (int) $counts['MESSAGES'];
}
return false;
}
/**
* Returns count of messages with \Recent flag in a folder
*
* @param string $mailbox Mailbox name
*
* @return int Number of messages, False on error
*/
public function countRecent($mailbox)
{
if ($this->selected === $mailbox && isset($this->data['RECENT'])) {
return $this->data['RECENT'];
}
// Check internal cache
$cache = $this->data['STATUS:'.$mailbox];
if (!empty($cache) && isset($cache['RECENT'])) {
return (int) $cache['RECENT'];
}
// Try STATUS (should be faster than SELECT)
$counts = $this->status($mailbox, array('RECENT'));
if (is_array($counts)) {
return (int) $counts['RECENT'];
}
return false;
}
/**
* Returns count of messages without \Seen flag in a specified folder
*
* @param string $mailbox Mailbox name
*
* @return int Number of messages, False on error
*/
public function countUnseen($mailbox)
{
// Check internal cache
$cache = $this->data['STATUS:'.$mailbox];
if (!empty($cache) && isset($cache['UNSEEN'])) {
return (int) $cache['UNSEEN'];
}
// Try STATUS (should be faster than SELECT+SEARCH)
$counts = $this->status($mailbox);
if (is_array($counts)) {
return (int) $counts['UNSEEN'];
}
// Invoke SEARCH as a fallback
$index = $this->search($mailbox, 'ALL UNSEEN', false, array('COUNT'));
if (!$index->is_error()) {
return $index->count();
}
return false;
}
/**
* Executes ID command (RFC2971)
*
* @param array $items Client identification information key/value hash
*
* @return array Server identification information key/value hash
* @since 0.6
*/
public function id($items = array())
{
if (is_array($items) && !empty($items)) {
foreach ($items as $key => $value) {
$args[] = $this->escape($key, true);
$args[] = $this->escape($value, true);
}
}
list($code, $response) = $this->execute('ID',
array(!empty($args) ? '(' . implode(' ', (array) $args) . ')' : $this->escape(null)),
0, '/^\* ID /i');
if ($code == self::ERROR_OK && $response) {
$response = substr($response, 5); // remove prefix "* ID "
$items = $this->tokenizeResponse($response, 1);
$result = null;
for ($i=0, $len=count($items); $i<$len; $i += 2) {
$result[$items[$i]] = $items[$i+1];
}
return $result;
}
return false;
}
/**
* Executes ENABLE command (RFC5161)
*
* @param mixed $extension Extension name to enable (or array of names)
*
* @return array|bool List of enabled extensions, False on error
* @since 0.6
*/
public function enable($extension)
{
if (empty($extension)) {
return false;
}
if (!$this->hasCapability('ENABLE')) {
return false;
}
if (!is_array($extension)) {
$extension = array($extension);
}
if (!empty($this->extensions_enabled)) {
// check if all extensions are already enabled
$diff = array_diff($extension, $this->extensions_enabled);
if (empty($diff)) {
return $extension;
}
// Make sure the mailbox isn't selected, before enabling extension(s)
if ($this->selected !== null) {
$this->close();
}
}
list($code, $response) = $this->execute('ENABLE', $extension, 0, '/^\* ENABLED /i');
if ($code == self::ERROR_OK && $response) {
$response = substr($response, 10); // remove prefix "* ENABLED "
$result = (array) $this->tokenizeResponse($response);
$this->extensions_enabled = array_unique(array_merge((array)$this->extensions_enabled, $result));
return $this->extensions_enabled;
}
return false;
}
/**
* Executes SORT command
*
* @param string $mailbox Mailbox name
* @param string $field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
* @param string $criteria Searching criteria
* @param bool $return_uid Enables UID SORT usage
* @param string $encoding Character set
*
* @return rcube_result_index Response data
*/
public function sort($mailbox, $field = 'ARRIVAL', $criteria = '', $return_uid = false, $encoding = 'US-ASCII')
{
$old_sel = $this->selected;
$supported = array('ARRIVAL', 'CC', 'DATE', 'FROM', 'SIZE', 'SUBJECT', 'TO');
$field = strtoupper($field);
if ($field == 'INTERNALDATE') {
$field = 'ARRIVAL';
}
if (!in_array($field, $supported)) {
return new rcube_result_index($mailbox);
}
if (!$this->select($mailbox)) {
return new rcube_result_index($mailbox);
}
// return empty result when folder is empty and we're just after SELECT
if ($old_sel != $mailbox && !$this->data['EXISTS']) {
return new rcube_result_index($mailbox, '* SORT');
}
// RFC 5957: SORT=DISPLAY
if (($field == 'FROM' || $field == 'TO') && $this->getCapability('SORT=DISPLAY')) {
$field = 'DISPLAY' . $field;
}
$encoding = $encoding ? trim($encoding) : 'US-ASCII';
$criteria = $criteria ? 'ALL ' . trim($criteria) : 'ALL';
list($code, $response) = $this->execute($return_uid ? 'UID SORT' : 'SORT',
array("($field)", $encoding, $criteria));
if ($code != self::ERROR_OK) {
$response = null;
}
return new rcube_result_index($mailbox, $response);
}
/**
* Executes THREAD command
*
* @param string $mailbox Mailbox name
* @param string $algorithm Threading algorithm (ORDEREDSUBJECT, REFERENCES, REFS)
* @param string $criteria Searching criteria
* @param bool $return_uid Enables UIDs in result instead of sequence numbers
* @param string $encoding Character set
*
* @return rcube_result_thread Thread data
*/
public function thread($mailbox, $algorithm = 'REFERENCES', $criteria = '', $return_uid = false, $encoding = 'US-ASCII')
{
$old_sel = $this->selected;
if (!$this->select($mailbox)) {
return new rcube_result_thread($mailbox);
}
// return empty result when folder is empty and we're just after SELECT
if ($old_sel != $mailbox && !$this->data['EXISTS']) {
return new rcube_result_thread($mailbox, '* THREAD');
}
$encoding = $encoding ? trim($encoding) : 'US-ASCII';
$algorithm = $algorithm ? trim($algorithm) : 'REFERENCES';
$criteria = $criteria ? 'ALL '.trim($criteria) : 'ALL';
list($code, $response) = $this->execute($return_uid ? 'UID THREAD' : 'THREAD',
array($algorithm, $encoding, $criteria));
if ($code != self::ERROR_OK) {
$response = null;
}
return new rcube_result_thread($mailbox, $response);
}
/**
* Executes SEARCH command
*
* @param string $mailbox Mailbox name
* @param string $criteria Searching criteria
* @param bool $return_uid Enable UID in result instead of sequence ID
* @param array $items Return items (MIN, MAX, COUNT, ALL)
*
* @return rcube_result_index Result data
*/
public function search($mailbox, $criteria, $return_uid = false, $items = array())
{
$old_sel = $this->selected;
if (!$this->select($mailbox)) {
return new rcube_result_index($mailbox);
}
// return empty result when folder is empty and we're just after SELECT
if ($old_sel != $mailbox && !$this->data['EXISTS']) {
return new rcube_result_index($mailbox, '* SEARCH');
}
// If ESEARCH is supported always use ALL
// but not when items are specified or using simple id2uid search
if (empty($items) && preg_match('/[^0-9]/', $criteria)) {
$items = array('ALL');
}
$esearch = empty($items) ? false : $this->getCapability('ESEARCH');
$criteria = trim($criteria);
$params = '';
// RFC4731: ESEARCH
if (!empty($items) && $esearch) {
$params .= 'RETURN (' . implode(' ', $items) . ')';
}
if (!empty($criteria)) {
$params .= ($params ? ' ' : '') . $criteria;
}
else {
$params .= 'ALL';
}
list($code, $response) = $this->execute($return_uid ? 'UID SEARCH' : 'SEARCH',
array($params));
if ($code != self::ERROR_OK) {
$response = null;
}
return new rcube_result_index($mailbox, $response);
}
/**
* Simulates SORT command by using FETCH and sorting.
*
* @param string $mailbox Mailbox name
* @param string|array $message_set Searching criteria (list of messages to return)
* @param string $index_field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
* @param bool $skip_deleted Makes that DELETED messages will be skipped
* @param bool $uidfetch Enables UID FETCH usage
* @param bool $return_uid Enables returning UIDs instead of IDs
*
* @return rcube_result_index Response data
*/
public function index($mailbox, $message_set, $index_field='', $skip_deleted=true,
$uidfetch=false, $return_uid=false)
{
$msg_index = $this->fetchHeaderIndex($mailbox, $message_set,
$index_field, $skip_deleted, $uidfetch, $return_uid);
if (!empty($msg_index)) {
asort($msg_index); // ASC
$msg_index = array_keys($msg_index);
$msg_index = '* SEARCH ' . implode(' ', $msg_index);
}
else {
$msg_index = is_array($msg_index) ? '* SEARCH' : null;
}
return new rcube_result_index($mailbox, $msg_index);
}
/**
* Fetches specified header/data value for a set of messages.
*
* @param string $mailbox Mailbox name
* @param string|array $message_set Searching criteria (list of messages to return)
* @param string $index_field Field to sort by (ARRIVAL, CC, DATE, FROM, SIZE, SUBJECT, TO)
* @param bool $skip_deleted Makes that DELETED messages will be skipped
* @param bool $uidfetch Enables UID FETCH usage
* @param bool $return_uid Enables returning UIDs instead of IDs
*
* @return array|bool List of header values or False on failure
*/
public function fetchHeaderIndex($mailbox, $message_set, $index_field = '', $skip_deleted = true,
$uidfetch = false, $return_uid = false)
{
if (is_array($message_set)) {
if (!($message_set = $this->compressMessageSet($message_set))) {
return false;
}
}
else {
list($from_idx, $to_idx) = explode(':', $message_set);
if (empty($message_set) ||
(isset($to_idx) && $to_idx != '*' && (int)$from_idx > (int)$to_idx)
) {
return false;
}
}
$index_field = empty($index_field) ? 'DATE' : strtoupper($index_field);
$fields_a['DATE'] = 1;
$fields_a['INTERNALDATE'] = 4;
$fields_a['ARRIVAL'] = 4;
$fields_a['FROM'] = 1;
$fields_a['REPLY-TO'] = 1;
$fields_a['SENDER'] = 1;
$fields_a['TO'] = 1;
$fields_a['CC'] = 1;
$fields_a['SUBJECT'] = 1;
$fields_a['UID'] = 2;
$fields_a['SIZE'] = 2;
$fields_a['SEEN'] = 3;
$fields_a['RECENT'] = 3;
$fields_a['DELETED'] = 3;
if (!($mode = $fields_a[$index_field])) {
return false;
}
// Select the mailbox
if (!$this->select($mailbox)) {
return false;
}
// build FETCH command string
$key = $this->nextTag();
$cmd = $uidfetch ? 'UID FETCH' : 'FETCH';
$fields = array();
if ($return_uid) {
$fields[] = 'UID';
}
if ($skip_deleted) {
$fields[] = 'FLAGS';
}
if ($mode == 1) {
if ($index_field == 'DATE') {
$fields[] = 'INTERNALDATE';
}
$fields[] = "BODY.PEEK[HEADER.FIELDS ($index_field)]";
}
else if ($mode == 2) {
if ($index_field == 'SIZE') {
$fields[] = 'RFC822.SIZE';
}
else if (!$return_uid || $index_field != 'UID') {
$fields[] = $index_field;
}
}
else if ($mode == 3 && !$skip_deleted) {
$fields[] = 'FLAGS';
}
else if ($mode == 4) {
$fields[] = 'INTERNALDATE';
}
$request = "$key $cmd $message_set (" . implode(' ', $fields) . ")";
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
return false;
}
$result = array();
do {
$line = rtrim($this->readLine(200));
$line = $this->multLine($line);
if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) {
$id = $m[1];
$flags = null;
if ($return_uid) {
if (preg_match('/UID ([0-9]+)/', $line, $matches)) {
$id = (int) $matches[1];
}
else {
continue;
}
}
if ($skip_deleted && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) {
$flags = explode(' ', strtoupper($matches[1]));
if (in_array('\\DELETED', $flags)) {
continue;
}
}
if ($mode == 1 && $index_field == 'DATE') {
if (preg_match('/BODY\[HEADER\.FIELDS \("*DATE"*\)\] (.*)/', $line, $matches)) {
$value = preg_replace(array('/^"*[a-z]+:/i'), '', $matches[1]);
$value = trim($value);
$result[$id] = rcube_utils::strtotime($value);
}
// non-existent/empty Date: header, use INTERNALDATE
if (empty($result[$id])) {
if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) {
$result[$id] = rcube_utils::strtotime($matches[1]);
}
else {
$result[$id] = 0;
}
}
}
else if ($mode == 1) {
if (preg_match('/BODY\[HEADER\.FIELDS \("?(FROM|REPLY-TO|SENDER|TO|SUBJECT)"?\)\] (.*)/', $line, $matches)) {
$value = preg_replace(array('/^"*[a-z]+:/i', '/\s+$/sm'), array('', ''), $matches[2]);
$result[$id] = trim($value);
}
else {
$result[$id] = '';
}
}
else if ($mode == 2) {
if (preg_match('/' . $index_field . ' ([0-9]+)/', $line, $matches)) {
$result[$id] = trim($matches[1]);
}
else {
$result[$id] = 0;
}
}
else if ($mode == 3) {
if (!$flags && preg_match('/FLAGS \(([^)]+)\)/', $line, $matches)) {
$flags = explode(' ', $matches[1]);
}
$result[$id] = in_array("\\".$index_field, (array) $flags) ? 1 : 0;
}
else if ($mode == 4) {
if (preg_match('/INTERNALDATE "([^"]+)"/', $line, $matches)) {
$result[$id] = rcube_utils::strtotime($matches[1]);
}
else {
$result[$id] = 0;
}
}
}
}
while (!$this->startsWith($line, $key, true, true));
return $result;
}
/**
* Returns message sequence identifier
*
* @param string $mailbox Mailbox name
* @param int $uid Message unique identifier (UID)
*
* @return int Message sequence identifier
*/
public function UID2ID($mailbox, $uid)
{
if ($uid > 0) {
$index = $this->search($mailbox, "UID $uid");
if ($index->count() == 1) {
$arr = $index->get();
return (int) $arr[0];
}
}
}
/**
* Returns message unique identifier (UID)
*
* @param string $mailbox Mailbox name
* @param int $uid Message sequence identifier
*
* @return int Message unique identifier
*/
public function ID2UID($mailbox, $id)
{
if (empty($id) || $id < 0) {
return null;
}
if (!$this->select($mailbox)) {
return null;
}
if ($uid = $this->data['UID-MAP'][$id]) {
return $uid;
}
if (isset($this->data['EXISTS']) && $id > $this->data['EXISTS']) {
return null;
}
$index = $this->search($mailbox, $id, true);
if ($index->count() == 1) {
$arr = $index->get();
return $this->data['UID-MAP'][$id] = (int) $arr[0];
}
}
/**
* Sets flag of the message(s)
*
* @param string $mailbox Mailbox name
* @param string|array $messages Message UID(s)
* @param string $flag Flag name
*
* @return bool True on success, False on failure
*/
public function flag($mailbox, $messages, $flag)
{
return $this->modFlag($mailbox, $messages, $flag, '+');
}
/**
* Unsets flag of the message(s)
*
* @param string $mailbox Mailbox name
* @param string|array $messages Message UID(s)
* @param string $flag Flag name
*
* @return bool True on success, False on failure
*/
public function unflag($mailbox, $messages, $flag)
{
return $this->modFlag($mailbox, $messages, $flag, '-');
}
/**
* Changes flag of the message(s)
*
* @param string $mailbox Mailbox name
* @param string|array $messages Message UID(s)
* @param string $flag Flag name
* @param string $mod Modifier [+|-]. Default: "+".
*
* @return bool True on success, False on failure
*/
protected function modFlag($mailbox, $messages, $flag, $mod = '+')
{
if (!$flag) {
return false;
}
if (!$this->select($mailbox)) {
return false;
}
if (!$this->data['READ-WRITE']) {
$this->setError(self::ERROR_READONLY, "Mailbox is read-only");
return false;
}
if ($this->flags[strtoupper($flag)]) {
$flag = $this->flags[strtoupper($flag)];
}
// if PERMANENTFLAGS is not specified all flags are allowed
if (!empty($this->data['PERMANENTFLAGS'])
&& !in_array($flag, (array) $this->data['PERMANENTFLAGS'])
&& !in_array('\\*', (array) $this->data['PERMANENTFLAGS'])
) {
return false;
}
// Clear internal status cache
if ($flag == 'SEEN') {
unset($this->data['STATUS:'.$mailbox]['UNSEEN']);
}
if ($mod != '+' && $mod != '-') {
$mod = '+';
}
$result = $this->execute('UID STORE', array(
$this->compressMessageSet($messages), $mod . 'FLAGS.SILENT', "($flag)"),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Copies message(s) from one folder to another
*
* @param string|array $messages Message UID(s)
* @param string $from Mailbox name
* @param string $to Destination mailbox name
*
* @return bool True on success, False on failure
*/
public function copy($messages, $from, $to)
{
// Clear last COPYUID data
unset($this->data['COPYUID']);
if (!$this->select($from)) {
return false;
}
// Clear internal status cache
unset($this->data['STATUS:'.$to]);
$result = $this->execute('UID COPY', array(
$this->compressMessageSet($messages), $this->escape($to)),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
/**
* Moves message(s) from one folder to another.
*
* @param string|array $messages Message UID(s)
* @param string $from Mailbox name
* @param string $to Destination mailbox name
*
* @return bool True on success, False on failure
*/
public function move($messages, $from, $to)
{
if (!$this->select($from)) {
return false;
}
if (!$this->data['READ-WRITE']) {
$this->setError(self::ERROR_READONLY, "Mailbox is read-only");
return false;
}
// use MOVE command (RFC 6851)
if ($this->hasCapability('MOVE')) {
// Clear last COPYUID data
unset($this->data['COPYUID']);
// Clear internal status cache
unset($this->data['STATUS:'.$to]);
$this->clear_status_cache($from);
$result = $this->execute('UID MOVE', array(
$this->compressMessageSet($messages), $this->escape($to)),
self::COMMAND_NORESPONSE);
return $result == self::ERROR_OK;
}
// use COPY + STORE +FLAGS.SILENT \Deleted + EXPUNGE
$result = $this->copy($messages, $from, $to);
if ($result) {
// Clear internal status cache
unset($this->data['STATUS:'.$from]);
$result = $this->flag($from, $messages, 'DELETED');
if ($messages == '*') {
// CLOSE+SELECT should be faster than EXPUNGE
$this->close();
}
else {
$this->expunge($from, $messages);
}
}
return $result;
}
/**
* FETCH command (RFC3501)
*
* @param string $mailbox Mailbox name
* @param mixed $message_set Message(s) sequence identifier(s) or UID(s)
* @param bool $is_uid True if $message_set contains UIDs
* @param array $query_items FETCH command data items
* @param string $mod_seq Modification sequence for CHANGEDSINCE (RFC4551) query
* @param bool $vanished Enables VANISHED parameter (RFC5162) for CHANGEDSINCE query
*
* @return array List of rcube_message_header elements, False on error
* @since 0.6
*/
public function fetch($mailbox, $message_set, $is_uid = false, $query_items = array(),
$mod_seq = null, $vanished = false)
{
if (!$this->select($mailbox)) {
return false;
}
$message_set = $this->compressMessageSet($message_set);
$result = array();
$key = $this->nextTag();
$cmd = ($is_uid ? 'UID ' : '') . 'FETCH';
$request = "$key $cmd $message_set (" . implode(' ', $query_items) . ")";
if ($mod_seq !== null && $this->hasCapability('CONDSTORE')) {
$request .= " (CHANGEDSINCE $mod_seq" . ($vanished ? " VANISHED" : '') .")";
}
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
return false;
}
do {
$line = $this->readLine(4096);
if (!$line) {
break;
}
// Sample reply line:
// * 321 FETCH (UID 2417 RFC822.SIZE 2730 FLAGS (\Seen)
// INTERNALDATE "16-Nov-2008 21:08:46 +0100" BODYSTRUCTURE (...)
// BODY[HEADER.FIELDS ...
if (preg_match('/^\* ([0-9]+) FETCH/', $line, $m)) {
$id = intval($m[1]);
$result[$id] = new rcube_message_header;
$result[$id]->id = $id;
$result[$id]->subject = '';
$result[$id]->messageID = 'mid:' . $id;
$headers = null;
$lines = array();
$line = substr($line, strlen($m[0]) + 2);
$ln = 0;
// get complete entry
while (preg_match('/\{([0-9]+)\}\r\n$/', $line, $m)) {
$bytes = $m[1];
$out = '';
while (strlen($out) < $bytes) {
$out = $this->readBytes($bytes);
if ($out === null) {
break;
}
$line .= $out;
}
$str = $this->readLine(4096);
if ($str === false) {
break;
}
$line .= $str;
}
// Tokenize response and assign to object properties
while (list($name, $value) = $this->tokenizeResponse($line, 2)) {
if ($name == 'UID') {
$result[$id]->uid = intval($value);
}
else if ($name == 'RFC822.SIZE') {
$result[$id]->size = intval($value);
}
else if ($name == 'RFC822.TEXT') {
$result[$id]->body = $value;
}
else if ($name == 'INTERNALDATE') {
$result[$id]->internaldate = $value;
$result[$id]->date = $value;
$result[$id]->timestamp = rcube_utils::strtotime($value);
}
else if ($name == 'FLAGS') {
if (!empty($value)) {
foreach ((array)$value as $flag) {
$flag = str_replace(array('$', "\\"), '', $flag);
$flag = strtoupper($flag);
$result[$id]->flags[$flag] = true;
}
}
}
else if ($name == 'MODSEQ') {
$result[$id]->modseq = $value[0];
}
else if ($name == 'ENVELOPE') {
$result[$id]->envelope = $value;
}
else if ($name == 'BODYSTRUCTURE' || ($name == 'BODY' && count($value) > 2)) {
if (!is_array($value[0]) && (strtolower($value[0]) == 'message' && strtolower($value[1]) == 'rfc822')) {
$value = array($value);
}
$result[$id]->bodystructure = $value;
}
else if ($name == 'RFC822') {
$result[$id]->body = $value;
}
else if (stripos($name, 'BODY[') === 0) {
$name = str_replace(']', '', substr($name, 5));
if ($name == 'HEADER.FIELDS') {
// skip ']' after headers list
$this->tokenizeResponse($line, 1);
$headers = $this->tokenizeResponse($line, 1);
}
else if (strlen($name)) {
$result[$id]->bodypart[$name] = $value;
}
else {
$result[$id]->body = $value;
}
}
}
// create array with header field:data
if (!empty($headers)) {
$headers = explode("\n", trim($headers));
foreach ($headers as $resln) {
if (ord($resln[0]) <= 32) {
$lines[$ln] .= (empty($lines[$ln]) ? '' : "\n") . trim($resln);
}
else {
$lines[++$ln] = trim($resln);
}
}
foreach ($lines as $str) {
list($field, $string) = explode(':', $str, 2);
$field = strtolower($field);
$string = preg_replace('/\n[\t\s]*/', ' ', trim($string));
switch ($field) {
case 'date';
$string = substr($string, 0, 128);
$result[$id]->date = $string;
$result[$id]->timestamp = rcube_utils::strtotime($string);
break;
case 'to':
$result[$id]->to = preg_replace('/undisclosed-recipients:[;,]*/', '', $string);
break;
case 'from':
case 'subject':
$string = substr($string, 0, 2048);
case 'cc':
case 'bcc':
case 'references':
$result[$id]->{$field} = $string;
break;
case 'reply-to':
$result[$id]->replyto = $string;
break;
case 'content-transfer-encoding':
$result[$id]->encoding = substr($string, 0, 32);
break;
case 'content-type':
$ctype_parts = preg_split('/[; ]+/', $string);
$result[$id]->ctype = strtolower(array_shift($ctype_parts));
if (preg_match('/charset\s*=\s*"?([a-z0-9\-\.\_]+)"?/i', $string, $regs)) {
$result[$id]->charset = $regs[1];
}
break;
case 'in-reply-to':
$result[$id]->in_reply_to = str_replace(array("\n", '<', '>'), '', $string);
break;
case 'return-receipt-to':
case 'disposition-notification-to':
case 'x-confirm-reading-to':
$result[$id]->mdn_to = substr($string, 0, 2048);
break;
case 'message-id':
$result[$id]->messageID = substr($string, 0, 2048);
break;
case 'x-priority':
if (preg_match('/^(\d+)/', $string, $matches)) {
$result[$id]->priority = intval($matches[1]);
}
break;
default:
if (strlen($field) < 3) {
break;
}
if ($result[$id]->others[$field]) {
$string = array_merge((array)$result[$id]->others[$field], (array)$string);
}
$result[$id]->others[$field] = $string;
}
}
}
}
// VANISHED response (QRESYNC RFC5162)
// Sample: * VANISHED (EARLIER) 300:310,405,411
else if (preg_match('/^\* VANISHED [()EARLIER]*/i', $line, $match)) {
$line = substr($line, strlen($match[0]));
$v_data = $this->tokenizeResponse($line, 1);
$this->data['VANISHED'] = $v_data;
}
}
while (!$this->startsWith($line, $key, true));
return $result;
}
/**
* Returns message(s) data (flags, headers, etc.)
*
* @param string $mailbox Mailbox name
* @param mixed $message_set Message(s) sequence identifier(s) or UID(s)
* @param bool $is_uid True if $message_set contains UIDs
* @param bool $bodystr Enable to add BODYSTRUCTURE data to the result
* @param array $add_headers List of additional headers
*
* @return bool|array List of rcube_message_header elements, False on error
*/
public function fetchHeaders($mailbox, $message_set, $is_uid = false, $bodystr = false, $add_headers = array())
{
$query_items = array('UID', 'RFC822.SIZE', 'FLAGS', 'INTERNALDATE');
$headers = array('DATE', 'FROM', 'TO', 'SUBJECT', 'CONTENT-TYPE', 'CC', 'REPLY-TO',
'LIST-POST', 'DISPOSITION-NOTIFICATION-TO', 'X-PRIORITY');
if (!empty($add_headers)) {
$add_headers = array_map('strtoupper', $add_headers);
$headers = array_unique(array_merge($headers, $add_headers));
}
if ($bodystr) {
$query_items[] = 'BODYSTRUCTURE';
}
$query_items[] = 'BODY.PEEK[HEADER.FIELDS (' . implode(' ', $headers) . ')]';
return $this->fetch($mailbox, $message_set, $is_uid, $query_items);
}
/**
* Returns message data (flags, headers, etc.)
*
* @param string $mailbox Mailbox name
* @param int $id Message sequence identifier or UID
* @param bool $is_uid True if $id is an UID
* @param bool $bodystr Enable to add BODYSTRUCTURE data to the result
* @param array $add_headers List of additional headers
*
* @return bool|rcube_message_header Message data, False on error
*/
public function fetchHeader($mailbox, $id, $is_uid = false, $bodystr = false, $add_headers = array())
{
$a = $this->fetchHeaders($mailbox, $id, $is_uid, $bodystr, $add_headers);
if (is_array($a)) {
return array_shift($a);
}
return false;
}
/**
* Sort messages by specified header field
*
* @param array $messages Array of rcube_message_header objects
* @param string $field Name of the property to sort by
* @param string $flag Sorting order (ASC|DESC)
*
* @return array Sorted input array
*/
public static function sortHeaders($messages, $field, $flag)
{
$field = empty($field) ? 'uid' : strtolower($field);
$order = empty($flag) ? 'ASC' : strtoupper($flag);
$index = array();
reset($messages);
// Create an index
foreach ($messages as $key => $headers) {
switch ($field) {
case 'arrival':
$field = 'internaldate';
// no-break
case 'date':
case 'internaldate':
case 'timestamp':
$value = rcube_utils::strtotime($headers->$field);
if (!$value && $field != 'timestamp') {
$value = $headers->timestamp;
}
break;
default:
// @TODO: decode header value, convert to UTF-8
$value = $headers->$field;
if (is_string($value)) {
$value = str_replace('"', '', $value);
if ($field == 'subject') {
$value = preg_replace('/^(Re:\s*|Fwd:\s*|Fw:\s*)+/i', '', $value);
}
}
}
$index[$key] = $value;
}
$sort_order = $flag == 'ASC' ? SORT_ASC : SORT_DESC;
$sort_flags = SORT_STRING | SORT_FLAG_CASE;
if (in_array($field, array('arrival', 'date', 'internaldate', 'timestamp'))) {
$sort_flags = SORT_NUMERIC;
}
array_multisort($index, $sort_order, $sort_flags, $messages);
return $messages;
}
/**
* Fetch MIME headers of specified message parts
*
* @param string $mailbox Mailbox name
* @param int $uid Message UID
* @param array $parts Message part identifiers
* @param bool $mime Use MIME instad of HEADER
*
* @return array|bool Array containing headers string for each specified body
* False on failure.
*/
public function fetchMIMEHeaders($mailbox, $uid, $parts, $mime = true)
{
if (!$this->select($mailbox)) {
return false;
}
$result = false;
$parts = (array) $parts;
$key = $this->nextTag();
$peeks = array();
$type = $mime ? 'MIME' : 'HEADER';
// format request
foreach ($parts as $part) {
$peeks[] = "BODY.PEEK[$part.$type]";
}
$request = "$key UID FETCH $uid (" . implode(' ', $peeks) . ')';
// send request
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send UID FETCH command");
return false;
}
do {
$line = $this->readLine(1024);
if (preg_match('/^\* [0-9]+ FETCH [0-9UID( ]+/', $line, $m)) {
$line = ltrim(substr($line, strlen($m[0])));
while (preg_match('/^BODY\[([0-9\.]+)\.'.$type.'\]/', $line, $matches)) {
$line = substr($line, strlen($matches[0]));
$result[$matches[1]] = trim($this->multLine($line));
$line = $this->readLine(1024);
}
}
}
while (!$this->startsWith($line, $key, true));
return $result;
}
/**
* Fetches message part header
*/
public function fetchPartHeader($mailbox, $id, $is_uid = false, $part = null)
{
$part = empty($part) ? 'HEADER' : $part.'.MIME';
return $this->handlePartBody($mailbox, $id, $is_uid, $part);
}
/**
* Fetches body of the specified message part
*/
public function handlePartBody($mailbox, $id, $is_uid=false, $part='', $encoding=null, $print=null, $file=null, $formatted=false, $max_bytes=0)
{
if (!$this->select($mailbox)) {
return false;
}
$binary = true;
do {
if (!$initiated) {
switch ($encoding) {
case 'base64':
$mode = 1;
break;
case 'quoted-printable':
$mode = 2;
break;
case 'x-uuencode':
case 'x-uue':
case 'uue':
case 'uuencode':
$mode = 3;
break;
default:
$mode = 0;
}
// Use BINARY extension when possible (and safe)
$binary = $binary && $mode && preg_match('/^[0-9.]+$/', $part) && $this->hasCapability('BINARY');
$fetch_mode = $binary ? 'BINARY' : 'BODY';
$partial = $max_bytes ? sprintf('<0.%d>', $max_bytes) : '';
// format request
$key = $this->nextTag();
$cmd = ($is_uid ? 'UID ' : '') . 'FETCH';
$request = "$key $cmd $id ($fetch_mode.PEEK[$part]$partial)";
$result = false;
$found = false;
$initiated = true;
// send request
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
return false;
}
if ($binary) {
// WARNING: Use $formatted argument with care, this may break binary data stream
$mode = -1;
}
}
$line = trim($this->readLine(1024));
if (!$line) {
break;
}
// handle UNKNOWN-CTE response - RFC 3516, try again with standard BODY request
if ($binary && !$found && preg_match('/^' . $key . ' NO \[(UNKNOWN-CTE|PARSE)\]/i', $line)) {
$binary = $initiated = false;
continue;
}
// skip irrelevant untagged responses (we have a result already)
if ($found || !preg_match('/^\* ([0-9]+) FETCH (.*)$/', $line, $m)) {
continue;
}
$line = $m[2];
// handle one line response
if ($line[0] == '(' && substr($line, -1) == ')') {
// tokenize content inside brackets
// the content can be e.g.: (UID 9844 BODY[2.4] NIL)
$tokens = $this->tokenizeResponse(preg_replace('/(^\(|\)$)/', '', $line));
for ($i=0; $i<count($tokens); $i+=2) {
if (preg_match('/^(BODY|BINARY)/i', $tokens[$i])) {
$result = $tokens[$i+1];
$found = true;
break;
}
}
if ($result !== false) {
if ($mode == 1) {
$result = base64_decode($result);
}
else if ($mode == 2) {
$result = quoted_printable_decode($result);
}
else if ($mode == 3) {
$result = convert_uudecode($result);
}
}
}
// response with string literal
else if (preg_match('/\{([0-9]+)\}$/', $line, $m)) {
$bytes = (int) $m[1];
$prev = '';
$found = true;
// empty body
if (!$bytes) {
$result = '';
}
else while ($bytes > 0) {
$line = $this->readLine(8192);
if ($line === null) {
break;
}
$len = strlen($line);
if ($len > $bytes) {
$line = substr($line, 0, $bytes);
$len = strlen($line);
}
$bytes -= $len;
// BASE64
if ($mode == 1) {
$line = preg_replace('|[^a-zA-Z0-9+=/]|', '', $line);
// create chunks with proper length for base64 decoding
$line = $prev.$line;
$length = strlen($line);
if ($length % 4) {
$length = floor($length / 4) * 4;
$prev = substr($line, $length);
$line = substr($line, 0, $length);
}
else {
$prev = '';
}
$line = base64_decode($line);
}
// QUOTED-PRINTABLE
else if ($mode == 2) {
$line = rtrim($line, "\t\r\0\x0B");
$line = quoted_printable_decode($line);
}
// UUENCODE
else if ($mode == 3) {
$line = rtrim($line, "\t\r\n\0\x0B");
if ($line == 'end' || preg_match('/^begin\s+[0-7]+\s+.+$/', $line)) {
continue;
}
$line = convert_uudecode($line);
}
// default
else if ($formatted) {
$line = rtrim($line, "\t\r\n\0\x0B") . "\n";
}
if ($file) {
if (fwrite($file, $line) === false) {
break;
}
}
else if ($print) {
echo $line;
}
else {
$result .= $line;
}
}
}
}
while (!$this->startsWith($line, $key, true) || !$initiated);
if ($result !== false) {
if ($file) {
return fwrite($file, $result);
}
else if ($print) {
echo $result;
return true;
}
return $result;
}
return false;
}
/**
* Handler for IMAP APPEND command
*
* @param string $mailbox Mailbox name
* @param string|array $message The message source string or array (of strings and file pointers)
* @param array $flags Message flags
* @param string $date Message internal date
* @param bool $binary Enable BINARY append (RFC3516)
*
* @return string|bool On success APPENDUID response (if available) or True, False on failure
*/
public function append($mailbox, &$message, $flags = array(), $date = null, $binary = false)
{
unset($this->data['APPENDUID']);
if ($mailbox === null || $mailbox === '') {
return false;
}
$binary = $binary && $this->getCapability('BINARY');
$literal_plus = !$binary && $this->prefs['literal+'];
$len = 0;
$msg = is_array($message) ? $message : array(&$message);
$chunk_size = 512000;
for ($i=0, $cnt=count($msg); $i<$cnt; $i++) {
if (is_resource($msg[$i])) {
$stat = fstat($msg[$i]);
if ($stat === false) {
return false;
}
$len += $stat['size'];
}
else {
if (!$binary) {
$msg[$i] = str_replace("\r", '', $msg[$i]);
$msg[$i] = str_replace("\n", "\r\n", $msg[$i]);
}
$len += strlen($msg[$i]);
}
}
if (!$len) {
return false;
}
// build APPEND command
$key = $this->nextTag();
$request = "$key APPEND " . $this->escape($mailbox) . ' (' . $this->flagsToStr($flags) . ')';
if (!empty($date)) {
$request .= ' ' . $this->escape($date);
}
$request .= ' ' . ($binary ? '~' : '') . '{' . $len . ($literal_plus ? '+' : '') . '}';
// send APPEND command
if (!$this->putLine($request)) {
$this->setError(self::ERROR_COMMAND, "Failed to send APPEND command");
return false;
}
// Do not wait when LITERAL+ is supported
if (!$literal_plus) {
$line = $this->readReply();
if ($line[0] != '+') {
$this->parseResult($line, 'APPEND: ');
return false;
}
}
foreach ($msg as $msg_part) {
// file pointer
if (is_resource($msg_part)) {
rewind($msg_part);
while (!feof($msg_part) && $this->fp) {
$buffer = fread($msg_part, $chunk_size);
$this->putLine($buffer, false);
}
fclose($msg_part);
}
// string
else {
$size = strlen($msg_part);
// Break up the data by sending one chunk (up to 512k) at a time.
// This approach reduces our peak memory usage
for ($offset = 0; $offset < $size; $offset += $chunk_size) {
$chunk = substr($msg_part, $offset, $chunk_size);
if (!$this->putLine($chunk, false)) {
return false;
}
}
}
}
if (!$this->putLine('')) { // \r\n
return false;
}
do {
$line = $this->readLine();
} while (!$this->startsWith($line, $key, true, true));
// Clear internal status cache
unset($this->data['STATUS:'.$mailbox]);
if ($this->parseResult($line, 'APPEND: ') != self::ERROR_OK) {
return false;
}
if (!empty($this->data['APPENDUID'])) {
return $this->data['APPENDUID'];
}
return true;
}
/**
* Handler for IMAP APPEND command.
*
* @param string $mailbox Mailbox name
* @param string $path Path to the file with message body
* @param string $headers Message headers
* @param array $flags Message flags
* @param string $date Message internal date
* @param bool $binary Enable BINARY append (RFC3516)
*
* @return string|bool On success APPENDUID response (if available) or True, False on failure
*/
public function appendFromFile($mailbox, $path, $headers=null, $flags = array(), $date = null, $binary = false)
{
// open message file
if (file_exists(realpath($path))) {
$fp = fopen($path, 'r');
}
if (!$fp) {
$this->setError(self::ERROR_UNKNOWN, "Couldn't open $path for reading");
return false;
}
$message = array();
if ($headers) {
$message[] = trim($headers, "\r\n") . "\r\n\r\n";
}
$message[] = $fp;
return $this->append($mailbox, $message, $flags, $date, $binary);
}
/**
* Returns QUOTA information
*
* @param string $mailbox Mailbox name
*
* @return array Quota information
*/
public function getQuota($mailbox = null)
{
if ($mailbox === null || $mailbox === '') {
$mailbox = 'INBOX';
}
// a0001 GETQUOTAROOT INBOX
// * QUOTAROOT INBOX user/sample
// * QUOTA user/sample (STORAGE 654 9765)
// a0001 OK Completed
list($code, $response) = $this->execute('GETQUOTAROOT', array($this->escape($mailbox)), 0, '/^\* QUOTA /i');
$result = false;
$min_free = PHP_INT_MAX;
$all = array();
if ($code == self::ERROR_OK) {
foreach (explode("\n", $response) as $line) {
list(, , $quota_root) = $this->tokenizeResponse($line, 3);
$quotas = $this->tokenizeResponse($line, 1);
if (empty($quotas)) {
continue;
}
foreach (array_chunk($quotas, 3) as $quota) {
list($type, $used, $total) = $quota;
$type = strtolower($type);
if ($type && $total) {
$all[$quota_root][$type]['used'] = intval($used);
$all[$quota_root][$type]['total'] = intval($total);
}
}
if (empty($all[$quota_root]['storage'])) {
continue;
}
$used = $all[$quota_root]['storage']['used'];
$total = $all[$quota_root]['storage']['total'];
$free = $total - $used;
// calculate lowest available space from all storage quotas
if ($free < $min_free) {
$min_free = $free;
$result['used'] = $used;
$result['total'] = $total;
$result['percent'] = min(100, round(($used/max(1,$total))*100));
$result['free'] = 100 - $result['percent'];
}
}
}
if (!empty($result)) {
$result['all'] = $all;
}
return $result;
}
/**
* Send the SETACL command (RFC4314)
*
* @param string $mailbox Mailbox name
* @param string $user User name
* @param mixed $acl ACL string or array
*
* @return boolean True on success, False on failure
*
* @since 0.5-beta
*/
public function setACL($mailbox, $user, $acl)
{
if (is_array($acl)) {
$acl = implode('', $acl);
}
$result = $this->execute('SETACL', array(
$this->escape($mailbox), $this->escape($user), strtolower($acl)),
self::COMMAND_NORESPONSE);
return ($result == self::ERROR_OK);
}
/**
* Send the DELETEACL command (RFC4314)
*
* @param string $mailbox Mailbox name
* @param string $user User name
*
* @return boolean True on success, False on failure
*
* @since 0.5-beta
*/
public function deleteACL($mailbox, $user)
{
$result = $this->execute('DELETEACL', array(
$this->escape($mailbox), $this->escape($user)),
self::COMMAND_NORESPONSE);
return ($result == self::ERROR_OK);
}
/**
* Send the GETACL command (RFC4314)
*
* @param string $mailbox Mailbox name
*
* @return array User-rights array on success, NULL on error
* @since 0.5-beta
*/
public function getACL($mailbox)
{
list($code, $response) = $this->execute('GETACL', array($this->escape($mailbox)), 0, '/^\* ACL /i');
if ($code == self::ERROR_OK && $response) {
// Parse server response (remove "* ACL ")
$response = substr($response, 6);
$ret = $this->tokenizeResponse($response);
$mbox = array_shift($ret);
$size = count($ret);
// Create user-rights hash array
// @TODO: consider implementing fixACL() method according to RFC4314.2.1.1
// so we could return only standard rights defined in RFC4314,
// excluding 'c' and 'd' defined in RFC2086.
if ($size % 2 == 0) {
for ($i=0; $i<$size; $i++) {
$ret[$ret[$i]] = str_split($ret[++$i]);
unset($ret[$i-1]);
unset($ret[$i]);
}
return $ret;
}
$this->setError(self::ERROR_COMMAND, "Incomplete ACL response");
}
}
/**
* Send the LISTRIGHTS command (RFC4314)
*
* @param string $mailbox Mailbox name
* @param string $user User name
*
* @return array List of user rights
* @since 0.5-beta
*/
public function listRights($mailbox, $user)
{
list($code, $response) = $this->execute('LISTRIGHTS',
array($this->escape($mailbox), $this->escape($user)), 0, '/^\* LISTRIGHTS /i');
if ($code == self::ERROR_OK && $response) {
// Parse server response (remove "* LISTRIGHTS ")
$response = substr($response, 13);
$ret_mbox = $this->tokenizeResponse($response, 1);
$ret_user = $this->tokenizeResponse($response, 1);
$granted = $this->tokenizeResponse($response, 1);
$optional = trim($response);
return array(
'granted' => str_split($granted),
'optional' => explode(' ', $optional),
);
}
}
/**
* Send the MYRIGHTS command (RFC4314)
*
* @param string $mailbox Mailbox name
*
* @return array MYRIGHTS response on success, NULL on error
* @since 0.5-beta
*/
public function myRights($mailbox)
{
list($code, $response) = $this->execute('MYRIGHTS', array($this->escape($mailbox)), 0, '/^\* MYRIGHTS /i');
if ($code == self::ERROR_OK && $response) {
// Parse server response (remove "* MYRIGHTS ")
$response = substr($response, 11);
$ret_mbox = $this->tokenizeResponse($response, 1);
$rights = $this->tokenizeResponse($response, 1);
return str_split($rights);
}
}
/**
* Send the SETMETADATA command (RFC5464)
*
* @param string $mailbox Mailbox name
* @param array $entries Entry-value array (use NULL value as NIL)
*
* @return boolean True on success, False on failure
* @since 0.5-beta
*/
public function setMetadata($mailbox, $entries)
{
if (!is_array($entries) || empty($entries)) {
$this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command");
return false;
}
foreach ($entries as $name => $value) {
$entries[$name] = $this->escape($name) . ' ' . $this->escape($value, true);
}
$entries = implode(' ', $entries);
$result = $this->execute('SETMETADATA', array(
$this->escape($mailbox), '(' . $entries . ')'),
self::COMMAND_NORESPONSE);
return ($result == self::ERROR_OK);
}
/**
* Send the SETMETADATA command with NIL values (RFC5464)
*
* @param string $mailbox Mailbox name
* @param array $entries Entry names array
*
* @return boolean True on success, False on failure
*
* @since 0.5-beta
*/
public function deleteMetadata($mailbox, $entries)
{
if (!is_array($entries) && !empty($entries)) {
$entries = explode(' ', $entries);
}
if (empty($entries)) {
$this->setError(self::ERROR_COMMAND, "Wrong argument for SETMETADATA command");
return false;
}
foreach ($entries as $entry) {
$data[$entry] = null;
}
return $this->setMetadata($mailbox, $data);
}
/**
* Send the GETMETADATA command (RFC5464)
*
* @param string $mailbox Mailbox name
* @param array $entries Entries
* @param array $options Command options (with MAXSIZE and DEPTH keys)
*
* @return array GETMETADATA result on success, NULL on error
*
* @since 0.5-beta
*/
public function getMetadata($mailbox, $entries, $options=array())
{
if (!is_array($entries)) {
$entries = array($entries);
}
// create entries string
foreach ($entries as $idx => $name) {
$entries[$idx] = $this->escape($name);
}
$optlist = '';
$entlist = '(' . implode(' ', $entries) . ')';
// create options string
if (is_array($options)) {
$options = array_change_key_case($options, CASE_UPPER);
$opts = array();
if (!empty($options['MAXSIZE'])) {
$opts[] = 'MAXSIZE '.intval($options['MAXSIZE']);
}
if (!empty($options['DEPTH'])) {
$opts[] = 'DEPTH '.intval($options['DEPTH']);
}
if ($opts) {
$optlist = '(' . implode(' ', $opts) . ')';
}
}
$optlist .= ($optlist ? ' ' : '') . $entlist;
list($code, $response) = $this->execute('GETMETADATA', array(
$this->escape($mailbox), $optlist));
if ($code == self::ERROR_OK) {
$result = array();
$data = $this->tokenizeResponse($response);
// The METADATA response can contain multiple entries in a single
// response or multiple responses for each entry or group of entries
for ($i = 0, $size = count($data); $i < $size; $i++) {
if ($data[$i] === '*'
&& $data[++$i] === 'METADATA'
&& is_string($mbox = $data[++$i])
&& is_array($data[++$i])
) {
for ($x = 0, $size2 = count($data[$i]); $x < $size2; $x += 2) {
if ($data[$i][$x+1] !== null) {
$result[$mbox][$data[$i][$x]] = $data[$i][$x+1];
}
}
}
}
return $result;
}
}
/**
* Send the SETANNOTATION command (draft-daboo-imap-annotatemore)
*
* @param string $mailbox Mailbox name
* @param array $data Data array where each item is an array with
* three elements: entry name, attribute name, value
*
* @return boolean True on success, False on failure
* @since 0.5-beta
*/
public function setAnnotation($mailbox, $data)
{
if (!is_array($data) || empty($data)) {
$this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command");
return false;
}
foreach ($data as $entry) {
// ANNOTATEMORE drafts before version 08 require quoted parameters
$entries[] = sprintf('%s (%s %s)', $this->escape($entry[0], true),
$this->escape($entry[1], true), $this->escape($entry[2], true));
}
$entries = implode(' ', $entries);
$result = $this->execute('SETANNOTATION', array(
$this->escape($mailbox), $entries), self::COMMAND_NORESPONSE);
return ($result == self::ERROR_OK);
}
/**
* Send the SETANNOTATION command with NIL values (draft-daboo-imap-annotatemore)
*
* @param string $mailbox Mailbox name
* @param array $data Data array where each item is an array with
* two elements: entry name and attribute name
*
* @return boolean True on success, False on failure
*
* @since 0.5-beta
*/
public function deleteAnnotation($mailbox, $data)
{
if (!is_array($data) || empty($data)) {
$this->setError(self::ERROR_COMMAND, "Wrong argument for SETANNOTATION command");
return false;
}
return $this->setAnnotation($mailbox, $data);
}
/**
* Send the GETANNOTATION command (draft-daboo-imap-annotatemore)
*
* @param string $mailbox Mailbox name
* @param array $entries Entries names
* @param array $attribs Attribs names
*
* @return array Annotations result on success, NULL on error
*
* @since 0.5-beta
*/
public function getAnnotation($mailbox, $entries, $attribs)
{
if (!is_array($entries)) {
$entries = array($entries);
}
// create entries string
// ANNOTATEMORE drafts before version 08 require quoted parameters
foreach ($entries as $idx => $name) {
$entries[$idx] = $this->escape($name, true);
}
$entries = '(' . implode(' ', $entries) . ')';
if (!is_array($attribs)) {
$attribs = array($attribs);
}
// create attributes string
foreach ($attribs as $idx => $name) {
$attribs[$idx] = $this->escape($name, true);
}
$attribs = '(' . implode(' ', $attribs) . ')';
list($code, $response) = $this->execute('GETANNOTATION', array(
$this->escape($mailbox), $entries, $attribs));
if ($code == self::ERROR_OK) {
$result = array();
$data = $this->tokenizeResponse($response);
// Here we returns only data compatible with METADATA result format
if (!empty($data) && ($size = count($data))) {
for ($i=0; $i<$size; $i++) {
$entry = $data[$i];
if (isset($mbox) && is_array($entry)) {
$attribs = $entry;
$entry = $last_entry;
}
else if ($entry == '*') {
if ($data[$i+1] == 'ANNOTATION') {
$mbox = $data[$i+2];
unset($data[$i]); // "*"
unset($data[++$i]); // "ANNOTATION"
unset($data[++$i]); // Mailbox
}
// get rid of other untagged responses
else {
unset($mbox);
unset($data[$i]);
}
continue;
}
else if (isset($mbox)) {
$attribs = $data[++$i];
}
else {
unset($data[$i]);
continue;
}
if (!empty($attribs)) {
for ($x=0, $len=count($attribs); $x<$len;) {
$attr = $attribs[$x++];
$value = $attribs[$x++];
if ($attr == 'value.priv' && $value !== null) {
$result[$mbox]['/private' . $entry] = $value;
}
else if ($attr == 'value.shared' && $value !== null) {
$result[$mbox]['/shared' . $entry] = $value;
}
}
}
$last_entry = $entry;
unset($data[$i]);
}
}
return $result;
}
}
/**
* Returns BODYSTRUCTURE for the specified message.
*
* @param string $mailbox Folder name
* @param int $id Message sequence number or UID
* @param bool $is_uid True if $id is an UID
*
* @return array/bool Body structure array or False on error.
* @since 0.6
*/
public function getStructure($mailbox, $id, $is_uid = false)
{
$result = $this->fetch($mailbox, $id, $is_uid, array('BODYSTRUCTURE'));
if (is_array($result)) {
$result = array_shift($result);
return $result->bodystructure;
}
return false;
}
/**
* Returns data of a message part according to specified structure.
*
* @param array $structure Message structure (getStructure() result)
* @param string $part Message part identifier
*
* @return array Part data as hash array (type, encoding, charset, size)
*/
public static function getStructurePartData($structure, $part)
{
$part_a = self::getStructurePartArray($structure, $part);
$data = array();
if (empty($part_a)) {
return $data;
}
// content-type
if (is_array($part_a[0])) {
$data['type'] = 'multipart';
}
else {
$data['type'] = strtolower($part_a[0]);
$data['subtype'] = strtolower($part_a[1]);
$data['encoding'] = strtolower($part_a[5]);
// charset
if (is_array($part_a[2])) {
foreach ($part_a[2] as $key => $val) {
if (strcasecmp($val, 'charset') == 0) {
$data['charset'] = $part_a[2][$key+1];
break;
}
}
}
}
// size
$data['size'] = intval($part_a[6]);
return $data;
}
public static function getStructurePartArray($a, $part)
{
if (!is_array($a)) {
return false;
}
if (empty($part)) {
return $a;
}
$ctype = is_string($a[0]) && is_string($a[1]) ? $a[0] . '/' . $a[1] : '';
if (strcasecmp($ctype, 'message/rfc822') == 0) {
$a = $a[8];
}
if (strpos($part, '.') > 0) {
$orig_part = $part;
$pos = strpos($part, '.');
$rest = substr($orig_part, $pos+1);
$part = substr($orig_part, 0, $pos);
return self::getStructurePartArray($a[$part-1], $rest);
}
else if ($part > 0) {
return (is_array($a[$part-1])) ? $a[$part-1] : $a;
}
}
/**
* Creates next command identifier (tag)
*
* @return string Command identifier
* @since 0.5-beta
*/
public function nextTag()
{
$this->cmd_num++;
$this->cmd_tag = sprintf('A%04d', $this->cmd_num);
return $this->cmd_tag;
}
/**
* Sends IMAP command and parses result
*
* @param string $command IMAP command
* @param array $arguments Command arguments
* @param int $options Execution options
* @param string $filter Line filter (regexp)
*
* @return mixed Response code or list of response code and data
* @since 0.5-beta
*/
public function execute($command, $arguments = array(), $options = 0, $filter = null)
{
$tag = $this->nextTag();
$query = $tag . ' ' . $command;
$noresp = ($options & self::COMMAND_NORESPONSE);
$response = $noresp ? null : '';
if (!empty($arguments)) {
foreach ($arguments as $arg) {
$query .= ' ' . self::r_implode($arg);
}
}
// Send command
if (!$this->putLineC($query, true, ($options & self::COMMAND_ANONYMIZED))) {
preg_match('/^[A-Z0-9]+ ((UID )?[A-Z]+)/', $query, $matches);
$cmd = $matches[1] ?: 'UNKNOWN';
$this->setError(self::ERROR_COMMAND, "Failed to send $cmd command");
return $noresp ? self::ERROR_COMMAND : array(self::ERROR_COMMAND, '');
}
// Parse response
do {
$line = $this->readLine(4096);
if ($response !== null) {
// TODO: Better string literals handling with filter
if (!$filter || preg_match($filter, $line)) {
$response .= $line;
}
}
// parse untagged response for [COPYUID 1204196876 3456:3457 123:124] (RFC6851)
if ($line && $command == 'UID MOVE') {
if (preg_match("/^\* OK \[COPYUID [0-9]+ ([0-9,:]+) ([0-9,:]+)\]/i", $line, $m)) {
$this->data['COPYUID'] = array($m[1], $m[2]);
}
}
}
while (!$this->startsWith($line, $tag . ' ', true, true));
$code = $this->parseResult($line, $command . ': ');
// Remove last line from response
if ($response) {
if (!$filter) {
$line_len = min(strlen($response), strlen($line));
$response = substr($response, 0, -$line_len);
}
$response = rtrim($response, "\r\n");
}
// optional CAPABILITY response
if (($options & self::COMMAND_CAPABILITY) && $code == self::ERROR_OK
&& preg_match('/\[CAPABILITY ([^]]+)\]/i', $line, $matches)
) {
$this->parseCapability($matches[1], true);
}
// return last line only (without command tag, result and response code)
if ($line && ($options & self::COMMAND_LASTLINE)) {
$response = preg_replace("/^$tag (OK|NO|BAD|BYE|PREAUTH)?\s*(\[[a-z-]+\])?\s*/i", '', trim($line));
}
return $noresp ? $code : array($code, $response);
}
/**
* Splits IMAP response into string tokens
*
* @param string &$str The IMAP's server response
* @param int $num Number of tokens to return
*
* @return mixed Tokens array or string if $num=1
* @since 0.5-beta
*/
public static function tokenizeResponse(&$str, $num=0)
{
$result = array();
while (!$num || count($result) < $num) {
// remove spaces from the beginning of the string
$str = ltrim($str);
+ // empty string
+ if ($str === '' || $str === null) {
+ break;
+ }
+
switch ($str[0]) {
// String literal
case '{':
if (($epos = strpos($str, "}\r\n", 1)) == false) {
// error
}
if (!is_numeric(($bytes = substr($str, 1, $epos - 1)))) {
// error
}
$result[] = $bytes ? substr($str, $epos + 3, $bytes) : '';
$str = substr($str, $epos + 3 + $bytes);
break;
// Quoted string
case '"':
$len = strlen($str);
for ($pos=1; $pos<$len; $pos++) {
if ($str[$pos] == '"') {
break;
}
if ($str[$pos] == "\\") {
if ($str[$pos + 1] == '"' || $str[$pos + 1] == "\\") {
$pos++;
}
}
}
// we need to strip slashes for a quoted string
$result[] = stripslashes(substr($str, 1, $pos - 1));
$str = substr($str, $pos + 1);
break;
// Parenthesized list
case '(':
$str = substr($str, 1);
$result[] = self::tokenizeResponse($str);
break;
case ')':
$str = substr($str, 1);
return $result;
// String atom, number, astring, NIL, *, %
default:
- // empty string
- if ($str === '' || $str === null) {
- break 2;
- }
-
// excluded chars: SP, CTL, ), DEL
// we do not exclude [ and ] (#1489223)
if (preg_match('/^([^\x00-\x20\x29\x7F]+)/', $str, $m)) {
$result[] = $m[1] == 'NIL' ? null : $m[1];
$str = substr($str, strlen($m[1]));
}
break;
}
}
return $num == 1 ? $result[0] : $result;
}
/**
* Joins IMAP command line elements (recursively)
*/
protected static function r_implode($element)
{
$string = '';
if (is_array($element)) {
reset($element);
foreach ($element as $value) {
$string .= ' ' . self::r_implode($value);
}
}
else {
return $element;
}
return '(' . trim($string) . ')';
}
/**
* Converts message identifiers array into sequence-set syntax
*
* @param array $messages Message identifiers
* @param bool $force Forces compression of any size
*
* @return string Compressed sequence-set
*/
public static function compressMessageSet($messages, $force=false)
{
// given a comma delimited list of independent mid's,
// compresses by grouping sequences together
if (!is_array($messages)) {
// if less than 255 bytes long, let's not bother
if (!$force && strlen($messages) < 255) {
return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages;
}
// see if it's already been compressed
if (strpos($messages, ':') !== false) {
return preg_match('/[^0-9:,*]/', $messages) ? 'INVALID' : $messages;
}
// separate, then sort
$messages = explode(',', $messages);
}
sort($messages);
$result = array();
$start = $prev = $messages[0];
foreach ($messages as $id) {
$incr = $id - $prev;
if ($incr > 1) { // found a gap
if ($start == $prev) {
$result[] = $prev; // push single id
}
else {
$result[] = $start . ':' . $prev; // push sequence as start_id:end_id
}
$start = $id; // start of new sequence
}
$prev = $id;
}
// handle the last sequence/id
if ($start == $prev) {
$result[] = $prev;
}
else {
$result[] = $start.':'.$prev;
}
// return as comma separated string
$result = implode(',', $result);
return preg_match('/[^0-9:,*]/', $result) ? 'INVALID' : $result;
}
/**
* Converts message sequence-set into array
*
* @param string $messages Message identifiers
*
* @return array List of message identifiers
*/
public static function uncompressMessageSet($messages)
{
if (empty($messages)) {
return array();
}
$result = array();
$messages = explode(',', $messages);
foreach ($messages as $idx => $part) {
$items = explode(':', $part);
$max = max($items[0], $items[1]);
for ($x=$items[0]; $x<=$max; $x++) {
$result[] = (int)$x;
}
unset($messages[$idx]);
}
return $result;
}
/**
* Clear internal status cache
*/
protected function clear_status_cache($mailbox)
{
unset($this->data['STATUS:' . $mailbox]);
$keys = array('EXISTS', 'RECENT', 'UNSEEN', 'UID-MAP');
foreach ($keys as $key) {
unset($this->data[$key]);
}
}
/**
* Clear internal cache of the current mailbox
*/
protected function clear_mailbox_cache()
{
$this->clear_status_cache($this->selected);
$keys = array('UIDNEXT', 'UIDVALIDITY', 'HIGHESTMODSEQ', 'NOMODSEQ',
'PERMANENTFLAGS', 'QRESYNC', 'VANISHED', 'READ-WRITE');
foreach ($keys as $key) {
unset($this->data[$key]);
}
}
/**
* Converts flags array into string for inclusion in IMAP command
*
* @param array $flags Flags (see self::flags)
*
* @return string Space-separated list of flags
*/
protected function flagsToStr($flags)
{
foreach ((array)$flags as $idx => $flag) {
if ($flag = $this->flags[strtoupper($flag)]) {
$flags[$idx] = $flag;
}
}
return implode(' ', (array)$flags);
}
/**
* CAPABILITY response parser
*/
protected function parseCapability($str, $trusted=false)
{
$str = preg_replace('/^\* CAPABILITY /i', '', $str);
$this->capability = explode(' ', strtoupper($str));
if (!empty($this->prefs['disabled_caps'])) {
$this->capability = array_diff($this->capability, $this->prefs['disabled_caps']);
}
if (!isset($this->prefs['literal+']) && in_array('LITERAL+', $this->capability)) {
$this->prefs['literal+'] = true;
}
if ($trusted) {
$this->capability_readed = true;
}
}
/**
* Escapes a string when it contains special characters (RFC3501)
*
* @param string $string IMAP string
* @param boolean $force_quotes Forces string quoting (for atoms)
*
* @return string String atom, quoted-string or string literal
* @todo lists
*/
public static function escape($string, $force_quotes=false)
{
if ($string === null) {
return 'NIL';
}
if ($string === '') {
return '""';
}
// atom-string (only safe characters)
if (!$force_quotes && !preg_match('/[\x00-\x20\x22\x25\x28-\x2A\x5B-\x5D\x7B\x7D\x80-\xFF]/', $string)) {
return $string;
}
// quoted-string
if (!preg_match('/[\r\n\x00\x80-\xFF]/', $string)) {
return '"' . addcslashes($string, '\\"') . '"';
}
// literal-string
return sprintf("{%d}\r\n%s", strlen($string), $string);
}
/**
* Set the value of the debugging flag.
*
* @param boolean $debug New value for the debugging flag.
* @param callback $handler Logging handler function
*
* @since 0.5-stable
*/
public function setDebug($debug, $handler = null)
{
$this->debug = $debug;
$this->debug_handler = $handler;
}
/**
* Write the given debug text to the current debug output handler.
*
* @param string $message Debug message text.
*
* @since 0.5-stable
*/
protected function debug($message)
{
if (($len = strlen($message)) > self::DEBUG_LINE_LENGTH) {
$diff = $len - self::DEBUG_LINE_LENGTH;
$message = substr($message, 0, self::DEBUG_LINE_LENGTH)
. "... [truncated $diff bytes]";
}
if ($this->resourceid) {
$message = sprintf('[%s] %s', $this->resourceid, $message);
}
if ($this->debug_handler) {
call_user_func_array($this->debug_handler, array($this, $message));
}
else {
echo "DEBUG: $message\n";
}
}
}
diff --git a/src/resources/js/admin/routes.js b/src/resources/js/admin/routes.js
index cb2a6848..53f02a35 100644
--- a/src/resources/js/admin/routes.js
+++ b/src/resources/js/admin/routes.js
@@ -1,62 +1,69 @@
import DashboardComponent from '../../vue/Admin/Dashboard'
import DistlistComponent from '../../vue/Admin/Distlist'
import DomainComponent from '../../vue/Admin/Domain'
import LoginComponent from '../../vue/Login'
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
+import ResourceComponent from '../../vue/Admin/Resource'
import StatsComponent from '../../vue/Admin/Stats'
import UserComponent from '../../vue/Admin/User'
const routes = [
{
path: '/',
redirect: { name: 'dashboard' }
},
{
path: '/dashboard',
name: 'dashboard',
component: DashboardComponent,
meta: { requiresAuth: true }
},
{
path: '/distlist/:list',
name: 'distlist',
component: DistlistComponent,
meta: { requiresAuth: true }
},
{
path: '/domain/:domain',
name: 'domain',
component: DomainComponent,
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'login',
component: LoginComponent
},
{
path: '/logout',
name: 'logout',
component: LogoutComponent
},
+ {
+ path: '/resource/:resource',
+ name: 'resource',
+ component: ResourceComponent,
+ meta: { requiresAuth: true }
+ },
{
path: '/stats',
name: 'stats',
component: StatsComponent,
meta: { requiresAuth: true }
},
{
path: '/user/:user',
name: 'user',
component: UserComponent,
meta: { requiresAuth: true }
},
{
name: '404',
path: '*',
component: PageComponent
}
]
export default routes
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
index aad37ace..af98c22a 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,543 +1,565 @@
/**
* 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 = '<div class="app-loader"><div class="spinner-border" role="status"><span class="visually-hidden">Loading</span></div></div>'
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 `<img src="${src}" alt="${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 = '<div id="error-page" class="error-page">'
+ `<div class="code">${code}</div><div class="message">${msg}</div><div class="hint">${hint}</div>`
+ '</div>'
$('#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')
},
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($('<div>').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) {
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.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 = $('<div class="invalid-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 {
+ } 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/js/fontawesome.js b/src/resources/js/fontawesome.js
index 51559d0d..605a6b26 100644
--- a/src/resources/js/fontawesome.js
+++ b/src/resources/js/fontawesome.js
@@ -1,68 +1,70 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
//import { } from '@fortawesome/free-brands-svg-icons'
import {
faCheckSquare,
faCreditCard,
faSquare,
} from '@fortawesome/free-regular-svg-icons'
import {
faCheck,
faCheckCircle,
+ faCog,
faComments,
faDownload,
faEnvelope,
faGlobe,
faUniversity,
faExclamationCircle,
faInfoCircle,
faLock,
faKey,
faPlus,
faSearch,
faSignInAlt,
faSyncAlt,
faTrashAlt,
faUser,
faUserCog,
faUserFriends,
faUsers,
faWallet
} from '@fortawesome/free-solid-svg-icons'
import {
faPaypal
} from '@fortawesome/free-brands-svg-icons'
// Register only these icons we need
library.add(
faCheck,
faCheckCircle,
faCheckSquare,
+ faCog,
faComments,
faCreditCard,
faPaypal,
faUniversity,
faDownload,
faEnvelope,
faExclamationCircle,
faGlobe,
faInfoCircle,
faLock,
faKey,
faPlus,
faSearch,
faSignInAlt,
faSquare,
faSyncAlt,
faTrashAlt,
faUser,
faUserCog,
faUserFriends,
faUsers,
faWallet
)
export default FontAwesomeIcon
diff --git a/src/resources/js/reseller/routes.js b/src/resources/js/reseller/routes.js
index d0296f0b..569a86d9 100644
--- a/src/resources/js/reseller/routes.js
+++ b/src/resources/js/reseller/routes.js
@@ -1,76 +1,83 @@
import DashboardComponent from '../../vue/Reseller/Dashboard'
import DistlistComponent from '../../vue/Admin/Distlist'
import DomainComponent from '../../vue/Admin/Domain'
import InvitationsComponent from '../../vue/Reseller/Invitations'
import LoginComponent from '../../vue/Login'
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
+import ResourceComponent from '../../vue/Admin/Resource'
import StatsComponent from '../../vue/Reseller/Stats'
import UserComponent from '../../vue/Admin/User'
import WalletComponent from '../../vue/Wallet'
const routes = [
{
path: '/',
redirect: { name: 'dashboard' }
},
{
path: '/dashboard',
name: 'dashboard',
component: DashboardComponent,
meta: { requiresAuth: true }
},
{
path: '/distlist/:list',
name: 'distlist',
component: DistlistComponent,
meta: { requiresAuth: true }
},
{
path: '/domain/:domain',
name: 'domain',
component: DomainComponent,
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'login',
component: LoginComponent
},
{
path: '/logout',
name: 'logout',
component: LogoutComponent
},
{
path: '/invitations',
name: 'invitations',
component: InvitationsComponent,
meta: { requiresAuth: true }
},
+ {
+ path: '/resource/:resource',
+ name: 'resource',
+ component: ResourceComponent,
+ meta: { requiresAuth: true }
+ },
{
path: '/stats',
name: 'stats',
component: StatsComponent,
meta: { requiresAuth: true }
},
{
path: '/user/:user',
name: 'user',
component: UserComponent,
meta: { requiresAuth: true }
},
{
path: '/wallet',
name: 'wallet',
component: WalletComponent,
meta: { requiresAuth: true }
},
{
name: '404',
path: '*',
component: PageComponent
}
]
export default routes
diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js
index 3c7c5f9c..8a1d5b95 100644
--- a/src/resources/js/user/routes.js
+++ b/src/resources/js/user/routes.js
@@ -1,129 +1,143 @@
import DashboardComponent from '../../vue/Dashboard'
import DistlistInfoComponent from '../../vue/Distlist/Info'
import DistlistListComponent from '../../vue/Distlist/List'
import DomainInfoComponent from '../../vue/Domain/Info'
import DomainListComponent from '../../vue/Domain/List'
import LoginComponent from '../../vue/Login'
import LogoutComponent from '../../vue/Logout'
import MeetComponent from '../../vue/Rooms'
import PageComponent from '../../vue/Page'
import PasswordResetComponent from '../../vue/PasswordReset'
+import ResourceInfoComponent from '../../vue/Resource/Info'
+import ResourceListComponent from '../../vue/Resource/List'
import SignupComponent from '../../vue/Signup'
import UserInfoComponent from '../../vue/User/Info'
import UserListComponent from '../../vue/User/List'
import UserProfileComponent from '../../vue/User/Profile'
import UserProfileDeleteComponent from '../../vue/User/ProfileDelete'
import WalletComponent from '../../vue/Wallet'
// Here's a list of lazy-loaded components
// Note: you can pack multiple components into the same chunk, webpackChunkName
// is also used to get a sensible file name instead of numbers
const RoomComponent = () => import(/* webpackChunkName: "room" */ '../../vue/Meet/Room.vue')
const routes = [
{
path: '/dashboard',
name: 'dashboard',
component: DashboardComponent,
meta: { requiresAuth: true }
},
{
path: '/distlist/:list',
name: 'distlist',
component: DistlistInfoComponent,
meta: { requiresAuth: true, perm: 'distlists' }
},
{
path: '/distlists',
name: 'distlists',
component: DistlistListComponent,
meta: { requiresAuth: true, perm: 'distlists' }
},
{
path: '/domain/:domain',
name: 'domain',
component: DomainInfoComponent,
meta: { requiresAuth: true, perm: 'domains' }
},
{
path: '/domains',
name: 'domains',
component: DomainListComponent,
meta: { requiresAuth: true, perm: 'domains' }
},
{
path: '/login',
name: 'login',
component: LoginComponent
},
{
path: '/logout',
name: 'logout',
component: LogoutComponent
},
{
path: '/password-reset/:code?',
name: 'password-reset',
component: PasswordResetComponent
},
{
path: '/profile',
name: 'profile',
component: UserProfileComponent,
meta: { requiresAuth: true }
},
{
path: '/profile/delete',
name: 'profile-delete',
component: UserProfileDeleteComponent,
meta: { requiresAuth: true }
},
+ {
+ path: '/resource/:resource',
+ name: 'resource',
+ component: ResourceInfoComponent,
+ meta: { requiresAuth: true, perm: 'resources' }
+ },
+ {
+ path: '/resources',
+ name: 'resources',
+ component: ResourceListComponent,
+ meta: { requiresAuth: true, perm: 'resources' }
+ },
{
component: RoomComponent,
name: 'room',
path: '/meet/:room',
meta: { loading: true }
},
{
path: '/rooms',
name: 'rooms',
component: MeetComponent,
meta: { requiresAuth: true }
},
{
path: '/signup/invite/:param',
name: 'signup-invite',
component: SignupComponent
},
{
path: '/signup/:param?',
alias: '/signup/voucher/:param',
name: 'signup',
component: SignupComponent
},
{
path: '/user/:user',
name: 'user',
component: UserInfoComponent,
meta: { requiresAuth: true, perm: 'users' }
},
{
path: '/users',
name: 'users',
component: UserListComponent,
meta: { requiresAuth: true, perm: 'users' }
},
{
path: '/wallet',
name: 'wallet',
component: WalletComponent,
meta: { requiresAuth: true, perm: 'wallets' }
},
{
name: '404',
path: '*',
component: PageComponent
}
]
export default routes
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
index 20e06726..49a40be1 100644
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -1,97 +1,106 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used in the application.
*/
'chart-created' => 'Created',
'chart-deleted' => 'Deleted',
'chart-average' => 'average',
'chart-allusers' => 'All Users - last year',
'chart-discounts' => 'Discounts',
'chart-vouchers' => 'Vouchers',
'chart-income' => 'Income in :currency - last 8 weeks',
'chart-users' => 'Users - last 8 weeks',
'mandate-delete-success' => 'The auto-payment has been removed.',
'mandate-update-success' => 'The auto-payment has been updated.',
'planbutton' => 'Choose :plan',
'process-async' => 'Setup process has been pushed. Please wait.',
'process-user-new' => 'Registering a user...',
'process-user-ldap-ready' => 'Creating a user...',
'process-user-imap-ready' => 'Creating a mailbox...',
- 'process-distlist-new' => 'Registering a distribution list...',
- 'process-distlist-ldap-ready' => 'Creating a distribution list...',
'process-domain-new' => 'Registering a custom domain...',
'process-domain-ldap-ready' => 'Creating a custom domain...',
'process-domain-verified' => 'Verifying a custom domain...',
'process-domain-confirmed' => 'Verifying an ownership of a custom domain...',
'process-success' => 'Setup process finished successfully.',
- 'process-error-user-ldap-ready' => 'Failed to create a user.',
- 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.',
+ 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.',
'process-error-domain-ldap-ready' => 'Failed to create a domain.',
'process-error-domain-verified' => 'Failed to verify a domain.',
'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.',
+ 'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.',
+ 'process-error-resource-ldap-ready' => 'Failed to create a resource.',
+ 'process-error-user-ldap-ready' => 'Failed to create a user.',
+ 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.',
'process-distlist-new' => 'Registering a distribution list...',
'process-distlist-ldap-ready' => 'Creating a distribution list...',
- 'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.',
+ 'process-resource-new' => 'Registering a resource...',
+ 'process-resource-imap-ready' => 'Creating a shared folder...',
+ 'process-resource-ldap-ready' => 'Creating a resource...',
'distlist-update-success' => 'Distribution list updated successfully.',
'distlist-create-success' => 'Distribution list created successfully.',
'distlist-delete-success' => 'Distribution list deleted successfully.',
'distlist-suspend-success' => 'Distribution list suspended successfully.',
'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.',
'distlist-setconfig-success' => 'Distribution list settings updated successfully.',
'domain-create-success' => 'Domain created successfully.',
'domain-delete-success' => 'Domain deleted successfully.',
'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.',
'domain-verify-success' => 'Domain verified successfully.',
'domain-verify-error' => 'Domain ownership verification failed.',
'domain-suspend-success' => 'Domain suspended successfully.',
'domain-unsuspend-success' => 'Domain unsuspended successfully.',
'domain-setconfig-success' => 'Domain settings updated successfully.',
+ 'resource-update-success' => 'Resource updated successfully.',
+ 'resource-create-success' => 'Resource created successfully.',
+ 'resource-delete-success' => 'Resource deleted successfully.',
+ 'resource-setconfig-success' => 'Resource settings updated successfully.',
+
'user-update-success' => 'User data updated successfully.',
'user-create-success' => 'User created successfully.',
'user-delete-success' => 'User deleted successfully.',
'user-suspend-success' => 'User suspended successfully.',
'user-unsuspend-success' => 'User unsuspended successfully.',
'user-reset-2fa-success' => '2-Factor authentication reset successfully.',
'user-setconfig-success' => 'User settings updated successfully.',
'user-set-sku-success' => 'The subscription added successfully.',
'user-set-sku-already-exists' => 'The subscription already exists.',
'search-foundxdomains' => ':x domains have been found.',
- 'search-foundxgroups' => ':x distribution lists have been found.',
+ 'search-foundxdistlists' => ':x distribution lists have been found.',
+ 'search-foundxresources' => ':x resources have been found.',
'search-foundxusers' => ':x user accounts have been found.',
'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.',
'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.',
'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.',
'signup-invitation-delete-success' => 'Invitation deleted successfully.',
'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.',
'support-request-success' => 'Support request submitted successfully.',
'support-request-error' => 'Failed to submit the support request.',
'siteuser' => ':site User',
'wallet-award-success' => 'The bonus has been added to the wallet successfully.',
'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.',
'wallet-update-success' => 'User wallet updated successfully.',
'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).',
'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.',
'wallet-notice-today' => 'You will run out of credit today, top up your balance now.',
'wallet-notice-trial' => 'You are in your free trial period.',
'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.',
];
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
index 369850fc..7a13c96e 100644
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -1,436 +1,456 @@
<?php
/**
* This file will be converted to a Vue-i18n compatible JSON format on build time
*
* Note: The Laravel localization features do not work here. Vue-i18n rules are different
*/
return [
'app' => [
'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",
'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: <var>.ess.barracuda.com</var>.",
'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 <b>must have one of the following entries</b> 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' => [
'amount' => "Amount",
'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",
'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 &amp; 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",
+ ],
+
'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-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-user' => "The user account is almost ready.",
'verify' => "Verify your domain to finish the setup process.",
'verify-domain' => "Verify domain",
'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 <b>{amount}</b> when under <b>{balance}</b> using {method}",
'country' => "Country",
'create' => "Create user",
'custno' => "Customer No.",
'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",
- 'distlists-none' => "There are no distribution lists in this account.",
'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 <a href=\"{href}\">{email}</a>.",
'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 <b>set</b> to fill up your account by <b>{amount}</b> every time your account balance gets under <b>{balance}</b>.",
'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/en/validation.php b/src/resources/lang/en/validation.php
index f578c1da..178d2924 100644
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -1,177 +1,177 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => 'The :attribute must be accepted.',
'active_url' => 'The :attribute is not a valid URL.',
'after' => 'The :attribute must be a date after :date.',
'after_or_equal' => 'The :attribute must be a date after or equal to :date.',
'alpha' => 'The :attribute may only contain letters.',
'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.',
'alpha_num' => 'The :attribute may only contain letters and numbers.',
'array' => 'The :attribute must be an array.',
'before' => 'The :attribute must be a date before :date.',
'before_or_equal' => 'The :attribute must be a date before or equal to :date.',
'between' => [
'numeric' => 'The :attribute must be between :min and :max.',
'file' => 'The :attribute must be between :min and :max kilobytes.',
'string' => 'The :attribute must be between :min and :max characters.',
'array' => 'The :attribute must have between :min and :max items.',
],
'boolean' => 'The :attribute field must be true or false.',
'confirmed' => 'The :attribute confirmation does not match.',
'date' => 'The :attribute is not a valid date.',
'date_equals' => 'The :attribute must be a date equal to :date.',
'date_format' => 'The :attribute does not match the format :format.',
'different' => 'The :attribute and :other must be different.',
'digits' => 'The :attribute must be :digits digits.',
'digits_between' => 'The :attribute must be between :min and :max digits.',
'dimensions' => 'The :attribute has invalid image dimensions.',
'distinct' => 'The :attribute field has a duplicate value.',
'email' => 'The :attribute must be a valid email address.',
'ends_with' => 'The :attribute must end with one of the following: :values',
'exists' => 'The selected :attribute is invalid.',
'file' => 'The :attribute must be a file.',
'filled' => 'The :attribute field must have a value.',
'gt' => [
'numeric' => 'The :attribute must be greater than :value.',
'file' => 'The :attribute must be greater than :value kilobytes.',
'string' => 'The :attribute must be greater than :value characters.',
'array' => 'The :attribute must have more than :value items.',
],
'gte' => [
'numeric' => 'The :attribute must be greater than or equal :value.',
'file' => 'The :attribute must be greater than or equal :value kilobytes.',
'string' => 'The :attribute must be greater than or equal :value characters.',
'array' => 'The :attribute must have :value items or more.',
],
'image' => 'The :attribute must be an image.',
'in' => 'The selected :attribute is invalid.',
'in_array' => 'The :attribute field does not exist in :other.',
'integer' => 'The :attribute must be an integer.',
'ip' => 'The :attribute must be a valid IP address.',
'ipv4' => 'The :attribute must be a valid IPv4 address.',
'ipv6' => 'The :attribute must be a valid IPv6 address.',
'json' => 'The :attribute must be a valid JSON string.',
'lt' => [
'numeric' => 'The :attribute must be less than :value.',
'file' => 'The :attribute must be less than :value kilobytes.',
'string' => 'The :attribute must be less than :value characters.',
'array' => 'The :attribute must have less than :value items.',
],
'lte' => [
'numeric' => 'The :attribute must be less than or equal :value.',
'file' => 'The :attribute must be less than or equal :value kilobytes.',
'string' => 'The :attribute must be less than or equal :value characters.',
'array' => 'The :attribute must not have more than :value items.',
],
'max' => [
'numeric' => 'The :attribute may not be greater than :max.',
'file' => 'The :attribute may not be greater than :max kilobytes.',
'string' => 'The :attribute may not be greater than :max characters.',
'array' => 'The :attribute may not have more than :max items.',
],
'mimes' => 'The :attribute must be a file of type: :values.',
'mimetypes' => 'The :attribute must be a file of type: :values.',
'min' => [
'numeric' => 'The :attribute must be at least :min.',
'file' => 'The :attribute must be at least :min kilobytes.',
'string' => 'The :attribute must be at least :min characters.',
'array' => 'The :attribute must have at least :min items.',
],
'not_in' => 'The selected :attribute is invalid.',
'not_regex' => 'The :attribute format is invalid.',
'numeric' => 'The :attribute must be a number.',
'present' => 'The :attribute field must be present.',
'regex' => 'The :attribute format is invalid.',
'required' => 'The :attribute field is required.',
'required_if' => 'The :attribute field is required when :other is :value.',
'required_unless' => 'The :attribute field is required unless :other is in :values.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_with_all' => 'The :attribute field is required when :values are present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'required_without_all' => 'The :attribute field is required when none of :values are present.',
'same' => 'The :attribute and :other must match.',
'size' => [
'numeric' => 'The :attribute must be :size.',
'file' => 'The :attribute must be :size kilobytes.',
'string' => 'The :attribute must be :size characters.',
'array' => 'The :attribute must contain :size items.',
],
'starts_with' => 'The :attribute must start with one of the following: :values',
'string' => 'The :attribute must be a string.',
'timezone' => 'The :attribute must be a valid zone.',
'unique' => 'The :attribute has already been taken.',
'uploaded' => 'The :attribute failed to upload.',
'url' => 'The :attribute format is invalid.',
'uuid' => 'The :attribute must be a valid UUID.',
'2fareq' => 'Second factor code is required.',
'2fainvalid' => 'Second factor code is invalid.',
'emailinvalid' => 'The specified email address is invalid.',
'domaininvalid' => 'The specified domain is invalid.',
'domainnotavailable' => 'The specified domain is not available.',
'logininvalid' => 'The specified login is invalid.',
'loginexists' => 'The specified login is not available.',
'domainexists' => 'The specified domain is not available.',
'noemailorphone' => 'The specified text is neither a valid email address nor a phone number.',
'packageinvalid' => 'Invalid package selected.',
'packagerequired' => 'Package is required.',
'usernotexists' => 'Unable to find user.',
'voucherinvalid' => 'The voucher code is invalid or expired.',
'noextemail' => 'This user has no external email address.',
'entryinvalid' => 'The specified :attribute is invalid.',
'entryexists' => 'The specified :attribute is not available.',
'minamount' => 'Minimum amount for a single payment is :amount.',
'minamountdebt' => 'The specified amount does not cover the balance on the account.',
'notalocaluser' => 'The specified email address does not exist.',
'memberislist' => 'A recipient cannot be the same as the list address.',
'listmembersrequired' => 'At least one recipient is required.',
'spf-entry-invalid' => 'The entry format is invalid. Expected a domain name starting with a dot.',
'sp-entry-invalid' => 'The entry format is invalid. Expected an email, domain, or part of it.',
+ 'ipolicy-invalid' => 'The specified invitation policy is invalid.',
'invalid-config-parameter' => 'The requested configuration parameter is not supported.',
'nameexists' => 'The specified name is not available.',
'nameinvalid' => 'The specified name is invalid.',
- 'nametoolong' => 'The specified name is too long.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap our attribute placeholder
| with something more reader friendly such as "E-Mail Address" instead
| of "email". This simply helps us make our message more expressive.
|
*/
'attributes' => [],
];
diff --git a/src/resources/lang/fr/app.php b/src/resources/lang/fr/app.php
index aebd9106..169366d1 100644
--- a/src/resources/lang/fr/app.php
+++ b/src/resources/lang/fr/app.php
@@ -1,95 +1,95 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used in the application.
*/
'chart-created' => "Crée",
'chart-deleted' => "Supprimé",
'chart-average' => "moyenne",
'chart-allusers' => "Tous Utilisateurs - l'année derniere",
'chart-discounts' => "Rabais",
'chart-vouchers' => "Coupons",
'chart-income' => "Revenus en :currency - 8 dernières semaines",
'chart-users' => "Utilisateurs - 8 dernières semaines",
'mandate-delete-success' => "L'auto-paiement a été supprimé.",
'mandate-update-success' => "L'auto-paiement a été mis-à-jour.",
'planbutton' => "Choisir :plan",
'siteuser' => "Utilisateur du :site",
'domain-setconfig-success' => "Les paramètres du domaine sont mis à jour avec succès.",
'user-setconfig-success' => "Les paramètres d'utilisateur sont mis à jour avec succès.",
'process-async' => "Le processus d'installation a été poussé. Veuillez patienter.",
'process-user-new' => "Enregistrement d'un utilisateur...",
'process-user-ldap-ready' => "Création d'un utilisateur...",
'process-user-imap-ready' => "Création d'une boîte aux lettres...",
'process-distlist-new' => "Enregistrement d'une liste de distribution...",
'process-distlist-ldap-ready' => "Création d'une liste de distribution...",
'process-domain-new' => "Enregistrement d'un domaine personnalisé...",
'process-domain-ldap-ready' => "Création d'un domaine personnalisé...",
'process-domain-verified' => "Vérification d'un domaine personnalisé...",
'process-domain-confirmed' => "vérification de la propriété d'un domaine personnalisé...",
'process-success' => "Le processus d'installation s'est terminé avec succès.",
'process-error-user-ldap-ready' => "Échec de créar un utilisateur.",
'process-error-user-imap-ready' => "Échec de la vérification de l'existence d'une boîte aux lettres.",
'process-error-domain-ldap-ready' => "Échec de créer un domaine.",
'process-error-domain-verified' => "Échec de vérifier un domaine.",
'process-error-domain-confirmed' => "Échec de la vérification de la propriété d'un domaine.",
'process-distlist-new' => "Enregistrement d'une liste de distribution...",
'process-distlist-ldap-ready' => "Création d'une liste de distribution...",
'process-error-distlist-ldap-ready' => "Échec de créer une liste de distrubion.",
'distlist-update-success' => "Liste de distribution mis-à-jour avec succès.",
'distlist-create-success' => "Liste de distribution créer avec succès.",
'distlist-delete-success' => "Liste de distribution suppriméee avec succès.",
'distlist-suspend-success' => "Liste de distribution à été suspendue avec succès.",
'distlist-unsuspend-success' => "Liste de distribution à été débloquée avec succès.",
'distlist-setconfig-success' => "Mise à jour des paramètres de la liste de distribution avec succès.",
'domain-create-success' => "Domaine a été crée avec succès.",
'domain-delete-success' => "Domaine supprimé avec succès.",
'domain-verify-success' => "Domaine vérifié avec succès.",
'domain-verify-error' => "Vérification de propriété de domaine à échoué.",
'domain-suspend-success' => "Domaine suspendue avec succès.",
'domain-unsuspend-success' => "Domaine debloqué avec succès.",
'user-update-success' => "Mis-à-jour des données de l'utilsateur effectué avec succès.",
'user-create-success' => "Utilisateur a été crée avec succès.",
'user-delete-success' => "Utilisateur a été supprimé avec succès.",
'user-suspend-success' => "Utilisateur a été suspendu avec succès.",
'user-unsuspend-success' => "Utilisateur a été debloqué avec succès.",
'user-reset-2fa-success' => "Réinstallation de l'authentification à 2-Facteur avec succès.",
'user-set-sku-success' => "Souscription ajoutée avec succès.",
'user-set-sku-already-exists' => "La souscription existe déjà.",
'search-foundxdomains' => "Les domaines :x ont été trouvés.",
- 'search-foundxgroups' => "Les listes de distribution :x ont été trouvées.",
+ 'search-foundxdistlists' => "Les listes de distribution :x ont été trouvées.",
'search-foundxusers' => "Les comptes d'utilisateurs :x ont été trouvés.",
'signup-invitations-created' => "L'invitation à été crée.|:count nombre d'invitations ont été crée.",
'signup-invitations-csv-empty' => "Aucune adresses email valides ont été trouvées dans le fichier téléchargé.",
'signup-invitations-csv-invalid-email' => "Une adresse email invalide a été trouvée (:email) on line :line.",
'signup-invitation-delete-success' => "Invitation supprimée avec succès.",
'signup-invitation-resend-success' => "Invitation ajoutée à la file d'attente d'envoi avec succès.",
'support-request-success' => "Demande de soutien soumise avec succès.",
'support-request-error' => "La soumission de demande de soutien a échoué.",
'wallet-award-success' => "Le bonus a été ajouté au portefeuille avec succès.",
'wallet-penalty-success' => "La pénalité a été ajoutée au portefeuille avec succès.",
'wallet-update-success' => "Portefeuille d'utilisateur a été mis-à-jour avec succès.",
'wallet-notice-date' => "Avec vos abonnements actuels, le solde de votre compte durera jusqu'à environ :date (:days).",
'wallet-notice-nocredit' => "Votre crédit a été epuisé, veuillez recharger immédiatement votre solde.",
'wallet-notice-today' => "Votre reste crédit sera épuisé aujourd'hui, veuillez recharger immédiatement.",
'wallet-notice-trial' => "Vous êtes dans votre période d'essai gratuite.",
'wallet-notice-trial-end' => "Vous approchez de la fin de votre période d'essai gratuite, veuillez recharger pour continuer.",
];
diff --git a/src/resources/lang/fr/ui.php b/src/resources/lang/fr/ui.php
index 40cfb636..ef7ba2b7 100644
--- a/src/resources/lang/fr/ui.php
+++ b/src/resources/lang/fr/ui.php
@@ -1,436 +1,435 @@
<?php
/**
* This file will be converted to a Vue-i18n compatible JSON format on build time
*
* Note: The Laravel localization features do not work here. Vue-i18n rules are different
*/
return [
'app' => [
'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 <b>doit avoir l'une des entrées suivantes</b> 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: <var>.ess.barracuda.com</var>.",
'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",
'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 <b>{amount}</b> quand le montant est inférieur à <b>{balance}</b> utilisant {method}",
'country' => "Pays",
'create' => "Créer un utilisateur",
'custno' => "No. de Client.",
'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",
- 'distlists-none' => "Il y a aucune liste de distribution dans ce compte.",
'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 <a href=\"{href}\">{email}</a>.",
'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 <b>set</b> pour recharger votre compte par <b>{amount}</b> lorsque le solde de votre compte devient inférieur à <b>{balance}</b>.",
'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/themes/app.scss b/src/resources/themes/app.scss
index 29968ff6..74b39dd0 100644
--- a/src/resources/themes/app.scss
+++ b/src/resources/themes/app.scss
@@ -1,451 +1,454 @@
html,
body,
body > .outer-container {
height: 100%;
}
#app {
display: flex;
flex-direction: column;
min-height: 100%;
overflow: hidden;
& > nav {
flex-shrink: 0;
z-index: 12;
}
& > div.container {
flex-grow: 1;
margin-top: 2rem;
margin-bottom: 2rem;
}
& > .filler {
flex-grow: 1;
}
& > div.container + .filler {
display: none;
}
}
.error-page {
position: absolute;
top: 0;
height: 100%;
width: 100%;
align-content: center;
align-items: center;
display: flex;
flex-wrap: wrap;
justify-content: center;
color: #636b6f;
z-index: 10;
background: white;
.code {
text-align: right;
border-right: 2px solid;
font-size: 26px;
padding: 0 15px;
}
.message {
font-size: 18px;
padding: 0 15px;
}
.hint {
margin-top: 3em;
text-align: center;
width: 100%;
}
}
.app-loader {
background-color: $body-bg;
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 8;
.spinner-border {
width: 120px;
height: 120px;
border-width: 15px;
color: #b2aa99;
}
&.small .spinner-border {
width: 25px;
height: 25px;
border-width: 3px;
}
&.fadeOut {
visibility: hidden;
opacity: 0;
transition: visibility 300ms linear, opacity 300ms linear;
}
}
pre {
margin: 1rem 0;
padding: 1rem;
background-color: $menu-bg-color;
}
.card-title {
font-size: 1.2rem;
font-weight: bold;
}
tfoot.table-fake-body {
background-color: #f8f8f8;
color: grey;
text-align: center;
td {
vertical-align: middle;
height: 8em;
border: 0;
}
tbody:not(:empty) + & {
display: none;
}
}
table {
th {
white-space: nowrap;
}
td.email,
td.price,
td.datetime,
td.selection {
width: 1%;
white-space: nowrap;
}
td.buttons,
th.price,
td.price {
width: 1%;
text-align: right;
white-space: nowrap;
}
&.form-list {
margin: 0;
td {
border: 0;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
button {
line-height: 1;
}
}
.btn-action {
line-height: 1;
padding: 0;
}
}
.list-details {
min-height: 1em;
& > ul {
margin: 0;
padding-left: 1.2em;
}
}
.plan-selector {
.plan-header {
display: flex;
}
.plan-ico {
margin:auto;
font-size: 3.8rem;
color: #f1a539;
border: 3px solid #f1a539;
width: 6rem;
height: 6rem;
border-radius: 50%;
}
}
.status-message {
display: flex;
align-items: center;
justify-content: center;
.app-loader {
width: auto;
position: initial;
.spinner-border {
color: $body-color;
}
}
svg {
font-size: 1.5em;
}
:first-child {
margin-right: 0.4em;
}
}
.form-separator {
position: relative;
margin: 1em 0;
display: flex;
justify-content: center;
hr {
border-color: #999;
margin: 0;
position: absolute;
top: 0.75em;
width: 100%;
}
span {
background: #fff;
padding: 0 1em;
z-index: 1;
}
}
#status-box {
background-color: lighten($green, 35);
.progress {
background-color: #fff;
height: 10px;
}
.progress-label {
font-size: 0.9em;
}
.progress-bar {
background-color: $green;
}
&.process-failed {
background-color: lighten($orange, 30);
.progress-bar {
background-color: $red;
}
}
}
@keyframes blinker {
50% {
opacity: 0;
}
}
.blinker {
animation: blinker 750ms step-start infinite;
}
#dashboard-nav {
display: flex;
flex-wrap: wrap;
justify-content: center;
& > a {
padding: 1rem;
text-align: center;
white-space: nowrap;
margin: 0.25rem;
text-decoration: none;
width: 150px;
&.disabled {
pointer-events: none;
opacity: 0.6;
}
// Some icons are too big, scale them down
+ &.link-domains,
+ &.link-resources,
+ &.link-wallet,
&.link-invitations {
svg {
transform: scale(0.9);
}
}
.badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
}
svg {
width: 6rem;
height: 6rem;
margin: auto;
}
}
#payment-method-selection {
display: flex;
flex-wrap: wrap;
justify-content: center;
& > a {
padding: 1rem;
text-align: center;
white-space: nowrap;
margin: 0.25rem;
text-decoration: none;
width: 150px;
}
svg {
width: 6rem;
height: 6rem;
margin: auto;
}
}
#logon-form {
flex-basis: auto; // Bootstrap issue? See logon page with width < 992
}
#logon-form-footer {
a:not(:first-child) {
margin-left: 2em;
}
}
// Various improvements for mobile
@include media-breakpoint-down(sm) {
.card,
.card-footer {
border: 0;
}
.card-body {
padding: 0.5rem 0;
}
.nav-tabs {
flex-wrap: nowrap;
.nav-link {
white-space: nowrap;
padding: 0.5rem 0.75rem;
}
}
#app > div.container {
margin-bottom: 1rem;
margin-top: 1rem;
max-width: 100%;
}
#header-menu-navbar {
padding: 0;
}
#dashboard-nav > a {
width: 135px;
}
.table-sm:not(.form-list) {
tbody td {
padding: 0.75rem 0.5rem;
svg {
vertical-align: -0.175em;
}
& > svg {
font-size: 125%;
margin-right: 0.25rem;
}
}
}
.table.transactions {
thead {
display: none;
}
tbody {
tr {
position: relative;
display: flex;
flex-wrap: wrap;
}
td {
width: auto;
border: 0;
padding: 0.5rem;
&.datetime {
width: 50%;
padding-left: 0;
}
&.description {
order: 3;
width: 100%;
border-bottom: 1px solid $border-color;
color: $secondary;
padding: 0 1.5em 0.5rem 0;
margin-top: -0.25em;
}
&.selection {
position: absolute;
right: 0;
border: 0;
top: 1.7em;
padding-right: 0;
}
&.price {
width: 50%;
padding-right: 0;
}
&.email {
display: none;
}
}
}
}
}
@include media-breakpoint-down(sm) {
.tab-pane > .card-body {
padding: 0.5rem;
}
}
diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss
index 62177ed4..ded08e86 100644
--- a/src/resources/themes/forms.scss
+++ b/src/resources/themes/forms.scss
@@ -1,127 +1,147 @@
.list-input {
& > div {
&:not(:last-child) {
margin-bottom: -1px;
input,
a.btn {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
}
&:not(:first-child) {
input,
a.btn {
border-top-right-radius: 0;
border-top-left-radius: 0;
}
}
}
input.is-invalid {
z-index: 2;
}
.btn svg {
vertical-align: middle;
}
}
.range-input {
display: flex;
label {
margin-right: 0.5em;
min-width: 4em;
text-align: right;
line-height: 1.7;
}
}
.input-group-activable {
&.active {
:not(.activable) {
display: none;
}
}
&:not(.active) {
.activable {
display: none;
}
}
// Label is always visible
.label {
color: $body-color;
display: initial !important;
}
.input-group-text {
border-color: transparent;
background: transparent;
padding-left: 0;
&:not(.label) {
flex: 1;
}
}
}
+// An input group with a select and input, where input is displayed
+// only for some select values
+.input-group-select {
+ &:not(.selected) {
+ input {
+ display: none;
+ }
+
+ select {
+ border-bottom-right-radius: .25rem !important;
+ border-top-right-radius: .25rem !important;
+ }
+ }
+
+ input {
+ border-bottom-right-radius: .25rem !important;
+ border-top-right-radius: .25rem !important;
+ }
+}
+
.form-control-plaintext .btn-sm {
margin-top: -0.25rem;
}
// Various improvements for mobile
@include media-breakpoint-down(sm) {
.row.mb-3 {
margin-bottom: 0.5rem !important;
}
.nav-tabs {
.nav-link {
white-space: nowrap;
padding: 0.5rem 0.75rem;
}
}
.tab-content {
margin-top: 0.5rem;
}
.col-form-label {
color: #666;
font-size: 95%;
}
.row.plaintext .col-form-label {
padding-bottom: 0;
}
form.read-only.short label {
width: 35%;
& + * {
width: 65%;
}
}
.row.checkbox {
position: relative;
& > div {
padding-top: 0 !important;
input {
position: absolute;
top: 0.5rem;
right: 1rem;
}
}
label {
padding-right: 2.5rem;
}
}
}
diff --git a/src/resources/vue/Admin/Resource.vue b/src/resources/vue/Admin/Resource.vue
new file mode 100644
index 00000000..5d8a804d
--- /dev/null
+++ b/src/resources/vue/Admin/Resource.vue
@@ -0,0 +1,80 @@
+<template>
+ <div v-if="resource.id" class="container">
+ <div class="card" id="resource-info">
+ <div class="card-body">
+ <div class="card-title">{{ resource.email }}</div>
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="row plaintext">
+ <label for="resourceid" class="col-sm-4 col-form-label">
+ {{ $t('form.id') }} <span class="text-muted">({{ $t('form.created') }})</span>
+ </label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="resourceid">
+ {{ resource.id }} <span class="text-muted">({{ resource.created_at }})</span>
+ </span>
+ </div>
+ </div>
+ <div class="row plaintext">
+ <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
+ <div class="col-sm-8">
+ <span :class="$root.resourceStatusClass(resource) + ' form-control-plaintext'" id="status">{{ $root.resourceStatusText(resource) }}</span>
+ </div>
+ </div>
+ <div class="row plaintext">
+ <label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="name">{{ resource.name }}</span>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ <ul class="nav nav-tabs mt-3" role="tablist">
+ <li class="nav-item">
+ <a class="nav-link active" id="tab-settings" href="#resource-settings" role="tab" aria-controls="resource-settings" aria-selected="false" @click="$root.tab">
+ {{ $t('form.settings') }}
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="resource-settings" role="tabpanel" aria-labelledby="tab-settings">
+ <div class="card-body">
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="row plaintext">
+ <label for="invitation_policy" class="col-sm-4 col-form-label">{{ $t('resource.invitation-policy') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="invitation_policy">
+ {{ resource.config.invitation_policy || $t('form.none') }}
+ </span>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ resource: { config: {} }
+ }
+ },
+ created() {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/resources/' + this.$route.params.resource)
+ .then(response => {
+ this.$root.stopLoading()
+ this.resource = response.data
+ })
+ .catch(this.$root.errorHandler)
+ }
+ }
+</script>
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
index 1b10669d..58e16101 100644
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -1,758 +1,800 @@
<template>
<div class="container">
<div class="card" id="user-info">
<div class="card-body">
<h1 class="card-title">{{ user.email }}</h1>
<div class="card-text">
<form class="read-only short">
<div v-if="user.wallet.user_id != user.id" class="row plaintext">
<label for="manager" class="col-sm-4 col-form-label">{{ $t('user.managed-by') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="manager">
<router-link :to="{ path: '/user/' + user.wallet.user_id }">{{ user.wallet.user_email }}</router-link>
</span>
</div>
</div>
<div class="row plaintext">
<label for="userid" class="col-sm-4 col-form-label">ID <span class="text-muted">({{ $t('form.created') }})</span></label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="userid">
{{ user.id }} <span class="text-muted">({{ user.created_at }})</span>
</span>
</div>
</div>
<div class="row plaintext">
<label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="status">
<span :class="$root.userStatusClass(user)">{{ $root.userStatusText(user) }}</span>
</span>
</div>
</div>
<div class="row plaintext" v-if="user.first_name">
<label for="first_name" class="col-sm-4 col-form-label">{{ $t('form.firstname') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="first_name">{{ user.first_name }}</span>
</div>
</div>
<div class="row plaintext" v-if="user.last_name">
<label for="last_name" class="col-sm-4 col-form-label">{{ $t('form.lastname') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="last_name">{{ user.last_name }}</span>
</div>
</div>
<div class="row plaintext" v-if="user.organization">
<label for="organization" class="col-sm-4 col-form-label">{{ $t('user.org') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="organization">{{ user.organization }}</span>
</div>
</div>
<div class="row plaintext" v-if="user.phone">
<label for="phone" class="col-sm-4 col-form-label">{{ $t('form.phone') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="phone">{{ user.phone }}</span>
</div>
</div>
<div class="row plaintext">
<label for="external_email" class="col-sm-4 col-form-label">{{ $t('user.ext-email') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="external_email">
<a v-if="user.external_email" :href="'mailto:' + user.external_email">{{ user.external_email }}</a>
<button type="button" class="btn btn-secondary btn-sm" @click="emailEdit">{{ $t('btn.edit') }}</button>
</span>
</div>
</div>
<div class="row plaintext" v-if="user.billing_address">
<label for="billing_address" class="col-sm-4 col-form-label">{{ $t('user.address') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" style="white-space:pre" id="billing_address">{{ user.billing_address }}</span>
</div>
</div>
<div class="row plaintext">
<label for="country" class="col-sm-4 col-form-label">{{ $t('user.country') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="country">{{ user.country }}</span>
</div>
</div>
</form>
<div class="mt-2">
<button v-if="!user.isSuspended" id="button-suspend" class="btn btn-warning" type="button" @click="suspendUser">
{{ $t('btn.suspend') }}
</button>
<button v-if="user.isSuspended" id="button-unsuspend" class="btn btn-warning" type="button" @click="unsuspendUser">
{{ $t('btn.unsuspend') }}
</button>
</div>
</div>
</div>
</div>
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="tab-finances" href="#user-finances" role="tab" aria-controls="user-finances" aria-selected="true">
{{ $t('user.finances') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-aliases" href="#user-aliases" role="tab" aria-controls="user-aliases" aria-selected="false">
{{ $t('user.aliases') }} ({{ user.aliases.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-subscriptions" href="#user-subscriptions" role="tab" aria-controls="user-subscriptions" aria-selected="false">
{{ $t('user.subscriptions') }} ({{ skus.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-domains" href="#user-domains" role="tab" aria-controls="user-domains" aria-selected="false">
{{ $t('user.domains') }} ({{ domains.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-users" href="#user-users" role="tab" aria-controls="user-users" aria-selected="false">
{{ $t('user.users') }} ({{ users.length }})
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="tab-distlists" href="#user-distlists" role="tab" aria-controls="user-distlists" aria-selected="false">
{{ $t('user.distlists') }} ({{ distlists.length }})
</a>
</li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-resources" href="#user-resources" role="tab" aria-controls="user-resources" aria-selected="false">
+ {{ $t('user.resources') }} ({{ resources.length }})
+ </a>
+ </li>
<li class="nav-item">
<a class="nav-link" id="tab-settings" href="#user-settings" role="tab" aria-controls="user-settings" aria-selected="false">
Settings
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane show active" id="user-finances" role="tabpanel" aria-labelledby="tab-finances">
<div class="card-body">
<h2 class="card-title">
{{ $t('wallet.title') }}
<span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(wallet.balance, wallet.currency) }}</strong></span>
</h2>
<div class="card-text">
<form class="read-only short">
<div class="row">
<label class="col-sm-4 col-form-label">{{ $t('user.discount') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="discount">
<span>{{ wallet.discount ? (wallet.discount + '% - ' + wallet.discount_description) : 'none' }}</span>
<button type="button" class="btn btn-secondary btn-sm" @click="discountEdit">{{ $t('btn.edit') }}</button>
</span>
</div>
</div>
<div class="row" v-if="wallet.mandate && wallet.mandate.id">
<label class="col-sm-4 col-form-label">{{ $t('user.auto-payment') }}</label>
<div class="col-sm-8">
<span id="autopayment" :class="'form-control-plaintext' + (wallet.mandateState ? ' text-danger' : '')"
v-html="$t('user.auto-payment-text', {
amount: wallet.mandate.amount + ' ' + wallet.currency,
balance: wallet.mandate.balance + ' ' + wallet.currency,
method: wallet.mandate.method
})"
>
<span v-if="wallet.mandateState">({{ wallet.mandateState }})</span>.
</span>
</div>
</div>
<div class="row" v-if="wallet.providerLink">
<label class="col-sm-4 col-form-label">{{ capitalize(wallet.provider) }} {{ $t('form.id') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" v-html="wallet.providerLink"></span>
</div>
</div>
</form>
<div class="mt-2">
<button id="button-award" class="btn btn-success" type="button" @click="awardDialog">{{ $t('user.add-bonus') }}</button>
<button id="button-penalty" class="btn btn-danger" type="button" @click="penalizeDialog">{{ $t('user.add-penalty') }}</button>
</div>
</div>
<h2 class="card-title mt-4">{{ $t('wallet.transactions') }}</h2>
<transaction-log v-if="wallet.id && !walletReload" class="card-text" :wallet-id="wallet.id" :is-admin="true"></transaction-log>
</div>
</div>
<div class="tab-pane" id="user-aliases" role="tabpanel" aria-labelledby="tab-aliases">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(alias, index) in user.aliases" :id="'alias' + index" :key="index">
<td>{{ alias }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.aliases-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-subscriptions" role="tabpanel" aria-labelledby="tab-subscriptions">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('user.subscription') }}</th>
<th scope="col">{{ $t('user.price') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(sku, sku_id) in skus" :id="'sku' + sku.id" :key="sku_id">
<td>{{ sku.name }}</td>
<td>{{ sku.price }}</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td colspan="2">{{ $t('user.subscriptions-none') }}</td>
</tr>
</tfoot>
</table>
<small v-if="discount > 0" class="hint">
<hr class="m-0">
&sup1; {{ $t('user.discount-hint') }}: {{ discount }}% - {{ discount_description }}
</small>
<div class="mt-2">
<button type="button" class="btn btn-danger" id="reset2fa" v-if="has2FA" @click="reset2FADialog">
{{ $t('user.reset-2fa') }}
</button>
<button type="button" class="btn btn-secondary" id="addbetasku" v-if="!hasBeta" @click="addBetaSku">
{{ $t('user.add-beta') }}
</button>
</div>
</div>
</div>
</div>
<div class="tab-pane" id="user-domains" role="tabpanel" aria-labelledby="tab-domains">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('domain.namespace') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="domain in domains" :id="'domain' + domain.id" :key="domain.id" @click="$root.clickRecord">
<td>
<svg-icon icon="globe" :class="$root.domainStatusClass(domain)" :title="$root.domainStatusText(domain)"></svg-icon>
<router-link :to="{ path: '/domain/' + domain.id }">{{ domain.namespace }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.domains-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-users" role="tabpanel" aria-labelledby="tab-users">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('form.primary-email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in users" :id="'user' + item.id" :key="item.id" @click="$root.clickRecord">
<td>
<svg-icon icon="user" :class="$root.userStatusClass(item)" :title="$root.userStatusText(item)"></svg-icon>
<router-link v-if="item.id != user.id" :to="{ path: '/user/' + item.id }">{{ item.email }}</router-link>
<span v-else>{{ item.email }}</span>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td>{{ $t('user.users-none') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-distlists" role="tabpanel" aria-labelledby="tab-distlists">
<div class="card-body">
<div class="card-text">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th scope="col">{{ $t('distlist.name') }}</th>
<th scope="col">{{ $t('form.email') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="list in distlists" :key="list.id" @click="$root.clickRecord">
<td>
<svg-icon icon="users" :class="$root.distlistStatusClass(list)" :title="$root.distlistStatusText(list)"></svg-icon>
<router-link :to="{ path: '/distlist/' + list.id }">{{ list.name }}</router-link>
</td>
<td>
<router-link :to="{ path: '/distlist/' + list.id }">{{ list.email }}</router-link>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
- <td colspan="2">{{ $t('user.distlists-none') }}</td>
+ <td colspan="2">{{ $t('distlist.list-empty') }}</td>
+ </tr>
+ </tfoot>
+ </table>
+ </div>
+ </div>
+ </div>
+ <div class="tab-pane" id="user-resources" role="tabpanel" aria-labelledby="tab-resources">
+ <div class="card-body">
+ <div class="card-text">
+ <table class="table table-sm table-hover mb-0">
+ <thead>
+ <tr>
+ <th scope="col">{{ $t('form.name') }}</th>
+ <th scope="col">{{ $t('form.email') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="resource in resources" :key="resource.id" @click="$root.clickRecord">
+ <td>
+ <svg-icon icon="cog" :class="$root.resourceStatusClass(resource)" :title="$root.resourceStatusText(resource)"></svg-icon>
+ <router-link :to="{ path: '/resource/' + resource.id }">{{ resource.name }}</router-link>
+ </td>
+ <td>
+ <router-link :to="{ path: '/resource/' + resource.id }">{{ resource.email }}</router-link>
+ </td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="2">{{ $t('resource.list-empty') }}</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="tab-pane" id="user-settings" role="tabpanel" aria-labelledby="tab-settings">
<div class="card-body">
<div class="card-text">
<form class="read-only short">
<div class="row plaintext">
<label for="greylist_enabled" class="col-sm-4 col-form-label">{{ $t('user.greylisting') }}</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="greylist_enabled">
<span v-if="user.config.greylist_enabled" class="text-success">{{ $t('form.enabled') }}</span>
<span v-else class="text-danger">{{ $t('form.disabled') }}</span>
</span>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div id="discount-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.discount-title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
</div>
<div class="modal-body">
<p>
<select v-model="wallet.discount_id" class="form-select">
<option value="">- {{ $t('form.none') }} -</option>
<option v-for="item in discounts" :value="item.id" :key="item.id">{{ item.label }}</option>
</select>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="submitDiscount()">
<svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
</button>
</div>
</div>
</div>
</div>
<div id="email-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.ext-email') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
</div>
<div class="modal-body">
<p>
<input v-model="external_email" name="external_email" class="form-control">
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="submitEmail()">
<svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
</button>
</div>
</div>
</div>
</div>
<div id="oneoff-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t(oneoff_negative ? 'user.add-penalty-title' : 'user.add-bonus-title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
</div>
<div class="modal-body">
<form data-validation-prefix="oneoff_">
<div class="row mb-3">
<label for="oneoff_amount" class="col-form-label">{{ $t('form.amount') }}</label>
<div class="input-group">
<input type="text" class="form-control" id="oneoff_amount" v-model="oneoff_amount" required>
<span class="input-group-text">{{ wallet.currency }}</span>
</div>
</div>
<div class="row">
<label for="oneoff_description" class="col-form-label">{{ $t('form.description') }}</label>
<input class="form-control" id="oneoff_description" v-model="oneoff_description" required>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="submitOneOff()">
<svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}
</button>
</div>
</div>
</div>
</div>
<div id="reset-2fa-dialog" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('user.reset-2fa-title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
</div>
<div class="modal-body">
<p>{{ $t('user.2fa-hint1') }}</p>
<p>{{ $t('user.2fa-hint2') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-danger modal-action" @click="reset2FA()">{{ $t('btn.reset') }}</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Modal } from 'bootstrap'
import TransactionLog from '../Widgets/TransactionLog'
export default {
components: {
TransactionLog
},
beforeRouteUpdate (to, from, next) {
// An event called when the route that renders this component has changed,
// but this component is reused in the new route.
// Required to handle links from /user/XXX to /user/YYY
next()
this.$parent.routerReload()
},
data() {
return {
oneoff_amount: '',
oneoff_description: '',
oneoff_negative: false,
discount: 0,
discount_description: '',
discounts: [],
external_email: '',
has2FA: false,
hasBeta: false,
wallet: {},
walletReload: false,
distlists: [],
domains: [],
+ resources: [],
skus: [],
sku2FA: null,
users: [],
user: {
aliases: [],
config: {},
wallet: {},
skus: {},
}
}
},
created() {
const user_id = this.$route.params.user
this.$root.startLoading()
axios.get('/api/v4/users/' + user_id)
.then(response => {
this.$root.stopLoading()
this.user = response.data
const financesTab = '#user-finances'
const keys = ['first_name', 'last_name', 'external_email', 'billing_address', 'phone', 'organization']
let country = this.user.settings.country
if (country && country in window.config.countries) {
country = window.config.countries[country][1]
}
this.user.country = country
keys.forEach(key => { this.user[key] = this.user.settings[key] })
this.discount = this.user.wallet.discount
this.discount_description = this.user.wallet.discount_description
// TODO: currencies, multi-wallets, accounts
// Get more info about the wallet (e.g. payment provider related)
this.$root.addLoader(financesTab)
axios.get('/api/v4/wallets/' + this.user.wallets[0].id)
.then(response => {
this.$root.removeLoader(financesTab)
this.wallet = response.data
this.setMandateState()
})
.catch(error => {
this.$root.removeLoader(financesTab)
})
// Create subscriptions list
axios.get('/api/v4/users/' + user_id + '/skus')
.then(response => {
// "merge" SKUs with user entitlement-SKUs
response.data.forEach(sku => {
const userSku = this.user.skus[sku.id]
if (userSku) {
let cost = userSku.costs.reduce((sum, current) => sum + current)
let item = {
id: sku.id,
name: sku.name,
cost: cost,
price: this.$root.priceLabel(cost, this.discount)
}
if (sku.range) {
item.name += ' ' + userSku.count + ' ' + sku.range.unit
}
this.skus.push(item)
if (sku.handler == 'auth2f') {
this.has2FA = true
this.sku2FA = sku.id
} else if (sku.handler == 'beta') {
this.hasBeta = true
}
}
})
})
// Fetch users
// TODO: Multiple wallets
axios.get('/api/v4/users?owner=' + user_id)
.then(response => {
this.users = response.data.list;
})
// Fetch domains
axios.get('/api/v4/domains?owner=' + user_id)
.then(response => {
this.domains = response.data.list
})
// Fetch distribution lists
axios.get('/api/v4/groups?owner=' + user_id)
.then(response => {
this.distlists = response.data.list
})
+
+ // Fetch resources lists
+ axios.get('/api/v4/resources?owner=' + user_id)
+ .then(response => {
+ this.resources = response.data.list
+ })
})
.catch(this.$root.errorHandler)
},
mounted() {
$(this.$el).find('ul.nav-tabs a').on('click', this.$root.tab)
},
methods: {
addBetaSku() {
axios.post('/api/v4/users/' + this.user.id + '/skus/beta')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.hasBeta = true
const sku = response.data.sku
this.skus.push({
id: sku.id,
name: sku.name,
cost: sku.cost,
price: this.$root.priceLabel(sku.cost, this.discount)
})
}
})
},
capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
awardDialog() {
this.oneOffDialog(false)
},
discountEdit() {
if (!this.discount_dialog) {
const dialog = $('#discount-dialog')[0]
dialog.addEventListener('shown.bs.modal', e => {
$(dialog).find('select').focus()
// Note: Vue v-model is strict, convert null to a string
this.wallet.discount_id = this.wallet_discount_id || ''
})
this.discount_dialog = new Modal(dialog)
}
this.discount_dialog.show()
if (!this.discounts.length) {
// Fetch discounts
axios.get('/api/v4/users/' + this.user.id + '/discounts')
.then(response => {
this.discounts = response.data.list
})
}
},
emailEdit() {
this.external_email = this.user.external_email
this.$root.clearFormValidation($('#email-dialog'))
if (!this.email_dialog) {
const dialog = $('#email-dialog')[0]
dialog.addEventListener('shown.bs.modal', e => {
$(dialog).find('input').focus()
})
this.email_dialog = new Modal(dialog)
}
this.email_dialog.show()
},
setMandateState() {
let mandate = this.wallet.mandate
if (mandate && mandate.id) {
if (!mandate.isValid) {
this.wallet.mandateState = mandate.isPending ? 'pending' : 'invalid'
} else if (mandate.isDisabled) {
this.wallet.mandateState = 'disabled'
}
}
},
oneOffDialog(negative) {
this.oneoff_negative = negative
if (!this.oneoff_dialog) {
const dialog = $('#oneoff-dialog')[0]
dialog.addEventListener('shown.bs.modal', () => {
this.$root.clearFormValidation(dialog)
$(dialog).find('#oneoff_amount').focus()
})
this.oneoff_dialog = new Modal(dialog)
}
this.oneoff_dialog.show()
},
penalizeDialog() {
this.oneOffDialog(true)
},
reload() {
// this is to reload transaction log
this.walletReload = true
this.$nextTick(() => { this.walletReload = false })
},
reset2FA() {
new Modal('#reset-2fa-dialog').hide()
axios.post('/api/v4/users/' + this.user.id + '/reset2FA')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.skus = this.skus.filter(sku => sku.id != this.sku2FA)
this.has2FA = false
}
})
},
reset2FADialog() {
new Modal('#reset-2fa-dialog').show()
},
submitDiscount() {
this.discount_dialog.hide()
axios.put('/api/v4/wallets/' + this.user.wallets[0].id, { discount: this.wallet.discount_id })
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.wallet = Object.assign({}, this.wallet, response.data)
// Update prices in Subscriptions tab
if (this.user.wallet.id == response.data.id) {
this.discount = this.wallet.discount
this.discount_description = this.wallet.discount_description
this.skus.forEach(sku => {
sku.price = this.$root.priceLabel(sku.cost, this.discount)
})
}
}
})
},
submitEmail() {
axios.put('/api/v4/users/' + this.user.id, { external_email: this.external_email })
.then(response => {
if (response.data.status == 'success') {
this.email_dialog.hide()
this.$toast.success(response.data.message)
this.user.external_email = this.external_email
this.external_email = null // required because of Vue
}
})
},
submitOneOff() {
let wallet_id = this.user.wallets[0].id
let post = {
amount: this.oneoff_amount,
description: this.oneoff_description
}
if (this.oneoff_negative && /^\d+(\.?\d+)?$/.test(post.amount)) {
post.amount *= -1
}
this.$root.clearFormValidation('#oneoff-dialog')
axios.post('/api/v4/wallets/' + wallet_id + '/one-off', post)
.then(response => {
if (response.data.status == 'success') {
this.oneoff_dialog.hide()
this.$toast.success(response.data.message)
this.wallet = Object.assign({}, this.wallet, {balance: response.data.balance})
this.oneoff_amount = ''
this.oneoff_description = ''
this.reload()
}
})
},
suspendUser() {
axios.post('/api/v4/users/' + this.user.id + '/suspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.user = Object.assign({}, this.user, { isSuspended: true })
}
})
},
unsuspendUser() {
axios.post('/api/v4/users/' + this.user.id + '/unsuspend', {})
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
this.user = Object.assign({}, this.user, { isSuspended: false })
}
})
}
}
}
</script>
diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue
index 7d116d97..e0246a25 100644
--- a/src/resources/vue/Dashboard.vue
+++ b/src/resources/vue/Dashboard.vue
@@ -1,68 +1,71 @@
<template>
<div class="container" dusk="dashboard-component">
<status-component :status="status" @status-update="statusUpdate"></status-component>
<div id="dashboard-nav">
<router-link class="card link-profile" :to="{ name: 'profile' }">
<svg-icon icon="user-cog"></svg-icon><span class="name">{{ $t('dashboard.profile') }}</span>
</router-link>
<router-link v-if="status.enableDomains" class="card link-domains" :to="{ name: 'domains' }">
<svg-icon icon="globe"></svg-icon><span class="name">{{ $t('dashboard.domains') }}</span>
</router-link>
<router-link v-if="status.enableUsers" class="card link-users" :to="{ name: 'users' }">
<svg-icon icon="user-friends"></svg-icon><span class="name">{{ $t('dashboard.users') }}</span>
</router-link>
<router-link v-if="status.enableDistlists" class="card link-distlists" :to="{ name: 'distlists' }">
<svg-icon icon="users"></svg-icon><span class="name">{{ $t('dashboard.distlists') }}</span>
</router-link>
+ <router-link v-if="status.enableResources" class="card link-resources" :to="{ name: 'resources' }">
+ <svg-icon icon="cog"></svg-icon><span class="name">{{ $t('dashboard.resources') }}</span>
+ </router-link>
<router-link v-if="status.enableWallets" class="card link-wallet" :to="{ name: 'wallet' }">
<svg-icon icon="wallet"></svg-icon><span class="name">{{ $t('dashboard.wallet') }}</span>
<span v-if="balance < 0" class="badge bg-danger">{{ $root.price(balance, currency) }}</span>
</router-link>
<router-link v-if="$root.hasSKU('meet')" class="card link-chat" :to="{ name: 'rooms' }">
<svg-icon icon="comments"></svg-icon><span class="name">{{ $t('dashboard.chat') }}</span>
<span class="badge bg-primary">{{ $t('dashboard.beta') }}</span>
</router-link>
<a v-if="webmailURL" class="card link-webmail" :href="webmailURL">
<svg-icon icon="envelope"></svg-icon><span class="name">{{ $t('dashboard.webmail') }}</span>
</a>
</div>
</div>
</template>
<script>
import StatusComponent from './Widgets/Status'
export default {
components: {
StatusComponent
},
data() {
return {
status: {},
balance: 0,
currency: '',
webmailURL: window.config['app.webmail_url']
}
},
mounted() {
const authInfo = this.$store.state.authInfo
this.status = authInfo.statusInfo
this.getBalance(authInfo)
},
methods: {
getBalance(authInfo) {
this.balance = 0;
// TODO: currencies, multi-wallets, accounts
authInfo.wallets.forEach(wallet => {
this.balance += wallet.balance
this.currency = wallet.currency
})
},
statusUpdate(user) {
this.status = Object.assign({}, this.status, user)
this.$store.state.authInfo.statusInfo = this.status
}
}
}
</script>
diff --git a/src/resources/vue/Resource/Info.vue b/src/resources/vue/Resource/Info.vue
new file mode 100644
index 00000000..a0925800
--- /dev/null
+++ b/src/resources/vue/Resource/Info.vue
@@ -0,0 +1,189 @@
+<template>
+ <div class="container">
+ <status-component v-if="resource_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
+
+ <div class="card" id="resource-info">
+ <div class="card-body">
+ <div class="card-title" v-if="resource_id !== 'new'">
+ {{ $tc('resource.list-title', 1) }}
+ <button class="btn btn-outline-danger button-delete float-end" @click="deleteResource()" tag="button">
+ <svg-icon icon="trash-alt"></svg-icon> {{ $t('resource.delete') }}
+ </button>
+ </div>
+ <div class="card-title" v-if="resource_id === 'new'">{{ $t('resource.new') }}</div>
+ <div class="card-text">
+ <ul class="nav nav-tabs mt-3" role="tablist">
+ <li class="nav-item">
+ <a class="nav-link active" id="tab-general" href="#general" role="tab" aria-controls="general" aria-selected="true" @click="$root.tab">
+ {{ $t('form.general') }}
+ </a>
+ </li>
+ <li v-if="resource_id !== 'new'" class="nav-item">
+ <a class="nav-link" id="tab-settings" href="#settings" role="tab" aria-controls="settings" aria-selected="false" @click="$root.tab">
+ {{ $t('form.settings') }}
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="general" role="tabpanel" aria-labelledby="tab-general">
+ <form @submit.prevent="submit" class="card-body">
+ <div v-if="resource_id !== 'new'" class="row plaintext mb-3">
+ <label for="status" class="col-sm-4 col-form-label">{{ $t('form.status') }}</label>
+ <div class="col-sm-8">
+ <span :class="$root.resourceStatusClass(resource) + ' form-control-plaintext'" id="status">{{ $root.resourceStatusText(resource) }}</span>
+ </div>
+ </div>
+ <div class="row mb-3">
+ <label for="name" class="col-sm-4 col-form-label">{{ $t('form.name') }}</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="name" v-model="resource.name">
+ </div>
+ </div>
+ <div v-if="domains.length" class="row mb-3">
+ <label for="domain" class="col-sm-4 col-form-label">{{ $t('form.domain') }}</label>
+ <div class="col-sm-8">
+ <select class="form-select" v-model="resource.domain">
+ <option v-for="_domain in domains" :key="_domain" :value="_domain.namespace">{{ _domain.namespace }}</option>
+ </select>
+ </div>
+ </div>
+ <div v-if="resource.email" class="row mb-3">
+ <label for="email" class="col-sm-4 col-form-label">{{ $t('form.email') }}</label>
+ <div class="col-sm-8">
+ <input type="text" class="form-control" id="email" disabled v-model="resource.email">
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ </form>
+ </div>
+ <div class="tab-pane" id="settings" role="tabpanel" aria-labelledby="tab-settings">
+ <form @submit.prevent="submitSettings" class="card-body">
+ <div class="row mb-3">
+ <label for="invitation_policy" class="col-sm-4 col-form-label">{{ $t('resource.invitation-policy') }}</label>
+ <div class="col-sm-8">
+ <div class="input-group input-group-select mb-1">
+ <select class="form-select" id="invitation_policy" v-model="resource.config.invitation_policy" @change="policyChange">
+ <option value="accept">{{ $t('resource.ipolicy-accept') }}</option>
+ <option value="manual">{{ $t('resource.ipolicy-manual') }}</option>
+ <option value="reject">{{ $t('resource.ipolicy-reject') }}</option>
+ </select>
+ <input type="text" class="form-control" id="owner" v-model="resource.config.owner" :placeholder="$t('form.email')">
+ </div>
+ <small id="invitation-policy-hint" class="text-muted">
+ {{ $t('resource.invitation-policy-text') }}
+ </small>
+ </div>
+ </div>
+ <button class="btn btn-primary" type="submit"><svg-icon icon="check"></svg-icon> {{ $t('btn.submit') }}</button>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ import StatusComponent from '../Widgets/Status'
+
+ export default {
+ components: {
+ StatusComponent
+ },
+ data() {
+ return {
+ domains: [],
+ resource_id: null,
+ resource: { config: {} },
+ status: {}
+ }
+ },
+ created() {
+ this.resource_id = this.$route.params.resource
+
+ if (this.resource_id != 'new') {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/resources/' + this.resource_id)
+ .then(response => {
+ this.$root.stopLoading()
+ this.resource = response.data
+ this.status = response.data.statusInfo
+
+ if (this.resource.config.invitation_policy.match(/^manual:(.+)$/)) {
+ this.resource.config.owner = RegExp.$1
+ this.resource.config.invitation_policy = 'manual'
+ }
+ this.$nextTick().then(() => { this.policyChange() })
+ })
+ .catch(this.$root.errorHandler)
+ } else {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/domains')
+ .then(response => {
+ this.$root.stopLoading()
+ this.domains = response.data
+ this.resource.domain = this.domains[0].namespace
+ })
+ .catch(this.$root.errorHandler)
+ }
+ },
+ mounted() {
+ $('#name').focus()
+ },
+ methods: {
+ deleteResource() {
+ axios.delete('/api/v4/resources/' + this.resource_id)
+ .then(response => {
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ this.$router.push({ name: 'resources' })
+ }
+ })
+ },
+ policyChange() {
+ let select = $('#invitation_policy')
+ select.parent()[select.val() == 'manual' ? 'addClass' : 'removeClass']('selected')
+ },
+ statusUpdate(resource) {
+ this.resource = Object.assign({}, this.resource, resource)
+ },
+ submit() {
+ this.$root.clearFormValidation($('#resource-info form'))
+
+ let method = 'post'
+ let location = '/api/v4/resources'
+
+ if (this.resource_id !== 'new') {
+ method = 'put'
+ location += '/' + this.resource_id
+ }
+
+ const post = this.$root.pick(this.resource, ['id', 'name', 'domain'])
+
+ axios[method](location, post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ this.$router.push({ name: 'resources' })
+ })
+ },
+ submitSettings() {
+ this.$root.clearFormValidation($('#settings form'))
+ let post = {...this.resource.config}
+
+ if (post.invitation_policy == 'manual') {
+ post.invitation_policy += ':' + post.owner
+ }
+
+ delete post.owner
+
+ axios.post('/api/v4/resources/' + this.resource_id + '/config', post)
+ .then(response => {
+ this.$toast.success(response.data.message)
+ })
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/Resource/List.vue b/src/resources/vue/Resource/List.vue
new file mode 100644
index 00000000..e7707f09
--- /dev/null
+++ b/src/resources/vue/Resource/List.vue
@@ -0,0 +1,60 @@
+<template>
+ <div class="container">
+ <div class="card" id="resource-list">
+ <div class="card-body">
+ <div class="card-title">
+ {{ $tc('resource.list-title', 2) }}
+ <router-link class="btn btn-success float-end create-resource" :to="{ path: 'resource/new' }" tag="button">
+ <svg-icon icon="cog"></svg-icon> {{ $t('resource.create') }}
+ </router-link>
+ </div>
+ <div class="card-text">
+ <table class="table table-sm table-hover">
+ <thead>
+ <tr>
+ <th scope="col">{{ $t('form.name') }}</th>
+ <th scope="col">{{ $t('form.email') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="resource in resources" :key="resource.id" @click="$root.clickRecord">
+ <td>
+ <svg-icon icon="cog" :class="$root.resourceStatusClass(resource)" :title="$root.resourceStatusText(resource)"></svg-icon>
+ <router-link :to="{ path: 'resource/' + resource.id }">{{ resource.name }}</router-link>
+ </td>
+ <td>
+ <router-link :to="{ path: 'resource/' + resource.id }">{{ resource.email }}</router-link>
+ </td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="2">{{ $t('resource.list-empty') }}</td>
+ </tr>
+ </tfoot>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ resources: []
+ }
+ },
+ created() {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/resources')
+ .then(response => {
+ this.$root.stopLoading()
+ this.resources = response.data
+ })
+ .catch(this.$root.errorHandler)
+ }
+ }
+</script>
diff --git a/src/resources/vue/Widgets/Status.vue b/src/resources/vue/Widgets/Status.vue
index db3ea1ea..9d4043dc 100644
--- a/src/resources/vue/Widgets/Status.vue
+++ b/src/resources/vue/Widgets/Status.vue
@@ -1,204 +1,203 @@
<template>
<div v-if="!state.isReady" id="status-box" :class="'p-4 mb-3 rounded process-' + className">
<div v-if="state.step != 'domain-confirmed'" class="d-flex align-items-start">
<p id="status-body" class="flex-grow-1">
<span v-if="scope == 'dashboard'">{{ $t('status.prepare-account') }}</span>
<span v-else-if="scope == 'domain'">{{ $t('status.prepare-domain') }}</span>
<span v-else-if="scope == 'distlist'">{{ $t('status.prepare-distlist') }}</span>
+ <span v-else-if="scope == 'resource'">{{ $t('status.prepare-resource') }}</span>
<span v-else>{{ $t('status.prepare-user') }}</span>
<br>
{{ $t('status.prepare-hint') }}
<br>
<span id="refresh-text" v-if="refresh">{{ $t('status.prepare-refresh') }}</span>
</p>
<button v-if="refresh" id="status-refresh" href="#" class="btn btn-secondary" @click="statusRefresh">
<svg-icon icon="sync-alt"></svg-icon> {{ $t('btn.refresh') }}
</button>
</div>
<div v-else class="d-flex align-items-start">
<p id="status-body" class="flex-grow-1">
<span v-if="scope == 'dashboard'">{{ $t('status.ready-account') }}</span>
<span v-else-if="scope == 'domain'">{{ $t('status.ready-domain') }}</span>
<span v-else-if="scope == 'distlist'">{{ $t('status.ready-distlist') }}</span>
+ <span v-else-if="scope == 'resource'">{{ $t('status.ready-resource') }}</span>
<span v-else>{{ $t('status.ready-user') }}</span>
<br>
{{ $t('status.verify') }}
</p>
<div v-if="scope == 'domain'">
<button id="status-verify" class="btn btn-secondary text-nowrap" @click="confirmDomain">
<svg-icon icon="sync-alt"></svg-icon> {{ $t('btn.verify') }}
</button>
</div>
<div v-else-if="state.link && scope != 'domain'">
<router-link id="status-link" class="btn btn-secondary" :to="{ path: state.link }">{{ $t('status.verify-domain') }}</router-link>
</div>
</div>
<div class="status-progress text-center">
<div class="progress">
<div class="progress-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<span class="progress-label">{{ state.title || $t('msg.initializing') }}</span>
</div>
</div>
</template>
<script>
export default {
props: {
status: { type: Object, default: () => {} }
},
data() {
return {
className: 'pending',
refresh: false,
delay: 5000,
scope: 'user',
state: { isReady: true },
waiting: 0,
}
},
watch: {
// We use property watcher because parent component
// might set the property with a delay and we need to parse it
// FIXME: Problem with this and update-status event is that whenever
// we emit the event a watcher function is executed, causing
// duplicate parseStatusInfo() calls. Fortunaltely this does not
// cause duplicate http requests.
status: function (val, oldVal) {
this.parseStatusInfo(val)
}
},
destroyed() {
clearTimeout(window.infoRequest)
},
mounted() {
this.scope = this.$route.name
},
methods: {
// Displays account status information
parseStatusInfo(info) {
if (info) {
if (!info.isReady) {
let failedCount = 0
let allCount = info.process.length
info.process.forEach((step, idx) => {
if (!step.state) {
failedCount++
if (!info.title) {
info.title = step.title
info.step = step.label
info.link = step.link
}
}
})
info.percent = Math.floor((allCount - failedCount) / allCount * 100);
}
this.state = info || {}
this.$nextTick(function() {
$(this.$el).find('.progress-bar')
.css('width', info.percent + '%')
.attr('aria-valuenow', info.percent)
})
// Unhide the Refresh button, the process is in failure state
this.refresh = info.processState == 'failed' && this.waiting == 0
if (this.refresh || info.step == 'domain-confirmed') {
this.className = 'failed'
}
// A async job has been dispatched, switch to a waiting mode where
// we hide the Refresh button and pull status for about a minute,
// after that we switch to normal mode, i.e. user can Refresh again (if still not ready)
if (info.processState == 'waiting') {
this.waiting = 10
this.delay = 5000
} else if (this.waiting > 0) {
this.waiting -= 1
}
}
// Update status process info every 5,6,7,8,9,... seconds
clearTimeout(window.infoRequest)
if ((!this.refresh || this.waiting > 0) && (!info || !info.isReady)) {
window.infoRequest = setTimeout(() => {
delete window.infoRequest
// Stop updates after user logged out
if (!this.$store.state.isLoggedIn) {
return;
}
axios.get(this.getUrl())
.then(response => {
this.parseStatusInfo(response.data)
this.emitEvent(response.data)
})
.catch(error => {
this.parseStatusInfo(info)
})
}, this.delay);
this.delay += 1000;
}
},
statusRefresh() {
clearTimeout(window.infoRequest)
axios.get(this.getUrl() + '?refresh=1')
.then(response => {
this.$toast[response.data.status](response.data.message)
this.parseStatusInfo(response.data)
this.emitEvent(response.data)
})
.catch(error => {
this.parseStatusInfo(this.state)
})
},
confirmDomain() {
axios.get('/api/v4/domains/' + this.$route.params.domain + '/confirm')
.then(response => {
if (response.data.message) {
this.$toast[response.data.status](response.data.message)
}
if (response.data.status == 'success') {
this.parseStatusInfo(response.data.statusInfo)
response.data.isConfirmed = true
this.emitEvent(response.data)
}
})
},
emitEvent(data) {
// Remove useless data and emit the event (to parent components)
delete data.status
delete data.message
this.$emit('status-update', data)
},
getUrl() {
let url
switch (this.scope) {
case 'dashboard':
url = '/api/v4/users/' + this.$store.state.authInfo.id + '/status'
break
- case 'domain':
- url = '/api/v4/domains/' + this.$route.params.domain + '/status'
- break
case 'distlist':
url = '/api/v4/groups/' + this.$route.params.list + '/status'
break
default:
- url = '/api/v4/users/' + this.$route.params.user + '/status'
+ url = '/api/v4/' + this.scope + 's/' + this.$route.params[this.scope] + '/status'
}
return url
}
}
}
</script>
diff --git a/src/routes/api.php b/src/routes/api.php
index 248f879f..51c83fe1 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,250 +1,257 @@
<?php
use Illuminate\Http\Request;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
$prefix = \trim(\parse_url(\config('app.url'), PHP_URL_PATH), '/') . '/';
Route::group(
[
'middleware' => 'api',
'prefix' => $prefix . 'api/auth'
],
function ($router) {
Route::post('login', 'API\AuthController@login');
Route::group(
['middleware' => 'auth:api'],
function ($router) {
Route::get('info', 'API\AuthController@info');
Route::post('info', 'API\AuthController@info');
Route::post('logout', 'API\AuthController@logout');
Route::post('refresh', 'API\AuthController@refresh');
}
);
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => 'api',
'prefix' => $prefix . 'api/auth'
],
function ($router) {
Route::post('password-reset/init', 'API\PasswordResetController@init');
Route::post('password-reset/verify', 'API\PasswordResetController@verify');
Route::post('password-reset', 'API\PasswordResetController@reset');
Route::post('signup/init', 'API\SignupController@init');
Route::get('signup/invitations/{id}', 'API\SignupController@invitation');
Route::get('signup/plans', 'API\SignupController@plans');
Route::post('signup/verify', 'API\SignupController@verify');
Route::post('signup', 'API\SignupController@signup');
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => 'auth:api',
'prefix' => $prefix . 'api/v4'
],
function () {
Route::post('companion/register', 'API\V4\CompanionAppsController@register');
Route::post('auth-attempts/{id}/confirm', 'API\V4\AuthAttemptsController@confirm');
Route::post('auth-attempts/{id}/deny', 'API\V4\AuthAttemptsController@deny');
Route::get('auth-attempts/{id}/details', 'API\V4\AuthAttemptsController@details');
Route::get('auth-attempts', 'API\V4\AuthAttemptsController@index');
Route::apiResource('domains', API\V4\DomainsController::class);
Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm');
Route::get('domains/{id}/skus', 'API\V4\SkusController@domainSkus');
Route::get('domains/{id}/status', 'API\V4\DomainsController@status');
Route::post('domains/{id}/config', 'API\V4\DomainsController@setConfig');
Route::apiResource('groups', API\V4\GroupsController::class);
Route::get('groups/{id}/status', 'API\V4\GroupsController@status');
Route::post('groups/{id}/config', 'API\V4\GroupsController@setConfig');
Route::apiResource('packages', API\V4\PackagesController::class);
+
+ Route::apiResource('resources', API\V4\ResourcesController::class);
+ Route::get('resources/{id}/status', 'API\V4\ResourcesController@status');
+ Route::post('resources/{id}/config', 'API\V4\ResourcesController@setConfig');
+
Route::apiResource('skus', API\V4\SkusController::class);
Route::apiResource('users', API\V4\UsersController::class);
Route::post('users/{id}/config', 'API\V4\UsersController@setConfig');
Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus');
Route::get('users/{id}/status', 'API\V4\UsersController@status');
Route::apiResource('wallets', API\V4\WalletsController::class);
Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions');
Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts');
Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload');
Route::post('payments', 'API\V4\PaymentsController@store');
//Route::delete('payments', 'API\V4\PaymentsController@cancel');
Route::get('payments/mandate', 'API\V4\PaymentsController@mandate');
Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate');
Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate');
Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete');
Route::get('payments/methods', 'API\V4\PaymentsController@paymentMethods');
Route::get('payments/pending', 'API\V4\PaymentsController@payments');
Route::get('payments/has-pending', 'API\V4\PaymentsController@hasPayments');
Route::get('openvidu/rooms', 'API\V4\OpenViduController@index');
Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom');
Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig');
// FIXME: I'm not sure about this one, should we use DELETE request maybe?
Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection');
Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection');
Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
}
);
// Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group
Route::group(
[
'domain' => \config('app.website_domain'),
'prefix' => $prefix . 'api/v4'
],
function () {
Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom');
Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection');
// FIXME: I'm not sure about this one, should we use DELETE request maybe?
Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection');
Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection');
Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => 'api',
'prefix' => $prefix . 'api/v4'
],
function ($router) {
Route::post('support/request', 'API\V4\SupportController@request');
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'prefix' => $prefix . 'api/webhooks'
],
function () {
Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook');
Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook');
}
);
if (\config('app.with_services')) {
Route::group(
[
'domain' => 'services.' . \config('app.website_domain'),
'prefix' => $prefix . 'api/webhooks'
],
function () {
Route::get('nginx', 'API\V4\NGINXController@authenticate');
Route::post('policy/greylist', 'API\V4\PolicyController@greylist');
Route::post('policy/ratelimit', 'API\V4\PolicyController@ratelimit');
Route::post('policy/spf', 'API\V4\PolicyController@senderPolicyFramework');
}
);
}
if (\config('app.with_admin')) {
Route::group(
[
'domain' => 'admin.' . \config('app.website_domain'),
'middleware' => ['auth:api', 'admin'],
'prefix' => $prefix . 'api/v4',
],
function () {
Route::apiResource('domains', API\V4\Admin\DomainsController::class);
Route::get('domains/{id}/skus', 'API\V4\Admin\SkusController@domainSkus');
Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend');
Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend');
Route::apiResource('groups', API\V4\Admin\GroupsController::class);
Route::post('groups/{id}/suspend', 'API\V4\Admin\GroupsController@suspend');
Route::post('groups/{id}/unsuspend', 'API\V4\Admin\GroupsController@unsuspend');
+ Route::apiResource('resources', API\V4\Admin\ResourcesController::class);
Route::apiResource('skus', API\V4\Admin\SkusController::class);
Route::apiResource('users', API\V4\Admin\UsersController::class);
Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts');
Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA');
Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus');
Route::post('users/{id}/skus/{sku}', 'API\V4\Admin\UsersController@setSku');
Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend');
Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend');
Route::apiResource('wallets', API\V4\Admin\WalletsController::class);
Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff');
Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions');
Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart');
}
);
}
if (\config('app.with_reseller')) {
Route::group(
[
'domain' => 'reseller.' . \config('app.website_domain'),
'middleware' => ['auth:api', 'reseller'],
'prefix' => $prefix . 'api/v4',
],
function () {
Route::apiResource('domains', API\V4\Reseller\DomainsController::class);
Route::get('domains/{id}/skus', 'API\V4\Reseller\SkusController@domainSkus');
Route::post('domains/{id}/suspend', 'API\V4\Reseller\DomainsController@suspend');
Route::post('domains/{id}/unsuspend', 'API\V4\Reseller\DomainsController@unsuspend');
Route::apiResource('groups', API\V4\Reseller\GroupsController::class);
Route::post('groups/{id}/suspend', 'API\V4\Reseller\GroupsController@suspend');
Route::post('groups/{id}/unsuspend', 'API\V4\Reseller\GroupsController@unsuspend');
Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class);
Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend');
Route::post('payments', 'API\V4\Reseller\PaymentsController@store');
Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate');
Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate');
Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate');
Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete');
Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods');
Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments');
Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments');
+ Route::apiResource('resources', API\V4\Reseller\ResourcesController::class);
Route::apiResource('skus', API\V4\Reseller\SkusController::class);
Route::apiResource('users', API\V4\Reseller\UsersController::class);
Route::get('users/{id}/discounts', 'API\V4\Reseller\DiscountsController@userDiscounts');
Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA');
Route::get('users/{id}/skus', 'API\V4\Reseller\SkusController@userSkus');
Route::post('users/{id}/skus/{sku}', 'API\V4\Admin\UsersController@setSku');
Route::post('users/{id}/suspend', 'API\V4\Reseller\UsersController@suspend');
Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend');
Route::apiResource('wallets', API\V4\Reseller\WalletsController::class);
Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff');
Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts');
Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload');
Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions');
Route::get('stats/chart/{chart}', 'API\V4\Reseller\StatsController@chart');
}
);
}
diff --git a/src/tests/Browser/Admin/ResourceTest.php b/src/tests/Browser/Admin/ResourceTest.php
new file mode 100644
index 00000000..9016af53
--- /dev/null
+++ b/src/tests/Browser/Admin/ResourceTest.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Tests\Browser\Admin;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\Browser;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Admin\Resource as ResourcePage;
+use Tests\Browser\Pages\Admin\User as UserPage;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\TestCaseDusk;
+
+class ResourceTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useAdminUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test resource info page (unauthenticated)
+ */
+ public function testResourceUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $user = $this->getTestUser('john@kolab.org');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ $browser->visit('/resource/' . $resource->id)->on(new Home());
+ });
+ }
+
+ /**
+ * Test resource info page
+ */
+ public function testInfo(): void
+ {
+ Queue::fake();
+
+ $this->browse(function (Browser $browser) {
+ $user = $this->getTestUser('john@kolab.org');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+ $resource->setSetting('invitation_policy', 'accept');
+
+ $resource_page = new ResourcePage($resource->id);
+ $user_page = new UserPage($user->id);
+
+ // Goto the resource page
+ $browser->visit(new Home())
+ ->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true)
+ ->on(new Dashboard())
+ ->visit($user_page)
+ ->on($user_page)
+ ->click('@nav #tab-resources')
+ ->pause(1000)
+ ->click('@user-resources table tbody tr:first-child td:first-child a')
+ ->on($resource_page)
+ ->assertSeeIn('@resource-info .card-title', $resource->email)
+ ->with('@resource-info form', function (Browser $browser) use ($resource) {
+ $browser->assertElementsCount('.row', 3)
+ ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
+ ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})")
+ ->assertSeeIn('.row:nth-child(2) label', 'Status')
+ ->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active')
+ ->assertSeeIn('.row:nth-child(3) label', 'Name')
+ ->assertSeeIn('.row:nth-child(3) #name', $resource->name);
+ })
+ ->assertElementsCount('ul.nav-tabs', 1)
+ ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings')
+ ->with('@resource-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:nth-child(1) label', 'Invitation policy')
+ ->assertSeeIn('.row:nth-child(1) #invitation_policy', 'accept');
+ });
+
+ // Test invalid resource identifier
+ $browser->visit('/resource/abc')->assertErrorPage(404);
+ });
+ }
+}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
index 34ba5986..bb46a597 100644
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -1,545 +1,573 @@
<?php
namespace Tests\Browser\Admin;
use App\Auth\SecondFactor;
use App\Discount;
use App\Entitlement;
use App\Sku;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UserTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
Entitlement::where('cost', '>=', 5000)->delete();
$this->deleteTestGroup('group-test@kolab.org');
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => null,
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
Entitlement::where('cost', '>=', 5000)->delete();
$this->deleteTestGroup('group-test@kolab.org');
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testUserUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$browser->visit('/user/' . $jack->id)->on(new Home());
});
}
/**
* Test user info page
*/
public function testUserInfo(): void
{
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$page = new UserPage($jack->id);
$browser->visit(new Home())
->submitLogon('jeroen@jeroen.jeroen', \App\Utils::generatePassphrase(), true)
->on(new Dashboard())
->visit($page)
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $jack->email)
->with('@user-info form', function (Browser $browser) use ($jack) {
$browser->assertElementsCount('.row', 7)
->assertSeeIn('.row:nth-child(1) label', 'Managed by')
->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org')
->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})")
->assertSeeIn('.row:nth-child(3) label', 'Status')
->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(4) label', 'First Name')
->assertSeeIn('.row:nth-child(4) #first_name', 'Jack')
->assertSeeIn('.row:nth-child(5) label', 'Last Name')
->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels')
->assertSeeIn('.row:nth-child(6) label', 'External Email')
->assertMissing('.row:nth-child(6) #external_email a')
->assertSeeIn('.row:nth-child(7) label', 'Country')
->assertSeeIn('.row:nth-child(7) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 7);
+ ->assertElementsCount('@nav a', 8);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '5,00 CHF')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,90 CHF')
->assertMissing('table tfoot')
->assertMissing('#reset2fa');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
+ // Assert Resources tab
+ $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)')
+ ->click('@nav #tab-resources')
+ ->with('@user-resources', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
->whenAvailable('@user-settings form', function (Browser $browser) {
$browser->assertElementsCount('.row', 1)
->assertSeeIn('.row:first-child label', 'Greylisting')
->assertSeeIn('.row:first-child .text-success', 'enabled');
});
});
}
/**
* Test user info page (continue)
*
* @depends testUserInfo
*/
public function testUserInfo2(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$page = new UserPage($john->id);
$discount = Discount::where('code', 'TEST')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
$group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']);
$group->assignToWallet($john->wallets->first());
$john->setSetting('greylist_enabled', null);
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $john->email)
->with('@user-info form', function (Browser $browser) use ($john) {
$ext_email = $john->getSetting('external_email');
$browser->assertElementsCount('.row', 9)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(3) label', 'First Name')
->assertSeeIn('.row:nth-child(3) #first_name', 'John')
->assertSeeIn('.row:nth-child(4) label', 'Last Name')
->assertSeeIn('.row:nth-child(4) #last_name', 'Doe')
->assertSeeIn('.row:nth-child(5) label', 'Organization')
->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers')
->assertSeeIn('.row:nth-child(6) label', 'Phone')
->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone'))
->assertSeeIn('.row:nth-child(7) label', 'External Email')
->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email)
->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email")
->assertSeeIn('.row:nth-child(8) label', 'Address')
->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address'))
->assertSeeIn('.row:nth-child(9) label', 'Country')
->assertSeeIn('.row:nth-child(9) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 7);
+ ->assertElementsCount('@nav a', 8);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (1)')
->click('@nav #tab-domains')
->with('@user-domains table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertMissing('tfoot');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)')
->click('@nav #tab-distlists')
->with('@user-distlists table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'Test Group')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger')
->assertSeeIn('tbody tr:nth-child(1) td:last-child a', 'group-test@kolab.org')
->assertMissing('tfoot');
});
+ // Assert Resources tab
+ $browser->assertSeeIn('@nav #tab-resources', 'Resources (2)')
+ ->click('@nav #tab-resources')
+ ->with('@user-resources', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 2)
+ ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Conference Room #1')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'resource-test1@kolab.org')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Conference Room #2')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'resource-test2@kolab.org')
+ ->assertMissing('table tfoot');
+ });
+
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (4)')
->click('@nav #tab-users')
->with('@user-users table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
->assertMissing('tfoot');
});
});
// Now we go to Ned's info page, he's a controller on John's wallet
$this->browse(function (Browser $browser) {
$ned = $this->getTestUser('ned@kolab.org');
$beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$wallet = $ned->wallet();
// Add an extra storage and beta entitlement with different prices
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $beta_sku->id,
'cost' => 5010,
'entitleable_id' => $ned->id,
'entitleable_type' => User::class
]);
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $storage_sku->id,
'cost' => 5000,
'entitleable_id' => $ned->id,
'entitleable_type' => User::class
]);
$page = new UserPage($ned->id);
$ned->setSetting('greylist_enabled', 'false');
$browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $ned->email)
->with('@user-info form', function (Browser $browser) use ($ned) {
$browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})");
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 7);
+ ->assertElementsCount('@nav a', 8);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'This user has no email aliases.');
});
// Assert Subscriptions tab, we expect John's discount here
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (6)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 6)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 6 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '45,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication')
->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(6) td:first-child', 'Private Beta (invitation only)')
->assertSeeIn('table tbody tr:nth-child(6) td:last-child', '45,09 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher')
->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth')
->assertMissing('#addbetasku');
});
// We don't expect John's domains here
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// We don't expect John's users here
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// We don't expect John's distribution lists here
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
+ // We don't expect John's resources here
+ $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)')
+ ->click('@nav #tab-resources')
+ ->with('@user-resources', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
->whenAvailable('@user-settings form', function (Browser $browser) {
$browser->assertElementsCount('.row', 1)
->assertSeeIn('.row:first-child label', 'Greylisting')
->assertSeeIn('.row:first-child .text-danger', 'disabled');
});
});
}
/**
* Test editing an external email
*
* @depends testUserInfo2
*/
public function testExternalEmail(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->waitFor('@user-info #external_email button')
->click('@user-info #external_email button')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'External Email')
->assertFocused('@body input')
->assertValue('@body input', 'john.doe.external@gmail.com')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#email-dialog')
->click('@user-info #external_email button')
// Test email validation error handling, and email update
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->type('@body input', 'test')
->click('@button-action')
->waitFor('@body input.is-invalid')
->assertSeeIn(
'@body input + .invalid-feedback',
'The external email must be a valid email address.'
)
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->type('@body input', 'test@test.com')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->assertSeeIn('@user-info #external_email a', 'test@test.com')
->click('@user-info #external_email button')
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertValue('@body input', 'test@test.com')
->assertMissing('@body input.is-invalid')
->assertMissing('@body input + .invalid-feedback')
->click('@button-cancel');
})
->assertSeeIn('@user-info #external_email a', 'test@test.com');
// $john->getSetting() may not work here as it uses internal cache
// read the value form database
$current_ext_email = $john->settings()->where('key', 'external_email')->first()->value;
$this->assertSame('test@test.com', $current_ext_email);
});
}
/**
* Test suspending/unsuspending the user
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend')
->click('@user-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.')
->assertSeeIn('@user-info #status span.text-warning', 'Suspended')
->assertMissing('@user-info #button-suspend')
->click('@user-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.')
->assertSeeIn('@user-info #status span.text-success', 'Active')
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend');
});
}
/**
* Test resetting 2FA for the user
*/
public function testReset2FA(): void
{
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
$sku2fa = Sku::withEnvTenantContext()->where('title', '2fa')->first();
$user->assignSku($sku2fa);
SecondFactor::seed('userstest1@kolabnow.com');
$browser->visit(new UserPage($user->id))
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) {
$browser->waitFor('#reset2fa')
->assertVisible('#sku' . $sku2fa->id);
})
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)')
->click('#reset2fa')
->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', '2-Factor Authentication Reset')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Reset')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.')
->assertMissing('#sku' . $sku2fa->id)
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)');
});
}
/**
* Test adding the beta SKU for the user
*/
public function testAddBetaSku(): void
{
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
$sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$browser->visit(new UserPage($user->id))
->click('@nav #tab-subscriptions')
->waitFor('@user-subscriptions #addbetasku')
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)')
->assertSeeIn('#addbetasku', 'Enable beta program')
->click('#addbetasku')
->assertToast(Toast::TYPE_SUCCESS, 'The subscription added successfully.')
->waitFor('#sku' . $sku->id)
->assertSeeIn("#sku{$sku->id} td:first-child", 'Private Beta (invitation only)')
->assertSeeIn("#sku{$sku->id} td:last-child", '0,00 CHF/month')
->assertMissing('#addbetasku')
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)');
});
}
}
diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/Resource.php
similarity index 50%
copy from src/tests/Browser/Pages/Admin/User.php
copy to src/tests/Browser/Pages/Admin/Resource.php
index 63dead31..cfe6269f 100644
--- a/src/tests/Browser/Pages/Admin/User.php
+++ b/src/tests/Browser/Pages/Admin/Resource.php
@@ -1,65 +1,57 @@
<?php
namespace Tests\Browser\Pages\Admin;
use Laravel\Dusk\Page;
-class User extends Page
+class Resource extends Page
{
- protected $userid;
+ protected $resourceId;
/**
* Object constructor.
*
- * @param int $userid User Id
+ * @param int $id Resource Id
*/
- public function __construct($userid)
+ public function __construct($id)
{
- $this->userid = $userid;
+ $this->resourceId = $id;
}
/**
* Get the URL for the page.
*
* @return string
*/
public function url(): string
{
- return '/user/' . $this->userid;
+ return '/resource/' . $this->resourceId;
}
/**
* Assert that the browser is on the page.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
public function assert($browser): void
{
$browser->waitForLocation($this->url())
- ->waitUntilMissing('@app .app-loader')
- ->waitFor('@user-info');
+ ->waitFor('@resource-info');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements(): array
{
return [
'@app' => '#app',
- '@user-info' => '#user-info',
- '@nav' => 'ul.nav-tabs',
- '@user-finances' => '#user-finances',
- '@user-aliases' => '#user-aliases',
- '@user-subscriptions' => '#user-subscriptions',
- '@user-distlists' => '#user-distlists',
- '@user-domains' => '#user-domains',
- '@user-users' => '#user-users',
- '@user-settings' => '#user-settings',
+ '@resource-info' => '#resource-info',
+ '@resource-settings' => '#resource-settings',
];
}
}
diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php
index 63dead31..4ad83025 100644
--- a/src/tests/Browser/Pages/Admin/User.php
+++ b/src/tests/Browser/Pages/Admin/User.php
@@ -1,65 +1,66 @@
<?php
namespace Tests\Browser\Pages\Admin;
use Laravel\Dusk\Page;
class User extends Page
{
protected $userid;
/**
* Object constructor.
*
* @param int $userid User Id
*/
public function __construct($userid)
{
$this->userid = $userid;
}
/**
* Get the URL for the page.
*
* @return string
*/
public function url(): string
{
return '/user/' . $this->userid;
}
/**
* Assert that the browser is on the page.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
public function assert($browser): void
{
$browser->waitForLocation($this->url())
->waitUntilMissing('@app .app-loader')
->waitFor('@user-info');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements(): array
{
return [
'@app' => '#app',
'@user-info' => '#user-info',
'@nav' => 'ul.nav-tabs',
'@user-finances' => '#user-finances',
'@user-aliases' => '#user-aliases',
'@user-subscriptions' => '#user-subscriptions',
'@user-distlists' => '#user-distlists',
'@user-domains' => '#user-domains',
+ '@user-resources' => '#user-resources',
'@user-users' => '#user-users',
'@user-settings' => '#user-settings',
];
}
}
diff --git a/src/tests/Browser/Pages/ResourceInfo.php b/src/tests/Browser/Pages/ResourceInfo.php
new file mode 100644
index 00000000..97743bbd
--- /dev/null
+++ b/src/tests/Browser/Pages/ResourceInfo.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class ResourceInfo extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->waitFor('@general')
+ ->waitUntilMissing('.app-loader');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@general' => '#general',
+ '@nav' => 'ul.nav-tabs',
+ '@settings' => '#settings',
+ '@status' => '#status-box',
+ ];
+ }
+}
diff --git a/src/tests/Browser/Pages/ResourceList.php b/src/tests/Browser/Pages/ResourceList.php
new file mode 100644
index 00000000..478ec022
--- /dev/null
+++ b/src/tests/Browser/Pages/ResourceList.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class ResourceList extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/resources';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->assertPathIs($this->url())
+ ->waitUntilMissing('@app .app-loader')
+ ->assertSeeIn('#resource-list .card-title', 'Resources');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@table' => '#resource-list table',
+ ];
+ }
+}
diff --git a/src/tests/Browser/Reseller/ResourceTest.php b/src/tests/Browser/Reseller/ResourceTest.php
new file mode 100644
index 00000000..6f3e6d83
--- /dev/null
+++ b/src/tests/Browser/Reseller/ResourceTest.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Tests\Browser\Reseller;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\Browser;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Admin\Resource as ResourcePage;
+use Tests\Browser\Pages\Admin\User as UserPage;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\TestCaseDusk;
+
+class ResourceTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test resource info page (unauthenticated)
+ */
+ public function testResourceUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $user = $this->getTestUser('john@kolab.org');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ $browser->visit('/resource/' . $resource->id)->on(new Home());
+ });
+ }
+
+ /**
+ * Test distribution list info page
+ */
+ public function testInfo(): void
+ {
+ Queue::fake();
+
+ $this->browse(function (Browser $browser) {
+ $user = $this->getTestUser('john@kolab.org');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+ $resource->setSetting('invitation_policy', 'accept');
+
+ $resource_page = new ResourcePage($resource->id);
+ $user_page = new UserPage($user->id);
+
+ // Goto the distlist page
+ $browser->visit(new Home())
+ ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true)
+ ->on(new Dashboard())
+ ->visit($user_page)
+ ->on($user_page)
+ ->click('@nav #tab-resources')
+ ->pause(1000)
+ ->click('@user-resources table tbody tr:first-child td:first-child a')
+ ->on($resource_page)
+ ->assertSeeIn('@resource-info .card-title', $resource->email)
+ ->with('@resource-info form', function (Browser $browser) use ($resource) {
+ $browser->assertElementsCount('.row', 3)
+ ->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
+ ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})")
+ ->assertSeeIn('.row:nth-child(2) label', 'Status')
+ ->assertSeeIn('.row:nth-child(2) #status.text-success', 'Active')
+ ->assertSeeIn('.row:nth-child(3) label', 'Name')
+ ->assertSeeIn('.row:nth-child(3) #name', $resource->name);
+ })
+ ->assertElementsCount('ul.nav-tabs', 1)
+ ->assertSeeIn('ul.nav-tabs .nav-link', 'Settings')
+ ->with('@resource-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:nth-child(1) label', 'Invitation policy')
+ ->assertSeeIn('.row:nth-child(1) #invitation_policy', 'accept');
+ });
+
+ // Test invalid resource identifier
+ $browser->visit('/resource/abc')->assertErrorPage(404);
+ });
+ }
+}
diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php
index e3cef7e6..8df9ec96 100644
--- a/src/tests/Browser/Reseller/UserTest.php
+++ b/src/tests/Browser/Reseller/UserTest.php
@@ -1,509 +1,546 @@
<?php
namespace Tests\Browser\Reseller;
use App\Auth\SecondFactor;
use App\Discount;
use App\Sku;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Admin\User as UserPage;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UserTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => '+48123123123',
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->deleteTestGroup('group-test@kolab.org');
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSettings([
'phone' => null,
'external_email' => 'john.doe.external@gmail.com',
]);
if ($john->isSuspended()) {
User::where('email', $john->email)->update(['status' => $john->status - User::STATUS_SUSPENDED]);
}
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->deleteTestGroup('group-test@kolab.org');
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user info page (unauthenticated)
*/
public function testUserUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$browser->visit('/user/' . $jack->id)->on(new Home());
});
}
/**
* Test user info page
*/
public function testUserInfo(): void
{
$this->browse(function (Browser $browser) {
$jack = $this->getTestUser('jack@kolab.org');
$page = new UserPage($jack->id);
$browser->visit(new Home())
->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true)
->on(new Dashboard())
->visit($page)
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $jack->email)
->with('@user-info form', function (Browser $browser) use ($jack) {
$browser->assertElementsCount('.row', 7)
->assertSeeIn('.row:nth-child(1) label', 'Managed by')
->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org')
->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})")
->assertSeeIn('.row:nth-child(3) label', 'Status')
->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(4) label', 'First Name')
->assertSeeIn('.row:nth-child(4) #first_name', 'Jack')
->assertSeeIn('.row:nth-child(5) label', 'Last Name')
->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels')
->assertSeeIn('.row:nth-child(6) label', 'External Email')
->assertMissing('.row:nth-child(6) #external_email a')
->assertSeeIn('.row:nth-child(7) label', 'Country')
->assertSeeIn('.row:nth-child(7) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 7);
+ ->assertElementsCount('@nav a', 8);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '5,00 CHF/month')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,90 CHF/month')
->assertMissing('table tfoot')
->assertMissing('#reset2fa');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
+
+ // Assert Resources tab
+ $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)')
+ ->click('@nav #tab-resources')
+ ->with('@user-resources', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
+ });
+
+ // Assert Settings tab
+ $browser->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->whenAvailable('@user-settings form', function (Browser $browser) {
+ $browser->assertElementsCount('.row', 1)
+ ->assertSeeIn('.row:first-child label', 'Greylisting')
+ ->assertSeeIn('.row:first-child .text-success', 'enabled');
+ });
});
}
/**
* Test user info page (continue)
*
* @depends testUserInfo
*/
public function testUserInfo2(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$page = new UserPage($john->id);
$discount = Discount::where('code', 'TEST')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->debit(2010);
$wallet->save();
$group = $this->getTestGroup('group-test@kolab.org', ['name' => 'Test Group']);
$group->assignToWallet($john->wallets->first());
// Click the managed-by link on Jack's page
$browser->click('@user-info #manager a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $john->email)
->with('@user-info form', function (Browser $browser) use ($john) {
$ext_email = $john->getSetting('external_email');
$browser->assertElementsCount('.row', 9)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})")
->assertSeeIn('.row:nth-child(2) label', 'Status')
->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active')
->assertSeeIn('.row:nth-child(3) label', 'First Name')
->assertSeeIn('.row:nth-child(3) #first_name', 'John')
->assertSeeIn('.row:nth-child(4) label', 'Last Name')
->assertSeeIn('.row:nth-child(4) #last_name', 'Doe')
->assertSeeIn('.row:nth-child(5) label', 'Organization')
->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers')
->assertSeeIn('.row:nth-child(6) label', 'Phone')
->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone'))
->assertSeeIn('.row:nth-child(7) label', 'External Email')
->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email)
->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email")
->assertSeeIn('.row:nth-child(8) label', 'Address')
->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address'))
->assertSeeIn('.row:nth-child(9) label', 'Country')
->assertSeeIn('.row:nth-child(9) #country', 'United States');
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 7);
+ ->assertElementsCount('@nav a', 8);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 1)
->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org')
->assertMissing('table tfoot');
});
// Assert Subscriptions tab
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 3)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher');
});
// Assert Domains tab
$browser->assertSeeIn('@nav #tab-domains', 'Domains (1)')
->click('@nav #tab-domains')
->with('@user-domains table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertMissing('tfoot');
});
// Assert Distribution lists tab
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (1)')
->click('@nav #tab-distlists')
->with('@user-distlists table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 1)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'Test Group')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-danger')
->assertSeeIn('tbody tr:nth-child(1) td:last-child a', 'group-test@kolab.org')
->assertMissing('tfoot');
});
+ // Assert Resources tab
+ $browser->assertSeeIn('@nav #tab-resources', 'Resources (2)')
+ ->click('@nav #tab-resources')
+ ->with('@user-resources', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 2)
+ ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Conference Room #1')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'resource-test1@kolab.org')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Conference Room #2')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'resource-test2@kolab.org')
+ ->assertMissing('table tfoot');
+ });
+
// Assert Users tab
$browser->assertSeeIn('@nav #tab-users', 'Users (4)')
->click('@nav #tab-users')
->with('@user-users table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org')
->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org')
->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(3) td:first-child span', 'john@kolab.org')
->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success')
->assertSeeIn('tbody tr:nth-child(4) td:first-child a', 'ned@kolab.org')
->assertVisible('tbody tr:nth-child(4) td:first-child svg.text-success')
->assertMissing('tfoot');
});
});
// Now we go to Ned's info page, he's a controller on John's wallet
$this->browse(function (Browser $browser) {
$ned = $this->getTestUser('ned@kolab.org');
$ned->setSetting('greylist_enabled', 'false');
$page = new UserPage($ned->id);
$browser->click('@user-users tbody tr:nth-child(4) td:first-child a')
->on($page);
// Assert main info box content
$browser->assertSeeIn('@user-info .card-title', $ned->email)
->with('@user-info form', function (Browser $browser) use ($ned) {
$browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created)')
->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})");
});
// Some tabs are loaded in background, wait a second
$browser->pause(500)
- ->assertElementsCount('@nav a', 7);
+ ->assertElementsCount('@nav a', 8);
// Note: Finances tab is tested in UserFinancesTest.php
$browser->assertSeeIn('@nav #tab-finances', 'Finances');
// Assert Aliases tab
$browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)')
->click('@nav #tab-aliases')
->whenAvailable('@user-aliases', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'This user has no email aliases.');
});
// Assert Subscriptions tab, we expect John's discount here
$browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)')
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 5)
->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox')
->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,50 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 5 GB')
->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features')
->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,41 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync')
->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,00 CHF/month¹')
->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication')
->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹')
->assertMissing('table tfoot')
->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher')
->assertSeeIn('#reset2fa', 'Reset 2-Factor Auth');
});
// We don't expect John's domains here
$browser->assertSeeIn('@nav #tab-domains', 'Domains (0)')
->click('@nav #tab-domains')
->with('@user-domains', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no domains in this account.');
});
// We don't expect John's users here
$browser->assertSeeIn('@nav #tab-users', 'Users (0)')
->click('@nav #tab-users')
->with('@user-users', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no users in this account.');
});
// We don't expect John's distribution lists here
$browser->assertSeeIn('@nav #tab-distlists', 'Distribution lists (0)')
->click('@nav #tab-distlists')
->with('@user-distlists', function (Browser $browser) {
$browser->assertElementsCount('table tbody tr', 0)
->assertSeeIn('table tfoot tr td', 'There are no distribution lists in this account.');
});
+ // Assert Resources tab
+ $browser->assertSeeIn('@nav #tab-resources', 'Resources (0)')
+ ->click('@nav #tab-resources')
+ ->with('@user-resources', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no resources in this account.');
+ });
+
// Assert Settings tab
$browser->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
->whenAvailable('@user-settings form', function (Browser $browser) {
$browser->assertElementsCount('.row', 1)
->assertSeeIn('.row:first-child label', 'Greylisting')
->assertSeeIn('.row:first-child .text-danger', 'disabled');
});
});
}
/**
* Test editing an external email
*
* @depends testUserInfo2
*/
public function testExternalEmail(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->waitFor('@user-info #external_email button')
->click('@user-info #external_email button')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'External Email')
->assertFocused('@body input')
->assertValue('@body input', 'john.doe.external@gmail.com')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
->click('@button-cancel');
})
->assertMissing('#email-dialog')
->click('@user-info #external_email button')
// Test email validation error handling, and email update
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->type('@body input', 'test')
->click('@button-action')
->waitFor('@body input.is-invalid')
->assertSeeIn(
'@body input + .invalid-feedback',
'The external email must be a valid email address.'
)
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->type('@body input', 'test@test.com')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->assertSeeIn('@user-info #external_email a', 'test@test.com')
->click('@user-info #external_email button')
->with(new Dialog('#email-dialog'), function (Browser $browser) {
$browser->assertValue('@body input', 'test@test.com')
->assertMissing('@body input.is-invalid')
->assertMissing('@body input + .invalid-feedback')
->click('@button-cancel');
})
->assertSeeIn('@user-info #external_email a', 'test@test.com');
// $john->getSetting() may not work here as it uses internal cache
// read the value form database
$current_ext_email = $john->settings()->where('key', 'external_email')->first()->value;
$this->assertSame('test@test.com', $current_ext_email);
});
}
/**
* Test suspending/unsuspending the user
*/
public function testSuspendAndUnsuspend(): void
{
$this->browse(function (Browser $browser) {
$john = $this->getTestUser('john@kolab.org');
$browser->visit(new UserPage($john->id))
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend')
->click('@user-info #button-suspend')
->assertToast(Toast::TYPE_SUCCESS, 'User suspended successfully.')
->assertSeeIn('@user-info #status span.text-warning', 'Suspended')
->assertMissing('@user-info #button-suspend')
->click('@user-info #button-unsuspend')
->assertToast(Toast::TYPE_SUCCESS, 'User unsuspended successfully.')
->assertSeeIn('@user-info #status span.text-success', 'Active')
->assertVisible('@user-info #button-suspend')
->assertMissing('@user-info #button-unsuspend');
});
}
/**
* Test resetting 2FA for the user
*/
public function testReset2FA(): void
{
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
$sku2fa = Sku::withEnvTenantContext()->where(['title' => '2fa'])->first();
$user->assignSku($sku2fa);
SecondFactor::seed('userstest1@kolabnow.com');
$browser->visit(new UserPage($user->id))
->click('@nav #tab-subscriptions')
->with('@user-subscriptions', function (Browser $browser) use ($sku2fa) {
$browser->waitFor('#reset2fa')
->assertVisible('#sku' . $sku2fa->id);
})
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)')
->click('#reset2fa')
->with(new Dialog('#reset-2fa-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', '2-Factor Authentication Reset')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Reset')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, '2-Factor authentication reset successfully.')
->assertMissing('#sku' . $sku2fa->id)
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)');
});
}
/**
* Test adding the beta SKU for the user
*/
public function testAddBetaSku(): void
{
$this->browse(function (Browser $browser) {
$this->deleteTestUser('userstest1@kolabnow.com');
$user = $this->getTestUser('userstest1@kolabnow.com');
$sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$browser->visit(new UserPage($user->id))
->click('@nav #tab-subscriptions')
->waitFor('@user-subscriptions #addbetasku')
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (0)')
->assertSeeIn('#addbetasku', 'Enable beta program')
->click('#addbetasku')
->assertToast(Toast::TYPE_SUCCESS, 'The subscription added successfully.')
->waitFor('#sku' . $sku->id)
->assertSeeIn("#sku{$sku->id} td:first-child", 'Private Beta (invitation only)')
->assertSeeIn("#sku{$sku->id} td:last-child", '0,00 CHF/month')
->assertMissing('#addbetasku')
->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (1)');
});
}
}
diff --git a/src/tests/Browser/ResourceTest.php b/src/tests/Browser/ResourceTest.php
new file mode 100644
index 00000000..0d85e2b2
--- /dev/null
+++ b/src/tests/Browser/ResourceTest.php
@@ -0,0 +1,301 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\Resource;
+use Tests\Browser;
+use Tests\Browser\Components\Status;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\ResourceInfo;
+use Tests\Browser\Pages\ResourceList;
+use Tests\TestCaseDusk;
+
+class ResourceTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ Resource::whereNotIn('email', ['resource-test1@kolab.org', 'resource-test2@kolab.org'])->delete();
+ $this->clearBetaEntitlements();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ Resource::whereNotIn('email', ['resource-test1@kolab.org', 'resource-test2@kolab.org'])->delete();
+ $this->clearBetaEntitlements();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test resource info page (unauthenticated)
+ */
+ public function testInfoUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/resource/abc')->on(new Home());
+ });
+ }
+
+ /**
+ * Test resource list page (unauthenticated)
+ */
+ public function testListUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/resources')->on(new Home());
+ });
+ }
+
+ /**
+ * Test resources list page
+ */
+ public function testList(): void
+ {
+ // Log on the user
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('john@kolab.org', 'simple123', true)
+ ->on(new Dashboard())
+ ->assertMissing('@links .link-resources');
+ });
+
+ // Test that Resources lists page is not accessible without the 'beta-resources' entitlement
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/resources')
+ ->assertErrorPage(403);
+ });
+
+ // Add beta+beta-resources entitlements
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-resources');
+ // Make sure the first resource is active
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+ $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE
+ | Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY;
+ $resource->save();
+
+ // Test resources lists page
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Dashboard())
+ ->assertSeeIn('@links .link-resources', 'Resources')
+ ->click('@links .link-resources')
+ ->on(new ResourceList())
+ ->whenAvailable('@table', function (Browser $browser) {
+ $browser->waitFor('tbody tr')
+ ->assertSeeIn('thead tr th:nth-child(1)', 'Name')
+ ->assertSeeIn('thead tr th:nth-child(2)', 'Email Address')
+ ->assertElementsCount('tbody tr', 2)
+ ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Conference Room #1')
+ ->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-success title', 'Active')
+ ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2) a', 'kolab.org')
+ ->assertMissing('tfoot');
+ });
+ });
+ }
+
+ /**
+ * Test resource creation/editing/deleting
+ *
+ * @depends testList
+ */
+ public function testCreateUpdateDelete(): void
+ {
+ // Test that the page is not available accessible without the 'beta-resources' entitlement
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/resource/new')
+ ->assertErrorPage(403);
+ });
+
+ // Add beta+beta-resource entitlements
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-resources');
+
+ $this->browse(function (Browser $browser) {
+ // Create a resource
+ $browser->visit(new ResourceList())
+ ->assertSeeIn('button.create-resource', 'Create resource')
+ ->click('button.create-resource')
+ ->on(new ResourceInfo())
+ ->assertSeeIn('#resource-info .card-title', 'New resource')
+ ->assertSeeIn('@nav #tab-general', 'General')
+ ->assertMissing('@nav #tab-settings')
+ ->with('@general', function (Browser $browser) {
+ // Assert form content
+ $browser->assertMissing('#status')
+ ->assertFocused('#name')
+ ->assertSeeIn('div.row:nth-child(1) label', 'Name')
+ ->assertValue('div.row:nth-child(1) input[type=text]', '')
+ ->assertSeeIn('div.row:nth-child(2) label', 'Domain')
+ ->assertSelectHasOptions('div.row:nth-child(2) select', ['kolab.org'])
+ ->assertValue('div.row:nth-child(2) select', 'kolab.org')
+ ->assertSeeIn('button[type=submit]', 'Submit');
+ })
+ // Test error conditions
+ ->type('#name', str_repeat('A', 192))
+ ->click('@general button[type=submit]')
+ ->waitFor('#name + .invalid-feedback')
+ ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.')
+ ->assertFocused('#name')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ // Test successful resource creation
+ ->type('#name', 'Test Resource')
+ ->click('@general button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Resource created successfully.')
+ ->on(new ResourceList())
+ ->assertElementsCount('@table tbody tr', 3);
+
+ // Test resource update
+ $browser->click('@table tr:nth-child(3) td:first-child a')
+ ->on(new ResourceInfo())
+ ->assertSeeIn('#resource-info .card-title', 'Resource')
+ ->with('@general', function (Browser $browser) {
+ // Assert form content
+ $browser->assertFocused('#name')
+ ->assertSeeIn('div.row:nth-child(1) label', 'Status')
+ ->assertSeeIn('div.row:nth-child(1) span.text-danger', 'Not Ready')
+ ->assertSeeIn('div.row:nth-child(2) label', 'Name')
+ ->assertValue('div.row:nth-child(2) input[type=text]', 'Test Resource')
+ ->assertSeeIn('div.row:nth-child(3) label', 'Email')
+ ->assertAttributeRegExp(
+ 'div.row:nth-child(3) input[type=text]:disabled',
+ 'value',
+ '/^resource-[0-9]+@kolab\.org$/'
+ )
+ ->assertSeeIn('button[type=submit]', 'Submit');
+ })
+ // Test error handling
+ ->type('#name', str_repeat('A', 192))
+ ->click('@general button[type=submit]')
+ ->waitFor('#name + .invalid-feedback')
+ ->assertSeeIn('#name + .invalid-feedback', 'The name may not be greater than 191 characters.')
+ ->assertVisible('#name.is-invalid')
+ ->assertFocused('#name')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ // Test successful update
+ ->type('#name', 'Test Resource Update')
+ ->click('@general button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Resource updated successfully.')
+ ->on(new ResourceList())
+ ->assertElementsCount('@table tbody tr', 3)
+ ->assertSeeIn('@table tr:nth-child(3) td:first-child a', 'Test Resource Update');
+
+ $this->assertSame(1, Resource::where('name', 'Test Resource Update')->count());
+
+ // Test resource deletion
+ $browser->click('@table tr:nth-child(3) td:first-child a')
+ ->on(new ResourceInfo())
+ ->assertSeeIn('button.button-delete', 'Delete resource')
+ ->click('button.button-delete')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Resource deleted successfully.')
+ ->on(new ResourceList())
+ ->assertElementsCount('@table tbody tr', 2);
+
+ $this->assertNull(Resource::where('name', 'Test Resource Update')->first());
+ });
+ }
+
+ /**
+ * Test resource status
+ *
+ * @depends testList
+ */
+ public function testStatus(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-resources');
+ $resource = $this->getTestResource('resource-test2@kolab.org');
+ $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE | Resource::STATUS_LDAP_READY;
+ $resource->created_at = \now();
+ $resource->save();
+
+ $this->assertFalse($resource->isImapReady());
+
+ $this->browse(function ($browser) use ($resource) {
+ // Test auto-refresh
+ $browser->visit('/resource/' . $resource->id)
+ ->on(new ResourceInfo())
+ ->with(new Status(), function ($browser) {
+ $browser->assertSeeIn('@body', 'We are preparing the resource')
+ ->assertProgress(85, 'Creating a shared folder...', 'pending')
+ ->assertMissing('@refresh-button')
+ ->assertMissing('@refresh-text')
+ ->assertMissing('#status-link')
+ ->assertMissing('#status-verify');
+ });
+
+ $resource->status |= Resource::STATUS_IMAP_READY;
+ $resource->save();
+
+ // Test Verify button
+ $browser->waitUntilMissing('@status', 10);
+ });
+
+ // TODO: Test all resource statuses on the list
+ }
+
+ /**
+ * Test resource settings
+ */
+ public function testSettings(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-resources');
+ $resource = $this->getTestResource('resource-test2@kolab.org');
+ $resource->setSetting('invitation_policy', null);
+
+ $this->browse(function ($browser) use ($resource) {
+ // Test auto-refresh
+ $browser->visit('/resource/' . $resource->id)
+ ->on(new ResourceInfo())
+ ->assertSeeIn('@nav #tab-general', 'General')
+ ->assertSeeIn('@nav #tab-settings', 'Settings')
+ ->click('@nav #tab-settings')
+ ->with('@settings form', function (Browser $browser) {
+ // Assert form content
+ $browser->assertSeeIn('div.row:nth-child(1) label', 'Invitation policy')
+ ->assertSelectHasOptions('div.row:nth-child(1) select', ['accept', 'manual', 'reject'])
+ ->assertValue('div.row:nth-child(1) select', 'accept')
+ ->assertMissing('div.row:nth-child(1) input')
+ ->assertSeeIn('div.row:nth-child(1) small', 'manual acceptance')
+ ->assertSeeIn('button[type=submit]', 'Submit');
+ })
+ // Test error handling
+ ->select('#invitation_policy', 'manual')
+ ->waitFor('#invitation_policy + input')
+ ->type('#invitation_policy + input', 'kolab.org')
+ ->click('@settings button[type=submit]')
+ ->waitFor('#invitation_policy + input + .invalid-feedback')
+ ->assertSeeIn(
+ '#invitation_policy + input + .invalid-feedback',
+ 'The specified email address is invalid.'
+ )
+ ->assertVisible('#invitation_policy + input.is-invalid')
+ ->assertFocused('#invitation_policy + input')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->type('#invitation_policy + input', 'jack@kolab.org')
+ ->click('@settings button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Resource settings updated successfully.')
+ ->assertMissing('.invalid-feedback')
+ ->refresh()
+ ->on(new ResourceInfo())
+ ->click('@nav #tab-settings')
+ ->with('@settings form', function (Browser $browser) {
+ $browser->assertValue('div.row:nth-child(1) select', 'manual')
+ ->assertVisible('div.row:nth-child(1) input')
+ ->assertValue('div.row:nth-child(1) input', 'jack@kolab.org');
+ });
+ });
+ }
+}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
index 7b898e12..e48d25ed 100644
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -1,788 +1,797 @@
<?php
namespace Tests\Browser;
use App\Discount;
use App\Entitlement;
use App\Sku;
use App\User;
use App\UserAlias;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\ListInput;
use Tests\Browser\Components\QuotaInput;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\UserInfo;
use Tests\Browser\Pages\UserList;
use Tests\Browser\Pages\Wallet as WalletPage;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class UsersTest extends TestCaseDusk
{
private $profile = [
'first_name' => 'John',
'last_name' => 'Doe',
'organization' => 'Kolab Developers',
];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
$activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete();
Entitlement::where('cost', '>=', 5000)->delete();
Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->currency = 'CHF';
$wallet->save();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('julia.roberts@kolab.org');
$john = User::where('email', 'john@kolab.org')->first();
$john->setSettings($this->profile);
UserAlias::where('user_id', $john->id)
->where('alias', 'john.test@kolab.org')->delete();
$activesync_sku = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
Entitlement::where('entitleable_id', $john->id)->where('sku_id', $activesync_sku->id)->delete();
Entitlement::where('cost', '>=', 5000)->delete();
Entitlement::where('cost', '=', 25)->where('sku_id', $storage_sku->id)->delete();
$wallet = $john->wallets()->first();
$wallet->discount()->dissociate();
$wallet->save();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test user account editing page (not profile page)
*/
public function testInfo(): void
{
$this->browse(function (Browser $browser) {
$user = User::where('email', 'john@kolab.org')->first();
// Test that the page requires authentication
$browser->visit('/user/' . $user->id)
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', false)
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@general', function (Browser $browser) {
// Assert form content
$browser->assertSeeIn('div.row:nth-child(1) label', 'Status')
->assertSeeIn('div.row:nth-child(1) #status', 'Active')
->assertFocused('div.row:nth-child(2) input')
->assertSeeIn('div.row:nth-child(2) label', 'First Name')
->assertValue('div.row:nth-child(2) input[type=text]', $this->profile['first_name'])
->assertSeeIn('div.row:nth-child(3) label', 'Last Name')
->assertValue('div.row:nth-child(3) input[type=text]', $this->profile['last_name'])
->assertSeeIn('div.row:nth-child(4) label', 'Organization')
->assertValue('div.row:nth-child(4) input[type=text]', $this->profile['organization'])
->assertSeeIn('div.row:nth-child(5) label', 'Email')
->assertValue('div.row:nth-child(5) input[type=text]', 'john@kolab.org')
->assertDisabled('div.row:nth-child(5) input[type=text]')
->assertSeeIn('div.row:nth-child(6) label', 'Email Aliases')
->assertVisible('div.row:nth-child(6) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['john.doe@kolab.org'])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(7) label', 'Password')
->assertValue('div.row:nth-child(7) input[type=password]', '')
->assertSeeIn('div.row:nth-child(8) label', 'Confirm Password')
->assertValue('div.row:nth-child(8) input[type=password]', '')
->assertSeeIn('button[type=submit]', 'Submit')
// Clear some fields and submit
->vueClear('#first_name')
->vueClear('#last_name')
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.')
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@general', function (Browser $browser) {
// Test error handling (password)
$browser->type('#password', 'aaaaaa')
->vueClear('#password_confirmation')
->click('button[type=submit]')
->waitFor('#password + .invalid-feedback')
->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.')
->assertFocused('#password')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
// TODO: Test password change
// Test form error handling (aliases)
$browser->vueClear('#password')
->vueClear('#password_confirmation')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(2, 'The specified alias is invalid.', false);
});
// Test adding aliases
$browser->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(2)
->addListEntry('john.test@kolab.org');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$john = User::where('email', 'john@kolab.org')->first();
$alias = UserAlias::where('user_id', $john->id)->where('alias', 'john.test@kolab.org')->first();
$this->assertTrue(!empty($alias));
// Test subscriptions
$browser->with('@general', function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(9) label', 'Subscriptions')
->assertVisible('@skus.row:nth-child(9)')
->with('@skus', function ($browser) {
$browser->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.name', 'User Mailbox')
->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 CHF/month')
->assertChecked('tbody tr:nth-child(1) td.selection input')
->assertDisabled('tbody tr:nth-child(1) td.selection input')
->assertTip(
'tbody tr:nth-child(1) td.buttons button',
'Just a mailbox'
)
// Storage SKU
->assertSeeIn('tbody tr:nth-child(2) td.name', 'Storage Quota')
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(2) td.selection input')
->assertDisabled('tbody tr:nth-child(2) td.selection input')
->assertTip(
'tbody tr:nth-child(2) td.buttons button',
'Some wiggle room'
)
->with(new QuotaInput('tbody tr:nth-child(2) .range-input'), function ($browser) {
$browser->assertQuotaValue(5)->setQuotaValue(6);
})
->assertSeeIn('tr:nth-child(2) td.price', '0,25 CHF/month')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.name', 'Groupware Features')
->assertSeeIn('tbody tr:nth-child(3) td.price', '4,90 CHF/month')
->assertChecked('tbody tr:nth-child(3) td.selection input')
->assertEnabled('tbody tr:nth-child(3) td.selection input')
->assertTip(
'tbody tr:nth-child(3) td.buttons button',
'Groupware functions like Calendar, Tasks, Notes, etc.'
)
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.name', 'Activesync')
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(4) td.selection input')
->assertEnabled('tbody tr:nth-child(4) td.selection input')
->assertTip(
'tbody tr:nth-child(4) td.buttons button',
'Mobile synchronization'
)
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.name', '2-Factor Authentication')
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(5) td.selection input')
->assertEnabled('tbody tr:nth-child(5) td.selection input')
->assertTip(
'tbody tr:nth-child(5) td.buttons button',
'Two factor authentication for webmail and administration panel'
)
// Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tbody tr:nth-child(6) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(6) td.selection input')
->assertEnabled('tbody tr:nth-child(6) td.selection input')
->assertTip(
'tbody tr:nth-child(6) td.buttons button',
'Video conferencing tool'
)
->click('tbody tr:nth-child(4) td.selection input');
})
->assertMissing('@skus table + .hint')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
})
->on(new UserList())
->click('@table tr:nth-child(3) a')
->on(new UserInfo());
$expected = ['activesync', 'groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage', 'storage'];
$this->assertEntitlements($john, $expected);
// Test subscriptions interaction
$browser->with('@general', function (Browser $browser) {
$browser->with('@skus', function ($browser) {
// Uncheck 'groupware', expect activesync unchecked
$browser->click('#sku-input-groupware')
->assertNotChecked('#sku-input-groupware')
->assertNotChecked('#sku-input-activesync')
->assertEnabled('#sku-input-activesync')
->assertNotReadonly('#sku-input-activesync')
// Check 'activesync', expect an alert
->click('#sku-input-activesync')
->assertDialogOpened('Activesync requires Groupware Features.')
->acceptDialog()
->assertNotChecked('#sku-input-activesync')
// Check 'meet', expect an alert
->click('#sku-input-meet')
->assertDialogOpened('Voice & Video Conferencing (public beta) requires Groupware Features.')
->acceptDialog()
->assertNotChecked('#sku-input-meet')
// Check '2FA', expect 'activesync' unchecked and readonly
->click('#sku-input-2fa')
->assertChecked('#sku-input-2fa')
->assertNotChecked('#sku-input-activesync')
->assertReadonly('#sku-input-activesync')
// Uncheck '2FA'
->click('#sku-input-2fa')
->assertNotChecked('#sku-input-2fa')
->assertNotReadonly('#sku-input-activesync');
});
});
});
}
/**
* Test user settings tab
*
* @depends testInfo
*/
public function testUserSettings(): void
{
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('greylist_enabled', null);
$this->browse(function (Browser $browser) use ($john) {
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->assertElementsCount('@nav a', 2)
->assertSeeIn('@nav #tab-general', 'General')
->assertSeeIn('@nav #tab-settings', 'Settings')
->click('@nav #tab-settings')
->with('#settings form', function (Browser $browser) {
$browser->assertSeeIn('div.row:nth-child(1) label', 'Greylisting')
->click('div.row:nth-child(1) input[type=checkbox]:checked')
->click('button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User settings updated successfully.');
});
});
$this->assertSame('false', $john->fresh()->getSetting('greylist_enabled'));
}
/**
* Test user adding page
*
* @depends testInfo
*/
public function testNewUser(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->assertSeeIn('button.create-user', 'Create user')
->click('button.create-user')
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'New user account')
->with('@general', function (Browser $browser) {
// Assert form content
$browser->assertFocused('div.row:nth-child(1) input')
->assertSeeIn('div.row:nth-child(1) label', 'First Name')
->assertValue('div.row:nth-child(1) input[type=text]', '')
->assertSeeIn('div.row:nth-child(2) label', 'Last Name')
->assertValue('div.row:nth-child(2) input[type=text]', '')
->assertSeeIn('div.row:nth-child(3) label', 'Organization')
->assertValue('div.row:nth-child(3) input[type=text]', '')
->assertSeeIn('div.row:nth-child(4) label', 'Email')
->assertValue('div.row:nth-child(4) input[type=text]', '')
->assertEnabled('div.row:nth-child(4) input[type=text]')
->assertSeeIn('div.row:nth-child(5) label', 'Email Aliases')
->assertVisible('div.row:nth-child(5) .list-input')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue([])
->assertValue('@input', '');
})
->assertSeeIn('div.row:nth-child(6) label', 'Password')
->assertValue('div.row:nth-child(6) input[type=password]', '')
->assertSeeIn('div.row:nth-child(7) label', 'Confirm Password')
->assertValue('div.row:nth-child(7) input[type=password]', '')
->assertSeeIn('div.row:nth-child(8) label', 'Package')
// assert packages list widget, select "Lite Account"
->with('@packages', function ($browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1)', 'Groupware Account')
->assertSeeIn('tbody tr:nth-child(2)', 'Lite Account')
->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 CHF/month')
->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 CHF/month')
->assertChecked('tbody tr:nth-child(1) input')
->click('tbody tr:nth-child(2) input')
->assertNotChecked('tbody tr:nth-child(1) input')
->assertChecked('tbody tr:nth-child(2) input');
})
->assertMissing('@packages table + .hint')
->assertSeeIn('button[type=submit]', 'Submit');
// Test browser-side required fields and error handling
$browser->click('button[type=submit]')
->assertFocused('#email')
->type('#email', 'invalid email')
->click('button[type=submit]')
->assertFocused('#password')
->type('#password', 'simple123')
->click('button[type=submit]')
->assertFocused('#password_confirmation')
->type('#password_confirmation', 'simple')
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#email + .invalid-feedback', 'The specified email is invalid.')
->assertSeeIn('#password + .invalid-feedback', 'The password confirmation does not match.');
});
// Test form error handling (aliases)
$browser->with('@general', function (Browser $browser) {
$browser->type('#email', 'julia.roberts@kolab.org')
->type('#password_confirmation', 'simple123')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->addListEntry('invalid address');
})
->click('button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertFormError(1, 'The specified alias is invalid.', false);
});
});
// Successful account creation
$browser->with('@general', function (Browser $browser) {
$browser->type('#first_name', 'Julia')
->type('#last_name', 'Roberts')
->type('#organization', 'Test Org')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->removeListEntry(1)
->addListEntry('julia.roberts2@kolab.org');
})
->click('button[type=submit]');
})
->assertToast(Toast::TYPE_SUCCESS, 'User created successfully.')
// check redirection to users list
->on(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 5)
->assertSeeIn('tbody tr:nth-child(4) a', 'julia.roberts@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$alias = UserAlias::where('user_id', $julia->id)->where('alias', 'julia.roberts2@kolab.org')->first();
$this->assertTrue(!empty($alias));
$this->assertEntitlements($julia, ['mailbox', 'storage', 'storage', 'storage', 'storage', 'storage']);
$this->assertSame('Julia', $julia->getSetting('first_name'));
$this->assertSame('Roberts', $julia->getSetting('last_name'));
$this->assertSame('Test Org', $julia->getSetting('organization'));
// Some additional tests for the list input widget
$browser->click('@table tbody tr:nth-child(4) a')
->on(new UserInfo())
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertListInputValue(['julia.roberts2@kolab.org'])
->addListEntry('invalid address')
->type('.input-group:nth-child(2) input', '@kolab.org')
->keys('.input-group:nth-child(2) input', '{enter}');
})
// TODO: Investigate why this click does not work, for now we
// submit the form with Enter key above
//->click('@general button[type=submit]')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->with(new ListInput('#aliases'), function (Browser $browser) {
$browser->assertVisible('.input-group:nth-child(2) input.is-invalid')
->assertVisible('.input-group:nth-child(3) input.is-invalid')
->type('.input-group:nth-child(2) input', 'julia.roberts3@kolab.org')
->type('.input-group:nth-child(3) input', 'julia.roberts4@kolab.org')
->keys('.input-group:nth-child(3) input', '{enter}');
})
// TODO: Investigate why this click does not work, for now we
// submit the form with Enter key above
//->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$aliases = $julia->aliases()->orderBy('alias')->get()->pluck('alias')->all();
$this->assertSame(['julia.roberts3@kolab.org', 'julia.roberts4@kolab.org'], $aliases);
});
}
/**
* Test user delete
*
* @depends testNewUser
*/
public function testDeleteUser(): void
{
// First create a new user
$john = $this->getTestUser('john@kolab.org');
$julia = $this->getTestUser('julia.roberts@kolab.org');
$package_kolab = \App\Package::where('title', 'kolab')->first();
$john->assignPackage($package_kolab, $julia);
// Test deleting non-controller user
$this->browse(function (Browser $browser) use ($julia) {
$browser->visit('/user/' . $julia->id)
->on(new UserInfo())
->assertSeeIn('button.button-delete', 'Delete user')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Delete julia.roberts@kolab.org')
->assertFocused('@button-cancel')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Delete')
->click('@button-cancel');
})
->waitUntilMissing('#delete-warning')
->click('button.button-delete')
->with(new Dialog('#delete-warning'), function (Browser $browser) {
$browser->click('@button-action');
})
->waitUntilMissing('#delete-warning')
->assertToast(Toast::TYPE_SUCCESS, 'User deleted successfully.')
->on(new UserList())
->with('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4)
->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org');
});
$julia = User::where('email', 'julia.roberts@kolab.org')->first();
$this->assertTrue(empty($julia));
});
// Test that non-controller user cannot see/delete himself on the users list
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->visit('/users')
->assertErrorPage(403);
});
// Test that controller user (Ned) can see all the users
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('ned@kolab.org', 'simple123', true)
->visit(new UserList())
->whenAvailable('@table', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 4);
});
// TODO: Test the delete action in details
});
// TODO: Test what happens with the logged in user session after he's been deleted by another user
}
/**
* Test discounted sku/package prices in the UI
*/
public function testDiscountedPrices(): void
{
// Add 10% discount
$discount = Discount::where('code', 'TEST')->first();
$john = User::where('email', 'john@kolab.org')->first();
$wallet = $john->wallet();
$wallet->discount()->associate($discount);
$wallet->save();
// SKUs on user edit page
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->visit(new UserList())
->waitFor('@table tr:nth-child(2)')
->click('@table tr:nth-child(2) a') // joe@kolab.org
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '4,50 CHF/month¹')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(100);
})
->assertSeeIn('tr:nth-child(2) td.price', '21,37 CHF/month¹')
// groupware SKU
->assertSeeIn('tbody tr:nth-child(3) td.price', '4,41 CHF/month¹')
// ActiveSync SKU
->assertSeeIn('tbody tr:nth-child(4) td.price', '0,00 CHF/month¹')
// 2FA SKU
->assertSeeIn('tbody tr:nth-child(5) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
// Packages on new user page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->click('button.create-user')
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@packages', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) .price', '8,91 CHF/month¹') // Groupware
->assertSeeIn('tbody tr:nth-child(2) .price', '4,50 CHF/month¹'); // Lite
})
->assertSeeIn('@packages table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
// Test using entitlement cost instead of the SKU cost
$this->browse(function (Browser $browser) use ($wallet) {
$joe = User::where('email', 'joe@kolab.org')->first();
$beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
// Add an extra storage and beta entitlement with different prices
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $beta_sku->id,
'cost' => 5010,
'entitleable_id' => $joe->id,
'entitleable_type' => User::class
]);
Entitlement::create([
'wallet_id' => $wallet->id,
'sku_id' => $storage_sku->id,
'cost' => 5000,
'entitleable_id' => $joe->id,
'entitleable_type' => User::class
]);
$browser->visit('/user/' . $joe->id)
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
// Beta SKU
->assertSeeIn('tbody tr:nth-child(7) td.price', '45,09 CHF/month¹')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '45,00 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(7);
})
->assertSeeIn('tr:nth-child(2) td.price', '45,22 CHF/month¹')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(5);
})
->assertSeeIn('tr:nth-child(2) td.price', '0,00 CHF/month¹');
})
->assertSeeIn('@skus table + .hint', '¹ applied discount: 10% - Test voucher');
});
});
}
/**
* Test non-default currency in the UI
*/
public function testCurrency(): void
{
// Add 10% discount
$john = User::where('email', 'john@kolab.org')->first();
$wallet = $john->wallet();
$wallet->balance = -1000;
$wallet->currency = 'EUR';
$wallet->save();
// On Dashboard and the wallet page
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-wallet .badge', '-10,00 €')
->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('#wallet .card-title', 'Account balance -10,00 €');
});
// SKUs on user edit page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->waitFor('@table tr:nth-child(2)')
->click('@table tr:nth-child(2) a') // joe@kolab.org
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@skus', function (Browser $browser) {
$quota_input = new QuotaInput('tbody tr:nth-child(2) .range-input');
$browser->waitFor('tbody tr')
->assertElementsCount('tbody tr', 6)
// Mailbox SKU
->assertSeeIn('tbody tr:nth-child(1) td.price', '5,00 €/month')
// Storage SKU
->assertSeeIn('tr:nth-child(2) td.price', '0,00 €/month')
->with($quota_input, function (Browser $browser) {
$browser->setQuotaValue(100);
})
->assertSeeIn('tr:nth-child(2) td.price', '23,75 €/month');
});
});
});
// Packages on new user page
$this->browse(function (Browser $browser) {
$browser->visit(new UserList())
->click('button.create-user')
->on(new UserInfo())
->with('@general', function (Browser $browser) {
$browser->whenAvailable('@packages', function (Browser $browser) {
$browser->assertElementsCount('tbody tr', 2)
->assertSeeIn('tbody tr:nth-child(1) .price', '9,90 €/month') // Groupware
->assertSeeIn('tbody tr:nth-child(2) .price', '5,00 €/month'); // Lite
});
});
});
}
/**
* Test beta entitlements
*
* @depends testInfo
*/
public function testBetaEntitlements(): void
{
$this->browse(function (Browser $browser) {
$john = User::where('email', 'john@kolab.org')->first();
$sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$john->assignSku($sku);
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->with('@skus', function ($browser) {
- $browser->assertElementsCount('tbody tr', 8)
+ $browser->assertElementsCount('tbody tr', 9)
// Meet SKU
->assertSeeIn('tbody tr:nth-child(6) td.name', 'Voice & Video Conferencing (public beta)')
->assertSeeIn('tr:nth-child(6) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(6) td.selection input')
->assertEnabled('tbody tr:nth-child(6) td.selection input')
->assertTip(
'tbody tr:nth-child(6) td.buttons button',
'Video conferencing tool'
)
// Beta SKU
->assertSeeIn('tbody tr:nth-child(7) td.name', 'Private Beta (invitation only)')
->assertSeeIn('tbody tr:nth-child(7) td.price', '0,00 CHF/month')
->assertChecked('tbody tr:nth-child(7) td.selection input')
->assertEnabled('tbody tr:nth-child(7) td.selection input')
->assertTip(
'tbody tr:nth-child(7) td.buttons button',
'Access to the private beta program subscriptions'
)
- // Distlist SKU
- ->assertSeeIn('tbody tr:nth-child(8) td.name', 'Distribution lists')
+ // Resources SKU
+ ->assertSeeIn('tbody tr:nth-child(8) td.name', 'Calendaring resources')
->assertSeeIn('tr:nth-child(8) td.price', '0,00 CHF/month')
->assertNotChecked('tbody tr:nth-child(8) td.selection input')
->assertEnabled('tbody tr:nth-child(8) td.selection input')
->assertTip(
'tbody tr:nth-child(8) td.buttons button',
+ 'Access to calendaring resources'
+ )
+ // Distlist SKU
+ ->assertSeeIn('tbody tr:nth-child(9) td.name', 'Distribution lists')
+ ->assertSeeIn('tr:nth-child(9) td.price', '0,00 CHF/month')
+ ->assertNotChecked('tbody tr:nth-child(9) td.selection input')
+ ->assertEnabled('tbody tr:nth-child(9) td.selection input')
+ ->assertTip(
+ 'tbody tr:nth-child(9) td.buttons button',
'Access to mail distribution lists'
)
// Check Distlist, Uncheck Beta, expect Distlist unchecked
->click('#sku-input-distlist')
->click('#sku-input-beta')
->assertNotChecked('#sku-input-beta')
->assertNotChecked('#sku-input-distlist')
// Click Distlist expect an alert
->click('#sku-input-distlist')
->assertDialogOpened('Distribution lists requires Private Beta (invitation only).')
->acceptDialog()
// Enable Beta and Distlist and submit
->click('#sku-input-beta')
->click('#sku-input-distlist');
})
->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$expected = [
'beta',
'distlist',
'groupware',
'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage'
];
$this->assertEntitlements($john, $expected);
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
->waitFor('#sku-input-beta')
->click('#sku-input-beta')
->click('@general button[type=submit]')
->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.');
$expected = [
'groupware',
'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage'
];
$this->assertEntitlements($john, $expected);
});
// TODO: Test that the Distlist SKU is not available for users that aren't a group account owners
// TODO: Test that entitlements change has immediate effect on the available items in dashboard
// i.e. does not require a page reload nor re-login.
}
}
diff --git a/src/tests/Feature/Backends/IMAPTest.php b/src/tests/Feature/Backends/IMAPTest.php
index e6267479..de3a4805 100644
--- a/src/tests/Feature/Backends/IMAPTest.php
+++ b/src/tests/Feature/Backends/IMAPTest.php
@@ -1,38 +1,52 @@
<?php
namespace Tests\Feature\Backends;
use App\Backends\IMAP;
use Tests\TestCase;
class IMAPTest extends TestCase
{
/**
* Test verifying IMAP account existence (existing account)
*
* @group imap
*/
public function testVerifyAccountExisting(): void
{
$result = IMAP::verifyAccount('john@kolab.org');
// TODO: Mocking rcube_imap_generic is not that nice,
// Find a way to be sure some testing account has folders
// initialized, and some other not, so we can make assertions
// on the verifyAccount() result
$this->markTestIncomplete();
}
/**
* Test verifying IMAP account existence (non-existing account)
*
* @group imap
*/
public function testVerifyAccountNonExisting(): void
{
$this->expectException(\Exception::class);
IMAP::verifyAccount('non-existing@domain.tld');
}
+
+ /**
+ * Test verifying IMAP shared folder existence
+ *
+ * @group imap
+ */
+ public function testVerifySharedFolder(): void
+ {
+ $result = IMAP::verifySharedFolder('shared/Resources/UnknownResource@kolab.org');
+ $this->assertFalse($result);
+
+ // TODO: Test with an existing shared folder
+ $this->markTestIncomplete();
+ }
}
diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php
index 1137ebc8..f61a88e3 100644
--- a/src/tests/Feature/Backends/LDAPTest.php
+++ b/src/tests/Feature/Backends/LDAPTest.php
@@ -1,413 +1,520 @@
<?php
namespace Tests\Feature\Backends;
use App\Backends\LDAP;
use App\Domain;
use App\Group;
use App\Entitlement;
+use App\Resource;
use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class LDAPTest extends TestCase
{
private $ldap_config = [];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->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');
// 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');
// 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 domain
+ // 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,
+ ];
+
+ 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';
+
+ $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/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);
// 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' => User::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 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 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/Controller/Admin/GroupsTest.php b/src/tests/Feature/Controller/Admin/GroupsTest.php
index 127a5965..dae05b1d 100644
--- a/src/tests/Feature/Controller/Admin/GroupsTest.php
+++ b/src/tests/Feature/Controller/Admin/GroupsTest.php
@@ -1,227 +1,227 @@
<?php
namespace Tests\Feature\Controller\Admin;
use App\Group;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class GroupsTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
$this->deleteTestGroup('group-test@kolab.org');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestGroup('group-test@kolab.org');
parent::tearDown();
}
/**
* Test groups searching (/api/v4/groups)
*/
public function testIndex(): void
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
// Non-admin user
$response = $this->actingAs($user)->get("api/v4/groups");
$response->assertStatus(403);
// Search with no search criteria
$response = $this->actingAs($admin)->get("api/v4/groups");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertSame([], $json['list']);
// Search with no matches expected
$response = $this->actingAs($admin)->get("api/v4/groups?search=john@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertSame([], $json['list']);
// Search by email
$response = $this->actingAs($admin)->get("api/v4/groups?search={$group->email}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($group->email, $json['list'][0]['email']);
// Search by owner
$response = $this->actingAs($admin)->get("api/v4/groups?owner={$user->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($group->email, $json['list'][0]['email']);
$this->assertSame($group->name, $json['list'][0]['name']);
// Search by owner (Ned is a controller on John's wallets,
- // here we expect only domains assigned to Ned's wallet(s))
+ // here we expect only groups assigned to Ned's wallet(s))
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($admin)->get("api/v4/groups?owner={$ned->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
}
/**
* Test fetching group info
*/
public function testShow(): void
{
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
// Only admins can access it
$response = $this->actingAs($user)->get("api/v4/groups/{$group->id}");
$response->assertStatus(403);
$response = $this->actingAs($admin)->get("api/v4/groups/{$group->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals($group->id, $json['id']);
$this->assertEquals($group->email, $json['email']);
$this->assertEquals($group->name, $json['name']);
$this->assertEquals($group->status, $json['status']);
}
/**
- * Test fetching domain status (GET /api/v4/domains/<domain-id>/status)
+ * Test fetching group status (GET /api/v4/groups/<group-id>/status)
*/
public function testStatus(): void
{
Queue::fake(); // disable jobs
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
// This end-point does not exist for admins
$response = $this->actingAs($admin)->get("/api/v4/groups/{$group->id}/status");
$response->assertStatus(404);
}
/**
* Test group creating (POST /api/v4/groups)
*/
public function testStore(): void
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/groups", []);
$response->assertStatus(403);
// Admin can't create groups
$response = $this->actingAs($admin)->post("/api/v4/groups", []);
$response->assertStatus(404);
}
/**
* Test group suspending (POST /api/v4/groups/<group-id>/suspend)
*/
public function testSuspend(): void
{
Queue::fake(); // disable jobs
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/suspend", []);
$response->assertStatus(403);
// Test non-existing group ID
$response = $this->actingAs($admin)->post("/api/v4/groups/abc/suspend", []);
$response->assertStatus(404);
$this->assertFalse($group->fresh()->isSuspended());
// Test suspending the group
$response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/suspend", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("Distribution list suspended successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertTrue($group->fresh()->isSuspended());
}
/**
* Test user un-suspending (POST /api/v4/users/<user-id>/unsuspend)
*/
public function testUnsuspend(): void
{
Queue::fake(); // disable jobs
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
$group->status |= Group::STATUS_SUSPENDED;
$group->save();
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/unsuspend", []);
$response->assertStatus(403);
// Invalid group ID
$response = $this->actingAs($admin)->post("/api/v4/groups/abc/unsuspend", []);
$response->assertStatus(404);
$this->assertTrue($group->fresh()->isSuspended());
// Test suspending the group
$response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/unsuspend", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("Distribution list unsuspended successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertFalse($group->fresh()->isSuspended());
}
}
diff --git a/src/tests/Feature/Controller/Admin/ResourcesTest.php b/src/tests/Feature/Controller/Admin/ResourcesTest.php
new file mode 100644
index 00000000..88ef05be
--- /dev/null
+++ b/src/tests/Feature/Controller/Admin/ResourcesTest.php
@@ -0,0 +1,148 @@
+<?php
+
+namespace Tests\Feature\Controller\Admin;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ResourcesTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useAdminUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test resources searching (/api/v4/resources)
+ */
+ public function testIndex(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/resources");
+ $response->assertStatus(403);
+
+ // Search with no search criteria
+ $response = $this->actingAs($admin)->get("api/v4/resources");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ $this->assertSame("0 resources have been found.", $json['message']);
+
+ // Search with no matches expected
+ $response = $this->actingAs($admin)->get("api/v4/resources?search=john@kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search by email
+ $response = $this->actingAs($admin)->get("api/v4/resources?search={$resource->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($resource->email, $json['list'][0]['email']);
+
+ // Search by owner
+ $response = $this->actingAs($admin)->get("api/v4/resources?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame("2 resources have been found.", $json['message']);
+ $this->assertSame($resource->email, $json['list'][0]['email']);
+ $this->assertSame($resource->name, $json['list'][0]['name']);
+
+ // Search by owner (Ned is a controller on John's wallets,
+ // here we expect only resources assigned to Ned's wallet(s))
+ $ned = $this->getTestUser('ned@kolab.org');
+ $response = $this->actingAs($admin)->get("api/v4/resources?owner={$ned->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertCount(0, $json['list']);
+ }
+
+ /**
+ * Test fetching resource info (GET /api/v4/resources/<resource-id>)
+ */
+ public function testShow(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('john@kolab.org');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ // Only admins can access it
+ $response = $this->actingAs($user)->get("api/v4/resources/{$resource->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/resources/{$resource->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($resource->id, $json['id']);
+ $this->assertEquals($resource->email, $json['email']);
+ $this->assertEquals($resource->name, $json['name']);
+ }
+
+ /**
+ * Test fetching resource status (GET /api/v4/resources/<resource-id>/status)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ // This end-point does not exist for admins
+ $response = $this->actingAs($admin)->get("/api/v4/resources/{$resource->id}/status");
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test resource creating (POST /api/v4/resources)
+ */
+ public function testStore(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+
+ // Test unauthorized access to admin API
+ $response = $this->actingAs($user)->post("/api/v4/resources", []);
+ $response->assertStatus(403);
+
+ // Admin can't create resources
+ $response = $this->actingAs($admin)->post("/api/v4/resources", []);
+ $response->assertStatus(404);
+ }
+}
diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php
index 962d6158..ee5b7b34 100644
--- a/src/tests/Feature/Controller/Admin/SkusTest.php
+++ b/src/tests/Feature/Controller/Admin/SkusTest.php
@@ -1,124 +1,124 @@
<?php
namespace Tests\Feature\Controller\Admin;
use App\Sku;
use Tests\TestCase;
class SkusTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
Sku::where('title', 'test')->delete();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
Sku::where('title', 'test')->delete();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test fetching SKUs list for a domain (GET /domains/<id>/skus)
*/
public function testDomainSkus(): void
{
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
$domain = $this->getTestDOmain('kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(401);
// Non-admin access not allowed
$response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(403);
$response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
// Note: Details are tested where we test API\V4\SkusController
}
/**
* Test fetching SKUs list
*/
public function testIndex(): void
{
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
$sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
// Unauth access not allowed
$response = $this->get("api/v4/skus");
$response->assertStatus(401);
// User access not allowed on admin API
$response = $this->actingAs($user)->get("api/v4/skus");
$response->assertStatus(403);
$response = $this->actingAs($admin)->get("api/v4/skus");
$response->assertStatus(200);
$json = $response->json();
- $this->assertCount(9, $json);
+ $this->assertCount(11, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
$this->assertSame($sku->title, $json[0]['title']);
$this->assertSame($sku->name, $json[0]['name']);
$this->assertSame($sku->description, $json[0]['description']);
$this->assertSame($sku->cost, $json[0]['cost']);
$this->assertSame($sku->units_free, $json[0]['units_free']);
$this->assertSame($sku->period, $json[0]['period']);
$this->assertSame($sku->active, $json[0]['active']);
$this->assertSame('user', $json[0]['type']);
$this->assertSame('mailbox', $json[0]['handler']);
}
/**
* Test fetching SKUs list for a user (GET /users/<id>/skus)
*/
public function testUserSkus(): void
{
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(401);
// Non-admin access not allowed
$response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(403);
$response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(6, $json);
// Note: Details are tested where we test API\V4\SkusController
}
}
diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php
index 74cedc66..39d00527 100644
--- a/src/tests/Feature/Controller/Admin/UsersTest.php
+++ b/src/tests/Feature/Controller/Admin/UsersTest.php
@@ -1,490 +1,501 @@
<?php
namespace Tests\Feature\Controller\Admin;
use App\Auth\SecondFactor;
use App\Sku;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class UsersTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('test@testsearch.com');
$this->deleteTestDomain('testsearch.com');
$this->deleteTestGroup('group-test@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$jack->setSetting('external_email', null);
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('test@testsearch.com');
$this->deleteTestDomain('testsearch.com');
$jack = $this->getTestUser('jack@kolab.org');
$jack->setSetting('external_email', null);
parent::tearDown();
}
/**
* Test user deleting (DELETE /api/v4/users/<id>)
*/
public function testDestroy(): void
{
$john = $this->getTestUser('john@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Test unauth access
$response = $this->delete("api/v4/users/{$user->id}");
$response->assertStatus(401);
// The end-point does not exist
$response = $this->actingAs($admin)->delete("api/v4/users/{$user->id}");
$response->assertStatus(404);
}
/**
* Test users searching (/api/v4/users)
*/
public function testIndex(): void
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
// Non-admin user
$response = $this->actingAs($user)->get("api/v4/users");
$response->assertStatus(403);
// Search with no search criteria
$response = $this->actingAs($admin)->get("api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertSame([], $json['list']);
// Search with no matches expected
$response = $this->actingAs($admin)->get("api/v4/users?search=abcd1234efgh5678");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertSame([], $json['list']);
// Search by domain
$response = $this->actingAs($admin)->get("api/v4/users?search=kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by user ID
$response = $this->actingAs($admin)->get("api/v4/users?search={$user->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by email (primary)
$response = $this->actingAs($admin)->get("api/v4/users?search=john@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by email (alias)
$response = $this->actingAs($admin)->get("api/v4/users?search=john.doe@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by email (external), expect two users in a result
$jack = $this->getTestUser('jack@kolab.org');
$jack->setSetting('external_email', 'john.doe.external@gmail.com');
$response = $this->actingAs($admin)->get("api/v4/users?search=john.doe.external@gmail.com");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(2, $json['count']);
$this->assertCount(2, $json['list']);
$emails = array_column($json['list'], 'email');
$this->assertContains($user->email, $emails);
$this->assertContains($jack->email, $emails);
// Search by owner
$response = $this->actingAs($admin)->get("api/v4/users?owner={$user->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(4, $json['count']);
$this->assertCount(4, $json['list']);
// Search by owner (Ned is a controller on John's wallets,
// here we expect only users assigned to Ned's wallet(s))
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($admin)->get("api/v4/users?owner={$ned->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
// Search by distribution list email
$response = $this->actingAs($admin)->get("api/v4/users?search=group-test@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
+ // Search by resource email
+ $response = $this->actingAs($admin)->get("api/v4/users?search=resource-test1@kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($user->id, $json['list'][0]['id']);
+ $this->assertSame($user->email, $json['list'][0]['email']);
+
// Deleted users/domains
$domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]);
$user = $this->getTestUser('test@testsearch.com');
$plan = \App\Plan::where('title', 'group')->first();
$user->assignPlan($plan, $domain);
$user->setAliases(['alias@testsearch.com']);
$wallet = $user->wallets()->first();
$wallet->setSetting('mollie_id', 'cst_nonsense');
\App\Payment::create(
[
'id' => 'tr_nonsense',
'wallet_id' => $wallet->id,
'status' => 'paid',
'amount' => 1337,
'description' => 'nonsense transaction for testing',
'provider' => 'self',
'type' => 'oneoff',
'currency' => 'CHF',
'currency_amount' => 1337
]
);
Queue::fake();
$user->delete();
$response = $this->actingAs($admin)->get("api/v4/users?search=test@testsearch.com");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
$response = $this->actingAs($admin)->get("api/v4/users?search=alias@testsearch.com");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
$response = $this->actingAs($admin)->get("api/v4/users?search=testsearch.com");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
$response = $this->actingAs($admin)->get("api/v4/users?search={$wallet->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
$response = $this->actingAs($admin)->get("api/v4/users?search=tr_nonsense");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
$response = $this->actingAs($admin)->get("api/v4/users?search=cst_nonsense");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
}
/**
* Test reseting 2FA (POST /api/v4/users/<user-id>/reset2FA)
*/
public function testReset2FA(): void
{
Queue::fake();
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$sku2fa = Sku::withEnvTenantContext()->where(['title' => '2fa'])->first();
$user->assignSku($sku2fa);
SecondFactor::seed('userscontrollertest1@userscontroller.com');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/reset2FA", []);
$response->assertStatus(403);
$entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get();
$this->assertCount(1, $entitlements);
$sf = new SecondFactor($user);
$this->assertCount(1, $sf->factors());
// Test reseting 2FA
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("2-Factor authentication reset successfully.", $json['message']);
$this->assertCount(2, $json);
$entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get();
$this->assertCount(0, $entitlements);
$sf = new SecondFactor($user);
$this->assertCount(0, $sf->factors());
}
/**
* Test adding beta SKU (POST /api/v4/users/<user-id>/skus/beta)
*/
public function testAddBetaSku(): void
{
Queue::fake();
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$sku = Sku::withEnvTenantContext()->where(['title' => 'beta'])->first();
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/skus/beta", []);
$response->assertStatus(403);
// For now we allow only the beta sku
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/skus/mailbox", []);
$response->assertStatus(404);
$entitlements = $user->entitlements()->where('sku_id', $sku->id)->get();
$this->assertCount(0, $entitlements);
// Test adding the beta sku
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/skus/beta", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("The subscription added successfully.", $json['message']);
$this->assertSame(0, $json['sku']['cost']);
$this->assertSame($sku->id, $json['sku']['id']);
$this->assertSame($sku->name, $json['sku']['name']);
$this->assertCount(3, $json);
$entitlements = $user->entitlements()->where('sku_id', $sku->id)->get();
$this->assertCount(1, $entitlements);
// Test adding the beta sku again, expect an error
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/skus/beta", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The subscription already exists.", $json['message']);
$this->assertCount(2, $json);
$entitlements = $user->entitlements()->where('sku_id', $sku->id)->get();
$this->assertCount(1, $entitlements);
}
/**
* Test user creation (POST /api/v4/users)
*/
public function testStore(): void
{
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// The end-point does not exist
$response = $this->actingAs($admin)->post("/api/v4/users", []);
$response->assertStatus(404);
}
/**
* Test user suspending (POST /api/v4/users/<user-id>/suspend)
*/
public function testSuspend(): void
{
Queue::fake(); // disable jobs
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/suspend", []);
$response->assertStatus(403);
$this->assertFalse($user->isSuspended());
// Test suspending the user
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User suspended successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertTrue($user->fresh()->isSuspended());
}
/**
* Test user un-suspending (POST /api/v4/users/<user-id>/unsuspend)
*/
public function testUnsuspend(): void
{
Queue::fake(); // disable jobs
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/unsuspend", []);
$response->assertStatus(403);
$this->assertFalse($user->isSuspended());
$user->suspend();
$this->assertTrue($user->isSuspended());
// Test suspending the user
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User unsuspended successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertFalse($user->fresh()->isSuspended());
}
/**
* Test user update (PUT /api/v4/users/<user-id>)
*/
public function testUpdate(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", []);
$response->assertStatus(403);
// Test updatig the user data (empty data)
$response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertCount(2, $json);
// Test error handling
$post = ['external_email' => 'aaa'];
$response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The external email must be a valid email address.", $json['errors']['external_email'][0]);
$this->assertCount(2, $json);
// Test real update
$post = ['external_email' => 'modified@test.com'];
$response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertSame('modified@test.com', $user->getSetting('external_email'));
}
}
diff --git a/src/tests/Feature/Controller/Reseller/GroupsTest.php b/src/tests/Feature/Controller/Reseller/GroupsTest.php
index 6d2e37e0..385f456c 100644
--- a/src/tests/Feature/Controller/Reseller/GroupsTest.php
+++ b/src/tests/Feature/Controller/Reseller/GroupsTest.php
@@ -1,280 +1,280 @@
<?php
namespace Tests\Feature\Controller\Reseller;
use App\Group;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class GroupsTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
$this->deleteTestGroup('group-test@kolab.org');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestGroup('group-test@kolab.org');
parent::tearDown();
}
/**
* Test groups searching (/api/v4/groups)
*/
public function testIndex(): void
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
// Non-admin user
$response = $this->actingAs($user)->get("api/v4/groups");
$response->assertStatus(403);
// Admin user
$response = $this->actingAs($admin)->get("api/v4/groups");
$response->assertStatus(403);
// Search with no search criteria
$response = $this->actingAs($reseller1)->get("api/v4/groups");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertSame([], $json['list']);
// Search with no matches expected
$response = $this->actingAs($reseller1)->get("api/v4/groups?search=john@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertSame([], $json['list']);
// Search by email
$response = $this->actingAs($reseller1)->get("api/v4/groups?search={$group->email}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($group->email, $json['list'][0]['email']);
// Search by owner
$response = $this->actingAs($reseller1)->get("api/v4/groups?owner={$user->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($group->email, $json['list'][0]['email']);
$this->assertSame($group->name, $json['list'][0]['name']);
// Search by owner (Ned is a controller on John's wallets,
- // here we expect only domains assigned to Ned's wallet(s))
+ // here we expect only groups assigned to Ned's wallet(s))
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($reseller1)->get("api/v4/groups?owner={$ned->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
- $response = $this->actingAs($reseller2)->get("api/v4/groups?search=kolab.org");
+ $response = $this->actingAs($reseller2)->get("api/v4/groups?search={$group->email}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertSame([], $json['list']);
$response = $this->actingAs($reseller2)->get("api/v4/groups?owner={$user->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertSame([], $json['list']);
}
/**
* Test fetching group info
*/
public function testShow(): void
{
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
// Only resellers can access it
$response = $this->actingAs($user)->get("api/v4/groups/{$group->id}");
$response->assertStatus(403);
$response = $this->actingAs($admin)->get("api/v4/groups/{$group->id}");
$response->assertStatus(403);
$response = $this->actingAs($reseller2)->get("api/v4/groups/{$group->id}");
$response->assertStatus(404);
$response = $this->actingAs($reseller1)->get("api/v4/groups/{$group->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals($group->id, $json['id']);
$this->assertEquals($group->email, $json['email']);
$this->assertEquals($group->name, $json['name']);
$this->assertEquals($group->status, $json['status']);
}
/**
- * Test fetching group status (GET /api/v4/domains/<domain-id>/status)
+ * Test fetching group status (GET /api/v4/groups/<group-id>/status)
*/
public function testStatus(): void
{
Queue::fake(); // disable jobs
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
// This end-point does not exist for admins
$response = $this->actingAs($reseller1)->get("/api/v4/groups/{$group->id}/status");
$response->assertStatus(404);
}
/**
* Test group creating (POST /api/v4/groups)
*/
public function testStore(): void
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
// Test unauthorized access to reseller API
$response = $this->actingAs($user)->post("/api/v4/groups", []);
$response->assertStatus(403);
// Reseller or admin can't create groups
$response = $this->actingAs($admin)->post("/api/v4/groups", []);
$response->assertStatus(403);
$response = $this->actingAs($reseller1)->post("/api/v4/groups", []);
$response->assertStatus(404);
}
/**
* Test group suspending (POST /api/v4/groups/<group-id>/suspend)
*/
public function testSuspend(): void
{
Queue::fake(); // disable jobs
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
// Test unauthorized access to reseller API
$response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/suspend", []);
$response->assertStatus(403);
// Test unauthorized access to reseller API
$response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/suspend", []);
$response->assertStatus(403);
// Test non-existing group ID
$response = $this->actingAs($reseller1)->post("/api/v4/groups/abc/suspend", []);
$response->assertStatus(404);
$this->assertFalse($group->fresh()->isSuspended());
// Test suspending the group
$response = $this->actingAs($reseller1)->post("/api/v4/groups/{$group->id}/suspend", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("Distribution list suspended successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertTrue($group->fresh()->isSuspended());
$response = $this->actingAs($reseller2)->post("/api/v4/groups/{$group->id}/suspend", []);
$response->assertStatus(404);
}
/**
* Test user un-suspending (POST /api/v4/users/<user-id>/unsuspend)
*/
public function testUnsuspend(): void
{
Queue::fake(); // disable jobs
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
$group->status |= Group::STATUS_SUSPENDED;
$group->save();
// Test unauthorized access to reseller API
$response = $this->actingAs($user)->post("/api/v4/groups/{$group->id}/unsuspend", []);
$response->assertStatus(403);
// Test unauthorized access to reseller API
$response = $this->actingAs($admin)->post("/api/v4/groups/{$group->id}/unsuspend", []);
$response->assertStatus(403);
// Invalid group ID
$response = $this->actingAs($reseller1)->post("/api/v4/groups/abc/unsuspend", []);
$response->assertStatus(404);
$this->assertTrue($group->fresh()->isSuspended());
// Test suspending the group
$response = $this->actingAs($reseller1)->post("/api/v4/groups/{$group->id}/unsuspend", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("Distribution list unsuspended successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertFalse($group->fresh()->isSuspended());
$response = $this->actingAs($reseller2)->post("/api/v4/groups/{$group->id}/unsuspend", []);
$response->assertStatus(404);
}
}
diff --git a/src/tests/Feature/Controller/Reseller/ResourcesTest.php b/src/tests/Feature/Controller/Reseller/ResourcesTest.php
new file mode 100644
index 00000000..7e9dd59f
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/ResourcesTest.php
@@ -0,0 +1,180 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ResourcesTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test resources searching (/api/v4/resources)
+ */
+ public function testIndex(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+ $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/resources");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->get("api/v4/resources");
+ $response->assertStatus(403);
+
+ // Search with no search criteria
+ $response = $this->actingAs($reseller1)->get("api/v4/resources");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search with no matches expected
+ $response = $this->actingAs($reseller1)->get("api/v4/resources?search=john@kolab.org");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ // Search by email
+ $response = $this->actingAs($reseller1)->get("api/v4/resources?search={$resource->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($resource->email, $json['list'][0]['email']);
+
+ // Search by owner
+ $response = $this->actingAs($reseller1)->get("api/v4/resources?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame($resource->email, $json['list'][0]['email']);
+ $this->assertSame($resource->name, $json['list'][0]['name']);
+
+ // Search by owner (Ned is a controller on John's wallets,
+ // here we expect only resources assigned to Ned's wallet(s))
+ $ned = $this->getTestUser('ned@kolab.org');
+ $response = $this->actingAs($reseller1)->get("api/v4/resources?owner={$ned->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertCount(0, $json['list']);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/resources?search={$resource->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/resources?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ }
+
+ /**
+ * Test fetching resource info (GET /api/v4/resources/<resource-id>)
+ */
+ public function testShow(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('john@kolab.org');
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+ $reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ // Only resellers can access it
+ $response = $this->actingAs($user)->get("api/v4/resources/{$resource->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/resources/{$resource->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/resources/{$resource->id}");
+ $response->assertStatus(404);
+
+ $response = $this->actingAs($reseller1)->get("api/v4/resources/{$resource->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($resource->id, $json['id']);
+ $this->assertEquals($resource->email, $json['email']);
+ $this->assertEquals($resource->name, $json['name']);
+ }
+
+ /**
+ * Test fetching resource status (GET /api/v4/resources/<resource-id>/status)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ // This end-point does not exist for resources
+ $response = $this->actingAs($reseller1)->get("/api/v4/resources/{$resource->id}/status");
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test resources creating (POST /api/v4/resources)
+ */
+ public function testStore(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+
+ // Test unauthorized access to reseller API
+ $response = $this->actingAs($user)->post("/api/v4/resources", []);
+ $response->assertStatus(403);
+
+ // Reseller or admin can't create resources
+ $response = $this->actingAs($admin)->post("/api/v4/resources", []);
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller1)->post("/api/v4/resources", []);
+ $response->assertStatus(404);
+ }
+}
diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php
index 392c5d4e..58d679a0 100644
--- a/src/tests/Feature/Controller/Reseller/SkusTest.php
+++ b/src/tests/Feature/Controller/Reseller/SkusTest.php
@@ -1,173 +1,173 @@
<?php
namespace Tests\Feature\Controller\Reseller;
use App\Sku;
use Tests\TestCase;
class SkusTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
Sku::where('title', 'test')->delete();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
Sku::where('title', 'test')->delete();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
parent::tearDown();
}
/**
* Test fetching SKUs list for a domain (GET /domains/<id>/skus)
*/
public function testDomainSkus(): void
{
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
$domain = $this->getTestDomain('kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(401);
// User access not allowed
$response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(403);
// Admin access not allowed
$response = $this->actingAs($admin)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(403);
// Reseller from another tenant
$response = $this->actingAs($reseller2)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(404);
// Reseller access
$response = $this->actingAs($reseller1)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
// Note: Details are tested where we test API\V4\SkusController
}
/**
* Test fetching SKUs list
*/
public function testIndex(): void
{
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
$sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
// Unauth access not allowed
$response = $this->get("api/v4/skus");
$response->assertStatus(401);
// User access not allowed
$response = $this->actingAs($user)->get("api/v4/skus");
$response->assertStatus(403);
// Admin access not allowed
$response = $this->actingAs($admin)->get("api/v4/skus");
$response->assertStatus(403);
$response = $this->actingAs($reseller1)->get("api/v4/skus");
$response->assertStatus(200);
$json = $response->json();
- $this->assertCount(9, $json);
+ $this->assertCount(11, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
$this->assertSame($sku->title, $json[0]['title']);
$this->assertSame($sku->name, $json[0]['name']);
$this->assertSame($sku->description, $json[0]['description']);
$this->assertSame($sku->cost, $json[0]['cost']);
$this->assertSame($sku->units_free, $json[0]['units_free']);
$this->assertSame($sku->period, $json[0]['period']);
$this->assertSame($sku->active, $json[0]['active']);
$this->assertSame('user', $json[0]['type']);
$this->assertSame('mailbox', $json[0]['handler']);
// Test with another tenant
$sku = Sku::where('title', 'mailbox')->where('tenant_id', $reseller2->tenant_id)->first();
$response = $this->actingAs($reseller2)->get("api/v4/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(6, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
$this->assertSame($sku->title, $json[0]['title']);
$this->assertSame($sku->name, $json[0]['name']);
$this->assertSame($sku->description, $json[0]['description']);
$this->assertSame($sku->cost, $json[0]['cost']);
$this->assertSame($sku->units_free, $json[0]['units_free']);
$this->assertSame($sku->period, $json[0]['period']);
$this->assertSame($sku->active, $json[0]['active']);
$this->assertSame('user', $json[0]['type']);
$this->assertSame('mailbox', $json[0]['handler']);
}
/**
* Test fetching SKUs list for a user (GET /users/<id>/skus)
*/
public function testUserSkus(): void
{
$reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
$reseller2 = $this->getTestUser('reseller@sample-tenant.dev-local');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user = $this->getTestUser('john@kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(401);
// User access not allowed
$response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(403);
// Admin access not allowed
$response = $this->actingAs($admin)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(403);
// Reseller from another tenant
$response = $this->actingAs($reseller2)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(404);
// Reseller access
$response = $this->actingAs($reseller1)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(6, $json);
// Note: Details are tested where we test API\V4\SkusController
}
}
diff --git a/src/tests/Feature/Controller/ResourcesTest.php b/src/tests/Feature/Controller/ResourcesTest.php
new file mode 100644
index 00000000..da6c8aa7
--- /dev/null
+++ b/src/tests/Feature/Controller/ResourcesTest.php
@@ -0,0 +1,482 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Resource;
+use App\Http\Controllers\API\V4\ResourcesController;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ResourcesTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestResource('resource-test@kolab.org');
+ Resource::where('name', 'Test Resource')->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestResource('resource-test@kolab.org');
+ Resource::where('name', 'Test Resource')->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test resource deleting (DELETE /api/v4/resources/<id>)
+ */
+ public function testDestroy(): void
+ {
+ // First create some groups to delete
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $resource = $this->getTestResource('resource-test@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+
+ // Test unauth access
+ $response = $this->delete("api/v4/resources/{$resource->id}");
+ $response->assertStatus(401);
+
+ // Test non-existing resource
+ $response = $this->actingAs($john)->delete("api/v4/resources/abc");
+ $response->assertStatus(404);
+
+ // Test access to other user's resource
+ $response = $this->actingAs($jack)->delete("api/v4/resources/{$resource->id}");
+ $response->assertStatus(403);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("Access denied", $json['message']);
+ $this->assertCount(2, $json);
+
+ // Test removing a resource
+ $response = $this->actingAs($john)->delete("api/v4/resources/{$resource->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals('success', $json['status']);
+ $this->assertEquals("Resource deleted successfully.", $json['message']);
+ }
+
+ /**
+ * Test resources listing (GET /api/v4/resources)
+ */
+ public function testIndex(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ // Test unauth access
+ $response = $this->get("api/v4/resources");
+ $response->assertStatus(401);
+
+ // Test a user with no resources
+ $response = $this->actingAs($jack)->get("/api/v4/resources");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(0, $json);
+
+ // Test a user with two resources
+ $response = $this->actingAs($john)->get("/api/v4/resources");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $resource = Resource::where('name', 'Conference Room #1')->first();
+
+ $this->assertCount(2, $json);
+ $this->assertSame($resource->id, $json[0]['id']);
+ $this->assertSame($resource->email, $json[0]['email']);
+ $this->assertSame($resource->name, $json[0]['name']);
+ $this->assertArrayHasKey('isDeleted', $json[0]);
+ $this->assertArrayHasKey('isActive', $json[0]);
+ $this->assertArrayHasKey('isLdapReady', $json[0]);
+ $this->assertArrayHasKey('isImapReady', $json[0]);
+
+ // Test that another wallet controller has access to resources
+ $response = $this->actingAs($ned)->get("/api/v4/resources");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame($resource->email, $json[0]['email']);
+ }
+
+ /**
+ * Test resource config update (POST /api/v4/resources/<resource>/config)
+ */
+ public function testSetConfig(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $resource = $this->getTestResource('resource-test@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+
+ // Test unknown resource id
+ $post = ['invitation_policy' => 'reject'];
+ $response = $this->actingAs($john)->post("/api/v4/resources/123/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(404);
+
+ // Test access by user not being a wallet controller
+ $post = ['invitation_policy' => 'reject'];
+ $response = $this->actingAs($jack)->post("/api/v4/resources/{$resource->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 = ['test' => 1];
+ $response = $this->actingAs($john)->post("/api/v4/resources/{$resource->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']['test']);
+
+ $resource->refresh();
+
+ $this->assertNull($resource->getSetting('test'));
+ $this->assertNull($resource->getSetting('invitation_policy'));
+
+ // Test some valid data
+ $post = ['invitation_policy' => 'reject'];
+ $response = $this->actingAs($john)->post("/api/v4/resources/{$resource->id}/config", $post);
+
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Resource settings updated successfully.", $json['message']);
+
+ $this->assertSame(['invitation_policy' => 'reject'], $resource->fresh()->getConfig());
+
+ // Test input validation
+ $post = ['invitation_policy' => 'aaa'];
+ $response = $this->actingAs($john)->post("/api/v4/resources/{$resource->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame(
+ "The specified invitation policy is invalid.",
+ $json['errors']['invitation_policy']
+ );
+
+ $this->assertSame(['invitation_policy' => 'reject'], $resource->fresh()->getConfig());
+ }
+
+ /**
+ * Test fetching resource data/profile (GET /api/v4/resources/<resource>)
+ */
+ public function testShow(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $resource = $this->getTestResource('resource-test@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+ $resource->setSetting('invitation_policy', 'reject');
+
+ // Test unauthorized access to a profile of other user
+ $response = $this->get("/api/v4/resources/{$resource->id}");
+ $response->assertStatus(401);
+
+ // Test unauthorized access to a resource of another user
+ $response = $this->actingAs($jack)->get("/api/v4/resources/{$resource->id}");
+ $response->assertStatus(403);
+
+ // John: Account owner - non-existing resource
+ $response = $this->actingAs($john)->get("/api/v4/resources/abc");
+ $response->assertStatus(404);
+
+ // John: Account owner
+ $response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($resource->id, $json['id']);
+ $this->assertSame($resource->email, $json['email']);
+ $this->assertSame($resource->name, $json['name']);
+ $this->assertTrue(!empty($json['statusInfo']));
+ $this->assertArrayHasKey('isDeleted', $json);
+ $this->assertArrayHasKey('isActive', $json);
+ $this->assertArrayHasKey('isLdapReady', $json);
+ $this->assertArrayHasKey('isImapReady', $json);
+ $this->assertSame(['invitation_policy' => 'reject'], $json['config']);
+ }
+
+ /**
+ * Test fetching a resource status (GET /api/v4/resources/<resource>/status)
+ * and forcing setup process update (?refresh=1)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake();
+
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ $resource = $this->getTestResource('resource-test@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+
+ // Test unauthorized access
+ $response = $this->get("/api/v4/resources/abc/status");
+ $response->assertStatus(401);
+
+ // Test unauthorized access
+ $response = $this->actingAs($jack)->get("/api/v4/resources/{$resource->id}/status");
+ $response->assertStatus(403);
+
+ $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE;
+ $resource->save();
+
+ // Get resource status
+ $response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}/status");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertFalse($json['isLdapReady']);
+ $this->assertFalse($json['isImapReady']);
+ $this->assertFalse($json['isReady']);
+ $this->assertFalse($json['isDeleted']);
+ $this->assertTrue($json['isActive']);
+ $this->assertCount(7, $json['process']);
+ $this->assertSame('resource-new', $json['process'][0]['label']);
+ $this->assertSame(true, $json['process'][0]['state']);
+ $this->assertSame('resource-ldap-ready', $json['process'][1]['label']);
+ $this->assertSame(false, $json['process'][1]['state']);
+ $this->assertTrue(empty($json['status']));
+ $this->assertTrue(empty($json['message']));
+ $this->assertSame('running', $json['processState']);
+
+ // Make sure the domain is confirmed (other test might unset that status)
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->status |= \App\Domain::STATUS_CONFIRMED;
+ $domain->save();
+ $resource->status |= Resource::STATUS_IMAP_READY;
+ $resource->save();
+
+ // Now "reboot" the process and get the resource status
+ $response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}/status?refresh=1");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertTrue($json['isLdapReady']);
+ $this->assertTrue($json['isImapReady']);
+ $this->assertTrue($json['isReady']);
+ $this->assertCount(7, $json['process']);
+ $this->assertSame('resource-ldap-ready', $json['process'][1]['label']);
+ $this->assertSame(true, $json['process'][1]['state']);
+ $this->assertSame('resource-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']);
+ $this->assertSame('done', $json['processState']);
+
+ // Test a case when a domain is not ready
+ $domain->status ^= \App\Domain::STATUS_CONFIRMED;
+ $domain->save();
+
+ $response = $this->actingAs($john)->get("/api/v4/resources/{$resource->id}/status?refresh=1");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertTrue($json['isLdapReady']);
+ $this->assertTrue($json['isReady']);
+ $this->assertCount(7, $json['process']);
+ $this->assertSame('resource-ldap-ready', $json['process'][1]['label']);
+ $this->assertSame(true, $json['process'][1]['state']);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('Setup process finished successfully.', $json['message']);
+ }
+
+ /**
+ * Test ResourcesController::statusInfo()
+ */
+ public function testStatusInfo(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $resource = $this->getTestResource('resource-test@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+ $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE;
+ $resource->save();
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->status |= \App\Domain::STATUS_CONFIRMED;
+ $domain->save();
+
+ $result = ResourcesController::statusInfo($resource);
+
+ $this->assertFalse($result['isReady']);
+ $this->assertCount(7, $result['process']);
+ $this->assertSame('resource-new', $result['process'][0]['label']);
+ $this->assertSame(true, $result['process'][0]['state']);
+ $this->assertSame('resource-ldap-ready', $result['process'][1]['label']);
+ $this->assertSame(false, $result['process'][1]['state']);
+ $this->assertSame('running', $result['processState']);
+
+ $resource->created_at = Carbon::now()->subSeconds(181);
+ $resource->save();
+
+ $result = ResourcesController::statusInfo($resource);
+
+ $this->assertSame('failed', $result['processState']);
+
+ $resource->status |= Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY;
+ $resource->save();
+
+ $result = ResourcesController::statusInfo($resource);
+
+ $this->assertTrue($result['isReady']);
+ $this->assertCount(7, $result['process']);
+ $this->assertSame('resource-new', $result['process'][0]['label']);
+ $this->assertSame(true, $result['process'][0]['state']);
+ $this->assertSame('resource-ldap-ready', $result['process'][1]['label']);
+ $this->assertSame(true, $result['process'][1]['state']);
+ $this->assertSame('resource-ldap-ready', $result['process'][1]['label']);
+ $this->assertSame(true, $result['process'][1]['state']);
+ $this->assertSame('done', $result['processState']);
+ }
+
+ /**
+ * Test resource creation (POST /api/v4/resources)
+ */
+ public function testStore(): void
+ {
+ Queue::fake();
+
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+
+ // Test unauth request
+ $response = $this->post("/api/v4/resources", []);
+ $response->assertStatus(401);
+
+ // Test non-controller user
+ $response = $this->actingAs($jack)->post("/api/v4/resources", []);
+ $response->assertStatus(403);
+
+ // Test empty request
+ $response = $this->actingAs($john)->post("/api/v4/resources", []);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The name field is required.", $json['errors']['name'][0]);
+ $this->assertCount(2, $json);
+ $this->assertCount(1, $json['errors']);
+
+ // Test too long name
+ $post = ['domain' => 'kolab.org', 'name' => str_repeat('A', 192)];
+ $response = $this->actingAs($john)->post("/api/v4/resources", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertSame("The name may not be greater than 191 characters.", $json['errors']['name'][0]);
+ $this->assertCount(1, $json['errors']);
+
+ // Test successful resource creation
+ $post['name'] = 'Test Resource';
+ $response = $this->actingAs($john)->post("/api/v4/resources", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Resource created successfully.", $json['message']);
+ $this->assertCount(2, $json);
+
+ $resource = Resource::where('name', $post['name'])->first();
+ $this->assertInstanceOf(Resource::class, $resource);
+ $this->assertTrue($john->resources()->get()->contains($resource));
+
+ // Resource name must be unique within a domain
+ $response = $this->actingAs($john)->post("/api/v4/resources", $post);
+ $response->assertStatus(422);
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(2, $json);
+ $this->assertCount(1, $json['errors']);
+ $this->assertSame("The specified name is not available.", $json['errors']['name'][0]);
+ }
+
+ /**
+ * Test resource update (PUT /api/v4/resources/<resource>)
+ */
+ public function testUpdate(): void
+ {
+ Queue::fake();
+
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $resource = $this->getTestResource('resource-test@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+
+ // Test unauthorized update
+ $response = $this->get("/api/v4/resources/{$resource->id}", []);
+ $response->assertStatus(401);
+
+ // Test unauthorized update
+ $response = $this->actingAs($jack)->get("/api/v4/resources/{$resource->id}", []);
+ $response->assertStatus(403);
+
+ // Name change
+ $post = [
+ 'name' => 'Test Res',
+ ];
+
+ $response = $this->actingAs($john)->put("/api/v4/resources/{$resource->id}", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Resource updated successfully.", $json['message']);
+ $this->assertCount(2, $json);
+
+ $resource->refresh();
+ $this->assertSame($post['name'], $resource->name);
+ }
+}
diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php
index 1fcb7894..8dbc2244 100644
--- a/src/tests/Feature/Controller/SkusTest.php
+++ b/src/tests/Feature/Controller/SkusTest.php
@@ -1,264 +1,273 @@
<?php
namespace Tests\Feature\Controller;
use App\Entitlement;
use App\Http\Controllers\API\V4\SkusController;
use App\Sku;
use App\Tenant;
use Tests\TestCase;
class SkusTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
Sku::where('title', 'test')->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->clearBetaEntitlements();
$this->clearMeetEntitlements();
Sku::where('title', 'test')->delete();
parent::tearDown();
}
/**
* Test fetching SKUs list for a domain (GET /domains/<id>/skus)
*/
public function testDomainSkus(): void
{
$user = $this->getTestUser('john@kolab.org');
$domain = $this->getTestDomain('kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(401);
// Create an sku for another tenant, to make sure it is not included in the result
$nsku = Sku::create([
'title' => 'test',
'name' => 'Test',
'description' => '',
'active' => true,
'cost' => 100,
'handler_class' => 'App\Handlers\Domain',
]);
$tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first();
$nsku->tenant_id = $tenant->id;
$nsku->save();
$response = $this->actingAs($user)->get("api/v4/domains/{$domain->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSkuElement('domain-hosting', $json[0], [
'prio' => 0,
'type' => 'domain',
'handler' => 'domainhosting',
'enabled' => false,
'readonly' => false,
]);
}
/**
* Test fetching SKUs list
*/
public function testIndex(): void
{
// Unauth access not allowed
$response = $this->get("api/v4/skus");
$response->assertStatus(401);
$user = $this->getTestUser('john@kolab.org');
$sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
// Create an sku for another tenant, to make sure it is not included in the result
$nsku = Sku::create([
'title' => 'test',
'name' => 'Test',
'description' => '',
'active' => true,
'cost' => 100,
'handler_class' => 'App\Handlers\Mailbox',
]);
$tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first();
$nsku->tenant_id = $tenant->id;
$nsku->save();
$response = $this->actingAs($user)->get("api/v4/skus");
$response->assertStatus(200);
$json = $response->json();
- $this->assertCount(9, $json);
+ $this->assertCount(11, $json);
$this->assertSame(100, $json[0]['prio']);
$this->assertSame($sku->id, $json[0]['id']);
$this->assertSame($sku->title, $json[0]['title']);
$this->assertSame($sku->name, $json[0]['name']);
$this->assertSame($sku->description, $json[0]['description']);
$this->assertSame($sku->cost, $json[0]['cost']);
$this->assertSame($sku->units_free, $json[0]['units_free']);
$this->assertSame($sku->period, $json[0]['period']);
$this->assertSame($sku->active, $json[0]['active']);
$this->assertSame('user', $json[0]['type']);
$this->assertSame('mailbox', $json[0]['handler']);
}
/**
* Test fetching SKUs list for a user (GET /users/<id>/skus)
*/
public function testUserSkus(): void
{
$user = $this->getTestUser('john@kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(401);
// Create an sku for another tenant, to make sure it is not included in the result
$nsku = Sku::create([
'title' => 'test',
'name' => 'Test',
'description' => '',
'active' => true,
'cost' => 100,
'handler_class' => 'App\Handlers\Mailbox',
]);
$tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first();
$nsku->tenant_id = $tenant->id;
$nsku->save();
$response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(6, $json);
$this->assertSkuElement('mailbox', $json[0], [
'prio' => 100,
'type' => 'user',
'handler' => 'mailbox',
'enabled' => true,
'readonly' => true,
]);
$this->assertSkuElement('storage', $json[1], [
'prio' => 90,
'type' => 'user',
'handler' => 'storage',
'enabled' => true,
'readonly' => true,
'range' => [
'min' => 5,
'max' => 100,
'unit' => 'GB',
]
]);
$this->assertSkuElement('groupware', $json[2], [
'prio' => 80,
'type' => 'user',
'handler' => 'groupware',
'enabled' => false,
'readonly' => false,
]);
$this->assertSkuElement('activesync', $json[3], [
'prio' => 70,
'type' => 'user',
'handler' => 'activesync',
'enabled' => false,
'readonly' => false,
'required' => ['groupware'],
]);
$this->assertSkuElement('2fa', $json[4], [
'prio' => 60,
'type' => 'user',
'handler' => 'auth2f',
'enabled' => false,
'readonly' => false,
'forbidden' => ['activesync'],
]);
$this->assertSkuElement('meet', $json[5], [
'prio' => 50,
'type' => 'user',
'handler' => 'meet',
'enabled' => false,
'readonly' => false,
'required' => ['groupware'],
]);
// Test inclusion of beta SKUs
$sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$user->assignSku($sku);
$response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(200);
$json = $response->json();
- $this->assertCount(8, $json);
+ $this->assertCount(9, $json);
$this->assertSkuElement('beta', $json[6], [
'prio' => 10,
'type' => 'user',
'handler' => 'beta',
'enabled' => false,
'readonly' => false,
]);
- $this->assertSkuElement('distlist', $json[7], [
+ $this->assertSkuElement('beta-resources', $json[7], [
+ 'prio' => 10,
+ 'type' => 'user',
+ 'handler' => 'resources', // TODO: shouldn't it be beta-resources or beta/resources?
+ 'enabled' => false,
+ 'readonly' => false,
+ 'required' => ['beta'],
+ ]);
+
+ $this->assertSkuElement('distlist', $json[8], [
'prio' => 10,
'type' => 'user',
'handler' => 'distlist',
'enabled' => false,
'readonly' => false,
'required' => ['beta'],
]);
}
/**
* Assert content of the SKU element in an API response
*
* @param string $sku_title The SKU title
* @param array $result The result to assert
* @param array $other Other items the SKU itself does not include
*/
protected function assertSkuElement($sku_title, $result, $other = []): void
{
$sku = Sku::withEnvTenantContext()->where('title', $sku_title)->first();
$this->assertSame($sku->id, $result['id']);
$this->assertSame($sku->title, $result['title']);
$this->assertSame($sku->name, $result['name']);
$this->assertSame($sku->description, $result['description']);
$this->assertSame($sku->cost, $result['cost']);
$this->assertSame($sku->units_free, $result['units_free']);
$this->assertSame($sku->period, $result['period']);
$this->assertSame($sku->active, $result['active']);
foreach ($other as $key => $value) {
$this->assertSame($value, $result[$key]);
}
$this->assertCount(8 + count($other), $result);
}
}
diff --git a/src/tests/Feature/DomainTest.php b/src/tests/Feature/DomainTest.php
index 7f71fb62..620aee29 100644
--- a/src/tests/Feature/DomainTest.php
+++ b/src/tests/Feature/DomainTest.php
@@ -1,336 +1,347 @@
<?php
namespace Tests\Feature;
use App\Domain;
use App\Entitlement;
use App\Sku;
use App\User;
use App\Tenant;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class DomainTest extends TestCase
{
private $domains = [
'public-active.com',
'gmail.com',
'ci-success-cname.kolab.org',
'ci-success-txt.kolab.org',
'ci-failure-cname.kolab.org',
'ci-failure-txt.kolab.org',
'ci-failure-none.kolab.org',
];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
foreach ($this->domains as $domain) {
$this->deleteTestDomain($domain);
}
$this->deleteTestUser('user@gmail.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
foreach ($this->domains as $domain) {
$this->deleteTestDomain($domain);
}
$this->deleteTestUser('user@gmail.com');
parent::tearDown();
}
/**
* Test domain create/creating observer
*/
public function testCreate(): void
{
Queue::fake();
$domain = Domain::create([
'namespace' => 'GMAIL.COM',
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]);
$result = Domain::where('namespace', 'gmail.com')->first();
$this->assertSame('gmail.com', $result->namespace);
$this->assertSame($domain->id, $result->id);
$this->assertSame($domain->type, $result->type);
$this->assertSame(Domain::STATUS_NEW, $result->status);
}
/**
* Test domain creating jobs
*/
public function testCreateJobs(): void
{
// Fake the queue, assert that no jobs were pushed...
Queue::fake();
Queue::assertNothingPushed();
$domain = Domain::create([
'namespace' => 'gmail.com',
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]);
Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\Domain\CreateJob::class,
function ($job) use ($domain) {
$domainId = TestCase::getObjectProperty($job, 'domainId');
$domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace');
return $domainId === $domain->id &&
$domainNamespace === $domain->namespace;
}
);
$job = new \App\Jobs\Domain\CreateJob($domain->id);
$job->handle();
}
/**
* Tests getPublicDomains() method
*/
public function testGetPublicDomains(): void
{
$public_domains = Domain::getPublicDomains();
$this->assertNotContains('public-active.com', $public_domains);
$queue = Queue::fake();
$domain = Domain::create([
'namespace' => 'public-active.com',
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]);
// External domains should not be returned
$public_domains = Domain::getPublicDomains();
$this->assertNotContains('public-active.com', $public_domains);
$domain->type = Domain::TYPE_PUBLIC;
$domain->save();
$public_domains = Domain::getPublicDomains();
$this->assertContains('public-active.com', $public_domains);
// Domains of other tenants should not be returned
$tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first();
$domain->tenant_id = $tenant->id;
$domain->save();
$public_domains = Domain::getPublicDomains();
$this->assertNotContains('public-active.com', $public_domains);
}
/**
* Test domain (ownership) confirmation
*
* @group dns
*/
public function testConfirm(): void
{
/*
DNS records for positive and negative tests - kolab.org:
ci-success-cname A 212.103.80.148
ci-success-cname MX 10 mx01.kolabnow.com.
ci-success-cname TXT "v=spf1 mx -all"
kolab-verify.ci-success-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-success-cname
ci-failure-cname A 212.103.80.148
ci-failure-cname MX 10 mx01.kolabnow.com.
kolab-verify.ci-failure-cname CNAME 2b719cfa4e1033b1e1e132977ed4fe3e.ci-failure-cname
ci-success-txt A 212.103.80.148
ci-success-txt MX 10 mx01.kolabnow.com.
ci-success-txt TXT "v=spf1 mx -all"
ci-success-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422"
ci-failure-txt A 212.103.80.148
ci-failure-txt MX 10 mx01.kolabnow.com.
kolab-verify.ci-failure-txt TXT "kolab-verify=de5d04ababb52d52e2519a2f16d11422"
ci-failure-none A 212.103.80.148
ci-failure-none MX 10 mx01.kolabnow.com.
*/
$queue = Queue::fake();
$domain_props = ['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL];
$domain = $this->getTestDomain('ci-failure-none.kolab.org', $domain_props);
$this->assertTrue($domain->confirm() === false);
$this->assertFalse($domain->isConfirmed());
$domain = $this->getTestDomain('ci-failure-txt.kolab.org', $domain_props);
$this->assertTrue($domain->confirm() === false);
$this->assertFalse($domain->isConfirmed());
$domain = $this->getTestDomain('ci-failure-cname.kolab.org', $domain_props);
$this->assertTrue($domain->confirm() === false);
$this->assertFalse($domain->isConfirmed());
$domain = $this->getTestDomain('ci-success-txt.kolab.org', $domain_props);
$this->assertTrue($domain->confirm());
$this->assertTrue($domain->isConfirmed());
$domain = $this->getTestDomain('ci-success-cname.kolab.org', $domain_props);
$this->assertTrue($domain->confirm());
$this->assertTrue($domain->isConfirmed());
}
/**
* Test domain deletion
*/
public function testDelete(): void
{
Queue::fake();
$domain = $this->getTestDomain('gmail.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$domain->delete();
$this->assertTrue($domain->fresh()->trashed());
$this->assertFalse($domain->fresh()->isDeleted());
// Delete the domain for real
$job = new \App\Jobs\Domain\DeleteJob($domain->id);
$job->handle();
$this->assertTrue(Domain::withTrashed()->where('id', $domain->id)->first()->isDeleted());
$domain->forceDelete();
$this->assertCount(0, Domain::withTrashed()->where('id', $domain->id)->get());
}
/**
* Test isEmpty() method
*/
public function testIsEmpty(): void
{
Queue::fake();
+ $this->deleteTestUser('user@gmail.com');
+ $this->deleteTestGroup('group@gmail.com');
+ $this->deleteTestResource('resource@gmail.com');
+
// Empty domain
$domain = $this->getTestDomain('gmail.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]);
$this->assertTrue($domain->isEmpty());
- // TODO: Test with adding a group/alias/user, each separately
+ $this->getTestUser('user@gmail.com');
+ $this->assertFalse($domain->isEmpty());
+ $this->deleteTestUser('user@gmail.com');
+ $this->assertTrue($domain->isEmpty());
+ $this->getTestGroup('group@gmail.com');
+ $this->assertFalse($domain->isEmpty());
+ $this->deleteTestGroup('group@gmail.com');
+ $this->assertTrue($domain->isEmpty());
+ $this->getTestResource('resource@gmail.com');
+ $this->assertFalse($domain->isEmpty());
+ $this->deleteTestResource('resource@gmail.com');
+
+ // TODO: Test with an existing alias, but not other objects in a domain
// Empty public domain
$domain = Domain::where('namespace', 'libertymail.net')->first();
$this->assertFalse($domain->isEmpty());
-
- // Non-empty private domain
- $domain = Domain::where('namespace', 'kolab.org')->first();
-
- $this->assertFalse($domain->isEmpty());
}
/**
* Test domain restoring
*/
public function testRestore(): void
{
Queue::fake();
$domain = $this->getTestDomain('gmail.com', [
'status' => Domain::STATUS_NEW | Domain::STATUS_SUSPENDED
| Domain::STATUS_LDAP_READY | Domain::STATUS_CONFIRMED,
'type' => Domain::TYPE_PUBLIC,
]);
$user = $this->getTestUser('user@gmail.com');
$sku = \App\Sku::where('title', 'domain-hosting')->first();
$now = \Carbon\Carbon::now();
// Assign two entitlements to the domain, so we can assert that only the
// ones deleted last will be restored
$ent1 = \App\Entitlement::create([
'wallet_id' => $user->wallets->first()->id,
'sku_id' => $sku->id,
'cost' => 0,
'entitleable_id' => $domain->id,
'entitleable_type' => Domain::class,
]);
$ent2 = \App\Entitlement::create([
'wallet_id' => $user->wallets->first()->id,
'sku_id' => $sku->id,
'cost' => 0,
'entitleable_id' => $domain->id,
'entitleable_type' => Domain::class,
]);
$domain->delete();
$this->assertTrue($domain->fresh()->trashed());
$this->assertFalse($domain->fresh()->isDeleted());
$this->assertTrue($ent1->fresh()->trashed());
$this->assertTrue($ent2->fresh()->trashed());
// Backdate some properties
\App\Entitlement::withTrashed()->where('id', $ent2->id)->update(['deleted_at' => $now->subMinutes(2)]);
\App\Entitlement::withTrashed()->where('id', $ent1->id)->update(['updated_at' => $now->subMinutes(10)]);
Queue::fake();
$domain->restore();
$domain->refresh();
$this->assertFalse($domain->trashed());
$this->assertFalse($domain->isDeleted());
$this->assertFalse($domain->isSuspended());
$this->assertFalse($domain->isLdapReady());
$this->assertTrue($domain->isActive());
$this->assertTrue($domain->isConfirmed());
// Assert entitlements
$this->assertTrue($ent2->fresh()->trashed());
$this->assertFalse($ent1->fresh()->trashed());
$this->assertTrue($ent1->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5)));
// We expect only one CreateJob and one UpdateJob
// 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(2, 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\Domain\CreateJob::class,
function ($job) use ($domain) {
return $domain->id === TestCase::getObjectProperty($job, 'domainId');
}
);
}
}
diff --git a/src/tests/Feature/ResourceTest.php b/src/tests/Feature/ResourceTest.php
new file mode 100644
index 00000000..319ce544
--- /dev/null
+++ b/src/tests/Feature/ResourceTest.php
@@ -0,0 +1,352 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ResourceTest extends TestCase
+{
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('user-test@kolabnow.com');
+ Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) {
+ $this->deleteTestResource($resource->email);
+ });
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('user-test@kolabnow.com');
+ Resource::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($resource) {
+ $this->deleteTestResource($resource->email);
+ });
+
+ parent::tearDown();
+ }
+
+ /**
+ * Tests for Resource::assignToWallet()
+ */
+ public function testAssignToWallet(): void
+ {
+ $user = $this->getTestUser('user-test@kolabnow.com');
+ $resource = $this->getTestResource('resource-test@kolabnow.com');
+
+ $result = $resource->assignToWallet($user->wallets->first());
+
+ $this->assertSame($resource, $result);
+ $this->assertSame(1, $resource->entitlements()->count());
+
+ // Can't be done twice on the same resource
+ $this->expectException(\Exception::class);
+ $result->assignToWallet($user->wallets->first());
+ }
+
+ /**
+ * Test Resource::getConfig() and setConfig() methods
+ */
+ public function testConfigTrait(): void
+ {
+ Queue::fake();
+
+ $resource = new Resource();
+ $resource->email = 'resource-test@kolabnow.com';
+ $resource->name = 'Test';
+ $resource->save();
+ $john = $this->getTestUser('john@kolab.org');
+ $resource->assignToWallet($john->wallets->first());
+
+ $this->assertSame(['invitation_policy' => 'accept'], $resource->getConfig());
+
+ $result = $resource->setConfig(['invitation_policy' => 'reject', 'unknown' => false]);
+
+ $this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig());
+ $this->assertSame('reject', $resource->getSetting('invitation_policy'));
+ $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
+
+ $result = $resource->setConfig(['invitation_policy' => 'unknown']);
+
+ $this->assertSame(['invitation_policy' => 'reject'], $resource->getConfig());
+ $this->assertSame('reject', $resource->getSetting('invitation_policy'));
+ $this->assertSame(['invitation_policy' => "The specified invitation policy is invalid."], $result);
+
+ // Test valid user for manual invitation policy
+ $result = $resource->setConfig(['invitation_policy' => 'manual:john@kolab.org']);
+
+ $this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig());
+ $this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy'));
+ $this->assertSame([], $result);
+
+ // Test invalid user email for manual invitation policy
+ $result = $resource->setConfig(['invitation_policy' => 'manual:john']);
+
+ $this->assertSame(['invitation_policy' => 'manual:john@kolab.org'], $resource->getConfig());
+ $this->assertSame('manual:john@kolab.org', $resource->getSetting('invitation_policy'));
+ $this->assertSame(['invitation_policy' => "The specified email address is invalid."], $result);
+
+ // Test non-existing user for manual invitation policy
+ $result = $resource->setConfig(['invitation_policy' => 'manual:unknown@kolab.org']);
+ $this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result);
+
+ // Test existing user from a different wallet, for manual invitation policy
+ $result = $resource->setConfig(['invitation_policy' => 'manual:user@sample-tenant.dev-local']);
+ $this->assertSame(['invitation_policy' => "The specified email address does not exist."], $result);
+ }
+
+ /**
+ * Test creating a resource
+ */
+ public function testCreate(): void
+ {
+ Queue::fake();
+
+ $resource = new Resource();
+ $resource->name = 'Reśo';
+ $resource->domain = 'kolabnow.com';
+ $resource->save();
+
+ $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $resource->id);
+ $this->assertMatchesRegularExpression('/^resource-[0-9]{1,20}@kolabnow\.com$/', $resource->email);
+ $this->assertSame('Reśo', $resource->name);
+ $this->assertTrue($resource->isNew());
+ $this->assertTrue($resource->isActive());
+ $this->assertFalse($resource->isDeleted());
+ $this->assertFalse($resource->isLdapReady());
+ $this->assertFalse($resource->isImapReady());
+
+ $settings = $resource->settings()->get();
+ $this->assertCount(1, $settings);
+ $this->assertSame('folder', $settings[0]->key);
+ $this->assertSame('shared/Resources/Reśo@kolabnow.com', $settings[0]->value);
+
+ Queue::assertPushed(
+ \App\Jobs\Resource\CreateJob::class,
+ function ($job) use ($resource) {
+ $resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail');
+ $resourceId = TestCase::getObjectProperty($job, 'resourceId');
+
+ return $resourceEmail === $resource->email
+ && $resourceId === $resource->id;
+ }
+ );
+
+ Queue::assertPushedWithChain(
+ \App\Jobs\Resource\CreateJob::class,
+ [
+ \App\Jobs\Resource\VerifyJob::class,
+ ]
+ );
+ }
+
+ /**
+ * Test resource deletion and force-deletion
+ */
+ public function testDelete(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('user-test@kolabnow.com');
+ $resource = $this->getTestResource('resource-test@kolabnow.com');
+ $resource->assignToWallet($user->wallets->first());
+
+ $entitlements = \App\Entitlement::where('entitleable_id', $resource->id);
+
+ $this->assertSame(1, $entitlements->count());
+
+ $resource->delete();
+
+ $this->assertTrue($resource->fresh()->trashed());
+ $this->assertSame(0, $entitlements->count());
+ $this->assertSame(1, $entitlements->withTrashed()->count());
+
+ $resource->forceDelete();
+
+ $this->assertSame(0, $entitlements->withTrashed()->count());
+ $this->assertCount(0, Resource::withTrashed()->where('id', $resource->id)->get());
+
+ Queue::assertPushed(\App\Jobs\Resource\DeleteJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\Resource\DeleteJob::class,
+ function ($job) use ($resource) {
+ $resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail');
+ $resourceId = TestCase::getObjectProperty($job, 'resourceId');
+
+ return $resourceEmail === $resource->email
+ && $resourceId === $resource->id;
+ }
+ );
+ }
+
+ /**
+ * Tests for Resource::emailExists()
+ */
+ public function testEmailExists(): void
+ {
+ Queue::fake();
+
+ $resource = $this->getTestResource('resource-test@kolabnow.com');
+
+ $this->assertFalse(Resource::emailExists('unknown@domain.tld'));
+ $this->assertTrue(Resource::emailExists($resource->email));
+
+ $result = Resource::emailExists($resource->email, true);
+ $this->assertSame($result->id, $resource->id);
+
+ $resource->delete();
+
+ $this->assertTrue(Resource::emailExists($resource->email));
+
+ $result = Resource::emailExists($resource->email, true);
+ $this->assertSame($result->id, $resource->id);
+ }
+
+ /**
+ * Tests for SettingsTrait functionality and ResourceSettingObserver
+ */
+ public function testSettings(): void
+ {
+ Queue::fake();
+ Queue::assertNothingPushed();
+
+ $resource = $this->getTestResource('resource-test@kolabnow.com');
+
+ Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0);
+
+ // Add a setting
+ $resource->setSetting('unknown', 'test');
+
+ Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0);
+
+ // Add a setting that is synced to LDAP
+ $resource->setSetting('invitation_policy', 'accept');
+
+ Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1);
+
+ // Note: We test both current resource as well as fresh resource object
+ // to make sure cache works as expected
+ $this->assertSame('test', $resource->getSetting('unknown'));
+ $this->assertSame('accept', $resource->fresh()->getSetting('invitation_policy'));
+
+ Queue::fake();
+
+ // Update a setting
+ $resource->setSetting('unknown', 'test1');
+
+ Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0);
+
+ // Update a setting that is synced to LDAP
+ $resource->setSetting('invitation_policy', 'reject');
+
+ Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1);
+
+ $this->assertSame('test1', $resource->getSetting('unknown'));
+ $this->assertSame('reject', $resource->fresh()->getSetting('invitation_policy'));
+
+ Queue::fake();
+
+ // Delete a setting (null)
+ $resource->setSetting('unknown', null);
+
+ Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 0);
+
+ // Delete a setting that is synced to LDAP
+ $resource->setSetting('invitation_policy', null);
+
+ Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1);
+
+ $this->assertSame(null, $resource->getSetting('unknown'));
+ $this->assertSame(null, $resource->fresh()->getSetting('invitation_policy'));
+ }
+
+ /**
+ * Test resource status assignment and is*() methods
+ */
+ public function testStatus(): void
+ {
+ $resource = new Resource();
+
+ $this->assertSame(false, $resource->isNew());
+ $this->assertSame(false, $resource->isActive());
+ $this->assertSame(false, $resource->isDeleted());
+ $this->assertSame(false, $resource->isLdapReady());
+ $this->assertSame(false, $resource->isImapReady());
+
+ $resource->status = Resource::STATUS_NEW;
+
+ $this->assertSame(true, $resource->isNew());
+ $this->assertSame(false, $resource->isActive());
+ $this->assertSame(false, $resource->isDeleted());
+ $this->assertSame(false, $resource->isLdapReady());
+ $this->assertSame(false, $resource->isImapReady());
+
+ $resource->status |= Resource::STATUS_ACTIVE;
+
+ $this->assertSame(true, $resource->isNew());
+ $this->assertSame(true, $resource->isActive());
+ $this->assertSame(false, $resource->isDeleted());
+ $this->assertSame(false, $resource->isLdapReady());
+ $this->assertSame(false, $resource->isImapReady());
+
+ $resource->status |= Resource::STATUS_LDAP_READY;
+
+ $this->assertSame(true, $resource->isNew());
+ $this->assertSame(true, $resource->isActive());
+ $this->assertSame(false, $resource->isDeleted());
+ $this->assertSame(true, $resource->isLdapReady());
+ $this->assertSame(false, $resource->isImapReady());
+
+ $resource->status |= Resource::STATUS_DELETED;
+
+ $this->assertSame(true, $resource->isNew());
+ $this->assertSame(true, $resource->isActive());
+ $this->assertSame(true, $resource->isDeleted());
+ $this->assertSame(true, $resource->isLdapReady());
+ $this->assertSame(false, $resource->isImapReady());
+
+ $resource->status |= Resource::STATUS_IMAP_READY;
+
+ $this->assertSame(true, $resource->isNew());
+ $this->assertSame(true, $resource->isActive());
+ $this->assertSame(true, $resource->isDeleted());
+ $this->assertSame(true, $resource->isLdapReady());
+ $this->assertSame(true, $resource->isImapReady());
+
+ // Unknown status value
+ $this->expectException(\Exception::class);
+ $resource->status = 111;
+ }
+
+ /**
+ * Test updating a resource
+ */
+ public function testUpdate(): void
+ {
+ Queue::fake();
+
+ $resource = $this->getTestResource('resource-test@kolabnow.com');
+
+ $resource->name = 'New';
+ $resource->save();
+
+ // Assert the folder changes on a resource name change
+ $settings = $resource->settings()->where('key', 'folder')->get();
+ $this->assertCount(1, $settings);
+ $this->assertSame('shared/Resources/New@kolabnow.com', $settings[0]->value);
+
+ Queue::assertPushed(\App\Jobs\Resource\UpdateJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\Resource\UpdateJob::class,
+ function ($job) use ($resource) {
+ $resourceEmail = TestCase::getObjectProperty($job, 'resourceEmail');
+ $resourceId = TestCase::getObjectProperty($job, 'resourceId');
+
+ return $resourceEmail === $resource->email
+ && $resourceId === $resource->id;
+ }
+ );
+ }
+}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
index 271b4d11..d6f39dd6 100644
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -1,1012 +1,1048 @@
<?php
namespace Tests\Feature;
use App\Domain;
use App\Group;
use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class UserTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
$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->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->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
parent::tearDown();
}
/**
* Tests for User::assignPackage()
*/
public function testAssignPackage(): void
{
$this->markTestIncomplete();
}
/**
* 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 = collect($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 = collect($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 = collect($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
*/
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'));
}
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.
$user = $this->getTestUser('john@kolab.org');
$storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
$count = 0;
foreach ($user->entitlements()->get() as $entitlement) {
if ($entitlement->sku_id == $storage_sku->id) {
$count += 1;
}
}
$this->assertTrue($count == 5);
}
/**
* 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
+ // 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());
$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);
$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());
// 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->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->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domain->isDeleted());
$this->assertFalse($group->isDeleted());
+ $this->assertFalse($resource->isDeleted());
$userA->forceDelete();
$all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id);
$this->assertSame(0, $all_entitlements->withTrashed()->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());
}
/**
* 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::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 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);
$this->assertSame($wallet->id, $users[0]->wallet_id);
$this->assertSame($wallet->id, $users[1]->wallet_id);
$this->assertSame($wallet->id, $users[2]->wallet_id);
$this->assertSame($wallet->id, $users[3]->wallet_id);
$users = $jack->users()->orderBy('email')->get();
$this->assertCount(0, $users);
$users = $ned->users()->orderBy('email')->get();
$this->assertCount(4, $users);
}
public function testWallets(): void
{
$this->markTestIncomplete();
}
}
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
index 7817f2ca..6580ea2a 100644
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -1,479 +1,547 @@
<?php
namespace Tests;
use App\Domain;
use App\Group;
+use App\Resource;
+use App\Sku;
use App\Transaction;
use App\User;
use Carbon\Carbon;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Assert;
trait TestCaseTrait
{
/**
* A domain that is hosted.
*
* @var ?\App\Domain
*/
protected $domainHosted;
/**
* The hosted domain owner.
*
* @var ?\App\User
*/
protected $domainOwner;
/**
* Some profile details for an owner of a domain
*
* @var array
*/
protected $domainOwnerSettings = [
'first_name' => '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
+ {
+ // 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);
+ }
+
/**
* 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.
+ * @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)
{
$wallets = [];
$ids = [];
foreach ($entitlements as $entitlement) {
$ids[] = $entitlement->id;
$wallets[] = $entitlement->wallet_id;
}
\App\Entitlement::whereIn('id', $ids)->update([
'created_at' => $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]);
}
}
/**
* Removes all beta entitlements from the database
*/
protected function clearBetaEntitlements(): void
{
$beta_handlers = [
'App\Handlers\Beta',
+ 'App\Handlers\Beta\Resources',
'App\Handlers\Distlist',
];
- $betas = \App\Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all();
+ $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;
}
$job = new \App\Jobs\Group\DeleteJob($group->id);
$job->handle();
$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;
+ }
+
+ $job = new \App\Jobs\Resource\DeleteJob($resource->id);
+ $job->handle();
+
+ $resource->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;
}
$job = new \App\Jobs\User\DeleteJob($user->id);
$job->handle();
$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 name+domain, 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 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(\App\Sku::where('title', '2fa')->first());
+ $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();
}
}

File Metadata

Mime Type
text/x-diff
Expires
Mon, Feb 2, 4:39 AM (1 d, 16 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
426788
Default Alt Text
(891 KB)

Event Timeline