Page MenuHomePhorge

No OneTemporary

Size
801 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 41d4bfe0..1fc1ef04 100644
--- a/src/app/Backends/IMAP.php
+++ b/src/app/Backends/IMAP.php
@@ -1,148 +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
+ * @param string $folder Folder name, e.g. 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)) {
+ if (\preg_match('#^(shared/|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();
// 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 24392fb3..16ede429 100644
--- a/src/app/Backends/LDAP.php
+++ b/src/app/Backends/LDAP.php
@@ -1,1211 +1,1379 @@
<?php
namespace App\Backends;
use App\Domain;
use App\Group;
use App\Resource;
+use App\SharedFolder;
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 Shared folder settings used by the backend */
+ public const SHARED_FOLDER_SETTINGS = [
+ 'folder',
+ 'acl',
+ ];
+
/** @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 shared folder in LDAP.
+ *
+ * @param \App\SharedFolder $folder The shared folder to create.
+ *
+ * @throws \Exception
+ */
+ public static function createSharedFolder(SharedFolder $folder): void
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ $domainName = explode('@', $folder->email, 2)[1];
+ $cn = $ldap->quote_string($folder->name);
+ $dn = "cn={$cn}," . self::baseDN($domainName, 'Shared Folders');
+
+ $entry = [
+ 'mail' => $folder->email,
+ 'objectclass' => [
+ 'top',
+ 'kolabsharedfolder',
+ 'mailrecipient',
+ ],
+ ];
+
+ self::setSharedFolderAttributes($ldap, $folder, $entry);
+
+ self::addEntry(
+ $ldap,
+ $dn,
+ $entry,
+ "Failed to create shared folder {$folder->id} in LDAP (" . __LINE__ . ")"
+ );
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+ }
+
/**
* Create a user in LDAP.
*
* Only need to add user if in any of the local domains? Figure that out here for now. Should
* have Context-Based Access Controls before the job is queued though, probably.
*
* Use one of three modes;
*
* 1) The authenticated user account.
*
* * Only valid if the authenticated user is a domain admin.
* * We don't know the originating user here.
* * We certainly don't have its password anymore.
*
* 2) The hosted kolab account.
*
* 3) The Directory Manager account.
*
* @param \App\User $user The user account to create.
*
* @throws \Exception
*/
public static function createUser(User $user): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$entry = [
'objectclass' => [
'top',
'inetorgperson',
'inetuser',
'kolabinetorgperson',
'mailrecipient',
'person'
],
'mail' => $user->email,
'uid' => $user->email,
'nsroledn' => []
];
if (!self::getUserEntry($ldap, $user->email, $dn)) {
if (empty($dn)) {
self::throwException($ldap, "Failed to create user {$user->email} in LDAP (" . __LINE__ . ")");
}
self::setUserAttributes($user, $entry);
self::addEntry(
$ldap,
$dn,
$entry,
"Failed to create user {$user->email} in LDAP (" . __LINE__ . ")"
);
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Delete a domain from LDAP.
*
* @param \App\Domain $domain The domain to delete
*
* @throws \Exception
*/
public static function deleteDomain(Domain $domain): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$domainBaseDN = self::baseDN($domain->namespace);
if ($ldap->get_entry($domainBaseDN)) {
$result = $ldap->delete_entry_recursive($domainBaseDN);
if (!$result) {
self::throwException(
$ldap,
"Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")"
);
}
}
if ($ldap_domain = $ldap->find_domain($domain->namespace)) {
if ($ldap->get_entry($ldap_domain['dn'])) {
$result = $ldap->delete_entry($ldap_domain['dn']);
if (!$result) {
self::throwException(
$ldap,
"Failed to delete domain {$domain->namespace} from LDAP (" . __LINE__ . ")"
);
}
}
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Delete a group from LDAP.
*
* @param \App\Group $group The group to delete.
*
* @throws \Exception
*/
public static function deleteGroup(Group $group): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
if (self::getGroupEntry($ldap, $group->email, $dn)) {
$result = $ldap->delete_entry($dn);
if (!$result) {
self::throwException(
$ldap,
"Failed to delete group {$group->email} from LDAP (" . __LINE__ . ")"
);
}
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Delete a resource from LDAP.
*
* @param \App\Resource $resource The resource to delete.
*
* @throws \Exception
*/
public static function deleteResource(Resource $resource): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
if (self::getResourceEntry($ldap, $resource->email, $dn)) {
$result = $ldap->delete_entry($dn);
if (!$result) {
self::throwException(
$ldap,
"Failed to delete resource {$resource->email} from LDAP (" . __LINE__ . ")"
);
}
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
+ /**
+ * Delete a shared folder from LDAP.
+ *
+ * @param \App\SharedFolder $folder The shared folder to delete.
+ *
+ * @throws \Exception
+ */
+ public static function deleteSharedFolder(SharedFolder $folder): void
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ if (self::getSharedFolderEntry($ldap, $folder->email, $dn)) {
+ $result = $ldap->delete_entry($dn);
+
+ if (!$result) {
+ self::throwException(
+ $ldap,
+ "Failed to delete shared folder {$folder->id} from LDAP (" . __LINE__ . ")"
+ );
+ }
+ }
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+ }
+
/**
* Delete a user from LDAP.
*
* @param \App\User $user The user account to delete.
*
* @throws \Exception
*/
public static function deleteUser(User $user): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
if (self::getUserEntry($ldap, $user->email, $dn)) {
$result = $ldap->delete_entry($dn);
if (!$result) {
self::throwException(
$ldap,
"Failed to delete user {$user->email} from LDAP (" . __LINE__ . ")"
);
}
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Get a domain data from LDAP.
*
* @param string $namespace The domain name
*
* @return array|false|null
* @throws \Exception
*/
public static function getDomain(string $namespace)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$ldapDomain = $ldap->find_domain($namespace);
if ($ldapDomain) {
$domain = $ldap->get_entry($ldapDomain['dn']);
}
if (empty(self::$ldap)) {
$ldap->close();
}
return $domain ?? null;
}
/**
* Get a group data from LDAP.
*
* @param string $email The group email.
*
* @return array|false|null
* @throws \Exception
*/
public static function getGroup(string $email)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$group = self::getGroupEntry($ldap, $email, $dn);
if (empty(self::$ldap)) {
$ldap->close();
}
return $group;
}
/**
* Get a resource data from LDAP.
*
* @param string $email The resource email.
*
* @return array|false|null
* @throws \Exception
*/
public static function getResource(string $email)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$resource = self::getResourceEntry($ldap, $email, $dn);
if (empty(self::$ldap)) {
$ldap->close();
}
return $resource;
}
+ /**
+ * Get a shared folder data from LDAP.
+ *
+ * @param string $email The resource email.
+ *
+ * @return array|false|null
+ * @throws \Exception
+ */
+ public static function getSharedFolder(string $email)
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ $folder = self::getSharedFolderEntry($ldap, $email, $dn);
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+
+ return $folder;
+ }
+
/**
* Get a user data from LDAP.
*
* @param string $email The user email.
*
* @return array|false|null
* @throws \Exception
*/
public static function getUser(string $email)
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$user = self::getUserEntry($ldap, $email, $dn, true);
if (empty(self::$ldap)) {
$ldap->close();
}
return $user;
}
/**
* Update a domain in LDAP.
*
* @param \App\Domain $domain The domain to update.
*
* @throws \Exception
*/
public static function updateDomain(Domain $domain): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$ldapDomain = $ldap->find_domain($domain->namespace);
if (!$ldapDomain) {
self::throwException(
$ldap,
"Failed to update domain {$domain->namespace} in LDAP (domain not found)"
);
}
$oldEntry = $ldap->get_entry($ldapDomain['dn']);
$newEntry = $oldEntry;
self::setDomainAttributes($domain, $newEntry);
if (array_key_exists('inetdomainstatus', $newEntry)) {
$newEntry['inetdomainstatus'] = (string) $newEntry['inetdomainstatus'];
}
$result = $ldap->modify_entry($ldapDomain['dn'], $oldEntry, $newEntry);
if (!is_array($result)) {
self::throwException(
$ldap,
"Failed to update domain {$domain->namespace} in LDAP (" . __LINE__ . ")"
);
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Update a group in LDAP.
*
* @param \App\Group $group The group to update
*
* @throws \Exception
*/
public static function updateGroup(Group $group): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$newEntry = $oldEntry = self::getGroupEntry($ldap, $group->email, $dn);
if (empty($oldEntry)) {
self::throwException(
$ldap,
"Failed to update group {$group->email} in LDAP (group not found)"
);
}
self::setGroupAttributes($ldap, $group, $newEntry);
$result = $ldap->modify_entry($dn, $oldEntry, $newEntry);
if (!is_array($result)) {
self::throwException(
$ldap,
"Failed to update group {$group->email} in LDAP (" . __LINE__ . ")"
);
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Update a resource in LDAP.
*
* @param \App\Resource $resource The resource to update
*
* @throws \Exception
*/
public static function updateResource(Resource $resource): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$newEntry = $oldEntry = self::getResourceEntry($ldap, $resource->email, $dn);
if (empty($oldEntry)) {
self::throwException(
$ldap,
"Failed to update resource {$resource->email} in LDAP (resource not found)"
);
}
self::setResourceAttributes($ldap, $resource, $newEntry);
$result = $ldap->modify_entry($dn, $oldEntry, $newEntry);
if (!is_array($result)) {
self::throwException(
$ldap,
"Failed to update resource {$resource->email} in LDAP (" . __LINE__ . ")"
);
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
+ /**
+ * Update a shared folder in LDAP.
+ *
+ * @param \App\SharedFolder $folder The shared folder to update
+ *
+ * @throws \Exception
+ */
+ public static function updateSharedFolder(SharedFolder $folder): void
+ {
+ $config = self::getConfig('admin');
+ $ldap = self::initLDAP($config);
+
+ $newEntry = $oldEntry = self::getSharedFolderEntry($ldap, $folder->email, $dn);
+
+ if (empty($oldEntry)) {
+ self::throwException(
+ $ldap,
+ "Failed to update shared folder {$folder->id} in LDAP (folder not found)"
+ );
+ }
+
+ self::setSharedFolderAttributes($ldap, $folder, $newEntry);
+
+ $result = $ldap->modify_entry($dn, $oldEntry, $newEntry);
+
+ if (!is_array($result)) {
+ self::throwException(
+ $ldap,
+ "Failed to update shared folder {$folder->id} in LDAP (" . __LINE__ . ")"
+ );
+ }
+
+ if (empty(self::$ldap)) {
+ $ldap->close();
+ }
+ }
+
/**
* Update a user in LDAP.
*
* @param \App\User $user The user account to update.
*
* @throws \Exception
*/
public static function updateUser(User $user): void
{
$config = self::getConfig('admin');
$ldap = self::initLDAP($config);
$newEntry = $oldEntry = self::getUserEntry($ldap, $user->email, $dn, true);
if (!$oldEntry) {
self::throwException(
$ldap,
"Failed to update user {$user->email} in LDAP (user not found)"
);
}
self::setUserAttributes($user, $newEntry);
if (array_key_exists('objectclass', $newEntry)) {
if (!in_array('inetuser', $newEntry['objectclass'])) {
$newEntry['objectclass'][] = 'inetuser';
}
}
if (array_key_exists('inetuserstatus', $newEntry)) {
$newEntry['inetuserstatus'] = (string) $newEntry['inetuserstatus'];
}
if (array_key_exists('mailquota', $newEntry)) {
$newEntry['mailquota'] = (string) $newEntry['mailquota'];
}
$result = $ldap->modify_entry($dn, $oldEntry, $newEntry);
if (!is_array($result)) {
self::throwException(
$ldap,
"Failed to update user {$user->email} in LDAP (" . __LINE__ . ")"
);
}
if (empty(self::$ldap)) {
$ldap->close();
}
}
/**
* Initialize connection to LDAP
*/
private static function initLDAP(array $config, string $privilege = 'admin')
{
if (self::$ldap) {
return self::$ldap;
}
$ldap = new \Net_LDAP3($config);
$connected = $ldap->connect();
if (!$connected) {
throw new \Exception("Failed to connect to LDAP");
}
$bound = $ldap->bind(
\config("ldap.{$privilege}.bind_dn"),
\config("ldap.{$privilege}.bind_pw")
);
if (!$bound) {
throw new \Exception("Failed to bind to LDAP");
}
return $ldap;
}
/**
* Set domain attributes
*/
private static function setDomainAttributes(Domain $domain, array &$entry)
{
$entry['inetdomainstatus'] = $domain->status;
}
/**
* Convert group member addresses in to valid entries.
*/
private static function setGroupAttributes($ldap, Group $group, &$entry)
{
$settings = $group->getSettings(['sender_policy']);
$entry['kolaballowsmtpsender'] = json_decode($settings['sender_policy'] ?: '[]', true);
$entry['cn'] = $group->name;
$entry['uniquemember'] = [];
$groupDomain = explode('@', $group->email, 2)[1];
$domainBaseDN = self::baseDN($groupDomain);
$validMembers = [];
foreach ($group->members as $member) {
list($local, $domainName) = explode('@', $member);
$memberDN = "uid={$member},ou=People,{$domainBaseDN}";
$memberEntry = $ldap->get_entry($memberDN);
// if the member is in the local domain but doesn't exist, drop it
if ($domainName == $groupDomain && !$memberEntry) {
continue;
}
// add the member if not in the local domain
if (!$memberEntry) {
$memberEntry = [
'cn' => $member,
'mail' => $member,
'objectclass' => [
'top',
'inetorgperson',
'organizationalperson',
'person'
],
'sn' => 'unknown'
];
$ldap->add_entry($memberDN, $memberEntry);
}
$entry['uniquemember'][] = $memberDN;
$validMembers[] = $member;
}
// Update members in sql (some might have been removed),
// skip model events to not invoke another update job
if ($group->members !== $validMembers) {
$group->members = $validMembers;
Group::withoutEvents(function () use ($group) {
$group->save();
});
}
}
/**
* Set common resource attributes
*/
private static function setResourceAttributes($ldap, Resource $resource, &$entry)
{
$entry['cn'] = $resource->name;
$entry['owner'] = null;
$entry['kolabinvitationpolicy'] = null;
$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 shared folder attributes
+ */
+ private static function setSharedFolderAttributes($ldap, SharedFolder $folder, &$entry)
+ {
+ $settings = $folder->getSettings(['acl', 'folder']);
+
+ $entry['cn'] = $folder->name;
+ $entry['kolabfoldertype'] = $folder->type;
+ $entry['kolabtargetfolder'] = $settings['folder'] ?? '';
+ $entry['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : '';
+ }
+
/**
* Set common user attributes
*/
private static function setUserAttributes(User $user, array &$entry)
{
$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 null|array Group entry, False on error, NULL if not found
*/
private static function getGroupEntry($ldap, $email, &$dn = null)
{
$domainName = explode('@', $email, 2)[1];
$base_dn = self::baseDN($domainName, 'Groups');
$attrs = ['dn', 'cn', 'mail', 'uniquemember', 'objectclass', 'kolaballowsmtpsender'];
// For groups we're using search() instead of get_entry() because
// a group name is not constant, so e.g. on update we might have
// the new name, but not the old one. Email address is constant.
return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn);
}
/**
* Get a resource entry from LDAP.
*
* @param \Net_LDAP3 $ldap Ldap connection
* @param string $email Resource email (mail)
* @param string $dn Reference to the resource DN
*
* @return null|array Resource entry, NULL if not found
*/
private static function getResourceEntry($ldap, $email, &$dn = null)
{
$domainName = explode('@', $email, 2)[1];
$base_dn = self::baseDN($domainName, 'Resources');
$attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder',
'kolabfoldertype', 'kolabinvitationpolicy', 'owner'];
// For resources we're using search() instead of get_entry() because
// a resource name is not constant, so e.g. on update we might have
// the new name, but not the old one. Email address is constant.
return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn);
}
+ /**
+ * Get a shared folder entry from LDAP.
+ *
+ * @param \Net_LDAP3 $ldap Ldap connection
+ * @param string $email Resource email (mail)
+ * @param string $dn Reference to the shared folder DN
+ *
+ * @return null|array Shared folder entry, NULL if not found
+ */
+ private static function getSharedFolderEntry($ldap, $email, &$dn = null)
+ {
+ $domainName = explode('@', $email, 2)[1];
+ $base_dn = self::baseDN($domainName, 'Shared Folders');
+
+ $attrs = ['dn', 'cn', 'mail', 'objectclass', 'kolabtargetfolder', 'kolabfoldertype', 'acl'];
+
+ // For shared folders we're using search() instead of get_entry() because
+ // a folder name is not constant, so e.g. on update we might have
+ // the new name, but not the old one. Email address is constant.
+ return self::searchEntry($ldap, $base_dn, "(mail=$email)", $attrs, $dn);
+ }
+
/**
* Get user entry from LDAP.
*
* @param \Net_LDAP3 $ldap Ldap connection
* @param string $email User email (uid)
* @param string $dn Reference to user DN
* @param bool $full Get extra attributes, e.g. nsroledn
*
* @return ?array User entry, NULL if not found
*/
private static function getUserEntry($ldap, $email, &$dn = null, $full = false)
{
$domainName = explode('@', $email, 2)[1];
$dn = "uid={$email}," . self::baseDN($domainName, 'People');
$entry = $ldap->get_entry($dn);
if ($entry && $full) {
if (!array_key_exists('nsroledn', $entry)) {
$roles = $ldap->get_entry_attributes($dn, ['nsroledn']);
if (!empty($roles)) {
$entry['nsroledn'] = (array) $roles['nsroledn'];
}
}
}
return $entry ?: null;
}
/**
* Logging callback
*/
public static function logHook($level, $msg): void
{
if (
(
$level == LOG_INFO
|| $level == LOG_DEBUG
|| $level == LOG_NOTICE
)
&& !\config('app.debug')
) {
return;
}
switch ($level) {
case LOG_CRIT:
$function = 'critical';
break;
case LOG_EMERG:
$function = 'emergency';
break;
case LOG_ERR:
$function = 'error';
break;
case LOG_ALERT:
$function = 'alert';
break;
case LOG_WARNING:
$function = 'warning';
break;
case LOG_INFO:
$function = 'info';
break;
case LOG_DEBUG:
$function = 'debug';
break;
case LOG_NOTICE:
$function = 'notice';
break;
default:
$function = 'info';
}
if (is_array($msg)) {
$msg = implode("\n", $msg);
}
$msg = '[LDAP] ' . $msg;
\Log::{$function}($msg);
}
/**
* A wrapper for Net_LDAP3::add_entry() with error handler
*
* @param \Net_LDAP3 $ldap Ldap connection
* @param string $dn Entry DN
* @param array $entry Entry attributes
* @param ?string $errorMsg A message to throw as an exception on error
*
* @throws \Exception
*/
private static function addEntry($ldap, string $dn, array $entry, $errorMsg = null)
{
// try/catch because Laravel converts warnings into exceptions
// and we want more human-friendly error message than that
try {
$result = $ldap->add_entry($dn, $entry);
} catch (\Exception $e) {
$result = false;
}
if (!$result) {
if (!$errorMsg) {
$errorMsg = "LDAP Error (" . __LINE__ . ")";
}
if (isset($e)) {
$errorMsg .= ": " . $e->getMessage();
}
self::throwException($ldap, $errorMsg);
}
}
/**
* Find a single entry in LDAP by using search.
*
* @param \Net_LDAP3 $ldap Ldap connection
* @param string $base_dn Base DN
* @param string $filter Search filter
* @param array $attrs Result attributes
* @param string $dn Reference to a DN of the found entry
*
* @return null|array LDAP entry, NULL if not found
*/
private static function searchEntry($ldap, $base_dn, $filter, $attrs, &$dn = null)
{
$result = $ldap->search($base_dn, $filter, 'sub', $attrs);
if ($result && $result->count() == 1) {
$entries = $result->entries(true);
$dn = key($entries);
$entry = $entries[$dn];
$entry['dn'] = $dn;
return $entry;
}
return null;
}
/**
* Throw exception and close the connection when needed
*
* @param \Net_LDAP3 $ldap Ldap connection
* @param string $message Exception message
*
* @throws \Exception
*/
private static function throwException($ldap, string $message): void
{
if (empty(self::$ldap) && !empty($ldap)) {
$ldap->close();
}
throw new \Exception($message);
}
/**
* Create a base DN string for specified object
*
* @param string $domainName Domain namespace
* @param ?string $ouName Optional name of the sub-tree (OU)
*
* @return string Full base DN
*/
private static function baseDN(string $domainName, string $ouName = null): string
{
$hostedRootDN = \config('ldap.hosted.root_dn');
$dn = "ou={$domainName},{$hostedRootDN}";
if ($ouName) {
$dn = "ou={$ouName},{$dn}";
}
return $dn;
}
}
diff --git a/src/app/Console/Commands/SharedFoldersCommand.php b/src/app/Console/Commands/SharedFoldersCommand.php
new file mode 100644
index 00000000..0df8cf3b
--- /dev/null
+++ b/src/app/Console/Commands/SharedFoldersCommand.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Console\ObjectListCommand;
+
+class SharedFoldersCommand extends ObjectListCommand
+{
+ protected $objectClass = \App\SharedFolder::class;
+ protected $objectName = 'shared-folder';
+ protected $objectTitle = 'name';
+}
diff --git a/src/app/Domain.php b/src/app/Domain.php
index 0d300ae1..33013a6d 100644
--- a/src/app/Domain.php
+++ b/src/app/Domain.php
@@ -1,501 +1,502 @@
<?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()
+ || \App\SharedFolder::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/SharedFolders.php b/src/app/Handlers/Beta/SharedFolders.php
new file mode 100644
index 00000000..93615186
--- /dev/null
+++ b/src/app/Handlers/Beta/SharedFolders.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace App\Handlers\Beta;
+
+class SharedFolders 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/SharedFolder.php b/src/app/Handlers/SharedFolder.php
index 52a8963e..e0582bac 100644
--- a/src/app/Handlers/SharedFolder.php
+++ b/src/app/Handlers/SharedFolder.php
@@ -1,17 +1,16 @@
<?php
namespace App\Handlers;
class SharedFolder extends \App\Handlers\Base
{
/**
* The entitleable class for this handler.
*
* @return string
*/
public static function entitleableClass(): string
{
- // TODO
- return '';
+ return \App\SharedFolder::class;
}
}
diff --git a/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php
new file mode 100644
index 00000000..88eacb53
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Admin/SharedFoldersController.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Admin;
+
+use App\SharedFolder;
+use App\User;
+use Illuminate\Http\Request;
+
+class SharedFoldersController extends \App\Http\Controllers\API\V4\SharedFoldersController
+{
+ /**
+ * Search for shared folders
+ *
+ * @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->sharedFolders(false)->orderBy('name')->get();
+ }
+ } elseif (!empty($search)) {
+ if ($folder = SharedFolder::where('email', $search)->first()) {
+ $result->push($folder);
+ }
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($folder) {
+ return $this->objectToClient($folder);
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxsharedfolders', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+
+ /**
+ * Create a new shared folder.
+ *
+ * @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 cf5664bd..d6665bbb 100644
--- a/src/app/Http/Controllers/API/V4/Admin/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/UsersController.php
@@ -1,380 +1,382 @@
<?php
namespace App\Http\Controllers\API\V4\Admin;
use App\Domain;
use App\Sku;
use App\User;
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 = \App\UserAlias::where('alias', $search)->get()->pluck('user_id');
// Search by an 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 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();
+ } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) {
+ $user_ids = $user_ids->merge([$folder->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/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
index 28b77266..8f62437c 100644
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -1,482 +1,482 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Providers\PaymentProvider;
use App\Tenant;
use App\Wallet;
use App\Payment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PaymentsController extends Controller
{
/**
* Get the auto-payment mandate info.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandate()
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$mandate = self::walletMandate($wallet);
return response()->json($mandate);
}
/**
* Create a new auto-payment mandate.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateCreate(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
// Input validation
if ($errors = self::mandateValidate($request, $wallet)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$wallet->setSettings([
'mandate_amount' => $request->amount,
'mandate_balance' => $request->balance,
]);
$mandate = [
'currency' => $wallet->currency,
'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup',
'methodId' => $request->methodId
];
// Normally the auto-payment setup operation is 0, if the balance is below the threshold
// we'll top-up the wallet with the configured auto-payment amount
if ($wallet->balance < intval($request->balance * 100)) {
$mandate['amount'] = intval($request->amount * 100);
}
$provider = PaymentProvider::factory($wallet);
$result = $provider->createMandate($wallet, $mandate);
$result['status'] = 'success';
return response()->json($result);
}
/**
* Revoke the auto-payment mandate.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateDelete()
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$provider = PaymentProvider::factory($wallet);
$provider->deleteMandate($wallet);
$wallet->setSetting('mandate_disabled', null);
return response()->json([
'status' => 'success',
'message' => \trans('app.mandate-delete-success'),
]);
}
/**
* Update a new auto-payment mandate.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateUpdate(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
// Input validation
if ($errors = self::mandateValidate($request, $wallet)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$wallet->setSettings([
'mandate_amount' => $request->amount,
'mandate_balance' => $request->balance,
// Re-enable the mandate to give it a chance to charge again
// after it has been disabled (e.g. because the mandate amount was too small)
'mandate_disabled' => null,
]);
// Trigger auto-payment if the balance is below the threshold
if ($wallet->balance < intval($request->balance * 100)) {
\App\Jobs\WalletCharge::dispatch($wallet);
}
$result = self::walletMandate($wallet);
$result['status'] = 'success';
$result['message'] = \trans('app.mandate-update-success');
return response()->json($result);
}
/**
* Validate an auto-payment mandate request.
*
* @param \Illuminate\Http\Request $request The API request.
* @param \App\Wallet $wallet The wallet
*
* @return array|null List of errors on error or Null on success
*/
protected static function mandateValidate(Request $request, Wallet $wallet)
{
$rules = [
'amount' => 'required|numeric',
'balance' => 'required|numeric|min:0',
];
// Check required fields
$v = Validator::make($request->all(), $rules);
// TODO: allow comma as a decimal point?
if ($v->fails()) {
return $v->errors()->toArray();
}
$amount = (int) ($request->amount * 100);
// Validate the minimum value
// It has to be at least minimum payment amount and must cover current debt
if (
$wallet->balance < 0
- && $wallet->balance * -1 > PaymentProvider::MIN_AMOUNT
+ && $wallet->balance <= PaymentProvider::MIN_AMOUNT * -1
&& $wallet->balance + $amount < 0
) {
return ['amount' => \trans('validation.minamountdebt')];
}
if ($amount < PaymentProvider::MIN_AMOUNT) {
$min = $wallet->money(PaymentProvider::MIN_AMOUNT);
return ['amount' => \trans('validation.minamount', ['amount' => $min])];
}
return null;
}
/**
* Create a new payment.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$rules = [
'amount' => 'required|numeric',
];
// Check required fields
$v = Validator::make($request->all(), $rules);
// TODO: allow comma as a decimal point?
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$amount = (int) ($request->amount * 100);
// Validate the minimum value
if ($amount < PaymentProvider::MIN_AMOUNT) {
$min = $wallet->money(PaymentProvider::MIN_AMOUNT);
$errors = ['amount' => \trans('validation.minamount', ['amount' => $min])];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$request = [
'type' => PaymentProvider::TYPE_ONEOFF,
'currency' => $request->currency,
'amount' => $amount,
'methodId' => $request->methodId,
'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment',
];
$provider = PaymentProvider::factory($wallet);
$result = $provider->payment($wallet, $request);
$result['status'] = 'success';
return response()->json($result);
}
/**
* Delete a pending payment.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
// TODO currently unused
// public function cancel(Request $request)
// {
// $user = $this->guard()->user();
// // TODO: Wallet selection
// $wallet = $user->wallets()->first();
// $paymentId = $request->payment;
// $user_owns_payment = Payment::where('id', $paymentId)
// ->where('wallet_id', $wallet->id)
// ->exists();
// if (!$user_owns_payment) {
// return $this->errorResponse(404);
// }
// $provider = PaymentProvider::factory($wallet);
// if ($provider->cancel($wallet, $paymentId)) {
// $result = ['status' => 'success'];
// return response()->json($result);
// }
// return $this->errorResponse(404);
// }
/**
* Update payment status (and balance).
*
* @param string $provider Provider name
*
* @return \Illuminate\Http\Response The response
*/
public function webhook($provider)
{
$code = 200;
if ($provider = PaymentProvider::factory($provider)) {
$code = $provider->webhook();
}
return response($code < 400 ? 'Success' : 'Server error', $code);
}
/**
* Top up a wallet with a "recurring" payment.
*
* @param \App\Wallet $wallet The wallet to charge
*
* @return bool True if the payment has been initialized
*/
public static function topUpWallet(Wallet $wallet): bool
{
$settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']);
if (!empty($settings['mandate_disabled'])) {
return false;
}
$min_balance = (int) (floatval($settings['mandate_balance']) * 100);
$amount = (int) (floatval($settings['mandate_amount']) * 100);
// The wallet balance is greater than the auto-payment threshold
if ($wallet->balance >= $min_balance) {
// Do nothing
return false;
}
$provider = PaymentProvider::factory($wallet);
$mandate = (array) $provider->getMandate($wallet);
if (empty($mandate['isValid'])) {
return false;
}
// The defined top-up amount is not enough
// Disable auto-payment and notify the user
if ($wallet->balance + $amount < 0) {
// Disable (not remove) the mandate
$wallet->setSetting('mandate_disabled', 1);
\App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet);
return false;
}
$request = [
'type' => PaymentProvider::TYPE_RECURRING,
'currency' => $wallet->currency,
'amount' => $amount,
'methodId' => PaymentProvider::METHOD_CREDITCARD,
'description' => Tenant::getConfig($wallet->owner->tenant_id, 'app.name') . ' Recurring Payment',
];
$result = $provider->payment($wallet, $request);
return !empty($result);
}
/**
* Returns auto-payment mandate info for the specified wallet
*
* @param \App\Wallet $wallet A wallet object
*
* @return array A mandate metadata
*/
public static function walletMandate(Wallet $wallet): array
{
$provider = PaymentProvider::factory($wallet);
$settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']);
// Get the Mandate info
$mandate = (array) $provider->getMandate($wallet);
$mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100);
$mandate['balance'] = 0;
$mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled'];
foreach (['amount', 'balance'] as $key) {
if (($value = $settings["mandate_{$key}"]) !== null) {
$mandate[$key] = $value;
}
}
return $mandate;
}
/**
* List supported payment methods.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function paymentMethods(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$methods = PaymentProvider::paymentMethods($wallet, $request->type);
\Log::debug("Provider methods" . var_export(json_encode($methods), true));
return response()->json($methods);
}
/**
* Check for pending payments.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function hasPayments(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$exists = Payment::where('wallet_id', $wallet->id)
->where('type', PaymentProvider::TYPE_ONEOFF)
->whereIn('status', [
PaymentProvider::STATUS_OPEN,
PaymentProvider::STATUS_PENDING,
PaymentProvider::STATUS_AUTHORIZED
])
->exists();
return response()->json([
'status' => 'success',
'hasPending' => $exists
]);
}
/**
* List pending payments.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function payments(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$pageSize = 10;
$page = intval(request()->input('page')) ?: 1;
$hasMore = false;
$result = Payment::where('wallet_id', $wallet->id)
->where('type', PaymentProvider::TYPE_ONEOFF)
->whereIn('status', [
PaymentProvider::STATUS_OPEN,
PaymentProvider::STATUS_PENDING,
PaymentProvider::STATUS_AUTHORIZED
])
->orderBy('created_at', 'desc')
->limit($pageSize + 1)
->offset($pageSize * ($page - 1))
->get();
if (count($result) > $pageSize) {
$result->pop();
$hasMore = true;
}
$result = $result->map(function ($item) use ($wallet) {
$provider = PaymentProvider::factory($item->provider);
$payment = $provider->getPayment($item->id);
$entry = [
'id' => $item->id,
'createdAt' => $item->created_at->format('Y-m-d H:i'),
'type' => $item->type,
'description' => $item->description,
'amount' => $item->amount,
'currency' => $wallet->currency,
// note: $item->currency/$item->currency_amount might be different
'status' => $item->status,
'isCancelable' => $payment['isCancelable'],
'checkoutUrl' => $payment['checkoutUrl']
];
return $entry;
});
return response()->json([
'status' => 'success',
'list' => $result,
'count' => count($result),
'hasMore' => $hasMore,
'page' => $page,
]);
}
}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php
new file mode 100644
index 00000000..a2ba6abe
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/SharedFoldersController.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Http\Controllers\API\V4\Reseller;
+
+use App\SharedFolder;
+use App\User;
+
+class SharedFoldersController extends \App\Http\Controllers\API\V4\Admin\SharedFoldersController
+{
+ /**
+ * Search for shared folders
+ *
+ * @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->sharedFolders(false)->orderBy('name')->get();
+ }
+ } elseif (!empty($search)) {
+ if ($folder = SharedFolder::withSubjectTenantContext()->where('email', $search)->first()) {
+ $result->push($folder);
+ }
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($folder) {
+ return $this->objectToClient($folder);
+ }
+ );
+
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'message' => \trans('app.search-foundxsharedfolders', ['x' => count($result)]),
+ ];
+
+ return response()->json($result);
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
index 01104e0c..7b3de67c 100644
--- a/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/Reseller/UsersController.php
@@ -1,108 +1,112 @@
<?php
namespace App\Http\Controllers\API\V4\Reseller;
use App\Domain;
use App\Group;
use App\User;
use App\UserAlias;
use App\UserSetting;
class UsersController extends \App\Http\Controllers\API\V4\Admin\UsersController
{
/**
* 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::where('id', $owner)
->withSubjectTenantContext()
->whereNull('role')
->first();
if ($owner) {
$result = $owner->users(false)->whereNull('role')->orderBy('email')->get();
}
} elseif (strpos($search, '@')) {
// Search by email
$result = User::withTrashed()->where('email', $search)
->withSubjectTenantContext()
->whereNull('role')
->orderBy('email')
->get();
if ($result->isEmpty()) {
// Search by an alias
$user_ids = UserAlias::where('alias', $search)->get()->pluck('user_id');
// Search by an external email
$ext_user_ids = 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()) {
$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();
+ } elseif ($folder = \App\SharedFolder::withTrashed()->where('email', $search)->first()) {
+ $user_ids = $user_ids->merge([$folder->wallet()->user_id])->unique();
}
if (!$user_ids->isEmpty()) {
$result = User::withTrashed()->whereIn('id', $user_ids)
->withSubjectTenantContext()
->whereNull('role')
->orderBy('email')
->get();
}
}
} elseif (is_numeric($search)) {
// Search by user ID
$user = User::withTrashed()->where('id', $search)
->withSubjectTenantContext()
->whereNull('role')
->first();
if ($user) {
$result->push($user);
}
} elseif (!empty($search)) {
// Search by domain
$domain = Domain::withTrashed()->where('namespace', $search)
->withSubjectTenantContext()
->first();
if ($domain) {
if (
($wallet = $domain->wallet())
&& ($owner = $wallet->owner()->withTrashed()->withSubjectTenantContext()->first())
&& empty($owner->role)
) {
$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);
}
}
diff --git a/src/app/Http/Controllers/API/V4/ResourcesController.php b/src/app/Http/Controllers/API/V4/ResourcesController.php
index b7c34d7b..d912df83 100644
--- a/src/app/Http/Controllers/API/V4/ResourcesController.php
+++ b/src/app/Http/Controllers/API/V4/ResourcesController.php
@@ -1,353 +1,352 @@
<?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/SharedFoldersController.php b/src/app/Http/Controllers/API/V4/SharedFoldersController.php
new file mode 100644
index 00000000..0bee2b5a
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/SharedFoldersController.php
@@ -0,0 +1,357 @@
+<?php
+
+namespace App\Http\Controllers\API\V4;
+
+use App\Http\Controllers\Controller;
+use App\SharedFolder;
+use App\Rules\SharedFolderName;
+use App\Rules\SharedFolderType;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+
+class SharedFoldersController extends Controller
+{
+ /** @var array Common object properties in the API response */
+ protected static $objectProps = ['email', 'name', 'type'];
+
+ /**
+ * Show the form for creating a new shared folder.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function create()
+ {
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Delete a shared folder.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function destroy($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canDelete($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $folder->delete();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-delete-success'),
+ ]);
+ }
+
+ /**
+ * Show the form for editing the specified shared folder.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function edit($id)
+ {
+ return $this->errorResponse(404);
+ }
+
+ /**
+ * Listing of a shared folders belonging to the authenticated user.
+ *
+ * The shared-folder entitlements billed to the current user wallet(s)
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function index()
+ {
+ $user = $this->guard()->user();
+
+ $result = $user->sharedFolders()->orderBy('name')->get()
+ ->map(function (SharedFolder $folder) {
+ return $this->objectToClient($folder);
+ });
+
+ return response()->json($result);
+ }
+
+ /**
+ * Set the shared folder configuration.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse|void
+ */
+ public function setConfig($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canUpdate($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $errors = $folder->setConfig(request()->input());
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-setconfig-success'),
+ ]);
+ }
+
+ /**
+ * Display information of a shared folder specified by $id.
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function show($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canRead($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $response = $this->objectToClient($folder, true);
+
+ $response['statusInfo'] = self::statusInfo($folder);
+
+ // Shared folder configuration, e.g. acl
+ $response['config'] = $folder->getConfig();
+
+ return response()->json($response);
+ }
+
+ /**
+ * Fetch a shared folder status (and reload setup process)
+ *
+ * @param int $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function status($id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ if (!$this->guard()->user()->canRead($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $response = $this->processStateUpdate($folder);
+ $response = array_merge($response, self::objectState($folder));
+
+ return response()->json($response);
+ }
+
+ /**
+ * SharedFolder status (extended) information
+ *
+ * @param \App\SharedFolder $folder SharedFolder object
+ *
+ * @return array Status information
+ */
+ public static function statusInfo(SharedFolder $folder): array
+ {
+ return self::processStateInfo(
+ $folder,
+ [
+ 'shared-folder-new' => true,
+ 'shared-folder-ldap-ready' => $folder->isLdapReady(),
+ 'shared-folder-imap-ready' => $folder->isImapReady(),
+ ]
+ );
+ }
+
+ /**
+ * Create a new shared folder 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 SharedFolderName($owner, $domain)],
+ 'type' => ['required', 'string', new SharedFolderType()]
+ ];
+
+ $v = Validator::make($request->all(), $rules);
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
+ }
+
+ DB::beginTransaction();
+
+ // Create the shared folder
+ $folder = new SharedFolder();
+ $folder->name = request()->input('name');
+ $folder->type = request()->input('type');
+ $folder->domain = $domain;
+ $folder->save();
+
+ $folder->assignToWallet($owner->wallets->first());
+
+ DB::commit();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-create-success'),
+ ]);
+ }
+
+ /**
+ * Update a shared folder.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ * @param string $id Shared folder identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function update(Request $request, $id)
+ {
+ $folder = SharedFolder::find($id);
+
+ if (!$this->checkTenant($folder)) {
+ return $this->errorResponse(404);
+ }
+
+ $current_user = $this->guard()->user();
+
+ if (!$current_user->canUpdate($folder)) {
+ return $this->errorResponse(403);
+ }
+
+ $owner = $folder->wallet()->owner;
+
+ $name = $request->input('name');
+ $errors = [];
+
+ // Validate the folder name
+ if ($name !== null && $name != $folder->name) {
+ $domainName = explode('@', $folder->email, 2)[1];
+ $rules = ['name' => ['required', 'string', new SharedFolderName($owner, $domainName)]];
+
+ $v = Validator::make($request->all(), $rules);
+
+ if ($v->fails()) {
+ $errors = $v->errors()->toArray();
+ } else {
+ $folder->name = $name;
+ }
+ }
+
+ if (!empty($errors)) {
+ return response()->json(['status' => 'error', 'errors' => $errors], 422);
+ }
+
+ $folder->save();
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.shared-folder-update-success'),
+ ]);
+ }
+
+ /**
+ * Execute (synchronously) specified step in a shared folder setup process.
+ *
+ * @param \App\SharedFolder $folder Shared folder 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(SharedFolder $folder, string $step): ?bool
+ {
+ try {
+ if (strpos($step, 'domain-') === 0) {
+ return DomainsController::execProcessStep($folder->domain(), $step);
+ }
+
+ switch ($step) {
+ case 'shared-folder-ldap-ready':
+ // Shared folder not in LDAP, create it
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $folder->refresh();
+
+ return $folder->isLdapReady();
+
+ case 'shared-folder-imap-ready':
+ // Shared folder 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\SharedFolder\VerifyJob::dispatch($folder->id);
+
+ return null;
+ }
+
+ $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id);
+ $job->handle();
+
+ $folder->refresh();
+
+ return $folder->isImapReady();
+ }
+ } catch (\Exception $e) {
+ \Log::error($e);
+ }
+
+ return false;
+ }
+
+ /**
+ * Prepare shared folder statuses for the UI
+ *
+ * @param \App\SharedFolder $folder Shared folder object
+ *
+ * @return array Statuses array
+ */
+ protected static function objectState(SharedFolder $folder): array
+ {
+ return [
+ 'isLdapReady' => $folder->isLdapReady(),
+ 'isImapReady' => $folder->isImapReady(),
+ 'isActive' => $folder->isActive(),
+ 'isDeleted' => $folder->isDeleted() || $folder->trashed(),
+ ];
+ }
+}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
index e427555c..56a9a618 100644
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -1,808 +1,810 @@
<?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'];
/**
* 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 'enableFolders' working for wallet controllers that aren't account owners
+ 'enableFolders' => $isController && $hasCustomDomain && in_array('beta-shared-folders', $skus),
// TODO: Make 'enableResources' working for wallet controllers that aren't account owners
'enableResources' => $isController && $hasCustomDomain && in_array('beta-resources', $skus),
'enableUsers' => $isController,
'enableWallets' => $isController,
];
return array_merge($process, $result);
}
/**
* Create a new user record.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->wallet()->owner;
if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
$this->deleteBeforeCreate = null;
if ($error_response = $this->validateUserRequest($request, null, $settings)) {
return $error_response;
}
if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) {
$errors = ['package' => \trans('validation.packagerequired')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if ($package->isDomain()) {
$errors = ['package' => \trans('validation.packageinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
DB::beginTransaction();
// @phpstan-ignore-next-line
if ($this->deleteBeforeCreate) {
$this->deleteBeforeCreate->forceDelete();
}
// Create user record
$user = User::create([
'email' => $request->email,
'password' => $request->password,
]);
$owner->assignPackage($package, $user);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->aliases)) {
$user->setAliases($request->aliases);
}
DB::commit();
return response()->json([
'status' => 'success',
'message' => \trans('app.user-create-success'),
]);
}
/**
* Update user data.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$user = User::withEnvTenantContext()->find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
$current_user = $this->guard()->user();
// TODO: Decide what attributes a user can change on his own profile
if (!$current_user->canUpdate($user)) {
return $this->errorResponse(403);
}
if ($error_response = $this->validateUserRequest($request, $user, $settings)) {
return $error_response;
}
// Entitlements, only controller can do that
if ($request->skus !== null && !$current_user->canDelete($user)) {
return $this->errorResponse(422, "You have no permission to change entitlements");
}
DB::beginTransaction();
$this->updateEntitlements($user, $request->skus);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->password)) {
$user->password = $request->password;
$user->save();
}
if (isset($request->aliases)) {
$user->setAliases($request->aliases);
}
// TODO: Make sure that UserUpdate job is created in case of entitlements update
// and no password change. So, for example quota change is applied to LDAP
// TODO: Review use of $user->save() in the above context
DB::commit();
$response = [
'status' => 'success',
'message' => \trans('app.user-update-success'),
];
// For self-update refresh the statusInfo in the UI
if ($user->id == $current_user->id) {
$response['statusInfo'] = self::statusInfo($user);
}
return response()->json($response);
}
/**
* Update user entitlements.
*
* @param \App\User $user The user
* @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty]
*/
protected function updateEntitlements(User $user, $rSkus)
{
if (!is_array($rSkus)) {
return;
}
// list of skus, [id=>obj]
$skus = Sku::withEnvTenantContext()->get()->mapWithKeys(
function ($sku) {
return [$sku->id => $sku];
}
);
// existing entitlement's SKUs
$eSkus = [];
$user->entitlements()->groupBy('sku_id')
->selectRaw('count(*) as total, sku_id')->each(
function ($e) use (&$eSkus) {
$eSkus[$e->sku_id] = $e->total;
}
);
foreach ($skus as $skuID => $sku) {
$e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0;
$r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0;
if ($sku->handler_class == \App\Handlers\Mailbox::class) {
if ($r != 1) {
throw new \Exception("Invalid quantity of mailboxes");
}
}
if ($e > $r) {
// remove those entitled more than existing
$user->removeSku($sku, ($e - $r));
} elseif ($e < $r) {
// add those requested more than entitled
$user->assignSku($sku, ($r - $e));
}
}
}
/**
* Create a response data array for specified user.
*
* @param \App\User $user User object
*
* @return array Response data
*/
public static function userResponse(User $user): array
{
$response = 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 or resource with specified address already exists
if (
($existing = Group::emailExists($email, true))
|| ($existing = \App\Resource::emailExists($email, true))
) {
// If this is a deleted group/resource in the same custom domain
// we'll force delete it before
if (!$domain->isPublic() && $existing->trashed()) {
$deleted = $existing;
} else {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
}
return null;
}
/**
* Email address validation for use as an alias.
*
* @param string $email Email address
* @param \App\User $user The account owner
*
* @return ?string Error message on validation error
*/
public static function validateAlias(string $email, \App\User $user): ?string
{
if (strpos($email, '@') === false) {
return \trans('validation.entryinvalid', ['attribute' => 'alias']);
}
list($login, $domain) = explode('@', Str::lower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return \trans('validation.entryinvalid', ['attribute' => 'alias']);
}
// Check if domain exists
$domain = Domain::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/CommonJob.php b/src/app/Jobs/CommonJob.php
index d0c2b001..fa9d0831 100644
--- a/src/app/Jobs/CommonJob.php
+++ b/src/app/Jobs/CommonJob.php
@@ -1,107 +1,140 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
/**
* The abstract \App\Jobs\DomainJob implements the logic needed for all dispatchable Jobs related to
* \App\Domain objects.
*
* ```php
* $job = new \App\Jobs\Domain\CreateJob($domainId);
* $job->handle();
* ```
*/
abstract class CommonJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
/**
* The failure message.
*
* @var string
*/
public $failureMessage;
+ /**
+ * The job deleted state.
+ *
+ * @var bool
+ */
+ protected $isDeleted = false;
+
/**
* The job released state.
*
* @var bool
*/
protected $isReleased = false;
/**
* The number of tries for this Job.
*
* @var int
*/
public $tries = 5;
/**
* Execute the job.
*
* @return void
*/
abstract public function handle();
+ /**
+ * Delete the job from the queue.
+ *
+ * @return void
+ */
+ public function delete()
+ {
+ // We need this for testing purposes
+ $this->isDeleted = true;
+
+ // @phpstan-ignore-next-line
+ if ($this->job) {
+ $this->job->delete();
+ }
+ }
+
/**
* Delete the job, call the "failed" method, and raise the failed job event.
*
* @param \Throwable|null $e An Exception
*
* @return void
*/
public function fail($e = null)
{
// Save the message, for testing purposes
$this->failureMessage = $e->getMessage();
// @phpstan-ignore-next-line
if ($this->job) {
$this->job->fail($e);
}
}
/**
* Check if the job has failed
*
* @return bool
*/
public function hasFailed(): bool
{
return $this->failureMessage !== null;
}
/**
* Release the job back into the queue.
*
* @param int $delay Time in seconds
* @return void
*/
public function release($delay = 0)
{
// We need this for testing purposes
$this->isReleased = true;
// @phpstan-ignore-next-line
if ($this->job) {
$this->job->release($delay);
}
}
+ /**
+ * Determine if the job has been deleted.
+ *
+ * @return bool
+ */
+ public function isDeleted(): bool
+ {
+ return $this->isDeleted;
+ }
+
/**
* Check if the job was released
*
* @return bool
*/
public function isReleased(): bool
{
return $this->isReleased;
}
}
diff --git a/src/app/Jobs/Resource/VerifyJob.php b/src/app/Jobs/Resource/VerifyJob.php
index 44ae6e69..9de19e72 100644
--- a/src/app/Jobs/Resource/VerifyJob.php
+++ b/src/app/Jobs/Resource/VerifyJob.php
@@ -1,36 +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)
+ // the resource was already verified
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/SharedFolder/CreateJob.php b/src/app/Jobs/SharedFolder/CreateJob.php
new file mode 100644
index 00000000..7e2cf586
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/CreateJob.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Jobs\SharedFolder;
+
+use App\Jobs\SharedFolderJob;
+
+class CreateJob extends SharedFolderJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $folder = $this->getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // sanity checks
+ if ($folder->isDeleted()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is marked as deleted."));
+ return;
+ }
+
+ if ($folder->trashed()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is actually deleted."));
+ return;
+ }
+
+ if ($folder->isLdapReady()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is already marked as ldap-ready."));
+ return;
+ }
+
+ // see if the domain is ready
+ $domain = $folder->domain();
+
+ if (!$domain) {
+ $this->fail(new \Exception("The domain for shared folder {$this->folderId} does not exist."));
+ return;
+ }
+
+ if ($domain->isDeleted()) {
+ $this->fail(new \Exception("The domain for shared folder {$this->folderId} is marked as deleted."));
+ return;
+ }
+
+ if (!$domain->isLdapReady()) {
+ $this->release(60);
+ return;
+ }
+
+ \App\Backends\LDAP::createSharedFolder($folder);
+
+ $folder->status |= \App\SharedFolder::STATUS_LDAP_READY;
+ $folder->save();
+ }
+}
diff --git a/src/app/Jobs/SharedFolder/DeleteJob.php b/src/app/Jobs/SharedFolder/DeleteJob.php
new file mode 100644
index 00000000..361d25dc
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/DeleteJob.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Jobs\SharedFolder;
+
+use App\Jobs\SharedFolderJob;
+
+class DeleteJob extends SharedFolderJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $folder = $this->getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // sanity checks
+ if ($folder->isDeleted()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is already marked as deleted."));
+ return;
+ }
+
+ \App\Backends\LDAP::deleteSharedFolder($folder);
+
+ $folder->status |= \App\SharedFolder::STATUS_DELETED;
+
+ if ($folder->isLdapReady()) {
+ $folder->status ^= \App\SharedFolder::STATUS_LDAP_READY;
+ }
+
+ if ($folder->isImapReady()) {
+ $folder->status ^= \App\SharedFolder::STATUS_IMAP_READY;
+ }
+
+ $folder->save();
+ }
+}
diff --git a/src/app/Jobs/SharedFolder/UpdateJob.php b/src/app/Jobs/SharedFolder/UpdateJob.php
new file mode 100644
index 00000000..7cd6e420
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/UpdateJob.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Jobs\SharedFolder;
+
+use App\Jobs\SharedFolderJob;
+
+class UpdateJob extends SharedFolderJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $folder = $this->getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // Cancel the update if the folder is deleted or not yet in LDAP
+ if (!$folder->isLdapReady() || $folder->isDeleted()) {
+ $this->delete();
+ return;
+ }
+
+ \App\Backends\LDAP::updateSharedFolder($folder);
+ }
+}
diff --git a/src/app/Jobs/SharedFolder/VerifyJob.php b/src/app/Jobs/SharedFolder/VerifyJob.php
new file mode 100644
index 00000000..0f3bc330
--- /dev/null
+++ b/src/app/Jobs/SharedFolder/VerifyJob.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Jobs\SharedFolder;
+
+use App\Jobs\SharedFolderJob;
+
+class VerifyJob extends SharedFolderJob
+{
+ /**
+ * Execute the job.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $folder = $this->getSharedFolder();
+
+ if (!$folder) {
+ return;
+ }
+
+ // the user has a mailbox (or is marked as such)
+ if ($folder->isImapReady()) {
+ $this->fail(new \Exception("Shared folder {$this->folderId} is already verified."));
+ return;
+ }
+
+ $folderName = $folder->getSetting('folder');
+
+ if (\App\Backends\IMAP::verifySharedFolder($folderName)) {
+ $folder->status |= \App\SharedFolder::STATUS_IMAP_READY;
+ $folder->status |= \App\SharedFolder::STATUS_ACTIVE;
+ $folder->save();
+ }
+ }
+}
diff --git a/src/app/Jobs/SharedFolderJob.php b/src/app/Jobs/SharedFolderJob.php
new file mode 100644
index 00000000..304b3dd7
--- /dev/null
+++ b/src/app/Jobs/SharedFolderJob.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace App\Jobs;
+
+/**
+ * The abstract \App\Jobs\SharedFolderJob implements the logic needed for all dispatchable Jobs related to
+ * \App\SharedFolder objects.
+ *
+ * ```php
+ * $job = new \App\Jobs\SharedFolder\CreateJob($folderId);
+ * $job->handle();
+ * ```
+ */
+abstract class SharedFolderJob extends CommonJob
+{
+ /**
+ * The ID for the \App\SharedFolder. This is the shortest globally unique identifier and saves Redis space
+ * compared to a serialized version of the complete \App\SharedFolder object.
+ *
+ * @var int
+ */
+ protected $folderId;
+ /**
+ * The \App\SharedFolder email property, for legibility in the queue management.
+ *
+ * @var string
+ */
+ protected $folderEmail;
+
+ /**
+ * Create a new job instance.
+ *
+ * @param int $folderId The ID for the shared folder to process.
+ *
+ * @return void
+ */
+ public function __construct(int $folderId)
+ {
+ $this->folderId = $folderId;
+
+ $folder = $this->getSharedFolder();
+
+ if ($folder) {
+ $this->folderEmail = $folder->email;
+ }
+ }
+
+ /**
+ * Get the \App\SharedFolder entry associated with this job.
+ *
+ * @return \App\SharedFolder|null
+ *
+ * @throws \Exception
+ */
+ protected function getSharedFolder()
+ {
+ $folder = \App\SharedFolder::withTrashed()->find($this->folderId);
+
+ if (!$folder) {
+ // 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 SharedFolder\CreateJob) {
+ $this->release(5);
+ return null;
+ }
+
+ $this->fail(new \Exception("Shared folder {$this->folderId} could not be found in the database."));
+ }
+
+ return $folder;
+ }
+}
diff --git a/src/app/Observers/SharedFolderObserver.php b/src/app/Observers/SharedFolderObserver.php
new file mode 100644
index 00000000..d1a5d64c
--- /dev/null
+++ b/src/app/Observers/SharedFolderObserver.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace App\Observers;
+
+use App\SharedFolder;
+
+class SharedFolderObserver
+{
+ /**
+ * Handle the shared folder "creating" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function creating(SharedFolder $folder): void
+ {
+ if (empty($folder->type)) {
+ $folder->type = 'mail';
+ }
+
+ if (empty($folder->email)) {
+ if (!isset($folder->name)) {
+ throw new \Exception("Missing 'domain' property for a new shared folder");
+ }
+
+ $domainName = \strtolower($folder->domain);
+
+ $folder->email = "{$folder->type}-{$folder->id}@{$domainName}";
+ } else {
+ $folder->email = \strtolower($folder->email);
+ }
+
+ $folder->status |= SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
+ }
+
+ /**
+ * Handle the shared folder "created" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function created(SharedFolder $folder)
+ {
+ $domainName = explode('@', $folder->email, 2)[1];
+
+ $settings = [
+ 'folder' => "shared/{$folder->name}@{$domainName}",
+ ];
+
+ foreach ($settings as $key => $value) {
+ $settings[$key] = [
+ 'key' => $key,
+ 'value' => $value,
+ 'shared_folder_id' => $folder->id,
+ ];
+ }
+
+ // Note: Don't use setSettings() here to bypass SharedFolderSetting observers
+ // Note: This is a single multi-insert query
+ $folder->settings()->insert(array_values($settings));
+
+ // Create folder record in LDAP, then check if it is created in IMAP
+ $chain = [
+ new \App\Jobs\SharedFolder\VerifyJob($folder->id),
+ ];
+
+ \App\Jobs\SharedFolder\CreateJob::withChain($chain)->dispatch($folder->id);
+ }
+
+ /**
+ * Handle the shared folder "deleting" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function deleting(SharedFolder $folder)
+ {
+ // 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', $folder->id)
+ ->where('entitleable_type', SharedFolder::class)
+ ->delete();
+ }
+
+ /**
+ * Handle the shared folder "deleted" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function deleted(SharedFolder $folder)
+ {
+ if ($folder->isForceDeleting()) {
+ return;
+ }
+
+ \App\Jobs\SharedFolder\DeleteJob::dispatch($folder->id);
+ }
+
+ /**
+ * Handle the shared folder "updated" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function updated(SharedFolder $folder)
+ {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folder->id);
+
+ // Update the folder property if name changed
+ if ($folder->name != $folder->getOriginal('name')) {
+ $domainName = explode('@', $folder->email, 2)[1];
+ $folderName = "shared/{$folder->name}@{$domainName}";
+
+ // Note: This does not invoke SharedFolderSetting observer events, good.
+ $folder->settings()->where('key', 'folder')->update(['value' => $folderName]);
+ }
+ }
+
+ /**
+ * Handle the shared folder "force deleted" event.
+ *
+ * @param \App\SharedFolder $folder The folder
+ *
+ * @return void
+ */
+ public function forceDeleted(SharedFolder $folder)
+ {
+ // A folder can be force-deleted separately from the owner
+ // we have to force-delete entitlements
+ \App\Entitlement::where('entitleable_id', $folder->id)
+ ->where('entitleable_type', SharedFolder::class)
+ ->forceDelete();
+ }
+}
diff --git a/src/app/Observers/SharedFolderSettingObserver.php b/src/app/Observers/SharedFolderSettingObserver.php
new file mode 100644
index 00000000..7accfb03
--- /dev/null
+++ b/src/app/Observers/SharedFolderSettingObserver.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace App\Observers;
+
+use App\Backends\LDAP;
+use App\SharedFolderSetting;
+
+class SharedFolderSettingObserver
+{
+ /**
+ * Handle the shared folder setting "created" event.
+ *
+ * @param \App\SharedFolderSetting $folderSetting Settings object
+ *
+ * @return void
+ */
+ public function created(SharedFolderSetting $folderSetting)
+ {
+ if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
+ }
+ }
+
+ /**
+ * Handle the shared folder setting "updated" event.
+ *
+ * @param \App\SharedFolderSetting $folderSetting Settings object
+ *
+ * @return void
+ */
+ public function updated(SharedFolderSetting $folderSetting)
+ {
+ if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
+ }
+ }
+
+ /**
+ * Handle the shared folder setting "deleted" event.
+ *
+ * @param \App\SharedFolderSetting $folderSetting Settings object
+ *
+ * @return void
+ */
+ public function deleted(SharedFolderSetting $folderSetting)
+ {
+ if (in_array($folderSetting->key, LDAP::SHARED_FOLDER_SETTINGS)) {
+ \App\Jobs\SharedFolder\UpdateJob::dispatch($folderSetting->shared_folder_id);
+ }
+ }
+}
diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php
index 08ad8503..72927071 100644
--- a/src/app/Observers/UserObserver.php
+++ b/src/app/Observers/UserObserver.php
@@ -1,366 +1,384 @@
<?php
namespace App\Observers;
use App\Entitlement;
use App\Domain;
use App\Group;
use App\Resource;
+use App\SharedFolder;
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 = [];
+ $folders = [];
$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;
+ } elseif ($entitlement->entitleable_type == SharedFolder::class) {
+ $folders[] = $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();
}
}
+ if (!empty($folders)) {
+ foreach (SharedFolder::whereIn('id', array_unique($folders))->get() as $_folder) {
+ $_folder->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 = [];
+ $folders = [];
$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;
+ } elseif ($entitlement->entitleable_type == SharedFolder::class) {
+ $folders[] = $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();
}
+ // Shared folders can be just removed
+ if (!empty($folders)) {
+ SharedFolder::withTrashed()->whereIn('id', array_unique($folders))->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 29904762..ac784b42 100644
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -1,164 +1,166 @@
<?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\SharedFolder::observe(\App\Observers\SharedFolderObserver::class);
+ \App\SharedFolderSetting::observe(\App\Observers\SharedFolderSettingObserver::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
index 732d088b..7345b755 100644
--- a/src/app/Resource.php
+++ b/src/app/Resource.php
@@ -1,210 +1,209 @@
<?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 */
+ /** @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/Rules/ResourceName.php b/src/app/Rules/ResourceName.php
index 73ab9b1e..fb88b3bd 100644
--- a/src/app/Rules/ResourceName.php
+++ b/src/app/Rules/ResourceName.php
@@ -1,79 +1,86 @@
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class ResourceName implements Rule
{
private $message;
private $owner;
private $domain;
+ private const FORBIDDEN_CHARS = '+/^%*!`@(){}|\\?<;"';
+
/**
* 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 Resource name input
*
* @return bool
*/
public function passes($attribute, $name): bool
{
if (empty($name) || !is_string($name)) {
$this->message = \trans('validation.nameinvalid');
return false;
}
+ if (strcspn($name, self::FORBIDDEN_CHARS) < strlen($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.max.string', ['max' => 191]);
return false;
}
- // Check if specified domain is belongs to the user
+ // Check if specified domain 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 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/Rules/ResourceName.php b/src/app/Rules/SharedFolderName.php
similarity index 69%
copy from src/app/Rules/ResourceName.php
copy to src/app/Rules/SharedFolderName.php
index 73ab9b1e..1dff6aeb 100644
--- a/src/app/Rules/ResourceName.php
+++ b/src/app/Rules/SharedFolderName.php
@@ -1,79 +1,86 @@
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
-class ResourceName implements Rule
+class SharedFolderName implements Rule
{
private $message;
private $owner;
private $domain;
+ private const FORBIDDEN_CHARS = '+/^%*!`@(){}|\\?<;"';
+
/**
* 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 Resource name input
+ * @param mixed $name Shared folder name input
*
* @return bool
*/
public function passes($attribute, $name): bool
{
- if (empty($name) || !is_string($name)) {
+ if (empty($name) || !is_string($name) || $name == 'Resources') {
+ $this->message = \trans('validation.nameinvalid');
+ return false;
+ }
+
+ if (strcspn($name, self::FORBIDDEN_CHARS) < strlen($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.max.string', ['max' => 191]);
return false;
}
- // Check if specified domain is belongs to the user
+ // Check if specified domain 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 resources table would be faster than resources()?
- $exists = $this->owner->resources()
- ->where('resources.name', $name)
- ->where('resources.email', 'like', '%@' . $this->domain)
+ // FIXME: Maybe just using the whole shared_folders table would be faster than sharedFolders()?
+ $exists = $this->owner->sharedFolders()
+ ->where('shared_folders.name', $name)
+ ->where('shared_folders.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/SharedFolderType.php b/src/app/Rules/SharedFolderType.php
new file mode 100644
index 00000000..c6b868ff
--- /dev/null
+++ b/src/app/Rules/SharedFolderType.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\Str;
+
+class SharedFolderType implements Rule
+{
+ private $message;
+
+ /**
+ * Determine if the validation rule passes.
+ *
+ * @param string $attribute Attribute name
+ * @param mixed $type Shared folder type input
+ *
+ * @return bool
+ */
+ public function passes($attribute, $type): bool
+ {
+ if (empty($type) || !is_string($type) || !in_array($type, \App\SharedFolder::SUPPORTED_TYPES)) {
+ $this->message = \trans('validation.entryinvalid', ['attribute' => $attribute]);
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the validation error message.
+ *
+ * @return string
+ */
+ public function message(): ?string
+ {
+ return $this->message;
+ }
+}
diff --git a/src/app/Resource.php b/src/app/SharedFolder.php
similarity index 58%
copy from src/app/Resource.php
copy to src/app/SharedFolder.php
index 732d088b..e22df5cf 100644
--- a/src/app/Resource.php
+++ b/src/app/SharedFolder.php
@@ -1,210 +1,229 @@
<?php
namespace App;
use App\Traits\BelongsToTenantTrait;
use App\Traits\EntitleableTrait;
-use App\Traits\ResourceConfigTrait;
+use App\Traits\SharedFolderConfigTrait;
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.
+ * The eloquent definition of a SharedFolder.
*
- * @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 $id The folder identifier
+ * @property string $name The folder name
+ * @property int $status The folder status
* @property int $tenant_id Tenant identifier
+ * @property string $type The folder type
*/
-class Resource extends Model
+class SharedFolder extends Model
{
use BelongsToTenantTrait;
use EntitleableTrait;
- use ResourceConfigTrait;
+ use SharedFolderConfigTrait;
use SettingsTrait;
use SoftDeletes;
use UuidIntKeyTrait;
- // we've simply never heard of this resource
+ // we've simply never heard of this folder
public const STATUS_NEW = 1 << 0;
- // resource has been activated
+ // folder has been activated
public const STATUS_ACTIVE = 1 << 1;
- // resource has been suspended.
+ // folder has been suspended.
// public const STATUS_SUSPENDED = 1 << 2;
- // resource has been deleted
+ // folder has been deleted
public const STATUS_DELETED = 1 << 3;
- // resource has been created in LDAP
+ // folder has been created in LDAP
public const STATUS_LDAP_READY = 1 << 4;
- // resource has been created in IMAP
+ // folder has been created in IMAP
public const STATUS_IMAP_READY = 1 << 8;
+ /** @const array Supported folder type labels */
+ public const SUPPORTED_TYPES = ['mail', 'event', 'contact', 'task', 'note'];
+
+ /** @var array Mass-assignable properties */
protected $fillable = [
'email',
'name',
'status',
+ 'type',
];
- /**
- * @var ?string Domain name for a resource to be created */
+ /** @var ?string Domain name for a shared folder to be created */
public $domain;
/**
- * Assign the resource to a wallet.
+ * Assign the folder to a wallet.
*
* @param \App\Wallet $wallet The wallet
*
- * @return \App\Resource Self
+ * @return \App\SharedFolder Self
* @throws \Exception
*/
- public function assignToWallet(Wallet $wallet): Resource
+ public function assignToWallet(Wallet $wallet): SharedFolder
{
if (empty($this->id)) {
- throw new \Exception("Resource not yet exists");
+ throw new \Exception("Shared folder not yet exists");
}
if ($this->entitlements()->count()) {
- throw new \Exception("Resource already assigned to a wallet");
+ throw new \Exception("Shared folder already assigned to a wallet");
}
- $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'resource')->first();
+ $sku = \App\Sku::withObjectTenantContext($this)->where('title', 'shared-folder')->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
+ 'entitleable_type' => SharedFolder::class
]);
return $this;
}
/**
- * Returns the resource domain.
+ * Returns the shared folder domain.
*
- * @return ?\App\Domain The domain to which the resource belongs to, NULL if it does not exist
+ * @return ?\App\Domain The domain to which the folder 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).
+ * Find whether an email address exists as a shared folder (including deleted folders).
*
- * @param string $email Email address
- * @param bool $return_resource Return Resource instance instead of boolean
+ * @param string $email Email address
+ * @param bool $return_folder Return SharedFolder instance instead of boolean
*
- * @return \App\Resource|bool True or Resource model object if found, False otherwise
+ * @return \App\SharedFolder|bool True or Resource model object if found, False otherwise
*/
- public static function emailExists(string $email, bool $return_resource = false)
+ public static function emailExists(string $email, bool $return_folder = false)
{
if (strpos($email, '@') === false) {
return false;
}
$email = \strtolower($email);
- $resource = self::withTrashed()->where('email', $email)->first();
+ $folder = self::withTrashed()->where('email', $email)->first();
- if ($resource) {
- return $return_resource ? $resource : true;
+ if ($folder) {
+ return $return_folder ? $folder : true;
}
return false;
}
/**
- * Returns whether this resource is active.
+ * Returns whether this folder is active.
*
* @return bool
*/
public function isActive(): bool
{
return ($this->status & self::STATUS_ACTIVE) > 0;
}
/**
- * Returns whether this resource is deleted.
+ * Returns whether this folder is deleted.
*
* @return bool
*/
public function isDeleted(): bool
{
return ($this->status & self::STATUS_DELETED) > 0;
}
/**
- * Returns whether this resource's folder exists in IMAP.
+ * Returns whether this 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.
+ * Returns whether this folder is registered in LDAP.
*
* @return bool
*/
public function isLdapReady(): bool
{
return ($this->status & self::STATUS_LDAP_READY) > 0;
}
/**
- * Returns whether this resource is new.
+ * Returns whether this folder is new.
*
* @return bool
*/
public function isNew(): bool
{
return ($this->status & self::STATUS_NEW) > 0;
}
/**
- * Resource status mutator
+ * Folder 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}");
+ throw new \Exception("Invalid shared folder status: {$status}");
}
$this->attributes['status'] = $new_status;
}
+
+ /**
+ * Folder type mutator
+ *
+ * @throws \Exception
+ */
+ public function setTypeAttribute($type)
+ {
+ if (!in_array($type, self::SUPPORTED_TYPES)) {
+ throw new \Exception("Invalid shared folder type: {$type}");
+ }
+
+ $this->attributes['type'] = $type;
+ }
}
diff --git a/src/app/SharedFolderSetting.php b/src/app/SharedFolderSetting.php
new file mode 100644
index 00000000..a73740e6
--- /dev/null
+++ b/src/app/SharedFolderSetting.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A collection of settings for a SharedFolder.
+ *
+ * @property int $id
+ * @property int $shared_folder_id
+ * @property string $key
+ * @property string $value
+ */
+class SharedFolderSetting extends Model
+{
+ protected $fillable = [
+ 'shared_folder_id', 'key', 'value'
+ ];
+
+ /**
+ * The folder to which this setting belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function folder()
+ {
+ return $this->belongsTo(\App\SharedFolder::class, 'shared_folder_id', 'id');
+ }
+}
diff --git a/src/app/Traits/SharedFolderConfigTrait.php b/src/app/Traits/SharedFolderConfigTrait.php
new file mode 100644
index 00000000..4c8f375a
--- /dev/null
+++ b/src/app/Traits/SharedFolderConfigTrait.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Traits;
+
+use Illuminate\Support\Facades\Validator;
+
+trait SharedFolderConfigTrait
+{
+ /**
+ * A helper to get a shared folder configuration.
+ */
+ public function getConfig(): array
+ {
+ $config = [];
+
+ $settings = $this->getSettings(['acl']);
+
+ $config['acl'] = !empty($settings['acl']) ? json_decode($settings['acl'], true) : [];
+
+ return $config;
+ }
+
+ /**
+ * A helper to update a shared folder 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 acl
+ if ($key === 'acl') {
+ // Here's the list of acl labels supported by kolabd
+ // 'all': 'lrsedntxakcpiw',
+ // 'append': 'wip',
+ // 'full': 'lrswipkxtecdn',
+ // 'read': 'lrs',
+ // 'read-only': 'lrs',
+ // 'read-write': 'lrswitedn',
+ // 'post': 'p',
+ // 'semi-full': 'lrswit',
+ // 'write': 'lrswite',
+ // For now we support read-only, read-write, and full
+
+ if (!is_array($value)) {
+ $value = (array) $value;
+ }
+
+ $users = [];
+
+ foreach ($value as $i => $v) {
+ if (!is_string($v) || empty($v) || !substr_count($v, ',')) {
+ $errors[$key][$i] = \trans('validation.acl-entry-invalid');
+ } else {
+ list($user, $acl) = explode(',', $v, 2);
+ $user = trim($user);
+ $acl = trim($acl);
+ $error = null;
+
+ if (
+ !in_array($acl, ['read-only', 'read-write', 'full'])
+ || ($error = $this->validateAclIdentifier($user))
+ || in_array($user, $users)
+ ) {
+ $errors[$key][$i] = $error ?: \trans('validation.acl-entry-invalid');
+ }
+
+ $value[$i] = "$user, $acl";
+ $users[] = $user;
+ }
+ }
+
+ if (empty($errors[$key])) {
+ $this->setSetting($key, json_encode($value));
+ }
+ } else {
+ $errors[$key] = \trans('validation.invalid-config-parameter');
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Validate an ACL identifier.
+ *
+ * @param string $identifier Email address or a special identifier
+ *
+ * @return ?string Error message on validation error
+ */
+ protected function validateAclIdentifier(string $identifier): ?string
+ {
+ if ($identifier === 'anyone') {
+ return null;
+ }
+
+ $v = Validator::make(['email' => $identifier], ['email' => 'required|email']);
+
+ if ($v->fails()) {
+ return \trans('validation.emailinvalid');
+ }
+
+ $user = \App\User::where('email', \strtolower($identifier))->first();
+
+ // The user and shared folder 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 5679af44..595cf940 100644
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -1,886 +1,909 @@
<?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.
*
* @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($with_accounts = true, $with_public = true): array
{
$domains = [];
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;
}
}
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);
}
+ /**
+ * Return shared folders controlled by the current user.
+ *
+ * @param bool $with_accounts Include folders assigned to wallets
+ * the current user controls but not owns.
+ *
+ * @return \Illuminate\Database\Eloquent\Builder Query builder
+ */
+ public function sharedFolders($with_accounts = true)
+ {
+ $wallets = $this->wallets()->pluck('id')->all();
+
+ if ($with_accounts) {
+ $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
+ }
+
+ return \App\SharedFolder::select(['shared_folders.*', 'entitlements.wallet_id'])
+ ->distinct()
+ ->join('entitlements', 'entitlements.entitleable_id', '=', 'shared_folders.id')
+ ->whereIn('entitlements.wallet_id', $wallets)
+ ->where('entitlements.entitleable_type', \App\SharedFolder::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/database/migrations/2021_11_25_100000_create_shared_folders_table.php b/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php
new file mode 100644
index 00000000..01ce6f94
--- /dev/null
+++ b/src/database/migrations/2021_11_25_100000_create_shared_folders_table.php
@@ -0,0 +1,83 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+// phpcs:ignore
+class CreateSharedFoldersTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create(
+ 'shared_folders',
+ function (Blueprint $table) {
+ $table->unsignedBigInteger('id');
+ $table->string('email')->unique();
+ $table->string('name');
+ $table->string('type', 8);
+ $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(
+ 'shared_folder_settings',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->unsignedBigInteger('shared_folder_id');
+ $table->string('key');
+ $table->text('value');
+ $table->timestamps();
+
+ $table->foreign('shared_folder_id')->references('id')->on('shared_folders')
+ ->onDelete('cascade')->onUpdate('cascade');
+
+ $table->unique(['shared_folder_id', 'key']);
+ }
+ );
+
+ \App\Sku::where('title', 'shared_folder')->update([
+ 'active' => true,
+ 'cost' => 0,
+ 'title' => 'shared-folder',
+ ]);
+
+ if (!\App\Sku::where('title', 'beta-shared-folders')->first()) {
+ \App\Sku::create([
+ 'title' => 'beta-shared-folders',
+ 'name' => 'Shared folders',
+ 'description' => 'Access to shared folders',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\SharedFolders',
+ 'active' => true,
+ ]);
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('shared_folder_settings');
+ Schema::dropIfExists('shared_folders');
+
+ // there's no need to remove the SKU
+ }
+}
diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php
index 3c463ac9..4947ec86 100644
--- a/src/database/seeds/DatabaseSeeder.php
+++ b/src/database/seeds/DatabaseSeeder.php
@@ -1,43 +1,44 @@
<?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',
+ 'SharedFolderSeeder',
];
$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/SharedFolderSeeder.php b/src/database/seeds/local/SharedFolderSeeder.php
new file mode 100644
index 00000000..fdb9ed7c
--- /dev/null
+++ b/src/database/seeds/local/SharedFolderSeeder.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Database\Seeds\Local;
+
+use App\SharedFolder;
+use App\User;
+use Illuminate\Database\Seeder;
+
+class SharedFolderSeeder extends Seeder
+{
+ /**
+ * Run the database seeds.
+ *
+ * @return void
+ */
+ public function run()
+ {
+ $john = User::where('email', 'john@kolab.org')->first();
+ $wallet = $john->wallets()->first();
+
+ $folder = SharedFolder::create([
+ 'name' => 'Calendar',
+ 'email' => 'folder-event@kolab.org',
+ 'type' => 'event',
+ ]);
+ $folder->assignToWallet($wallet);
+
+ $folder = SharedFolder::create([
+ 'name' => 'Contacts',
+ 'email' => 'folder-contact@kolab.org',
+ 'type' => 'contact',
+ ]);
+ $folder->assignToWallet($wallet);
+ }
+}
diff --git a/src/database/seeds/local/SkuSeeder.php b/src/database/seeds/local/SkuSeeder.php
index 4e0ef730..eaa0db48 100644
--- a/src/database/seeds/local/SkuSeeder.php
+++ b/src/database/seeds/local/SkuSeeder.php
@@ -1,348 +1,364 @@
<?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' => true,
]
);
Sku::create(
[
- 'title' => 'shared_folder',
+ 'title' => 'shared-folder',
'name' => 'Shared Folder',
'description' => 'A shared folder',
'cost' => 89,
'period' => 'monthly',
'handler_class' => 'App\Handlers\SharedFolder',
- 'active' => false,
+ 'active' => true,
]
);
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 = 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 = 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 = 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 = Sku::where(['title' => 'distlist', 'tenant_id' => \config('app.tenant_id')])->first();
if (!$sku) {
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,
]);
}
+ // Check existence because migration might have added this already
+ $sku = Sku::where(['title' => 'beta-shared-folders', 'tenant_id' => \config('app.tenant_id')])->first();
+
+ if (!$sku) {
+ Sku::create([
+ 'title' => 'beta-shared-folders',
+ 'name' => 'Shared folders',
+ 'description' => 'Access to shared folders',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\SharedFolders',
+ '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 128d9a1d..107b76b5 100644
--- a/src/database/seeds/production/SkuSeeder.php
+++ b/src/database/seeds/production/SkuSeeder.php
@@ -1,231 +1,245 @@
<?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' => 0,
'period' => 'monthly',
'handler_class' => 'App\Handlers\Resource',
'active' => true,
]
);
Sku::create(
[
- 'title' => 'shared_folder',
+ '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 (!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 (!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 (!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 (!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,
]);
}
+
+ // Check existence because migration might have added this already
+ if (!Sku::where('title', 'beta-shared-folders')->first()) {
+ Sku::create([
+ 'title' => 'beta-shared-folders',
+ 'name' => 'Shared folders',
+ 'description' => 'Access to shared folders',
+ 'cost' => 0,
+ 'units_free' => 0,
+ 'period' => 'monthly',
+ 'handler_class' => 'App\Handlers\Beta\SharedFolders',
+ 'active' => true,
+ ]);
+ }
}
}
diff --git a/src/phpstan.neon b/src/phpstan.neon
index 3c10190b..539c683d 100644
--- a/src/phpstan.neon
+++ b/src/phpstan.neon
@@ -1,15 +1,16 @@
includes:
- ./vendor/nunomaduro/larastan/extension.neon
parameters:
ignoreErrors:
- '#Access to an undefined property [a-zA-Z\\]+::\$pivot#'
- '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withEnvTenantContext\(\)#'
- '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withObjectTenantContext\(\)#'
- '#Call to an undefined [a-zA-Z0-9<>\\ ]+::withSubjectTenantContext\(\)#'
- '#Call to an undefined method Tests\\Browser::#'
level: 4
parallel:
processTimeout: 300.0
paths:
- app/
+ - config/
- tests/
diff --git a/src/resources/js/admin/routes.js b/src/resources/js/admin/routes.js
index 53f02a35..1d73c098 100644
--- a/src/resources/js/admin/routes.js
+++ b/src/resources/js/admin/routes.js
@@ -1,69 +1,76 @@
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 SharedFolderComponent from '../../vue/Admin/SharedFolder'
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: '/shared-folder/:folder',
+ name: 'shared-folder',
+ component: SharedFolderComponent,
+ 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 af98c22a..c6e4e30e 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,565 +1,571 @@
/**
* 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')
},
+ folderStatusClass(folder) {
+ return this.userStatusClass(folder)
+ },
+ folderStatusText(folder) {
+ return this.userStatusText(folder)
+ },
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 {
// 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 605a6b26..4454bb89 100644
--- a/src/resources/js/fontawesome.js
+++ b/src/resources/js/fontawesome.js
@@ -1,70 +1,72 @@
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,
+ faFolderOpen,
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,
+ faFolderOpen,
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 569a86d9..b1770e9d 100644
--- a/src/resources/js/reseller/routes.js
+++ b/src/resources/js/reseller/routes.js
@@ -1,83 +1,90 @@
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 SharedFolderComponent from '../../vue/Admin/SharedFolder'
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: '/shared-folder/:folder',
+ name: 'shared-folder',
+ component: SharedFolderComponent,
+ 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 8a1d5b95..ce9e6e47 100644
--- a/src/resources/js/user/routes.js
+++ b/src/resources/js/user/routes.js
@@ -1,143 +1,157 @@
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 SharedFolderInfoComponent from '../../vue/SharedFolder/Info'
+import SharedFolderListComponent from '../../vue/SharedFolder/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: '/shared-folder/:folder',
+ name: 'shared-folder',
+ component: SharedFolderInfoComponent,
+ meta: { requiresAuth: true, perm: 'folders' }
+ },
+ {
+ path: '/shared-folders',
+ name: 'shared-folders',
+ component: SharedFolderListComponent,
+ meta: { requiresAuth: true, perm: 'folders' }
+ },
{
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 49a40be1..d17b211a 100644
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -1,106 +1,117 @@
<?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-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-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-shared-folder-imap-ready' => 'Failed to verify that a shared folder exists.',
+ 'process-error-shared-folder-ldap-ready' => 'Failed to create a shared folder.',
'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-resource-new' => 'Registering a resource...',
'process-resource-imap-ready' => 'Creating a shared folder...',
'process-resource-ldap-ready' => 'Creating a resource...',
+ 'process-shared-folder-new' => 'Registering a shared folder...',
+ 'process-shared-folder-imap-ready' => 'Creating a shared folder...',
+ 'process-shared-folder-ldap-ready' => 'Creating a shared folder...',
'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.',
+ 'shared-folder-update-success' => 'Shared folder updated successfully.',
+ 'shared-folder-create-success' => 'Shared folder created successfully.',
+ 'shared-folder-delete-success' => 'Shared folder deleted successfully.',
+ 'shared-folder-setconfig-success' => 'Shared folder 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-foundxdistlists' => ':x distribution lists have been found.',
'search-foundxresources' => ':x resources have been found.',
+ 'search-foundxsharedfolders' => ':x shared folders 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 7a13c96e..795ac2c8 100644
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -1,456 +1,480 @@
<?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",
+ 'shared-folders' => "Shared folders",
'users' => "User accounts",
'wallet' => "Wallet",
'webmail' => "Webmail",
'stats' => "Stats",
],
'distlist' => [
'list-title' => "Distribution list | Distribution lists",
'create' => "Create list",
'delete' => "Delete list",
'email' => "Email",
'list-empty' => "There are no distribution lists in this account.",
'name' => "Name",
'new' => "New distribution list",
'recipients' => "Recipients",
'sender-policy' => "Sender Access List",
'sender-policy-text' => "With this list you can specify who can send mail to the distribution list."
. " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to."
. " If the list is empty, mail from anyone is allowed.",
],
'domain' => [
'delete' => "Delete domain",
'delete-domain' => "Delete {domain}",
'delete-text' => "Do you really want to delete this domain permanently?"
. " This is only possible if there are no users, aliases or other objects in this domain."
. " Please note that this action cannot be undone.",
'dns-verify' => "Domain DNS verification sample:",
'dns-config' => "Domain DNS configuration sample:",
'namespace' => "Namespace",
'spf-whitelist' => "SPF Whitelist",
'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, "
. "which systems are allowed to send emails with an envelope sender address within said domain.",
'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: <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' => [
+ 'acl' => "Access rights",
+ 'acl-full' => "All",
+ 'acl-read-only' => "Read-only",
+ 'acl-read-write' => "Read-write",
'amount' => "Amount",
+ 'anyone' => "Anyone",
'code' => "Confirmation Code",
'config' => "Configuration",
'date' => "Date",
'description' => "Description",
'details' => "Details",
'disabled' => "disabled",
'domain' => "Domain",
'email' => "Email Address",
'enabled' => "enabled",
'firstname' => "First Name",
'general' => "General",
'lastname' => "Last Name",
'name' => "Name",
'none' => "none",
'or' => "or",
'password' => "Password",
'password-confirm' => "Confirm Password",
'phone' => "Phone",
'settings' => "Settings",
'shared-folder' => "Shared Folder",
'status' => "Status",
'surname' => "Surname",
+ 'type' => "Type",
'user' => "User",
'primary-email' => "Primary Email",
'id' => "ID",
'created' => "Created",
'deleted' => "Deleted",
],
'invitation' => [
'create' => "Create invite(s)",
'create-title' => "Invite for a signup",
'create-email' => "Enter an email address of the person you want to invite.",
'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.",
'empty-list' => "There are no invitations in the database.",
'title' => "Signup invitations",
'search' => "Email address or domain",
'send' => "Send invite(s)",
'status-completed' => "User signed up",
'status-failed' => "Sending failed",
'status-sent' => "Sent",
'status-new' => "Not sent yet",
],
'lang' => [
'en' => "English",
'de' => "German",
'fr' => "French",
'it' => "Italian",
],
'login' => [
'2fa' => "Second factor code",
'2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.",
'forgot_password' => "Forgot password?",
'header' => "Please sign in",
'sign_in' => "Sign in",
'webmail' => "Webmail"
],
'meet' => [
'title' => "Voice & Video Conferencing",
'welcome' => "Welcome to our beta program for Voice & Video Conferencing.",
'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.",
'notice' => "This is a work in progress and more features will be added over time. Current features include:",
'sharing' => "Screen Sharing",
'sharing-text' => "Share your screen for presentations or show-and-tell.",
'security' => "Room Security",
'security-text' => "Increase the room security by setting a password that attendees will need to know"
. " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.",
'qa' => "Raise Hand (Q&A)",
'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.",
'moderation' => "Moderator Delegation",
'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly"
. " interrupted with attendees knocking and other moderator duties.",
'eject' => "Eject Attendees",
'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy"
. " violations. Click the user icon for effective dismissal.",
'silent' => "Silent Audience Members",
'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.",
'interpreters' => "Language Specific Audio Channels",
'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions"
. " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.",
'beta-notice' => "Keep in mind that this is still in beta and might come with some issues."
. " Should you encounter any on your way, let us know by contacting support.",
// Room options dialog
'options' => "Room options",
'password' => "Password",
'password-none' => "none",
'password-clear' => "Clear password",
'password-set' => "Set password",
'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.",
'lock' => "Locked room",
'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.",
'nomedia' => "Subscribers only",
'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)."
. " Moderators will be able to promote them to publishers throughout the session.",
// Room menu
'partcnt' => "Number of participants",
'menu-audio-mute' => "Mute audio",
'menu-audio-unmute' => "Unmute audio",
'menu-video-mute' => "Mute video",
'menu-video-unmute' => "Unmute video",
'menu-screen' => "Share screen",
'menu-hand-lower' => "Lower hand",
'menu-hand-raise' => "Raise hand",
'menu-channel' => "Interpreted language channel",
'menu-chat' => "Chat",
'menu-fullscreen' => "Full screen",
'menu-fullscreen-exit' => "Exit full screen",
'menu-leave' => "Leave session",
// Room setup screen
'setup-title' => "Set up your session",
'mic' => "Microphone",
'cam' => "Camera",
'nick' => "Nickname",
'nick-placeholder' => "Your name",
'join' => "JOIN",
'joinnow' => "JOIN NOW",
'imaowner' => "I'm the owner",
// Room
'qa' => "Q & A",
'leave-title' => "Room closed",
'leave-body' => "The session has been closed by the room owner.",
'media-title' => "Media setup",
'join-request' => "Join request",
'join-requested' => "{user} requested to join.",
// Status messages
'status-init' => "Checking the room...",
'status-323' => "The room is closed. Please, wait for the owner to start the session.",
'status-324' => "The room is closed. It will be open for others after you join.",
'status-325' => "The room is ready. Please, provide a valid password.",
'status-326' => "The room is locked. Please, enter your name and try again.",
'status-327' => "Waiting for permission to join the room.",
'status-404' => "The room does not exist.",
'status-429' => "Too many requests. Please, wait.",
'status-500' => "Failed to connect to the room. Server error.",
// Other menus
'media-setup' => "Media setup",
'perm' => "Permissions",
'perm-av' => "Audio &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",
],
+ 'shf' => [
+ 'create' => "Create folder",
+ 'delete' => "Delete folder",
+ 'acl-text' => "Defines user permissions to access the shared folder.",
+ 'list-title' => "Shared folder | Shared folders",
+ 'list-empty' => "There are no shared folders in this account.",
+ 'new' => "New shared folder",
+ 'type-mail' => "Mail",
+ 'type-event' => "Calendar",
+ 'type-contact' => "Address Book",
+ 'type-task' => "Tasks",
+ 'type-note' => "Notes",
+ 'type-file' => "Files",
+ ],
+
'signup' => [
'email' => "Existing Email Address",
'login' => "Login",
'title' => "Sign Up",
'step1' => "Sign up to start your free month.",
'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.",
'step3' => "Create your Kolab identity (you can choose additional addresses later).",
'voucher' => "Voucher Code",
],
'status' => [
'prepare-account' => "We are preparing your account.",
'prepare-domain' => "We are preparing the domain.",
'prepare-distlist' => "We are preparing the distribution list.",
'prepare-resource' => "We are preparing the resource.",
+ 'prepare-shared-folder' => "We are preparing the shared folder.",
'prepare-user' => "We are preparing the user account.",
'prepare-hint' => "Some features may be missing or readonly at the moment.",
'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.",
'ready-account' => "Your account is almost ready.",
'ready-domain' => "The domain is almost ready.",
'ready-distlist' => "The distribution list is almost ready.",
'ready-resource' => "The resource is almost ready.",
+ 'ready-shared-folder' => "The shared-folder is almost ready.",
'ready-user' => "The user account is almost ready.",
'verify' => "Verify your domain to finish the setup process.",
'verify-domain' => "Verify domain",
'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",
'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 178d2924..761dda11 100644
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -1,177 +1,178 @@
<?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.',
+ 'acl-entry-invalid' => 'The entry format is invalid. Expected an email address.',
'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.',
/*
|--------------------------------------------------------------------------
| 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/themes/forms.scss b/src/resources/themes/forms.scss
index ded08e86..de5b08eb 100644
--- a/src/resources/themes/forms.scss
+++ b/src/resources/themes/forms.scss
@@ -1,147 +1,154 @@
.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;
}
}
+.acl-input {
+ select.acl,
+ select.mod-user {
+ max-width: fit-content;
+ }
+}
+
.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/SharedFolder.vue b/src/resources/vue/Admin/SharedFolder.vue
new file mode 100644
index 00000000..d712c284
--- /dev/null
+++ b/src/resources/vue/Admin/SharedFolder.vue
@@ -0,0 +1,91 @@
+<template>
+ <div v-if="folder.id" class="container">
+ <div class="card" id="folder-info">
+ <div class="card-body">
+ <div class="card-title">{{ folder.email }}</div>
+ <div class="card-text">
+ <form class="read-only short">
+ <div class="row plaintext">
+ <label for="folderid" 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="folderid">
+ {{ folder.id }} <span class="text-muted">({{ folder.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.folderStatusClass(folder) + ' form-control-plaintext'" id="status">{{ $root.folderStatusText(folder) }}</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">{{ folder.name }}</span>
+ </div>
+ </div>
+ <div class="row plaintext">
+ <label for="type" class="col-sm-4 col-form-label">{{ $t('form.type') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="type">{{ $t('shf.type-' + folder.type) }}</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="#folder-settings" role="tab" aria-controls="folder-settings" aria-selected="false" @click="$root.tab">
+ {{ $t('form.settings') }}
+ </a>
+ </li>
+ </ul>
+ <div class="tab-content">
+ <div class="tab-pane show active" id="folder-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="acl" class="col-sm-4 col-form-label">{{ $t('form.acl') }}</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="acl">
+ <span v-if="folder.config.acl.length">
+ <span v-for="(entry, index) in folder.config.acl" :key="index">
+ {{ entry.replace(',', ':') }}<br>
+ </span>
+ </span>
+ <span v-else>{{ $t('form.none') }}</span>
+ </span>
+ </div>
+ </div>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ folder: { config: {} }
+ }
+ },
+ created() {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/shared-folders/' + this.$route.params.folder)
+ .then(response => {
+ this.$root.stopLoading()
+ this.folder = response.data
+ })
+ .catch(this.$root.errorHandler)
+ }
+ }
+</script>
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
index 58e16101..98612cad 100644
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -1,800 +1,842 @@
<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-shared-folders" href="#user-shared-folders" role="tab" aria-controls="user-shared-folders" aria-selected="false">
+ {{ $t('dashboard.shared-folders') }} ({{ folders.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
+ {{ $t('form.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('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-shared-folders" role="tabpanel" aria-labelledby="tab-shared-folders">
+ <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.type') }}</th>
+ <th scope="col">{{ $t('form.email') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="folder in folders" :key="folder.id" @click="$root.clickRecord">
+ <td>
+ <svg-icon icon="folder-open" :class="$root.folderStatusClass(folder)" :title="$root.folderStatusText(folder)"></svg-icon>
+ <router-link :to="{ path: '/shared-folder/' + folder.id }">{{ folder.name }}</router-link>
+ </td>
+ <td>{{ $t('shf.type-' + folder.type) }}</td>
+ <td><router-link :to="{ path: '/shared-folder/' + folder.id }">{{ folder.email }}</router-link></td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="3">{{ $t('shf.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: '',
+ folders: [],
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
})
+
+ // Fetch shared folders lists
+ axios.get('/api/v4/shared-folders?owner=' + user_id)
+ .then(response => {
+ this.folders = 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 e0246a25..9ac9c1a5 100644
--- a/src/resources/vue/Dashboard.vue
+++ b/src/resources/vue/Dashboard.vue
@@ -1,71 +1,74 @@
<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.enableFolders" class="card link-shared-folders" :to="{ name: 'shared-folders' }">
+ <svg-icon icon="folder-open"></svg-icon><span class="name">{{ $t('dashboard.shared-folders') }}</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
index a0925800..e2d28eeb 100644
--- a/src/resources/vue/Resource/Info.vue
+++ b/src/resources/vue/Resource/Info.vue
@@ -1,189 +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>
+ <option v-for="_domain in domains" :key="_domain.id" :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/Info.vue b/src/resources/vue/SharedFolder/Info.vue
similarity index 56%
copy from src/resources/vue/Resource/Info.vue
copy to src/resources/vue/SharedFolder/Info.vue
index a0925800..706c6467 100644
--- a/src/resources/vue/Resource/Info.vue
+++ b/src/resources/vue/SharedFolder/Info.vue
@@ -1,189 +1,177 @@
<template>
<div class="container">
- <status-component v-if="resource_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
+ <status-component v-if="folder_id !== 'new'" :status="status" @status-update="statusUpdate"></status-component>
- <div class="card" id="resource-info">
+ <div class="card" id="folder-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') }}
+ <div class="card-title" v-if="folder_id !== 'new'">
+ {{ $tc('shf.list-title', 1) }}
+ <button class="btn btn-outline-danger button-delete float-end" @click="deleteFolder()" tag="button">
+ <svg-icon icon="trash-alt"></svg-icon> {{ $t('shf.delete') }}
</button>
</div>
- <div class="card-title" v-if="resource_id === 'new'">{{ $t('resource.new') }}</div>
+ <div class="card-title" v-if="folder_id === 'new'">{{ $t('shf.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">
+ <li v-if="folder_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">
+ <div v-if="folder_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>
+ <span :class="$root.folderStatusClass(folder) + ' form-control-plaintext'" id="status">{{ $root.folderStatusText(folder) }}</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">
+ <input type="text" class="form-control" id="name" v-model="folder.name">
+ </div>
+ </div>
+ <div class="row mb-3">
+ <label for="type" class="col-sm-4 col-form-label">{{ $t('form.type') }}</label>
+ <div class="col-sm-8">
+ <select id="type" class="form-select" v-model="folder.type" :disabled="folder_id !== 'new'">
+ <option v-for="type in types" :key="type" :value="type">{{ $t('shf.type-' + type) }}</option>
+ </select>
</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>
+ <div v-if="domains.length" class="col-sm-8">
+ <select class="form-select" v-model="folder.domain">
+ <option v-for="_domain in domains" :key="_domain.id" :value="_domain.namespace">{{ _domain.namespace }}</option>
</select>
</div>
</div>
- <div v-if="resource.email" class="row mb-3">
+ <div v-if="folder.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">
+ <input type="text" class="form-control" id="email" disabled v-model="folder.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>
+ <label for="acl-input" class="col-sm-4 col-form-label">{{ $t('form.acl') }}</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') }}
+ <acl-input id="acl" v-model="folder.config.acl" :list="folder.config.acl" class="mb-1"></acl-input>
+ <small id="acl-hint" class="text-muted">
+ {{ $t('shf.acl-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 AclInput from '../Widgets/AclInput'
import StatusComponent from '../Widgets/Status'
export default {
components: {
+ AclInput,
StatusComponent
},
data() {
return {
domains: [],
- resource_id: null,
- resource: { config: {} },
- status: {}
+ folder_id: null,
+ folder: { type: 'mail', config: {} },
+ status: {},
+ types: [ 'mail', 'event', 'task', 'contact', 'note', 'file' ]
}
},
created() {
- this.resource_id = this.$route.params.resource
+ this.folder_id = this.$route.params.folder
- if (this.resource_id != 'new') {
+ if (this.folder_id != 'new') {
this.$root.startLoading()
- axios.get('/api/v4/resources/' + this.resource_id)
+ axios.get('/api/v4/shared-folders/' + this.folder_id)
.then(response => {
this.$root.stopLoading()
- this.resource = response.data
+ this.folder = 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
+ this.folder.domain = this.domains[0].namespace
})
.catch(this.$root.errorHandler)
}
},
mounted() {
$('#name').focus()
},
methods: {
- deleteResource() {
- axios.delete('/api/v4/resources/' + this.resource_id)
+ deleteFolder() {
+ axios.delete('/api/v4/shared-folders/' + this.folder_id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
- this.$router.push({ name: 'resources' })
+ this.$router.push({ name: 'shared-folders' })
}
})
},
- policyChange() {
- let select = $('#invitation_policy')
- select.parent()[select.val() == 'manual' ? 'addClass' : 'removeClass']('selected')
- },
- statusUpdate(resource) {
- this.resource = Object.assign({}, this.resource, resource)
+ statusUpdate(folder) {
+ this.folder = Object.assign({}, this.folder, folder)
},
submit() {
- this.$root.clearFormValidation($('#resource-info form'))
+ this.$root.clearFormValidation($('#folder-info form'))
let method = 'post'
- let location = '/api/v4/resources'
+ let location = '/api/v4/shared-folders'
- if (this.resource_id !== 'new') {
+ if (this.folder_id !== 'new') {
method = 'put'
- location += '/' + this.resource_id
+ location += '/' + this.folder_id
}
- const post = this.$root.pick(this.resource, ['id', 'name', 'domain'])
+ const post = this.$root.pick(this.folder, ['id', 'name', 'domain', 'type'])
axios[method](location, post)
.then(response => {
this.$toast.success(response.data.message)
- this.$router.push({ name: 'resources' })
+ this.$router.push({ name: 'shared-folders' })
})
},
submitSettings() {
this.$root.clearFormValidation($('#settings form'))
- let post = {...this.resource.config}
-
- if (post.invitation_policy == 'manual') {
- post.invitation_policy += ':' + post.owner
- }
-
- delete post.owner
+ let post = {...this.folder.config}
- axios.post('/api/v4/resources/' + this.resource_id + '/config', post)
+ axios.post('/api/v4/shared-folders/' + this.folder_id + '/config', post)
.then(response => {
this.$toast.success(response.data.message)
})
}
}
}
</script>
diff --git a/src/resources/vue/SharedFolder/List.vue b/src/resources/vue/SharedFolder/List.vue
new file mode 100644
index 00000000..8553210f
--- /dev/null
+++ b/src/resources/vue/SharedFolder/List.vue
@@ -0,0 +1,60 @@
+<template>
+ <div class="container">
+ <div class="card" id="folder-list">
+ <div class="card-body">
+ <div class="card-title">
+ {{ $tc('shf.list-title', 2) }}
+ <router-link class="btn btn-success float-end create-folder" :to="{ path: 'shared-folder/new' }" tag="button">
+ <svg-icon icon="cog"></svg-icon> {{ $t('shf.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.type') }}</th>
+ <th scope="col">{{ $t('form.email') }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-for="folder in folders" :key="folder.id" @click="$root.clickRecord">
+ <td>
+ <svg-icon icon="folder-open" :class="$root.folderStatusClass(folder)" :title="$root.folderStatusText(folder)"></svg-icon>
+ <router-link :to="{ path: 'shared-folder/' + folder.id }">{{ folder.name }}</router-link>
+ </td>
+ <td>{{ $t('shf.type-' + folder.type) }}</td>
+ <td><router-link :to="{ path: 'shared-folder/' + folder.id }">{{ folder.email }}</router-link></td>
+ </tr>
+ </tbody>
+ <tfoot class="table-fake-body">
+ <tr>
+ <td colspan="3">{{ $t('shf.list-empty') }}</td>
+ </tr>
+ </tfoot>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ folders: []
+ }
+ },
+ created() {
+ this.$root.startLoading()
+
+ axios.get('/api/v4/shared-folders')
+ .then(response => {
+ this.$root.stopLoading()
+ this.folders = response.data
+ })
+ .catch(this.$root.errorHandler)
+ }
+ }
+</script>
diff --git a/src/resources/vue/Widgets/AclInput.vue b/src/resources/vue/Widgets/AclInput.vue
new file mode 100644
index 00000000..6e03a965
--- /dev/null
+++ b/src/resources/vue/Widgets/AclInput.vue
@@ -0,0 +1,118 @@
+<template>
+ <div class="list-input acl-input" :id="id">
+ <div class="input-group">
+ <select class="form-select mod mod-user" @change="changeMod" v-model="mod">
+ <option value="user">{{ $t('form.user') }}</option>
+ <option value="anyone">{{ $t('form.anyone') }}</option>
+ </select>
+ <input :id="id + '-input'" type="text" class="form-control main-input" :placeholder="$t('form.email')" @keydown="keyDown">
+ <select class="form-select acl" v-model="perm">
+ <option v-for="t in types" :key="t" :value="t">{{ $t('form.acl-' + t) }}</option>
+ </select>
+ <a href="#" class="btn btn-outline-secondary" @click.prevent="addItem">
+ <svg-icon icon="plus"></svg-icon><span class="visually-hidden">{{ $t('btn.add') }}</span>
+ </a>
+ </div>
+ <div class="input-group" v-for="(item, index) in list" :key="index">
+ <input type="text" class="form-control" :value="aclIdent(item)" :readonly="aclIdent(item) == 'anyone'" :placeholder="$t('form.email')">
+ <select class="form-select acl">
+ <option v-for="t in types" :key="t" :value="t" :selected="aclPerm(item) == t">{{ $t('form.acl-' + t) }}</option>
+ </select>
+ <a href="#" class="btn btn-outline-secondary" @click.prevent="deleteItem(index)">
+ <svg-icon icon="trash-alt"></svg-icon><span class="visually-hidden">{{ $t('btn.delete') }}</span>
+ </a>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ props: {
+ list: { type: Array, default: () => [] },
+ id: { type: String, default: '' }
+ },
+ data() {
+ return {
+ mod: 'user',
+ perm: 'read-only',
+ types: [ 'read-only', 'read-write', 'full' ]
+ }
+ },
+ mounted() {
+ this.input = $(this.$el).find('.main-input')[0]
+ this.select = $(this.$el).find('select')[0]
+
+ // On form submit add the text from main input to the list
+ // Users tend to forget about pressing the "plus" button
+ // Note: We can't use form.onsubmit (too late)
+ // Note: Use of input.onblur has been proven to be problematic
+ // TODO: What with forms that have no submit button?
+ $(this.$el).closest('form').find('button[type=submit]').on('click', () => {
+ this.updateList()
+ this.addItem(false)
+ })
+ },
+ methods: {
+ aclIdent(item) {
+ return item.split(/\s*,\s*/)[0]
+ },
+ aclPerm(item) {
+ return item.split(/\s*,\s*/)[1]
+ },
+ addItem(focus) {
+ let value = this.input.value
+
+ if (value || this.mod == 'anyone') {
+ if (this.mod == 'anyone') {
+ value = 'anyone'
+ }
+
+ this.$set(this.list, this.list.length, value + ', ' + this.perm)
+
+ this.input.classList.remove('is-invalid')
+ this.input.value = ''
+ this.mod = 'user'
+ this.perm = 'read-only'
+ this.changeMod()
+
+ if (focus !== false) {
+ this.input.focus()
+ }
+
+ if (this.list.length == 1) {
+ this.$el.classList.remove('is-invalid')
+ }
+
+ this.$emit('change', this.$el)
+ }
+ },
+ changeMod() {
+ $(this.input)[this.mod == 'user' ? 'removeClass' : 'addClass']('d-none')
+ $(this.input).prev()[this.mod == 'user' ? 'addClass' : 'removeClass']('mod-user')
+ },
+ deleteItem(index) {
+ this.updateList()
+ this.$delete(this.list, index)
+ this.$emit('change', this.$el)
+
+ if (!this.list.length) {
+ this.$el.classList.remove('is-invalid')
+ }
+ },
+ keyDown(e) {
+ if (e.which == 13 && e.target.value) {
+ this.addItem()
+ e.preventDefault()
+ }
+ },
+ updateList() {
+ // Update this.list to the current state of the html elements
+ $(this.$el).children('.input-group:not(:first-child)').each((index, elem) => {
+ const perm = $(elem).find('select.acl').val()
+ const value = $(elem).find('input').val()
+ this.$set(this.list, index, value + ', ' + perm)
+ })
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/Widgets/Status.vue b/src/resources/vue/Widgets/Status.vue
index 9d4043dc..5c33688f 100644
--- a/src/resources/vue/Widgets/Status.vue
+++ b/src/resources/vue/Widgets/Status.vue
@@ -1,203 +1,198 @@
<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>
+ <span>{{ $t('status.prepare-' + scopeLabel()) }}</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>
+ <span>{{ $t('status.ready-' + scopeLabel()) }}</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 'distlist':
- url = '/api/v4/groups/' + this.$route.params.list + '/status'
- break
- default:
- url = '/api/v4/' + this.scope + 's/' + this.$route.params[this.scope] + '/status'
+ let scope = this.scope
+ let id = this.$route.params[scope]
+
+ if (scope == 'dashboard') {
+ id = this.$store.state.authInfo.id
+ scope = 'user'
+ } else if (scope =='distlist') {
+ id = this.$route.params.list
+ scope = 'group'
+ } else if (scope == 'shared-folder') {
+ id = this.$route.params.folder
}
- return url
+ return '/api/v4/' + scope + 's/' + id + '/status'
+ },
+ scopeLabel() {
+ return this.scope == 'dashboard' ? 'account' : this.scope
}
}
}
</script>
diff --git a/src/routes/api.php b/src/routes/api.php
index 51c83fe1..5459a435 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,257 +1,263 @@
<?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('shared-folders', API\V4\SharedFoldersController::class);
+ Route::get('shared-folders/{id}/status', 'API\V4\SharedFoldersController@status');
+ Route::post('shared-folders/{id}/config', 'API\V4\SharedFoldersController@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('shared-folders', API\V4\Admin\SharedFoldersController::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('shared-folders', API\V4\Reseller\SharedFoldersController::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
index 9016af53..4e914410 100644
--- a/src/tests/Browser/Admin/ResourceTest.php
+++ b/src/tests/Browser/Admin/ResourceTest.php
@@ -1,95 +1,98 @@
<?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->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE
+ | Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY;
+ $resource->save();
$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/ResourceTest.php b/src/tests/Browser/Admin/SharedFolderTest.php
similarity index 50%
copy from src/tests/Browser/Admin/ResourceTest.php
copy to src/tests/Browser/Admin/SharedFolderTest.php
index 9016af53..7e1dcb93 100644
--- a/src/tests/Browser/Admin/ResourceTest.php
+++ b/src/tests/Browser/Admin/SharedFolderTest.php
@@ -1,95 +1,101 @@
<?php
namespace Tests\Browser\Admin;
-use App\Resource;
+use App\SharedFolder;
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\SharedFolder as SharedFolderPage;
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
+class SharedFolderTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
parent::tearDown();
}
/**
- * Test resource info page (unauthenticated)
+ * Test shared folder info page (unauthenticated)
*/
- public function testResourceUnauth(): void
+ public function testSharedFolderUnauth(): 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');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
- $browser->visit('/resource/' . $resource->id)->on(new Home());
+ $browser->visit('/shared-folder/' . $folder->id)->on(new Home());
});
}
/**
- * Test resource info page
+ * Test shared folder 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');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]);
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE
+ | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
- $resource_page = new ResourcePage($resource->id);
+ $folder_page = new SharedFolderPage($folder->id);
$user_page = new UserPage($user->id);
- // Goto the resource page
+ // Goto the folder 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')
+ ->click('@nav #tab-shared-folders')
->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)
+ ->click('@user-shared-folders table tbody tr:first-child td:first-child a')
+ ->on($folder_page)
+ ->assertSeeIn('@folder-info .card-title', $folder->email)
+ ->with('@folder-info form', function (Browser $browser) use ($folder) {
+ $browser->assertElementsCount('.row', 4)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
- ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})")
+ ->assertSeeIn('.row:nth-child(1) #folderid', "{$folder->id} ({$folder->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);
+ ->assertSeeIn('.row:nth-child(3) #name', $folder->name)
+ ->assertSeeIn('.row:nth-child(4) label', 'Type')
+ ->assertSeeIn('.row:nth-child(4) #type', 'Calendar');
})
->assertElementsCount('ul.nav-tabs', 1)
->assertSeeIn('ul.nav-tabs .nav-link', 'Settings')
- ->with('@resource-settings form', function (Browser $browser) {
+ ->with('@folder-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');
+ ->assertSeeIn('.row:nth-child(1) label', 'Access rights')
+ ->assertSeeIn('.row:nth-child(1) #acl', 'anyone: read-only')
+ ->assertSeeIn('.row:nth-child(1) #acl', 'jack@kolab.org: read-write');
});
- // Test invalid resource identifier
- $browser->visit('/resource/abc')->assertErrorPage(404);
+ // Test invalid shared folder identifier
+ $browser->visit('/shared-folder/abc')->assertErrorPage(404);
});
}
}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
index bb46a597..310b2a4c 100644
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -1,573 +1,604 @@
<?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', 8);
+ ->assertElementsCount('@nav a', 9);
// 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 Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no shared folders 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', 8);
+ ->assertElementsCount('@nav a', 9);
// 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 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');
+ });
+
// 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');
+ // Assert Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (2)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 2)
+ ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org')
+ ->assertMissing('table 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')
+ $browser->click('@nav #tab-users')
+ ->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', 8);
+ ->assertElementsCount('@nav a', 9);
// 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.');
});
+ // We don't expect John's folders here
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no shared folders 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/Components/AclInput.php b/src/tests/Browser/Components/AclInput.php
new file mode 100644
index 00000000..d0500624
--- /dev/null
+++ b/src/tests/Browser/Components/AclInput.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Tests\Browser\Components;
+
+use Laravel\Dusk\Component as BaseComponent;
+use PHPUnit\Framework\Assert as PHPUnit;
+
+class AclInput extends BaseComponent
+{
+ protected $selector;
+
+
+ public function __construct($selector)
+ {
+ $this->selector = $selector;
+ }
+
+ /**
+ * Get the root selector for the component.
+ *
+ * @return string
+ */
+ public function selector()
+ {
+ return $this->selector;
+ }
+
+ /**
+ * Assert that the browser page contains the component.
+ *
+ * @param \Laravel\Dusk\Browser $browser
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->assertVisible($this->selector)
+ ->assertVisible("{$this->selector} @input")
+ ->assertVisible("{$this->selector} @add-btn")
+ ->assertSelectHasOptions("{$this->selector} @mod-select", ['user', 'anyone'])
+ ->assertSelectHasOptions("{$this->selector} @acl-select", ['read-only', 'read-write', 'full']);
+ }
+
+ /**
+ * Get the element shortcuts for the component.
+ *
+ * @return array
+ */
+ public function elements()
+ {
+ return [
+ '@add-btn' => '.input-group:first-child a.btn',
+ '@input' => '.input-group:first-child input',
+ '@acl-select' => '.input-group:first-child select.acl',
+ '@mod-select' => '.input-group:first-child select.mod',
+ ];
+ }
+
+ /**
+ * Assert acl input content
+ */
+ public function assertAclValue($browser, array $list)
+ {
+ if (empty($list)) {
+ $browser->assertMissing('.input-group:not(:first-child)');
+ return;
+ }
+
+ foreach ($list as $idx => $value) {
+ $selector = '.input-group:nth-child(' . ($idx + 2) . ')';
+ list($ident, $acl) = preg_split('/\s*,\s*/', $value);
+
+ $input = $ident == 'anyone' ? 'input:read-only' : 'input:not(:read-only)';
+
+ $browser->assertVisible("$selector $input")
+ ->assertVisible("$selector select")
+ ->assertVisible("$selector a.btn")
+ ->assertValue("$selector $input", $ident)
+ ->assertSelected("$selector select", $acl);
+ }
+ }
+
+ /**
+ * Add acl entry
+ */
+ public function addAclEntry($browser, string $value)
+ {
+ list($ident, $acl) = preg_split('/\s*,\s*/', $value);
+
+ $browser->select('@mod-select', $ident == 'anyone' ? 'anyone' : 'user')
+ ->select('@acl-select', $acl);
+
+ if ($ident == 'anyone') {
+ $browser->assertValue('@input', '')->assertMissing('@input');
+ } else {
+ $browser->type('@input', $ident);
+ }
+
+ $browser->click('@add-btn')
+ ->assertSelected('@mod-select', 'user')
+ ->assertSelected('@acl-select', 'read-only')
+ ->assertValue('@input', '');
+ }
+
+ /**
+ * Remove acl entry
+ */
+ public function removeAclEntry($browser, int $num)
+ {
+ $selector = '.input-group:nth-child(' . ($num + 1) . ') a.btn';
+ $browser->click($selector);
+ }
+
+ /**
+ * Update acl entry
+ */
+ public function updateAclEntry($browser, int $num, $value)
+ {
+ list($ident, $acl) = preg_split('/\s*,\s*/', $value);
+
+ $selector = '.input-group:nth-child(' . ($num + 1) . ')';
+
+ $browser->select("$selector select.acl", $acl)
+ ->type("$selector input", $ident);
+ }
+
+ /**
+ * Assert an error message on the widget
+ */
+ public function assertFormError($browser, int $num, string $msg, bool $focused = false)
+ {
+ $selector = '.input-group:nth-child(' . ($num + 1) . ') input.is-invalid';
+
+ $browser->waitFor($selector)
+ ->assertSeeIn(' + .invalid-feedback', $msg);
+
+ if ($focused) {
+ $browser->assertFocused($selector);
+ }
+ }
+}
diff --git a/src/tests/Browser/Pages/Admin/SharedFolder.php b/src/tests/Browser/Pages/Admin/SharedFolder.php
new file mode 100644
index 00000000..0985e5aa
--- /dev/null
+++ b/src/tests/Browser/Pages/Admin/SharedFolder.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Tests\Browser\Pages\Admin;
+
+use Laravel\Dusk\Page;
+
+class SharedFolder extends Page
+{
+ protected $folderId;
+
+ /**
+ * Object constructor.
+ *
+ * @param int $id Shared folder Id
+ */
+ public function __construct($id)
+ {
+ $this->folderId = $id;
+ }
+
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/shared-folder/' . $this->folderId;
+ }
+
+ /**
+ * 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())
+ ->waitFor('@folder-info');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@folder-info' => '#folder-info',
+ '@folder-settings' => '#folder-settings',
+ ];
+ }
+}
diff --git a/src/tests/Browser/Pages/Admin/User.php b/src/tests/Browser/Pages/Admin/User.php
index 4ad83025..c58b774b 100644
--- a/src/tests/Browser/Pages/Admin/User.php
+++ b/src/tests/Browser/Pages/Admin/User.php
@@ -1,66 +1,67 @@
<?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-shared-folders' => '#user-shared-folders',
'@user-users' => '#user-users',
'@user-settings' => '#user-settings',
];
}
}
diff --git a/src/tests/Browser/Pages/SharedFolderInfo.php b/src/tests/Browser/Pages/SharedFolderInfo.php
new file mode 100644
index 00000000..e29ae488
--- /dev/null
+++ b/src/tests/Browser/Pages/SharedFolderInfo.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class SharedFolderInfo 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/SharedFolderList.php b/src/tests/Browser/Pages/SharedFolderList.php
new file mode 100644
index 00000000..c155b288
--- /dev/null
+++ b/src/tests/Browser/Pages/SharedFolderList.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class SharedFolderList extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '/shared-folders';
+ }
+
+ /**
+ * 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('#folder-list .card-title', 'Shared folders');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@app' => '#app',
+ '@table' => '#folder-list table',
+ ];
+ }
+}
diff --git a/src/tests/Browser/Reseller/ResourceTest.php b/src/tests/Browser/Reseller/ResourceTest.php
index 6f3e6d83..4eea3c15 100644
--- a/src/tests/Browser/Reseller/ResourceTest.php
+++ b/src/tests/Browser/Reseller/ResourceTest.php
@@ -1,95 +1,98 @@
<?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->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE
+ | Resource::STATUS_LDAP_READY | Resource::STATUS_IMAP_READY;
+ $resource->save();
$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/ResourceTest.php b/src/tests/Browser/Reseller/SharedFolderTest.php
similarity index 50%
copy from src/tests/Browser/Reseller/ResourceTest.php
copy to src/tests/Browser/Reseller/SharedFolderTest.php
index 6f3e6d83..2e0b8125 100644
--- a/src/tests/Browser/Reseller/ResourceTest.php
+++ b/src/tests/Browser/Reseller/SharedFolderTest.php
@@ -1,95 +1,101 @@
<?php
namespace Tests\Browser\Reseller;
-use App\Resource;
+use App\SharedFolder;
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\SharedFolder as SharedFolderPage;
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
+class SharedFolderTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
parent::tearDown();
}
/**
- * Test resource info page (unauthenticated)
+ * Test shared folder info page (unauthenticated)
*/
- public function testResourceUnauth(): void
+ public function testSharedFolderUnauth(): 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');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
- $browser->visit('/resource/' . $resource->id)->on(new Home());
+ $browser->visit('/shared-folder/' . $folder->id)->on(new Home());
});
}
/**
- * Test distribution list info page
+ * Test shared folder 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');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->setConfig(['acl' => ['anyone, read-only', 'jack@kolab.org, read-write']]);
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE
+ | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
- $resource_page = new ResourcePage($resource->id);
+ $folder_page = new SharedFolderPage($folder->id);
$user_page = new UserPage($user->id);
- // Goto the distlist page
+ // Goto the folder 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')
+ ->click('@nav #tab-shared-folders')
->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)
+ ->click('@user-shared-folders table tbody tr:first-child td:first-child a')
+ ->on($folder_page)
+ ->assertSeeIn('@folder-info .card-title', $folder->email)
+ ->with('@folder-info form', function (Browser $browser) use ($folder) {
+ $browser->assertElementsCount('.row', 4)
->assertSeeIn('.row:nth-child(1) label', 'ID (Created)')
- ->assertSeeIn('.row:nth-child(1) #resourceid', "{$resource->id} ({$resource->created_at})")
+ ->assertSeeIn('.row:nth-child(1) #folderid', "{$folder->id} ({$folder->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);
+ ->assertSeeIn('.row:nth-child(3) #name', $folder->name)
+ ->assertSeeIn('.row:nth-child(4) label', 'Type')
+ ->assertSeeIn('.row:nth-child(4) #type', 'Calendar');
})
->assertElementsCount('ul.nav-tabs', 1)
->assertSeeIn('ul.nav-tabs .nav-link', 'Settings')
- ->with('@resource-settings form', function (Browser $browser) {
+ ->with('@folder-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');
+ ->assertSeeIn('.row:nth-child(1) label', 'Access rights')
+ ->assertSeeIn('.row:nth-child(1) #acl', 'anyone: read-only')
+ ->assertSeeIn('.row:nth-child(1) #acl', 'jack@kolab.org: read-write');
});
- // Test invalid resource identifier
- $browser->visit('/resource/abc')->assertErrorPage(404);
+ // Test invalid shared folder identifier
+ $browser->visit('/shared-folder/abc')->assertErrorPage(404);
});
}
}
diff --git a/src/tests/Browser/Reseller/UserTest.php b/src/tests/Browser/Reseller/UserTest.php
index 8df9ec96..c891975e 100644
--- a/src/tests/Browser/Reseller/UserTest.php
+++ b/src/tests/Browser/Reseller/UserTest.php
@@ -1,546 +1,577 @@
<?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', 8);
+ ->assertElementsCount('@nav a', 9);
// 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 Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no shared folders 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', 8);
+ ->assertElementsCount('@nav a', 9);
// 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 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');
+ });
+
// 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');
+ // Assert Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (2)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 2)
+ ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'Calendar')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:nth-child(2)', 'Calendar')
+ ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', 'folder-event@kolab.org')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Contacts')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:nth-child(2)', 'Address Book')
+ ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', 'folder-contact@kolab.org')
+ ->assertMissing('table 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')
+ $browser->click('@nav #tab-users')
+ ->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', 8);
+ ->assertElementsCount('@nav a', 9);
// 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 Shared folders tab
+ $browser->assertSeeIn('@nav #tab-shared-folders', 'Shared folders (0)')
+ ->click('@nav #tab-shared-folders')
+ ->with('@user-shared-folders', function (Browser $browser) {
+ $browser->assertElementsCount('table tbody tr', 0)
+ ->assertSeeIn('table tfoot tr td', 'There are no shared folders 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/SharedFolderTest.php b/src/tests/Browser/SharedFolderTest.php
new file mode 100644
index 00000000..aea65f7c
--- /dev/null
+++ b/src/tests/Browser/SharedFolderTest.php
@@ -0,0 +1,333 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\SharedFolder;
+use Tests\Browser;
+use Tests\Browser\Components\AclInput;
+use Tests\Browser\Components\Status;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\SharedFolderInfo;
+use Tests\Browser\Pages\SharedFolderList;
+use Tests\TestCaseDusk;
+
+class SharedFolderTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ SharedFolder::whereNotIn('email', ['folder-event@kolab.org', 'folder-contact@kolab.org'])->delete();
+ $this->clearBetaEntitlements();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ SharedFolder::whereNotIn('email', ['folder-event@kolab.org', 'folder-contact@kolab.org'])->delete();
+ $this->clearBetaEntitlements();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test shared folder info page (unauthenticated)
+ */
+ public function testInfoUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/shared-folder/abc')->on(new Home());
+ });
+ }
+
+ /**
+ * Test shared folder list page (unauthenticated)
+ */
+ public function testListUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/shared-folders')->on(new Home());
+ });
+ }
+
+ /**
+ * Test shared folders 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-shared-folders');
+ });
+
+ // Test that shared folders lists page is not accessible without the 'beta-shared-folders' entitlement
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/shared-folders')
+ ->assertErrorPage(403);
+ });
+
+ // Add beta+beta-shared-folders entitlements
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-shared-folders');
+ // Make sure the first folder is active
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE
+ | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ // Test shared folders lists page
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Dashboard())
+ ->assertSeeIn('@links .link-shared-folders', 'Shared folders')
+ ->click('@links .link-shared-folders')
+ ->on(new SharedFolderList())
+ ->whenAvailable('@table', function (Browser $browser) {
+ $browser->waitFor('tbody tr')
+ ->assertSeeIn('thead tr th:nth-child(1)', 'Name')
+ ->assertSeeIn('thead tr th:nth-child(2)', 'Type')
+ ->assertSeeIn('thead tr th:nth-child(3)', 'Email Address')
+ ->assertElementsCount('tbody tr', 2)
+ ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(1) a', 'Calendar')
+ ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(2)', 'Calendar')
+ ->assertSeeIn('tbody tr:nth-child(1) td:nth-child(3) a', 'folder-event@kolab.org')
+ ->assertText('tbody tr:nth-child(1) td:nth-child(1) svg.text-success title', 'Active')
+ ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(1) a', 'Contacts')
+ ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(2)', 'Address Book')
+ ->assertSeeIn('tbody tr:nth-child(2) td:nth-child(3) a', 'folder-contact@kolab.org')
+ ->assertMissing('tfoot');
+ });
+ });
+ }
+
+ /**
+ * Test shared folder creation/editing/deleting
+ *
+ * @depends testList
+ */
+ public function testCreateUpdateDelete(): void
+ {
+ // Test that the page is not available accessible without the 'beta-shared-folders' entitlement
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/shared-folder/new')
+ ->assertErrorPage(403);
+ });
+
+ // Add beta+beta-shared-folders entitlements
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-shared-folders');
+
+ $this->browse(function (Browser $browser) {
+ // Create a folder
+ $browser->visit(new SharedFolderList())
+ ->assertSeeIn('button.create-folder', 'Create folder')
+ ->click('button.create-folder')
+ ->on(new SharedFolderInfo())
+ ->assertSeeIn('#folder-info .card-title', 'New shared folder')
+ ->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', 'Type')
+ ->assertSelectHasOptions(
+ 'div.row:nth-child(2) select',
+ ['mail', 'event', 'task', 'contact', 'note', 'file']
+ )
+ ->assertValue('div.row:nth-child(2) select', 'mail')
+ ->assertSeeIn('div.row:nth-child(3) label', 'Domain')
+ ->assertSelectHasOptions('div.row:nth-child(3) select', ['kolab.org'])
+ ->assertValue('div.row:nth-child(3) 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 folder creation
+ ->type('#name', 'Test Folder')
+ ->select('#type', 'event')
+ ->click('@general button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder created successfully.')
+ ->on(new SharedFolderList())
+ ->assertElementsCount('@table tbody tr', 3);
+
+ // Test folder update
+ $browser->click('@table tr:nth-child(3) td:first-child a')
+ ->on(new SharedFolderInfo())
+ ->assertSeeIn('#folder-info .card-title', 'Shared folder')
+ ->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 Folder')
+ ->assertSeeIn('div.row:nth-child(3) label', 'Type')
+ ->assertSelected('div.row:nth-child(3) select:disabled', 'event')
+ ->assertSeeIn('div.row:nth-child(4) label', 'Email Address')
+ ->assertAttributeRegExp(
+ 'div.row:nth-child(4) input[type=text]:disabled',
+ 'value',
+ '/^event-[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 Folder Update')
+ ->click('@general button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder updated successfully.')
+ ->on(new SharedFolderList())
+ ->assertElementsCount('@table tbody tr', 3)
+ ->assertSeeIn('@table tr:nth-child(3) td:first-child a', 'Test Folder Update');
+
+ $this->assertSame(1, SharedFolder::where('name', 'Test Folder Update')->count());
+
+ // Test folder deletion
+ $browser->click('@table tr:nth-child(3) td:first-child a')
+ ->on(new SharedFolderInfo())
+ ->assertSeeIn('button.button-delete', 'Delete folder')
+ ->click('button.button-delete')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder deleted successfully.')
+ ->on(new SharedFolderList())
+ ->assertElementsCount('@table tbody tr', 2);
+
+ $this->assertNull(SharedFolder::where('name', 'Test Folder Update')->first());
+ });
+ }
+
+ /**
+ * Test shared folder status
+ *
+ * @depends testList
+ */
+ public function testStatus(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-shared-folders');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE | SharedFolder::STATUS_LDAP_READY;
+ $folder->created_at = \now();
+ $folder->save();
+
+ $this->assertFalse($folder->isImapReady());
+
+ $this->browse(function ($browser) use ($folder) {
+ // Test auto-refresh
+ $browser->visit('/shared-folder/' . $folder->id)
+ ->on(new SharedFolderInfo())
+ ->with(new Status(), function ($browser) {
+ $browser->assertSeeIn('@body', 'We are preparing the shared folder')
+ ->assertProgress(85, 'Creating a shared folder...', 'pending')
+ ->assertMissing('@refresh-button')
+ ->assertMissing('@refresh-text')
+ ->assertMissing('#status-link')
+ ->assertMissing('#status-verify');
+ });
+
+ $folder->status |= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ // Test Verify button
+ $browser->waitUntilMissing('@status', 10);
+ });
+
+ // TODO: Test all shared folder statuses on the list
+ }
+
+ /**
+ * Test shared folder settings
+ */
+ public function testSettings(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $this->addBetaEntitlement($john, 'beta-shared-folders');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->setSetting('acl', null);
+
+ $this->browse(function ($browser) use ($folder) {
+ $aclInput = new AclInput('@settings #acl');
+ // Test auto-refresh
+ $browser->visit('/shared-folder/' . $folder->id)
+ ->on(new SharedFolderInfo())
+ ->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', 'Access rights')
+ ->assertSeeIn('div.row:nth-child(1) #acl-hint', 'permissions')
+ ->assertSeeIn('button[type=submit]', 'Submit');
+ })
+ // Test the AclInput widget
+ ->with($aclInput, function (Browser $browser) {
+ $browser->assertAclValue([])
+ ->addAclEntry('anyone, read-only')
+ ->addAclEntry('test, read-write')
+ ->addAclEntry('john@kolab.org, full')
+ ->assertAclValue([
+ 'anyone, read-only',
+ 'test, read-write',
+ 'john@kolab.org, full',
+ ]);
+ })
+ // Test error handling
+ ->click('@settings button[type=submit]')
+ ->with($aclInput, function (Browser $browser) {
+ $browser->assertFormError(2, 'The specified email address is invalid.');
+ })
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ // Test successful update
+ ->with($aclInput, function (Browser $browser) {
+ $browser->removeAclEntry(2)
+ ->assertAclValue([
+ 'anyone, read-only',
+ 'john@kolab.org, full',
+ ])
+ ->updateAclEntry(2, 'jack@kolab.org, read-write')
+ ->assertAclValue([
+ 'anyone, read-only',
+ 'jack@kolab.org, read-write',
+ ]);
+ })
+ ->click('@settings button[type=submit]')
+ ->assertToast(Toast::TYPE_SUCCESS, 'Shared folder settings updated successfully.')
+ ->assertMissing('.invalid-feedback')
+ // Refresh the page and check if everything was saved
+ ->refresh()
+ ->on(new SharedFolderInfo())
+ ->click('@nav #tab-settings')
+ ->with($aclInput, function (Browser $browser) {
+ $browser->assertAclValue([
+ 'anyone, read-only',
+ 'jack@kolab.org, read-write',
+ ]);
+ });
+ });
+ }
+}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
index e48d25ed..89b22525 100644
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -1,797 +1,806 @@
<?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', 9)
+ $browser->assertElementsCount('tbody tr', 10)
// 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'
)
// 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')
+ // Shared folders SKU
+ ->assertSeeIn('tbody tr:nth-child(9) td.name', 'Shared folders')
->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 shared folders'
+ )
+ // Distlist SKU
+ ->assertSeeIn('tbody tr:nth-child(10) td.name', 'Distribution lists')
+ ->assertSeeIn('tr:nth-child(10) td.price', '0,00 CHF/month')
+ ->assertNotChecked('tbody tr:nth-child(10) td.selection input')
+ ->assertEnabled('tbody tr:nth-child(10) td.selection input')
+ ->assertTip(
+ 'tbody tr:nth-child(10) 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 de3a4805..c28241de 100644
--- a/src/tests/Feature/Backends/IMAPTest.php
+++ b/src/tests/Feature/Backends/IMAPTest.php
@@ -1,52 +1,41 @@
<?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
{
+ // existing user
$result = IMAP::verifyAccount('john@kolab.org');
+ $this->assertTrue($result);
- // 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
- {
+ // non-existing user
$this->expectException(\Exception::class);
-
IMAP::verifyAccount('non-existing@domain.tld');
}
/**
* Test verifying IMAP shared folder existence
*
* @group imap
*/
public function testVerifySharedFolder(): void
{
+ // non-existing
$result = IMAP::verifySharedFolder('shared/Resources/UnknownResource@kolab.org');
$this->assertFalse($result);
- // TODO: Test with an existing shared folder
- $this->markTestIncomplete();
+ // existing
+ $result = IMAP::verifySharedFolder('shared/Calendar@kolab.org');
+ $this->assertTrue($result);
}
}
diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php
index f61a88e3..5c0bdbe2 100644
--- a/src/tests/Feature/Backends/LDAPTest.php
+++ b/src/tests/Feature/Backends/LDAPTest.php
@@ -1,520 +1,624 @@
<?php
namespace Tests\Feature\Backends;
use App\Backends\LDAP;
use App\Domain;
use App\Group;
use App\Entitlement;
use App\Resource;
+use App\SharedFolder;
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');
+ $this->deleteTestSharedFolder('test-folder@kolab.org');
// TODO: Remove group members
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
\config($this->ldap_config);
$this->deleteTestUser('user-ldap-test@' . \config('app.domain'));
$this->deleteTestDomain('testldap.com');
$this->deleteTestGroup('group@kolab.org');
$this->deleteTestResource('test-resource@kolab.org');
+ $this->deleteTestSharedFolder('test-folder@kolab.org');
// TODO: Remove group members
parent::tearDown();
}
/**
* Test handling connection errors
*
* @group ldap
*/
public function testConnectException(): void
{
\config(['ldap.hosts' => 'non-existing.host']);
$this->expectException(\Exception::class);
LDAP::connect();
}
/**
* Test creating/updating/deleting a domain record
*
* @group ldap
*/
public function testDomain(): void
{
Queue::fake();
$domain = $this->getTestDomain('testldap.com', [
'type' => Domain::TYPE_EXTERNAL,
'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE,
]);
// Create the domain
LDAP::createDomain($domain);
$ldap_domain = LDAP::getDomain($domain->namespace);
$expected = [
'associateddomain' => $domain->namespace,
'inetdomainstatus' => $domain->status,
'objectclass' => [
'top',
'domainrelatedobject',
'inetdomain'
],
];
foreach ($expected as $attr => $value) {
$this->assertEquals($value, isset($ldap_domain[$attr]) ? $ldap_domain[$attr] : null);
}
// TODO: Test other attributes, aci, roles/ous
// Update the domain
$domain->status |= User::STATUS_LDAP_READY;
LDAP::updateDomain($domain);
$expected['inetdomainstatus'] = $domain->status;
$ldap_domain = LDAP::getDomain($domain->namespace);
foreach ($expected as $attr => $value) {
$this->assertEquals($value, isset($ldap_domain[$attr]) ? $ldap_domain[$attr] : null);
}
// Delete the domain
LDAP::deleteDomain($domain);
$this->assertSame(null, LDAP::getDomain($domain->namespace));
}
/**
* Test creating/updating/deleting a group record
*
* @group ldap
*/
public function testGroup(): void
{
Queue::fake();
$root_dn = \config('ldap.hosted.root_dn');
$group = $this->getTestGroup('group@kolab.org', [
'members' => ['member1@testldap.com', 'member2@testldap.com']
]);
$group->setSetting('sender_policy', '["test.com"]');
// Create the group
LDAP::createGroup($group);
$ldap_group = LDAP::getGroup($group->email);
$expected = [
'cn' => 'group',
'dn' => 'cn=group,ou=Groups,ou=kolab.org,' . $root_dn,
'mail' => $group->email,
'objectclass' => [
'top',
'groupofuniquenames',
'kolabgroupofuniquenames'
],
'kolaballowsmtpsender' => 'test.com',
'uniquemember' => [
'uid=member1@testldap.com,ou=People,ou=kolab.org,' . $root_dn,
'uid=member2@testldap.com,ou=People,ou=kolab.org,' . $root_dn,
],
];
foreach ($expected as $attr => $value) {
$this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute");
}
// Update members
$group->members = ['member3@testldap.com'];
$group->save();
$group->setSetting('sender_policy', '["test.com","-"]');
LDAP::updateGroup($group);
// TODO: Should we force this to be always an array?
$expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn;
$expected['kolaballowsmtpsender'] = ['test.com', '-'];
$ldap_group = LDAP::getGroup($group->email);
foreach ($expected as $attr => $value) {
$this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute");
}
$this->assertSame(['member3@testldap.com'], $group->fresh()->members);
// Update members (add non-existing local member, expect it to be aot-removed from the group)
// Update group name and sender_policy
$group->members = ['member3@testldap.com', 'member-local@kolab.org'];
$group->name = 'Te(=ść)1';
$group->save();
$group->setSetting('sender_policy', null);
LDAP::updateGroup($group);
// TODO: Should we force this to be always an array?
$expected['uniquemember'] = 'uid=member3@testldap.com,ou=People,ou=kolab.org,' . $root_dn;
$expected['kolaballowsmtpsender'] = null;
$expected['dn'] = 'cn=Te(\\3dść)1,ou=Groups,ou=kolab.org,' . $root_dn;
$expected['cn'] = 'Te(=ść)1';
$ldap_group = LDAP::getGroup($group->email);
foreach ($expected as $attr => $value) {
$this->assertEquals($value, isset($ldap_group[$attr]) ? $ldap_group[$attr] : null, "Group $attr attribute");
}
$this->assertSame(['member3@testldap.com'], $group->fresh()->members);
// We called save() twice, and setSettings() three times,
// this is making sure that there's no job executed by the LDAP backend
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 5);
// Delete the group
LDAP::deleteGroup($group);
$this->assertSame(null, LDAP::getGroup($group->email));
}
/**
* Test creating/updating/deleting a resource record
*
* @group ldap
*/
public function testResource(): void
{
Queue::fake();
$root_dn = \config('ldap.hosted.root_dn');
$resource = $this->getTestResource('test-resource@kolab.org', ['name' => 'Test1']);
$resource->setSetting('invitation_policy', null);
// Make sure the resource does not exist
// LDAP::deleteResource($resource);
// Create the resource
LDAP::createResource($resource);
$ldap_resource = LDAP::getResource($resource->email);
$expected = [
'cn' => 'Test1',
'dn' => 'cn=Test1,ou=Resources,ou=kolab.org,' . $root_dn,
'mail' => $resource->email,
'objectclass' => [
'top',
'kolabresource',
'kolabsharedfolder',
'mailrecipient',
],
'kolabfoldertype' => 'event',
'kolabtargetfolder' => 'shared/Resources/Test1@kolab.org',
'kolabinvitationpolicy' => null,
'owner' => null,
];
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/updating/deleting a shared folder record
+ *
+ * @group ldap
+ */
+ public function testSharedFolder(): void
+ {
+ Queue::fake();
+
+ $root_dn = \config('ldap.hosted.root_dn');
+ $folder = $this->getTestSharedFolder('test-folder@kolab.org', ['type' => 'event']);
+ $folder->setSetting('acl', null);
+
+ // Make sure the shared folder does not exist
+ // LDAP::deleteSharedFolder($folder);
+
+ // Create the shared folder
+ LDAP::createSharedFolder($folder);
+
+ $ldap_folder = LDAP::getSharedFolder($folder->email);
+
+ $expected = [
+ 'cn' => 'test-folder',
+ 'dn' => 'cn=test-folder,ou=Shared Folders,ou=kolab.org,' . $root_dn,
+ 'mail' => $folder->email,
+ 'objectclass' => [
+ 'top',
+ 'kolabsharedfolder',
+ 'mailrecipient',
+ ],
+ 'kolabfoldertype' => 'event',
+ 'kolabtargetfolder' => 'shared/test-folder@kolab.org',
+ 'acl' => null,
+ ];
+
+ foreach ($expected as $attr => $value) {
+ $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null;
+ $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute");
+ }
+
+ // Update folder name and acl
+ $folder->name = 'Te(=ść)1';
+ $folder->save();
+ $folder->setSetting('acl', '["john@kolab.org, read-write","anyone, read-only"]');
+
+ LDAP::updateSharedFolder($folder);
+
+ $expected['kolabtargetfolder'] = 'shared/Te(=ść)1@kolab.org';
+ $expected['acl'] = ['john@kolab.org, read-write', 'anyone, read-only'];
+ $expected['dn'] = 'cn=Te(\\3dść)1,ou=Shared Folders,ou=kolab.org,' . $root_dn;
+ $expected['cn'] = 'Te(=ść)1';
+
+ $ldap_folder = LDAP::getSharedFolder($folder->email);
+
+ foreach ($expected as $attr => $value) {
+ $ldap_value = isset($ldap_folder[$attr]) ? $ldap_folder[$attr] : null;
+ $this->assertEquals($value, $ldap_value, "Shared folder $attr attribute");
+ }
+
+ // Delete the resource
+ LDAP::deleteSharedFolder($folder);
+
+ $this->assertSame(null, LDAP::getSharedFolder($folder->email));
+ }
+
/**
* Test creating/editing/deleting a user record
*
* @group ldap
*/
public function testUser(): void
{
Queue::fake();
$user = $this->getTestUser('user-ldap-test@' . \config('app.domain'));
LDAP::createUser($user);
$ldap_user = LDAP::getUser($user->email);
$expected = [
'objectclass' => [
'top',
'inetorgperson',
'inetuser',
'kolabinetorgperson',
'mailrecipient',
'person',
'organizationalPerson',
],
'mail' => $user->email,
'uid' => $user->email,
'nsroledn' => [
'cn=imap-user,' . \config('ldap.hosted.root_dn')
],
'cn' => 'unknown',
'displayname' => '',
'givenname' => '',
'sn' => 'unknown',
'inetuserstatus' => $user->status,
'mailquota' => null,
'o' => '',
'alias' => null,
];
foreach ($expected as $attr => $value) {
$this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null);
}
// Add aliases, and change some user settings, and entitlements
$user->setSettings([
'first_name' => 'Firstname',
'last_name' => 'Lastname',
'organization' => 'Org',
'country' => 'PL',
]);
$user->status |= User::STATUS_IMAP_READY;
$user->save();
$aliases = ['t1-' . $user->email, 't2-' . $user->email];
$user->setAliases($aliases);
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$user->assignPackage($package_kolab);
LDAP::updateUser($user->fresh());
$expected['alias'] = $aliases;
$expected['o'] = 'Org';
$expected['displayname'] = 'Lastname, Firstname';
$expected['givenname'] = 'Firstname';
$expected['cn'] = 'Firstname Lastname';
$expected['sn'] = 'Lastname';
$expected['inetuserstatus'] = $user->status;
$expected['mailquota'] = 5242880;
$expected['nsroledn'] = null;
$ldap_user = LDAP::getUser($user->email);
foreach ($expected as $attr => $value) {
$this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null);
}
// Update entitlements
$sku_activesync = \App\Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$sku_groupware = \App\Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$user->assignSku($sku_activesync, 1);
Entitlement::where(['sku_id' => $sku_groupware->id, 'entitleable_id' => $user->id])->delete();
LDAP::updateUser($user->fresh());
$expected_roles = [
'activesync-user',
'imap-user'
];
$ldap_user = LDAP::getUser($user->email);
$this->assertCount(2, $ldap_user['nsroledn']);
$ldap_roles = array_map(
function ($role) {
if (preg_match('/^cn=([a-z0-9-]+)/', $role, $m)) {
return $m[1];
} else {
return $role;
}
},
$ldap_user['nsroledn']
);
$this->assertSame($expected_roles, $ldap_roles);
// 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,
+ 'status' => Resource::STATUS_ACTIVE,
]);
LDAP::createResource($resource);
}
/**
* Test handling errors on a group creation
*
* @group ldap
*/
public function testCreateGroupException(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessageMatches('/Failed to create group/');
$group = new Group([
'name' => 'test',
'email' => 'test@testldap.com',
'status' => Group::STATUS_NEW | Group::STATUS_ACTIVE,
]);
LDAP::createGroup($group);
}
+ /**
+ * Test handling errors on a shared folder creation
+ *
+ * @group ldap
+ */
+ public function testCreateSharedFolderException(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessageMatches('/Failed to create shared folder/');
+
+ $folder = new SharedFolder([
+ 'email' => 'test-non-existing-ldap@non-existing.org',
+ 'name' => 'Test',
+ 'status' => SharedFolder::STATUS_ACTIVE,
+ ]);
+
+ LDAP::createSharedFolder($folder);
+ }
+
/**
* Test handling errors on user creation
*
* @group ldap
*/
public function testCreateUserException(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessageMatches('/Failed to create user/');
$user = new User([
'email' => 'test-non-existing-ldap@non-existing.org',
'status' => User::STATUS_ACTIVE,
]);
LDAP::createUser($user);
}
/**
* Test handling update of a non-existing domain
*
* @group ldap
*/
public function testUpdateDomainException(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessageMatches('/domain not found/');
$domain = new Domain([
'namespace' => 'testldap.com',
'type' => Domain::TYPE_EXTERNAL,
'status' => Domain::STATUS_NEW | Domain::STATUS_ACTIVE,
]);
LDAP::updateDomain($domain);
}
/**
* Test handling update of a non-existing group
*
* @group ldap
*/
public function testUpdateGroupException(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessageMatches('/group not found/');
$group = new Group([
'name' => 'test',
'email' => 'test@testldap.com',
'status' => Group::STATUS_NEW | Group::STATUS_ACTIVE,
]);
LDAP::updateGroup($group);
}
/**
* Test handling update of a non-existing resource
*
* @group ldap
*/
public function testUpdateResourceException(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessageMatches('/resource not found/');
$resource = new Resource([
'email' => 'test-resource@kolab.org',
]);
LDAP::updateResource($resource);
}
+ /**
+ * Test handling update of a non-existing shared folder
+ *
+ * @group ldap
+ */
+ public function testUpdateSharedFolderException(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessageMatches('/folder not found/');
+
+ $folder = new SharedFolder([
+ 'email' => 'test-folder-unknown@kolab.org',
+ ]);
+
+ LDAP::updateSharedFolder($folder);
+ }
+
/**
* Test handling update of a non-existing user
*
* @group ldap
*/
public function testUpdateUserException(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionMessageMatches('/user not found/');
$user = new User([
'email' => 'test-non-existing-ldap@kolab.org',
'status' => User::STATUS_ACTIVE,
]);
LDAP::updateUser($user);
}
}
diff --git a/src/tests/Feature/Controller/Admin/SharedFoldersTest.php b/src/tests/Feature/Controller/Admin/SharedFoldersTest.php
new file mode 100644
index 00000000..56a0094c
--- /dev/null
+++ b/src/tests/Feature/Controller/Admin/SharedFoldersTest.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace Tests\Feature\Controller\Admin;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SharedFoldersTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useAdminUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test shared folders searching (/api/v4/shared-folders)
+ */
+ public function testIndex(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/shared-folders");
+ $response->assertStatus(403);
+
+ // Search with no search criteria
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ $this->assertSame("0 shared folders have been found.", $json['message']);
+
+ // Search with no matches expected
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders?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/shared-folders?search={$folder->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($folder->email, $json['list'][0]['email']);
+
+ // Search by owner
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame("2 shared folders have been found.", $json['message']);
+ $this->assertSame($folder->email, $json['list'][0]['email']);
+ $this->assertSame($folder->name, $json['list'][0]['name']);
+
+ // Search by owner (Ned is a controller on John's wallets,
+ // here we expect only folders assigned to Ned's wallet(s))
+ $ned = $this->getTestUser('ned@kolab.org');
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders?owner={$ned->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertCount(0, $json['list']);
+ }
+
+ /**
+ * Test fetching shared folder info (GET /api/v4/shared-folders/<folder-id>)
+ */
+ public function testShow(): void
+ {
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $user = $this->getTestUser('john@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // Only admins can access it
+ $response = $this->actingAs($user)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($folder->id, $json['id']);
+ $this->assertEquals($folder->email, $json['email']);
+ $this->assertEquals($folder->name, $json['name']);
+ $this->assertEquals($folder->type, $json['type']);
+ }
+
+ /**
+ * Test fetching shared folder status (GET /api/v4/shared-folders/<folder-id>/status)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // This end-point does not exist for admins
+ $response = $this->actingAs($admin)->get("/api/v4/shared-folders/{$folder->id}/status");
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test shared folder creating (POST /api/v4/shared-folders)
+ */
+ 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/shared-folders", []);
+ $response->assertStatus(403);
+
+ // Admin can't create shared folders
+ $response = $this->actingAs($admin)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(404);
+ }
+}
diff --git a/src/tests/Feature/Controller/Admin/SkusTest.php b/src/tests/Feature/Controller/Admin/SkusTest.php
index ee5b7b34..82152625 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(11, $json);
+ $this->assertCount(13, $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 39d00527..02159f94 100644
--- a/src/tests/Feature/Controller/Admin/UsersTest.php
+++ b/src/tests/Feature/Controller/Admin/UsersTest.php
@@ -1,501 +1,512 @@
<?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']);
+ // Search by shared folder email
+ $response = $this->actingAs($admin)->get("api/v4/users?search=folder-event@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/SharedFoldersTest.php b/src/tests/Feature/Controller/Reseller/SharedFoldersTest.php
new file mode 100644
index 00000000..5b720315
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/SharedFoldersTest.php
@@ -0,0 +1,180 @@
+<?php
+
+namespace Tests\Feature\Controller\Reseller;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SharedFoldersTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ self::useResellerUrl();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test shared folders searching (/api/v4/shared-folders)
+ */
+ 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');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/shared-folders");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders");
+ $response->assertStatus(403);
+
+ // Search with no search criteria
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders");
+ $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/shared-folders?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/shared-folders?search={$folder->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($folder->email, $json['list'][0]['email']);
+
+ // Search by owner
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(2, $json['count']);
+ $this->assertCount(2, $json['list']);
+ $this->assertSame($folder->email, $json['list'][0]['email']);
+ $this->assertSame($folder->name, $json['list'][0]['name']);
+
+ // Search by owner (Ned is a controller on John's wallets,
+ // here we expect only folders assigned to Ned's wallet(s))
+ $ned = $this->getTestUser('ned@kolab.org');
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders?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/shared-folders?search={$folder->email}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/shared-folders?owner={$user->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(0, $json['count']);
+ $this->assertSame([], $json['list']);
+ }
+
+ /**
+ * Test fetching shared folder info (GET /api/v4/shared-folders/<folder-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');
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // Only resellers can access it
+ $response = $this->actingAs($user)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($admin)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller2)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(404);
+
+ $response = $this->actingAs($reseller1)->get("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals($folder->id, $json['id']);
+ $this->assertEquals($folder->email, $json['email']);
+ $this->assertEquals($folder->name, $json['name']);
+ }
+
+ /**
+ * Test fetching shared folder status (GET /api/v4/shared-folders/<folder-id>/status)
+ */
+ public function testStatus(): void
+ {
+ Queue::fake(); // disable jobs
+
+ $reseller1 = $this->getTestUser('reseller@' . \config('app.domain'));
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ // This end-point does not exist for folders
+ $response = $this->actingAs($reseller1)->get("/api/v4/shared-folders/{$folder->id}/status");
+ $response->assertStatus(404);
+ }
+
+ /**
+ * Test shared folder creating (POST /api/v4/shared-folders)
+ */
+ 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/shared-folders", []);
+ $response->assertStatus(403);
+
+ // Reseller or admin can't create folders
+ $response = $this->actingAs($admin)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(403);
+
+ $response = $this->actingAs($reseller1)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(404);
+ }
+}
diff --git a/src/tests/Feature/Controller/Reseller/SkusTest.php b/src/tests/Feature/Controller/Reseller/SkusTest.php
index 58d679a0..421c9e32 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(11, $json);
+ $this->assertCount(13, $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/SharedFoldersTest.php b/src/tests/Feature/Controller/SharedFoldersTest.php
new file mode 100644
index 00000000..b803b468
--- /dev/null
+++ b/src/tests/Feature/Controller/SharedFoldersTest.php
@@ -0,0 +1,488 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\SharedFolder;
+use App\Http\Controllers\API\V4\SharedFoldersController;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SharedFoldersTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestSharedFolder('folder-test@kolab.org');
+ SharedFolder::where('name', 'Test Folder')->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestSharedFolder('folder-test@kolab.org');
+ SharedFolder::where('name', 'Test Folder')->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test resource deleting (DELETE /api/v4/resources/<id>)
+ */
+ public function testDestroy(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ // Test unauth access
+ $response = $this->delete("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(401);
+
+ // Test non-existing folder
+ $response = $this->actingAs($john)->delete("api/v4/shared-folders/abc");
+ $response->assertStatus(404);
+
+ // Test access to other user's folder
+ $response = $this->actingAs($jack)->delete("api/v4/shared-folders/{$folder->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 folder
+ $response = $this->actingAs($john)->delete("api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals('success', $json['status']);
+ $this->assertEquals("Shared folder deleted successfully.", $json['message']);
+ }
+
+ /**
+ * Test shared folders listing (GET /api/v4/shared-folders)
+ */
+ 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/shared-folders");
+ $response->assertStatus(401);
+
+ // Test a user with no shared folders
+ $response = $this->actingAs($jack)->get("/api/v4/shared-folders");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(0, $json);
+
+ // Test a user with two shared folders
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $folder = SharedFolder::where('name', 'Calendar')->first();
+
+ $this->assertCount(2, $json);
+ $this->assertSame($folder->id, $json[0]['id']);
+ $this->assertSame($folder->email, $json[0]['email']);
+ $this->assertSame($folder->name, $json[0]['name']);
+ $this->assertSame($folder->type, $json[0]['type']);
+ $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 shared folders
+ $response = $this->actingAs($ned)->get("/api/v4/shared-folders");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame($folder->email, $json[0]['email']);
+ }
+
+ /**
+ * Test shared folder config update (POST /api/v4/shared-folders/<folder>/config)
+ */
+ public function testSetConfig(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ // Test unknown resource id
+ $post = ['acl' => ['john@kolab.org, full']];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders/123/config", $post);
+ $json = $response->json();
+
+ $response->assertStatus(404);
+
+ // Test access by user not being a wallet controller
+ $response = $this->actingAs($jack)->post("/api/v4/shared-folders/{$folder->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/shared-folders/{$folder->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']);
+
+ $folder->refresh();
+
+ $this->assertNull($folder->getSetting('test'));
+ $this->assertNull($folder->getSetting('acl'));
+
+ // Test some valid data
+ $post = ['acl' => ['john@kolab.org, full']];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post);
+
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Shared folder settings updated successfully.", $json['message']);
+
+ $this->assertSame(['acl' => $post['acl']], $folder->fresh()->getConfig());
+
+ // Test input validation
+ $post = ['acl' => ['john@kolab.org, full', 'test, full']];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders/{$folder->id}/config", $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('error', $json['status']);
+ $this->assertCount(1, $json['errors']);
+ $this->assertCount(1, $json['errors']['acl']);
+ $this->assertSame(
+ "The specified email address is invalid.",
+ $json['errors']['acl'][1]
+ );
+
+ $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->fresh()->getConfig());
+ }
+
+ /**
+ * Test fetching shared folder data/profile (GET /api/v4/shared-folders/<folder>)
+ */
+ public function testShow(): void
+ {
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+ $folder->setSetting('acl', '["anyone, full"]');
+
+ // Test unauthenticated access
+ $response = $this->get("/api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(401);
+
+ // Test unauthorized access to a shared folder of another user
+ $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(403);
+
+ // John: Account owner - non-existing folder
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders/abc");
+ $response->assertStatus(404);
+
+ // John: Account owner
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($folder->id, $json['id']);
+ $this->assertSame($folder->email, $json['email']);
+ $this->assertSame($folder->name, $json['name']);
+ $this->assertSame($folder->type, $json['type']);
+ $this->assertTrue(!empty($json['statusInfo']));
+ $this->assertArrayHasKey('isDeleted', $json);
+ $this->assertArrayHasKey('isActive', $json);
+ $this->assertArrayHasKey('isLdapReady', $json);
+ $this->assertArrayHasKey('isImapReady', $json);
+ $this->assertSame(['acl' => ['anyone, full']], $json['config']);
+ }
+
+ /**
+ * Test fetching a shared folder status (GET /api/v4/shared-folders/<folder>/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');
+
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ // Test unauthorized access
+ $response = $this->get("/api/v4/shared-folders/abc/status");
+ $response->assertStatus(401);
+
+ // Test unauthorized access
+ $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}/status");
+ $response->assertStatus(403);
+
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
+ $folder->save();
+
+ // Get resource status
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->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('shared-folder-new', $json['process'][0]['label']);
+ $this->assertSame(true, $json['process'][0]['state']);
+ $this->assertSame('shared-folder-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();
+ $folder->status |= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ // Now "reboot" the process and get the folder status
+ $response = $this->actingAs($john)->get("/api/v4/shared-folders/{$folder->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('shared-folder-ldap-ready', $json['process'][1]['label']);
+ $this->assertSame(true, $json['process'][1]['state']);
+ $this->assertSame('shared-folder-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/shared-folders/{$folder->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('shared-folder-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 SharedFoldersController::statusInfo()
+ */
+ public function testStatusInfo(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
+ $folder->save();
+ $domain = $this->getTestDomain('kolab.org');
+ $domain->status |= \App\Domain::STATUS_CONFIRMED;
+ $domain->save();
+
+ $result = SharedFoldersController::statusInfo($folder);
+
+ $this->assertFalse($result['isReady']);
+ $this->assertCount(7, $result['process']);
+ $this->assertSame('shared-folder-new', $result['process'][0]['label']);
+ $this->assertSame(true, $result['process'][0]['state']);
+ $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']);
+ $this->assertSame(false, $result['process'][1]['state']);
+ $this->assertSame('running', $result['processState']);
+
+ $folder->created_at = Carbon::now()->subSeconds(181);
+ $folder->save();
+
+ $result = SharedFoldersController::statusInfo($folder);
+
+ $this->assertSame('failed', $result['processState']);
+
+ $folder->status |= SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ $result = SharedFoldersController::statusInfo($folder);
+
+ $this->assertTrue($result['isReady']);
+ $this->assertCount(7, $result['process']);
+ $this->assertSame('shared-folder-new', $result['process'][0]['label']);
+ $this->assertSame(true, $result['process'][0]['state']);
+ $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']);
+ $this->assertSame(true, $result['process'][1]['state']);
+ $this->assertSame('shared-folder-ldap-ready', $result['process'][1]['label']);
+ $this->assertSame(true, $result['process'][1]['state']);
+ $this->assertSame('done', $result['processState']);
+ }
+
+ /**
+ * Test shared folder creation (POST /api/v4/shared-folders)
+ */
+ 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/shared-folders", []);
+ $response->assertStatus(401);
+
+ // Test non-controller user
+ $response = $this->actingAs($jack)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(403);
+
+ // Test empty request
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders", []);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame("The name field is required.", $json['errors']['name'][0]);
+ $this->assertSame("The type field is required.", $json['errors']['type'][0]);
+ $this->assertCount(2, $json);
+ $this->assertCount(2, $json['errors']);
+
+ // Test too long name
+ $post = ['domain' => 'kolab.org', 'name' => str_repeat('A', 192), 'type' => 'unknown'];
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders", $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']);
+ $this->assertSame(["The specified type is invalid."], $json['errors']['type']);
+ $this->assertCount(2, $json['errors']);
+
+ // Test successful folder creation
+ $post['name'] = 'Test Folder';
+ $post['type'] = 'event';
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Shared folder created successfully.", $json['message']);
+ $this->assertCount(2, $json);
+
+ $folder = SharedFolder::where('name', $post['name'])->first();
+ $this->assertInstanceOf(SharedFolder::class, $folder);
+ $this->assertSame($post['type'], $folder->type);
+ $this->assertTrue($john->sharedFolders()->get()->contains($folder));
+
+ // Shared folder name must be unique within a domain
+ $post['type'] = 'mail';
+ $response = $this->actingAs($john)->post("/api/v4/shared-folders", $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 shared folder update (PUT /api/v4/shared-folders/<folder)
+ */
+ public function testUpdate(): void
+ {
+ Queue::fake();
+
+ $jack = $this->getTestUser('jack@kolab.org');
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+
+ $folder = $this->getTestSharedFolder('folder-test@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ // Test unauthorized update
+ $response = $this->get("/api/v4/shared-folders/{$folder->id}", []);
+ $response->assertStatus(401);
+
+ // Test unauthorized update
+ $response = $this->actingAs($jack)->get("/api/v4/shared-folders/{$folder->id}", []);
+ $response->assertStatus(403);
+
+ // Name change
+ $post = [
+ 'name' => 'Test Res',
+ ];
+
+ $response = $this->actingAs($john)->put("/api/v4/shared-folders/{$folder->id}", $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame("Shared folder updated successfully.", $json['message']);
+ $this->assertCount(2, $json);
+
+ $folder->refresh();
+ $this->assertSame($post['name'], $folder->name);
+ }
+}
diff --git a/src/tests/Feature/Controller/SkusTest.php b/src/tests/Feature/Controller/SkusTest.php
index 8dbc2244..277c5ec9 100644
--- a/src/tests/Feature/Controller/SkusTest.php
+++ b/src/tests/Feature/Controller/SkusTest.php
@@ -1,273 +1,282 @@
<?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(11, $json);
+ $this->assertCount(13, $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(9, $json);
+ $this->assertCount(10, $json);
$this->assertSkuElement('beta', $json[6], [
'prio' => 10,
'type' => 'user',
'handler' => 'beta',
'enabled' => false,
'readonly' => false,
]);
$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], [
+ $this->assertSkuElement('beta-shared-folders', $json[8], [
+ 'prio' => 10,
+ 'type' => 'user',
+ 'handler' => 'sharedfolders',
+ 'enabled' => false,
+ 'readonly' => false,
+ 'required' => ['beta'],
+ ]);
+
+ $this->assertSkuElement('distlist', $json[9], [
'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 620aee29..ebf09c72 100644
--- a/src/tests/Feature/DomainTest.php
+++ b/src/tests/Feature/DomainTest.php
@@ -1,347 +1,351 @@
<?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');
+ $this->deleteTestSharedFolder('folder@gmail.com');
// Empty domain
$domain = $this->getTestDomain('gmail.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]);
$this->assertTrue($domain->isEmpty());
$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');
+ $this->getTestSharedFolder('folder@gmail.com');
+ $this->assertFalse($domain->isEmpty());
+ $this->deleteTestSharedFolder('folder@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());
}
/**
* 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/Jobs/DomainCreateTest.php b/src/tests/Feature/Jobs/Domain/CreateTest.php
similarity index 96%
rename from src/tests/Feature/Jobs/DomainCreateTest.php
rename to src/tests/Feature/Jobs/Domain/CreateTest.php
index f369362f..0e5989d8 100644
--- a/src/tests/Feature/Jobs/DomainCreateTest.php
+++ b/src/tests/Feature/Jobs/Domain/CreateTest.php
@@ -1,74 +1,74 @@
<?php
-namespace Tests\Feature\Jobs;
+namespace Tests\Feature\Jobs\Domain;
use App\Domain;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
-class DomainCreateTest extends TestCase
+class CreateTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestDomain('domain-create-test.com');
}
public function tearDown(): void
{
$this->deleteTestDomain('domain-create-test.com');
parent::tearDown();
}
/**
* Test job handle
*
* @group ldap
*/
public function testHandle(): void
{
$domain = $this->getTestDomain(
'domain-create-test.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$this->assertFalse($domain->isLdapReady());
// Fake the queue, assert that no jobs were pushed...
Queue::fake();
Queue::assertNothingPushed();
$job = new \App\Jobs\Domain\CreateJob($domain->id);
$job->handle();
$this->assertTrue($domain->fresh()->isLdapReady());
Queue::assertPushed(\App\Jobs\Domain\VerifyJob::class, 1);
Queue::assertPushed(
\App\Jobs\Domain\VerifyJob::class,
function ($job) use ($domain) {
$domainId = TestCase::getObjectProperty($job, 'domainId');
$domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace');
return $domainId === $domain->id &&
$domainNamespace === $domain->namespace;
}
);
// Test job releasing on unknown identifier
$job = new \App\Jobs\Domain\CreateJob(123);
$job->handle();
$this->assertTrue($job->isReleased());
$this->assertFalse($job->hasFailed());
}
}
diff --git a/src/tests/Feature/Jobs/DomainVerifyTest.php b/src/tests/Feature/Jobs/Domain/VerifyTest.php
similarity index 96%
rename from src/tests/Feature/Jobs/DomainVerifyTest.php
rename to src/tests/Feature/Jobs/Domain/VerifyTest.php
index f0e12d5f..c94ff155 100644
--- a/src/tests/Feature/Jobs/DomainVerifyTest.php
+++ b/src/tests/Feature/Jobs/Domain/VerifyTest.php
@@ -1,81 +1,81 @@
<?php
-namespace Tests\Feature\Jobs;
+namespace Tests\Feature\Jobs\Domain;
use App\Domain;
use Tests\TestCase;
-class DomainVerifyTest extends TestCase
+class VerifyTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestDomain('gmail.com');
$this->deleteTestDomain('some-non-existing-domain.fff');
}
public function tearDown(): void
{
$this->deleteTestDomain('gmail.com');
$this->deleteTestDomain('some-non-existing-domain.fff');
parent::tearDown();
}
/**
* Test job handle (existing domain)
*
* @group dns
*/
public function testHandle(): void
{
$domain = $this->getTestDomain(
'gmail.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$this->assertFalse($domain->isVerified());
$job = new \App\Jobs\Domain\VerifyJob($domain->id);
$job->handle();
$this->assertTrue($domain->fresh()->isVerified());
// Test non-existing domain ID
$job = new \App\Jobs\Domain\VerifyJob(123);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("Domain 123 could not be found in the database.", $job->failureMessage);
}
/**
* Test job handle (non-existing domain)
*
* @group dns
*/
public function testHandleNonExisting(): void
{
$domain = $this->getTestDomain(
'some-non-existing-domain.fff',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$this->assertFalse($domain->isVerified());
$job = new \App\Jobs\Domain\VerifyJob($domain->id);
$job->handle();
$this->assertFalse($domain->fresh()->isVerified());
}
}
diff --git a/src/tests/Feature/Jobs/Resource/CreateTest.php b/src/tests/Feature/Jobs/Resource/CreateTest.php
new file mode 100644
index 00000000..496b8228
--- /dev/null
+++ b/src/tests/Feature/Jobs/Resource/CreateTest.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Tests\Feature\Jobs\Resource;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class CreateTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestResource('resource-test@' . \config('app.domain'));
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestResource('resource-test@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group ldap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test unknown resource
+ $job = new \App\Jobs\Resource\CreateJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->isReleased());
+ $this->assertFalse($job->hasFailed());
+
+ $resource = $this->getTestResource('resource-test@' . \config('app.domain'));
+
+ $this->assertFalse($resource->isLdapReady());
+
+ // Test resource creation
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($resource->fresh()->isLdapReady());
+ $this->assertFalse($job->hasFailed());
+
+ // Test job failures
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource {$resource->id} is already marked as ldap-ready.", $job->failureMessage);
+
+ $resource->status |= Resource::STATUS_DELETED;
+ $resource->save();
+
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource {$resource->id} is marked as deleted.", $job->failureMessage);
+
+ $resource->status ^= Resource::STATUS_DELETED;
+ $resource->save();
+ $resource->delete();
+
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource {$resource->id} is actually deleted.", $job->failureMessage);
+
+ // TODO: Test failures on domain sanity checks
+ }
+}
diff --git a/src/tests/Feature/Jobs/Resource/DeleteTest.php b/src/tests/Feature/Jobs/Resource/DeleteTest.php
new file mode 100644
index 00000000..6051af32
--- /dev/null
+++ b/src/tests/Feature/Jobs/Resource/DeleteTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Tests\Feature\Jobs\Resource;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class DeleteTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestResource('resource-test@' . \config('app.domain'));
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestResource('resource-test@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group ldap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test non-existing resource ID
+ $job = new \App\Jobs\Resource\DeleteJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage);
+
+ $resource = $this->getTestResource('resource-test@' . \config('app.domain'), [
+ 'status' => Resource::STATUS_NEW
+ ]);
+
+ // create the resource first
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
+ $job->handle();
+
+ $resource->refresh();
+
+ $this->assertTrue($resource->isLdapReady());
+
+ // Test successful deletion
+ $resource->status |= Resource::STATUS_IMAP_READY;
+ $resource->save();
+
+ $job = new \App\Jobs\Resource\DeleteJob($resource->id);
+ $job->handle();
+
+ $resource->refresh();
+
+ $this->assertFalse($resource->isLdapReady());
+ $this->assertFalse($resource->isImapReady());
+ $this->assertTrue($resource->isDeleted());
+
+ // Test deleting already deleted resource
+ $job = new \App\Jobs\Resource\DeleteJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource {$resource->id} is already marked as deleted.", $job->failureMessage);
+ }
+}
diff --git a/src/tests/Feature/Jobs/Resource/UpdateTest.php b/src/tests/Feature/Jobs/Resource/UpdateTest.php
new file mode 100644
index 00000000..8b509a86
--- /dev/null
+++ b/src/tests/Feature/Jobs/Resource/UpdateTest.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Tests\Feature\Jobs\Resource;
+
+use App\Backends\LDAP;
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class UpdateTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestResource('resource-test@' . \config('app.domain'));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestResource('resource-test@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group ldap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test non-existing resource ID
+ $job = new \App\Jobs\Resource\UpdateJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage);
+
+ $resource = $this->getTestResource('resource-test@' . \config('app.domain'));
+
+ // Create the resource in LDAP
+ $job = new \App\Jobs\Resource\CreateJob($resource->id);
+ $job->handle();
+
+ $resource->setConfig(['invitation_policy' => 'accept']);
+
+ $job = new \App\Jobs\Resource\UpdateJob($resource->id);
+ $job->handle();
+
+ $ldap_resource = LDAP::getResource($resource->email);
+
+ $this->assertSame('ACT_ACCEPT', $ldap_resource['kolabinvitationpolicy']);
+
+ // Test that the job is being deleted if the resource is not ldap ready or is deleted
+ $resource->refresh();
+ $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE;
+ $resource->save();
+
+ $job = new \App\Jobs\Resource\UpdateJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($job->isDeleted());
+
+ $resource->status = Resource::STATUS_NEW | Resource::STATUS_ACTIVE
+ | Resource::STATUS_LDAP_READY | Resource::STATUS_DELETED;
+ $resource->save();
+
+ $job = new \App\Jobs\Resource\UpdateJob($resource->id);
+ $job->handle();
+
+ $this->assertTrue($job->isDeleted());
+ }
+}
diff --git a/src/tests/Feature/Jobs/Resource/VerifyTest.php b/src/tests/Feature/Jobs/Resource/VerifyTest.php
new file mode 100644
index 00000000..f8732dcd
--- /dev/null
+++ b/src/tests/Feature/Jobs/Resource/VerifyTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Tests\Feature\Jobs\Resource;
+
+use App\Resource;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class VerifyTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+ $resource->status |= Resource::STATUS_IMAP_READY;
+ $resource->save();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+ $resource->status |= Resource::STATUS_IMAP_READY;
+ $resource->save();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group imap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test non-existing resource ID
+ $job = new \App\Jobs\Resource\VerifyJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Resource 123 could not be found in the database.", $job->failureMessage);
+
+ // Test existing resource
+ $resource = $this->getTestResource('resource-test1@kolab.org');
+
+ if ($resource->isImapReady()) {
+ $resource->status ^= Resource::STATUS_IMAP_READY;
+ $resource->save();
+ }
+
+ $this->assertFalse($resource->isImapReady());
+
+ for ($i = 0; $i < 10; $i++) {
+ $job = new \App\Jobs\Resource\VerifyJob($resource->id);
+ $job->handle();
+
+ if ($resource->fresh()->isImapReady()) {
+ $this->assertTrue(true);
+ return;
+ }
+
+ sleep(1);
+ }
+
+ $this->assertTrue(false, "Unable to verify the shared folder is set up in time");
+ }
+}
diff --git a/src/tests/Feature/Jobs/SharedFolder/CreateTest.php b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php
new file mode 100644
index 00000000..f9aca4b5
--- /dev/null
+++ b/src/tests/Feature/Jobs/SharedFolder/CreateTest.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Tests\Feature\Jobs\SharedFolder;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class CreateTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group ldap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test unknown folder
+ $job = new \App\Jobs\SharedFolder\CreateJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->isReleased());
+ $this->assertFalse($job->hasFailed());
+
+ $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain'));
+
+ $this->assertFalse($folder->isLdapReady());
+
+ // Test shared folder creation
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($folder->fresh()->isLdapReady());
+ $this->assertFalse($job->hasFailed());
+
+ // Test job failures
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder {$folder->id} is already marked as ldap-ready.", $job->failureMessage);
+
+ $folder->status |= SharedFolder::STATUS_DELETED;
+ $folder->save();
+
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder {$folder->id} is marked as deleted.", $job->failureMessage);
+
+ $folder->status ^= SharedFolder::STATUS_DELETED;
+ $folder->save();
+ $folder->delete();
+
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder {$folder->id} is actually deleted.", $job->failureMessage);
+
+ // TODO: Test failures on domain sanity checks
+ }
+}
diff --git a/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php b/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php
new file mode 100644
index 00000000..66f5d0c4
--- /dev/null
+++ b/src/tests/Feature/Jobs/SharedFolder/DeleteTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Tests\Feature\Jobs\SharedFolder;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class DeleteTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group ldap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test non-existing folder ID
+ $job = new \App\Jobs\SharedFolder\DeleteJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage);
+
+ $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain'), [
+ 'status' => SharedFolder::STATUS_NEW
+ ]);
+
+ // create the shared folder first
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $folder->refresh();
+
+ $this->assertTrue($folder->isLdapReady());
+
+ // Test successful deletion
+ $folder->status |= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ $job = new \App\Jobs\SharedFolder\DeleteJob($folder->id);
+ $job->handle();
+
+ $folder->refresh();
+
+ $this->assertFalse($folder->isLdapReady());
+ $this->assertFalse($folder->isImapReady());
+ $this->assertTrue($folder->isDeleted());
+
+ // Test deleting already deleted folder
+ $job = new \App\Jobs\SharedFolder\DeleteJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder {$folder->id} is already marked as deleted.", $job->failureMessage);
+ }
+}
diff --git a/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php b/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php
new file mode 100644
index 00000000..292726b5
--- /dev/null
+++ b/src/tests/Feature/Jobs/SharedFolder/UpdateTest.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Tests\Feature\Jobs\SharedFolder;
+
+use App\Backends\LDAP;
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class UpdateTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestSharedFolder('folder-test@' . \config('app.domain'));
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group ldap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test non-existing folder ID
+ $job = new \App\Jobs\SharedFolder\UpdateJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage);
+
+ $folder = $this->getTestSharedFolder('folder-test@' . \config('app.domain'));
+
+ // Create the folder in LDAP
+ $job = new \App\Jobs\SharedFolder\CreateJob($folder->id);
+ $job->handle();
+
+ $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue(is_array(LDAP::getSharedFolder($folder->email)));
+
+ // Test that the job is being deleted if the folder is not ldap ready or is deleted
+ $folder->refresh();
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE;
+ $folder->save();
+
+ $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($job->isDeleted());
+
+ $folder->status = SharedFolder::STATUS_NEW | SharedFolder::STATUS_ACTIVE
+ | SharedFolder::STATUS_LDAP_READY | SharedFolder::STATUS_DELETED;
+ $folder->save();
+
+ $job = new \App\Jobs\SharedFolder\UpdateJob($folder->id);
+ $job->handle();
+
+ $this->assertTrue($job->isDeleted());
+ }
+}
diff --git a/src/tests/Feature/Jobs/SharedFolder/VerifyTest.php b/src/tests/Feature/Jobs/SharedFolder/VerifyTest.php
new file mode 100644
index 00000000..39d8a3db
--- /dev/null
+++ b/src/tests/Feature/Jobs/SharedFolder/VerifyTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Tests\Feature\Jobs\SharedFolder;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class VerifyTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->status |= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+ $folder->status |= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test job handle
+ *
+ * @group imap
+ */
+ public function testHandle(): void
+ {
+ Queue::fake();
+
+ // Test non-existing folder ID
+ $job = new \App\Jobs\SharedFolder\VerifyJob(123);
+ $job->handle();
+
+ $this->assertTrue($job->hasFailed());
+ $this->assertSame("Shared folder 123 could not be found in the database.", $job->failureMessage);
+
+ // Test existing folder
+ $folder = $this->getTestSharedFolder('folder-event@kolab.org');
+
+ if ($folder->isImapReady()) {
+ $folder->status ^= SharedFolder::STATUS_IMAP_READY;
+ $folder->save();
+ }
+
+ $this->assertFalse($folder->isImapReady());
+
+ for ($i = 0; $i < 10; $i++) {
+ $job = new \App\Jobs\SharedFolder\VerifyJob($folder->id);
+ $job->handle();
+
+ if ($folder->fresh()->isImapReady()) {
+ $this->assertTrue(true);
+ return;
+ }
+
+ sleep(1);
+ }
+
+ $this->assertTrue(false, "Unable to verify the shared folder is set up in time");
+ }
+}
diff --git a/src/tests/Feature/Jobs/UserCreateTest.php b/src/tests/Feature/Jobs/User/CreateTest.php
similarity index 96%
rename from src/tests/Feature/Jobs/UserCreateTest.php
rename to src/tests/Feature/Jobs/User/CreateTest.php
index 0a67c562..fcc696e7 100644
--- a/src/tests/Feature/Jobs/UserCreateTest.php
+++ b/src/tests/Feature/Jobs/User/CreateTest.php
@@ -1,78 +1,78 @@
<?php
-namespace Tests\Feature\Jobs;
+namespace Tests\Feature\Jobs\User;
use App\User;
use Tests\TestCase;
-class UserCreateTest extends TestCase
+class CreateTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('new-job-user@' . \config('app.domain'));
}
public function tearDown(): void
{
$this->deleteTestUser('new-job-user@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test job handle
*
* @group ldap
*/
public function testHandle(): void
{
$user = $this->getTestUser('new-job-user@' . \config('app.domain'));
$this->assertFalse($user->isLdapReady());
$job = new \App\Jobs\User\CreateJob($user->id);
$job->handle();
$this->assertTrue($user->fresh()->isLdapReady());
$this->assertFalse($job->hasFailed());
// Test job failures
$job = new \App\Jobs\User\CreateJob($user->id);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("User {$user->id} is already marked as ldap-ready.", $job->failureMessage);
$user->status |= User::STATUS_DELETED;
$user->save();
$job = new \App\Jobs\User\CreateJob($user->id);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("User {$user->id} is marked as deleted.", $job->failureMessage);
$user->status ^= User::STATUS_DELETED;
$user->save();
$user->delete();
$job = new \App\Jobs\User\CreateJob($user->id);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("User {$user->id} is actually deleted.", $job->failureMessage);
// TODO: Test failures on domain sanity checks
$job = new \App\Jobs\User\CreateJob(123);
$job->handle();
$this->assertTrue($job->isReleased());
$this->assertFalse($job->hasFailed());
}
}
diff --git a/src/tests/Feature/Jobs/UserUpdateTest.php b/src/tests/Feature/Jobs/User/UpdateTest.php
similarity index 97%
rename from src/tests/Feature/Jobs/UserUpdateTest.php
rename to src/tests/Feature/Jobs/User/UpdateTest.php
index 27d371a3..72776b67 100644
--- a/src/tests/Feature/Jobs/UserUpdateTest.php
+++ b/src/tests/Feature/Jobs/User/UpdateTest.php
@@ -1,94 +1,94 @@
<?php
-namespace Tests\Feature\Jobs;
+namespace Tests\Feature\Jobs\User;
use App\Backends\LDAP;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
-class UserUpdateTest extends TestCase
+class UpdateTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('new-job-user@' . \config('app.domain'));
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('new-job-user@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test job handle
*
* @group ldap
*/
public function testHandle(): void
{
// Ignore any jobs created here (e.g. on setAliases() use)
Queue::fake();
$user = $this->getTestUser('new-job-user@' . \config('app.domain'));
// Create the user in LDAP
$job = new \App\Jobs\User\CreateJob($user->id);
$job->handle();
// Test setting two aliases
$aliases = [
'new-job-user1@' . \config('app.domain'),
'new-job-user2@' . \config('app.domain'),
];
$user->setAliases($aliases);
$job = new \App\Jobs\User\UpdateJob($user->id);
$job->handle();
$ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain'));
$this->assertSame($aliases, $ldap_user['alias']);
// Test updating aliases list
$aliases = [
'new-job-user1@' . \config('app.domain'),
];
$user->setAliases($aliases);
$job = new \App\Jobs\User\UpdateJob($user->id);
$job->handle();
$ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain'));
$this->assertSame($aliases, (array) $ldap_user['alias']);
// Test unsetting aliases list
$aliases = [];
$user->setAliases($aliases);
$job = new \App\Jobs\User\UpdateJob($user->id);
$job->handle();
$ldap_user = LDAP::getUser('new-job-user@' . \config('app.domain'));
$this->assertTrue(empty($ldap_user['alias']));
// Test non-existing user ID
$job = new \App\Jobs\User\UpdateJob(123);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("User 123 could not be found in the database.", $job->failureMessage);
}
}
diff --git a/src/tests/Feature/Jobs/UserVerifyTest.php b/src/tests/Feature/Jobs/User/VerifyTest.php
similarity index 95%
rename from src/tests/Feature/Jobs/UserVerifyTest.php
rename to src/tests/Feature/Jobs/User/VerifyTest.php
index d2130c18..17326fe4 100644
--- a/src/tests/Feature/Jobs/UserVerifyTest.php
+++ b/src/tests/Feature/Jobs/User/VerifyTest.php
@@ -1,75 +1,75 @@
<?php
-namespace Tests\Feature\Jobs;
+namespace Tests\Feature\Jobs\User;
use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
-class UserVerifyTest extends TestCase
+class VerifyTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$ned = $this->getTestUser('ned@kolab.org');
$ned->status |= User::STATUS_IMAP_READY;
$ned->save();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$ned = $this->getTestUser('ned@kolab.org');
$ned->status |= User::STATUS_IMAP_READY;
$ned->save();
parent::tearDown();
}
/**
* Test job handle
*
* @group imap
*/
public function testHandle(): void
{
Queue::fake();
// Test non-existing user ID
$job = new \App\Jobs\User\VerifyJob(123);
$job->handle();
$this->assertTrue($job->hasFailed());
$this->assertSame("User 123 could not be found in the database.", $job->failureMessage);
// Test existing user
$user = $this->getTestUser('ned@kolab.org');
if ($user->isImapReady()) {
$user->status ^= User::STATUS_IMAP_READY;
$user->save();
}
$this->assertFalse($user->isImapReady());
for ($i = 0; $i < 10; $i++) {
$job = new \App\Jobs\User\VerifyJob($user->id);
$job->handle();
if ($user->fresh()->isImapReady()) {
$this->assertTrue(true);
return;
}
sleep(1);
}
$this->assertTrue(false, "Unable to verify the IMAP account is set up in time");
}
}
diff --git a/src/tests/Feature/SharedFolderTest.php b/src/tests/Feature/SharedFolderTest.php
new file mode 100644
index 00000000..bf486ee6
--- /dev/null
+++ b/src/tests/Feature/SharedFolderTest.php
@@ -0,0 +1,304 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\SharedFolder;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class SharedFolderTest extends TestCase
+{
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('user-test@kolabnow.com');
+ SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) {
+ $this->deleteTestSharedFolder($folder->email);
+ });
+ }
+
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('user-test@kolabnow.com');
+ SharedFolder::withTrashed()->where('email', 'like', '%@kolabnow.com')->each(function ($folder) {
+ $this->deleteTestSharedFolder($folder->email);
+ });
+
+ parent::tearDown();
+ }
+
+ /**
+ * Tests for SharedFolder::assignToWallet()
+ */
+ public function testAssignToWallet(): void
+ {
+ $user = $this->getTestUser('user-test@kolabnow.com');
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+
+ $result = $folder->assignToWallet($user->wallets->first());
+
+ $this->assertSame($folder, $result);
+ $this->assertSame(1, $folder->entitlements()->count());
+ $this->assertSame('shared-folder', $folder->entitlements()->first()->sku->title);
+
+ // Can't be done twice on the same folder
+ $this->expectException(\Exception::class);
+ $result->assignToWallet($user->wallets->first());
+ }
+
+ /**
+ * Test SharedFolder::getConfig() and setConfig() methods
+ */
+ public function testConfigTrait(): void
+ {
+ Queue::fake();
+
+ $folder = new SharedFolder();
+ $folder->email = 'folder-test@kolabnow.com';
+ $folder->name = 'Test';
+ $folder->save();
+ $john = $this->getTestUser('john@kolab.org');
+ $folder->assignToWallet($john->wallets->first());
+
+ $this->assertSame(['acl' => []], $folder->getConfig());
+
+ $result = $folder->setConfig(['acl' => ['anyone, read-only'], 'unknown' => false]);
+
+ $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig());
+ $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl'));
+ $this->assertSame(['unknown' => "The requested configuration parameter is not supported."], $result);
+
+ $result = $folder->setConfig(['acl' => ['anyone, unknown']]);
+
+ $this->assertSame(['acl' => ['anyone, read-only']], $folder->getConfig());
+ $this->assertSame('["anyone, read-only"]', $folder->getSetting('acl'));
+ $this->assertSame(['acl' => ["The entry format is invalid. Expected an email address."]], $result);
+
+ // Test valid user for ACL
+ $result = $folder->setConfig(['acl' => ['john@kolab.org, full']]);
+
+ $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig());
+ $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl'));
+ $this->assertSame([], $result);
+
+ // Test invalid user for ACL
+ $result = $folder->setConfig(['acl' => ['john, full']]);
+
+ $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig());
+ $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl'));
+ $this->assertSame(['acl' => ["The specified email address is invalid."]], $result);
+
+ // Other invalid entries
+ $acl = [
+ // Test non-existing user for ACL
+ 'unknown@kolab.org, full',
+ // Test existing user from a different wallet
+ 'user@sample-tenant.dev-local, read-only',
+ // Valid entry
+ 'john@kolab.org, read-write',
+ ];
+
+ $result = $folder->setConfig(['acl' => $acl]);
+ $this->assertCount(2, $result['acl']);
+ $this->assertSame("The specified email address does not exist.", $result['acl'][0]);
+ $this->assertSame("The specified email address does not exist.", $result['acl'][1]);
+ $this->assertSame(['acl' => ['john@kolab.org, full']], $folder->getConfig());
+ $this->assertSame('["john@kolab.org, full"]', $folder->getSetting('acl'));
+ }
+
+ /**
+ * Test creating a shared folder
+ */
+ public function testCreate(): void
+ {
+ Queue::fake();
+
+ $folder = new SharedFolder();
+ $folder->name = 'Reśo';
+ $folder->domain = 'kolabnow.com';
+ $folder->save();
+
+ $this->assertMatchesRegularExpression('/^[0-9]{1,20}$/', $folder->id);
+ $this->assertMatchesRegularExpression('/^mail-[0-9]{1,20}@kolabnow\.com$/', $folder->email);
+ $this->assertSame('Reśo', $folder->name);
+ $this->assertTrue($folder->isNew());
+ $this->assertTrue($folder->isActive());
+ $this->assertFalse($folder->isDeleted());
+ $this->assertFalse($folder->isLdapReady());
+ $this->assertFalse($folder->isImapReady());
+
+ $settings = $folder->settings()->get();
+ $this->assertCount(1, $settings);
+ $this->assertSame('folder', $settings[0]->key);
+ $this->assertSame('shared/Reśo@kolabnow.com', $settings[0]->value);
+
+ Queue::assertPushed(
+ \App\Jobs\SharedFolder\CreateJob::class,
+ function ($job) use ($folder) {
+ $folderEmail = TestCase::getObjectProperty($job, 'folderEmail');
+ $folderId = TestCase::getObjectProperty($job, 'folderId');
+
+ return $folderEmail === $folder->email
+ && $folderId === $folder->id;
+ }
+ );
+
+ Queue::assertPushedWithChain(
+ \App\Jobs\SharedFolder\CreateJob::class,
+ [
+ \App\Jobs\SharedFolder\VerifyJob::class,
+ ]
+ );
+ }
+
+ /**
+ * Test a shared folder deletion and force-deletion
+ */
+ public function testDelete(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('user-test@kolabnow.com');
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+ $folder->assignToWallet($user->wallets->first());
+
+ $entitlements = \App\Entitlement::where('entitleable_id', $folder->id);
+
+ $this->assertSame(1, $entitlements->count());
+
+ $folder->delete();
+
+ $this->assertTrue($folder->fresh()->trashed());
+ $this->assertSame(0, $entitlements->count());
+ $this->assertSame(1, $entitlements->withTrashed()->count());
+
+ $folder->forceDelete();
+
+ $this->assertSame(0, $entitlements->withTrashed()->count());
+ $this->assertCount(0, SharedFolder::withTrashed()->where('id', $folder->id)->get());
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\DeleteJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\SharedFolder\DeleteJob::class,
+ function ($job) use ($folder) {
+ $folderEmail = TestCase::getObjectProperty($job, 'folderEmail');
+ $folderId = TestCase::getObjectProperty($job, 'folderId');
+
+ return $folderEmail === $folder->email
+ && $folderId === $folder->id;
+ }
+ );
+ }
+
+ /**
+ * Tests for SharedFolder::emailExists()
+ */
+ public function testEmailExists(): void
+ {
+ Queue::fake();
+
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+
+ $this->assertFalse(SharedFolder::emailExists('unknown@domain.tld'));
+ $this->assertTrue(SharedFolder::emailExists($folder->email));
+
+ $result = SharedFolder::emailExists($folder->email, true);
+ $this->assertSame($result->id, $folder->id);
+
+ $folder->delete();
+
+ $this->assertTrue(SharedFolder::emailExists($folder->email));
+
+ $result = SharedFolder::emailExists($folder->email, true);
+ $this->assertSame($result->id, $folder->id);
+ }
+
+ /**
+ * Tests for SettingsTrait functionality and SharedFolderSettingObserver
+ */
+ public function testSettings(): void
+ {
+ Queue::fake();
+ Queue::assertNothingPushed();
+
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
+
+ // Add a setting
+ $folder->setSetting('unknown', 'test');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
+
+ // Add a setting that is synced to LDAP
+ $folder->setSetting('acl', 'test');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+
+ // Note: We test both current folder as well as fresh folder object
+ // to make sure cache works as expected
+ $this->assertSame('test', $folder->getSetting('unknown'));
+ $this->assertSame('test', $folder->fresh()->getSetting('acl'));
+
+ Queue::fake();
+
+ // Update a setting
+ $folder->setSetting('unknown', 'test1');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
+
+ // Update a setting that is synced to LDAP
+ $folder->setSetting('acl', 'test1');
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+
+ $this->assertSame('test1', $folder->getSetting('unknown'));
+ $this->assertSame('test1', $folder->fresh()->getSetting('acl'));
+
+ Queue::fake();
+
+ // Delete a setting (null)
+ $folder->setSetting('unknown', null);
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 0);
+
+ // Delete a setting that is synced to LDAP
+ $folder->setSetting('acl', null);
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+
+ $this->assertSame(null, $folder->getSetting('unknown'));
+ $this->assertSame(null, $folder->fresh()->getSetting('acl'));
+ }
+
+ /**
+ * Test updating a shared folder
+ */
+ public function testUpdate(): void
+ {
+ Queue::fake();
+
+ $folder = $this->getTestSharedFolder('folder-test@kolabnow.com');
+
+ $folder->name = 'New';
+ $folder->save();
+
+ // Assert the imap folder changes on a folder name change
+ $settings = $folder->settings()->where('key', 'folder')->get();
+ $this->assertCount(1, $settings);
+ $this->assertSame('shared/New@kolabnow.com', $settings[0]->value);
+
+ Queue::assertPushed(\App\Jobs\SharedFolder\UpdateJob::class, 1);
+ Queue::assertPushed(
+ \App\Jobs\SharedFolder\UpdateJob::class,
+ function ($job) use ($folder) {
+ $folderEmail = TestCase::getObjectProperty($job, 'folderEmail');
+ $folderId = TestCase::getObjectProperty($job, 'folderId');
+
+ return $folderEmail === $folder->email
+ && $folderId === $folder->id;
+ }
+ );
+ }
+}
diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php
index d6f39dd6..c7b82b15 100644
--- a/src/tests/Feature/UserTest.php
+++ b/src/tests/Feature/UserTest.php
@@ -1,1048 +1,1084 @@
<?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->deleteTestSharedFolder('test-folder@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
}
public function tearDown(): void
{
\App\TenantSetting::truncate();
$this->deleteTestUser('user-test@' . \config('app.domain'));
$this->deleteTestUser('UserAccountA@UserAccount.com');
$this->deleteTestUser('UserAccountB@UserAccount.com');
$this->deleteTestUser('UserAccountC@UserAccount.com');
$this->deleteTestGroup('test-group@UserAccount.com');
$this->deleteTestResource('test-resource@UserAccount.com');
+ $this->deleteTestSharedFolder('test-folder@UserAccount.com');
$this->deleteTestDomain('UserAccount.com');
$this->deleteTestDomain('UserAccountAdd.com');
parent::tearDown();
}
/**
* Tests for User::assignPackage()
*/
public function testAssignPackage(): void
{
$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, and resource
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$userC = $this->getTestUser('UserAccountC@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$userA->assignPackage($package_kolab, $userC);
$group = $this->getTestGroup('test-group@UserAccount.com');
$group->assignToWallet($userA->wallets->first());
$resource = $this->getTestResource('test-resource@UserAccount.com', ['name' => 'test']);
$resource->assignToWallet($userA->wallets->first());
+ $folder = $this->getTestSharedFolder('test-folder@UserAccount.com', ['name' => 'test']);
+ $folder->assignToWallet($userA->wallets->first());
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsC = \App\Entitlement::where('entitleable_id', $userC->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domain->id);
$entitlementsGroup = \App\Entitlement::where('entitleable_id', $group->id);
$entitlementsResource = \App\Entitlement::where('entitleable_id', $resource->id);
+ $entitlementsFolder = \App\Entitlement::where('entitleable_id', $folder->id);
$this->assertSame(7, $entitlementsA->count());
$this->assertSame(7, $entitlementsB->count());
$this->assertSame(7, $entitlementsC->count());
$this->assertSame(1, $entitlementsDomain->count());
$this->assertSame(1, $entitlementsGroup->count());
$this->assertSame(1, $entitlementsResource->count());
+ $this->assertSame(1, $entitlementsFolder->count());
// Delete non-controller user
$userC->delete();
$this->assertTrue($userC->fresh()->trashed());
$this->assertFalse($userC->fresh()->isDeleted());
$this->assertSame(0, $entitlementsC->count());
// Delete the controller (and expect "sub"-users to be deleted too)
$userA->delete();
$this->assertSame(0, $entitlementsA->count());
$this->assertSame(0, $entitlementsB->count());
$this->assertSame(0, $entitlementsDomain->count());
$this->assertSame(0, $entitlementsGroup->count());
$this->assertSame(0, $entitlementsResource->count());
+ $this->assertSame(0, $entitlementsFolder->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domain->fresh()->trashed());
$this->assertTrue($group->fresh()->trashed());
$this->assertTrue($resource->fresh()->trashed());
+ $this->assertTrue($folder->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domain->isDeleted());
$this->assertFalse($group->isDeleted());
$this->assertFalse($resource->isDeleted());
+ $this->assertFalse($folder->isDeleted());
$userA->forceDelete();
$all_entitlements = \App\Entitlement::where('wallet_id', $userA->wallets->first()->id);
$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());
+ $this->assertCount(0, \App\SharedFolder::withTrashed()->where('id', $folder->id)->get());
}
/**
* Test user deletion vs. group membership
*/
public function testDeleteAndGroups(): void
{
Queue::fake();
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$userA = $this->getTestUser('UserAccountA@UserAccount.com');
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$userA->assignPackage($package_kolab, $userB);
$group = $this->getTestGroup('test-group@UserAccount.com');
$group->members = ['test@gmail.com', $userB->email];
$group->assignToWallet($userA->wallets->first());
$group->save();
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 1);
$userGroups = $userA->groups()->get();
$this->assertSame(1, $userGroups->count());
$this->assertSame($group->id, $userGroups->first()->id);
$userB->delete();
$this->assertSame(['test@gmail.com'], $group->fresh()->members);
// Twice, one for save() and one for delete() above
Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2);
}
/**
* Test handling negative balance on user deletion
*/
public function testDeleteWithNegativeBalance(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->balance = -1000;
$wallet->save();
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
\App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete();
$user->delete();
$reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->get();
$this->assertSame(-1000, $reseller_wallet->fresh()->balance);
$this->assertCount(1, $reseller_transactions);
$trans = $reseller_transactions[0];
$this->assertSame("Deleted user {$user->email}", $trans->description);
$this->assertSame(-1000, $trans->amount);
$this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type);
}
/**
* Test handling positive balance on user deletion
*/
public function testDeleteWithPositiveBalance(): void
{
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->balance = 1000;
$wallet->save();
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
$user->delete();
$this->assertSame(0, $reseller_wallet->fresh()->balance);
}
/**
* Test user deletion with PGP/WOAT enabled
*/
public function testDeleteWithPGP(): void
{
Queue::fake();
// Test with PGP disabled
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$user->tenant->setSetting('pgp.enable', 0);
$user->delete();
Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 0);
// Test with PGP enabled
$this->deleteTestUser('user-test@' . \config('app.domain'));
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$user->tenant->setSetting('pgp.enable', 1);
$user->delete();
$user->tenant->setSetting('pgp.enable', 0);
Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1);
Queue::assertPushed(
\App\Jobs\PGP\KeyDeleteJob::class,
function ($job) use ($user) {
$userId = TestCase::getObjectProperty($job, 'userId');
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
return $userId == $user->id && $userEmail === $user->email;
}
);
}
/**
* Tests for User::aliasExists()
*/
public function testAliasExists(): void
{
$this->assertTrue(User::aliasExists('jack.daniels@kolab.org'));
$this->assertFalse(User::aliasExists('j.daniels@kolab.org'));
$this->assertFalse(User::aliasExists('john@kolab.org'));
}
/**
* Tests for User::emailExists()
*/
public function testEmailExists(): void
{
$this->assertFalse(User::emailExists('jack.daniels@kolab.org'));
$this->assertFalse(User::emailExists('j.daniels@kolab.org'));
$this->assertTrue(User::emailExists('john@kolab.org'));
$user = User::emailExists('john@kolab.org', true);
$this->assertSame('john@kolab.org', $user->email);
}
/**
* Tests for User::findByEmail()
*/
public function testFindByEmail(): void
{
$user = $this->getTestUser('john@kolab.org');
$result = User::findByEmail('john');
$this->assertNull($result);
$result = User::findByEmail('non-existing@email.com');
$this->assertNull($result);
$result = User::findByEmail('john@kolab.org');
$this->assertInstanceOf(User::class, $result);
$this->assertSame($user->id, $result->id);
// Use an alias
$result = User::findByEmail('john.doe@kolab.org');
$this->assertInstanceOf(User::class, $result);
$this->assertSame($user->id, $result->id);
Queue::fake();
// A case where two users have the same alias
$ned = $this->getTestUser('ned@kolab.org');
$ned->setAliases(['joe.monster@kolab.org']);
$result = User::findByEmail('joe.monster@kolab.org');
$this->assertNull($result);
$ned->setAliases([]);
// TODO: searching by external email (setting)
$this->markTestIncomplete();
}
/**
* Test User::name()
*/
public function testName(): void
{
Queue::fake();
$user = $this->getTestUser('user-test@' . \config('app.domain'));
$this->assertSame('', $user->name());
$this->assertSame($user->tenant->title . ' User', $user->name(true));
$user->setSetting('first_name', 'First');
$this->assertSame('First', $user->name());
$this->assertSame('First', $user->name(true));
$user->setSetting('last_name', 'Last');
$this->assertSame('First Last', $user->name());
$this->assertSame('First Last', $user->name(true));
}
/**
* Test resources() method
*/
public function testResources(): void
{
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$resources = $john->resources()->orderBy('email')->get();
$this->assertSame(2, $resources->count());
$this->assertSame('resource-test1@kolab.org', $resources[0]->email);
$this->assertSame('resource-test2@kolab.org', $resources[1]->email);
$resources = $ned->resources()->orderBy('email')->get();
$this->assertSame(2, $resources->count());
$this->assertSame('resource-test1@kolab.org', $resources[0]->email);
$this->assertSame('resource-test2@kolab.org', $resources[1]->email);
$resources = $jack->resources()->get();
$this->assertSame(0, $resources->count());
}
+ /**
+ * Test sharedFolders() method
+ */
+ public function testSharedFolders(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $ned = $this->getTestUser('ned@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+
+ $folders = $john->sharedFolders()->orderBy('email')->get();
+
+ $this->assertSame(2, $folders->count());
+ $this->assertSame('folder-contact@kolab.org', $folders[0]->email);
+ $this->assertSame('folder-event@kolab.org', $folders[1]->email);
+
+ $folders = $ned->sharedFolders()->orderBy('email')->get();
+
+ $this->assertSame(2, $folders->count());
+ $this->assertSame('folder-contact@kolab.org', $folders[0]->email);
+ $this->assertSame('folder-event@kolab.org', $folders[1]->email);
+
+ $folders = $jack->sharedFolders()->get();
+
+ $this->assertSame(0, $folders->count());
+ }
+
/**
* Test user restoring
*/
public function testRestore(): void
{
Queue::fake();
// Test an account with users and domain
$userA = $this->getTestUser('UserAccountA@UserAccount.com', [
'status' => User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_SUSPENDED,
]);
$userB = $this->getTestUser('UserAccountB@UserAccount.com');
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$domainA = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$domainB = $this->getTestDomain('UserAccountAdd.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$userA->assignPackage($package_kolab);
$domainA->assignPackage($package_domain, $userA);
$domainB->assignPackage($package_domain, $userA);
$userA->assignPackage($package_kolab, $userB);
$storage_sku = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
$now = \Carbon\Carbon::now();
$wallet_id = $userA->wallets->first()->id;
// add an extra storage entitlement
$ent1 = \App\Entitlement::create([
'wallet_id' => $wallet_id,
'sku_id' => $storage_sku->id,
'cost' => 0,
'entitleable_id' => $userA->id,
'entitleable_type' => User::class,
]);
$entitlementsA = \App\Entitlement::where('entitleable_id', $userA->id);
$entitlementsB = \App\Entitlement::where('entitleable_id', $userB->id);
$entitlementsDomain = \App\Entitlement::where('entitleable_id', $domainA->id);
// First delete the user
$userA->delete();
$this->assertSame(0, $entitlementsA->count());
$this->assertSame(0, $entitlementsB->count());
$this->assertSame(0, $entitlementsDomain->count());
$this->assertTrue($userA->fresh()->trashed());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domainA->fresh()->trashed());
$this->assertTrue($domainB->fresh()->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userB->isDeleted());
$this->assertFalse($domainA->isDeleted());
// Backdate one storage entitlement (it's not expected to be restored)
\App\Entitlement::withTrashed()->where('id', $ent1->id)
->update(['deleted_at' => $now->copy()->subMinutes(2)]);
// Backdate entitlements to assert that they were restored with proper updated_at timestamp
\App\Entitlement::withTrashed()->where('wallet_id', $wallet_id)
->update(['updated_at' => $now->subMinutes(10)]);
Queue::fake();
// Then restore it
$userA->restore();
$userA->refresh();
$this->assertFalse($userA->trashed());
$this->assertFalse($userA->isDeleted());
$this->assertFalse($userA->isSuspended());
$this->assertFalse($userA->isLdapReady());
$this->assertFalse($userA->isImapReady());
$this->assertTrue($userA->isActive());
$this->assertTrue($userB->fresh()->trashed());
$this->assertTrue($domainB->fresh()->trashed());
$this->assertFalse($domainA->fresh()->trashed());
// Assert entitlements
$this->assertSame(7, $entitlementsA->count()); // mailbox + groupware + 5 x storage
$this->assertTrue($ent1->fresh()->trashed());
$entitlementsA->get()->each(function ($ent) {
$this->assertTrue($ent->updated_at->greaterThan(\Carbon\Carbon::now()->subSeconds(5)));
});
// We expect only CreateJob + UpdateJob pair for both user and domain.
// Because how Illuminate/Database/Eloquent/SoftDeletes::restore() method
// is implemented we cannot skip the UpdateJob in any way.
// I don't want to overwrite this method, the extra job shouldn't do any harm.
$this->assertCount(4, Queue::pushedJobs()); // @phpstan-ignore-line
Queue::assertPushed(\App\Jobs\Domain\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\User\CreateJob::class,
function ($job) use ($userA) {
return $userA->id === TestCase::getObjectProperty($job, 'userId');
}
);
Queue::assertPushedWithChain(
\App\Jobs\User\CreateJob::class,
[
\App\Jobs\User\VerifyJob::class,
]
);
}
/**
* Tests for UserAliasesTrait::setAliases()
*/
public function testSetAliases(): void
{
Queue::fake();
Queue::assertNothingPushed();
$user = $this->getTestUser('UserAccountA@UserAccount.com');
$domain = $this->getTestDomain('UserAccount.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_HOSTED,
]);
$this->assertCount(0, $user->aliases->all());
$user->tenant->setSetting('pgp.enable', 1);
// Add an alias
$user->setAliases(['UserAlias1@UserAccount.com']);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
$user->tenant->setSetting('pgp.enable', 0);
$aliases = $user->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
// Add another alias
$user->setAliases(['UserAlias1@UserAccount.com', 'UserAlias2@UserAccount.com']);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2);
Queue::assertPushed(\App\Jobs\PGP\KeyCreateJob::class, 1);
$aliases = $user->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]->alias);
$this->assertSame('useralias2@useraccount.com', $aliases[1]->alias);
$user->tenant->setSetting('pgp.enable', 1);
// Remove an alias
$user->setAliases(['UserAlias1@UserAccount.com']);
$user->tenant->setSetting('pgp.enable', 0);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
Queue::assertPushed(\App\Jobs\PGP\KeyDeleteJob::class, 1);
Queue::assertPushed(
\App\Jobs\PGP\KeyDeleteJob::class,
function ($job) use ($user) {
$userId = TestCase::getObjectProperty($job, 'userId');
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
return $userId == $user->id && $userEmail === 'useralias2@useraccount.com';
}
);
$aliases = $user->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias1@useraccount.com', $aliases[0]['alias']);
// Remove all aliases
$user->setAliases([]);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 4);
$this->assertCount(0, $user->aliases()->get());
}
/**
* Tests for UserSettingsTrait::setSettings() and getSetting() and getSettings()
*/
public function testUserSettings(): void
{
Queue::fake();
Queue::assertNothingPushed();
$user = $this->getTestUser('UserAccountA@UserAccount.com');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0);
// Test default settings
// Note: Technicly this tests UserObserver::created() behavior
$all_settings = $user->settings()->orderBy('key')->get();
$this->assertCount(2, $all_settings);
$this->assertSame('country', $all_settings[0]->key);
$this->assertSame('CH', $all_settings[0]->value);
$this->assertSame('currency', $all_settings[1]->key);
$this->assertSame('CHF', $all_settings[1]->value);
// Add a setting
$user->setSetting('first_name', 'Firstname');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname', $user->getSetting('first_name'));
$this->assertSame('Firstname', $user->fresh()->getSetting('first_name'));
// Update a setting
$user->setSetting('first_name', 'Firstname1');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 2);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname1', $user->getSetting('first_name'));
$this->assertSame('Firstname1', $user->fresh()->getSetting('first_name'));
// Delete a setting (null)
$user->setSetting('first_name', null);
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame(null, $user->getSetting('first_name'));
$this->assertSame(null, $user->fresh()->getSetting('first_name'));
// Delete a setting (empty string)
$user->setSetting('first_name', 'Firstname1');
$user->setSetting('first_name', '');
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 5);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame(null, $user->getSetting('first_name'));
$this->assertSame(null, $user->fresh()->getSetting('first_name'));
// Set multiple settings at once
$user->setSettings([
'first_name' => 'Firstname2',
'last_name' => 'Lastname2',
'country' => null,
]);
// TODO: This really should create a single UserUpdate job, not 3
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 7);
// Note: We test both current user as well as fresh user object
// to make sure cache works as expected
$this->assertSame('Firstname2', $user->getSetting('first_name'));
$this->assertSame('Firstname2', $user->fresh()->getSetting('first_name'));
$this->assertSame('Lastname2', $user->getSetting('last_name'));
$this->assertSame('Lastname2', $user->fresh()->getSetting('last_name'));
$this->assertSame(null, $user->getSetting('country'));
$this->assertSame(null, $user->fresh()->getSetting('country'));
$all_settings = $user->settings()->orderBy('key')->get();
$this->assertCount(3, $all_settings);
// Test getSettings() method
$this->assertSame(
[
'first_name' => 'Firstname2',
'last_name' => 'Lastname2',
'unknown' => null,
],
$user->getSettings(['first_name', 'last_name', 'unknown'])
);
}
/**
* Tests for User::users()
*/
public function testUsers(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$joe = $this->getTestUser('joe@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$wallet = $john->wallets()->first();
$users = $john->users()->orderBy('email')->get();
$this->assertCount(4, $users);
$this->assertEquals($jack->id, $users[0]->id);
$this->assertEquals($joe->id, $users[1]->id);
$this->assertEquals($john->id, $users[2]->id);
$this->assertEquals($ned->id, $users[3]->id);
$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 6580ea2a..2f46066a 100644
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -1,547 +1,599 @@
<?php
namespace Tests;
+use App\Backends\LDAP;
use App\Domain;
use App\Group;
use App\Resource;
+use App\SharedFolder;
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.
*/
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\Beta\SharedFolders',
'App\Handlers\Distlist',
];
$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();
+ LDAP::deleteGroup($group);
$group->forceDelete();
}
/**
* Delete a test resource whatever it takes.
*
* @coversNothing
*/
protected function deleteTestResource($email)
{
Queue::fake();
$resource = Resource::withTrashed()->where('email', $email)->first();
if (!$resource) {
return;
}
- $job = new \App\Jobs\Resource\DeleteJob($resource->id);
- $job->handle();
+ LDAP::deleteResource($resource);
$resource->forceDelete();
}
+ /**
+ * Delete a test shared folder whatever it takes.
+ *
+ * @coversNothing
+ */
+ protected function deleteTestSharedFolder($email)
+ {
+ Queue::fake();
+
+ $folder = SharedFolder::withTrashed()->where('email', $email)->first();
+
+ if (!$folder) {
+ return;
+ }
+
+ LDAP::deleteSharedFolder($folder);
+
+ $folder->forceDelete();
+ }
+
/**
* Delete a test user whatever it takes.
*
* @coversNothing
*/
protected function deleteTestUser($email)
{
Queue::fake();
$user = User::withTrashed()->where('email', $email)->first();
if (!$user) {
return;
}
- $job = new \App\Jobs\User\DeleteJob($user->id);
- $job->handle();
+ LDAP::deleteUser($user);
$user->forceDelete();
}
/**
* Helper to access protected property of an object
*/
protected static function getObjectProperty($object, $property_name)
{
$reflection = new \ReflectionClass($object);
$property = $reflection->getProperty($property_name);
$property->setAccessible(true);
return $property->getValue($object);
}
/**
* Get Domain object by namespace, create it if needed.
* Skip LDAP jobs.
*
* @coversNothing
*/
protected function getTestDomain($name, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
return Domain::firstOrCreate(['namespace' => $name], $attrib);
}
/**
* Get Group object by email, create it if needed.
* Skip LDAP jobs.
*/
protected function getTestGroup($email, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
return Group::firstOrCreate(['email' => $email], $attrib);
}
/**
- * Get Resource object by name+domain, create it if needed.
+ * Get Resource object by email, create it if needed.
* Skip LDAP jobs.
*/
protected function getTestResource($email, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
$resource = Resource::where('email', $email)->first();
if (!$resource) {
list($local, $domain) = explode('@', $email, 2);
$resource = new Resource();
$resource->email = $email;
$resource->domain = $domain;
if (!isset($attrib['name'])) {
$resource->name = $local;
}
}
foreach ($attrib as $key => $val) {
$resource->{$key} = $val;
}
$resource->save();
return $resource;
}
+ /**
+ * Get SharedFolder object by email, create it if needed.
+ * Skip LDAP jobs.
+ */
+ protected function getTestSharedFolder($email, $attrib = [])
+ {
+ // Disable jobs (i.e. skip LDAP oprations)
+ Queue::fake();
+
+ $folder = SharedFolder::where('email', $email)->first();
+
+ if (!$folder) {
+ list($local, $domain) = explode('@', $email, 2);
+
+ $folder = new SharedFolder();
+ $folder->email = $email;
+ $folder->domain = $domain;
+
+ if (!isset($attrib['name'])) {
+ $folder->name = $local;
+ }
+ }
+
+ foreach ($attrib as $key => $val) {
+ $folder->{$key} = $val;
+ }
+
+ $folder->save();
+
+ return $folder;
+ }
+
/**
* Get User object by email, create it if needed.
* Skip LDAP jobs.
*
* @coversNothing
*/
protected function getTestUser($email, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
$user = User::firstOrCreate(['email' => $email], $attrib);
if ($user->trashed()) {
// Note: we do not want to use user restore here
User::where('id', $user->id)->forceDelete();
$user = User::create(['email' => $email] + $attrib);
}
return $user;
}
/**
* Call protected/private method of a class.
*
* @param object $object Instantiated object that we will run method on.
* @param string $methodName Method name to call
* @param array $parameters Array of parameters to pass into method.
*
* @return mixed Method return.
*/
protected function invokeMethod($object, $methodName, array $parameters = array())
{
$reflection = new \ReflectionClass(get_class($object));
$method = $reflection->getMethod($methodName);
$method->setAccessible(true);
return $method->invokeArgs($object, $parameters);
}
protected function setUpTest()
{
$this->userPassword = \App\Utils::generatePassphrase();
$this->domainHosted = $this->getTestDomain(
'test.domain',
[
'type' => \App\Domain::TYPE_EXTERNAL,
'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED
]
);
$this->getTestDomain(
'test2.domain2',
[
'type' => \App\Domain::TYPE_EXTERNAL,
'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED
]
);
$packageKolab = \App\Package::where('title', 'kolab')->first();
$this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]);
$this->domainOwner->assignPackage($packageKolab);
$this->domainOwner->setSettings($this->domainOwnerSettings);
$this->domainOwner->setAliases(['alias1@test2.domain2']);
// separate for regular user
$this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]);
// separate for wallet controller
$this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]);
$this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]);
$this->domainUsers[] = $this->jack;
$this->domainUsers[] = $this->jane;
$this->domainUsers[] = $this->joe;
$this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]);
foreach ($this->domainUsers as $user) {
$this->domainOwner->assignPackage($packageKolab, $user);
}
$this->domainUsers[] = $this->domainOwner;
// assign second factor to joe
$this->joe->assignSku(Sku::where('title', '2fa')->first());
\App\Auth\SecondFactor::seed($this->joe->email);
usort(
$this->domainUsers,
function ($a, $b) {
return $a->email > $b->email;
}
);
$this->domainHosted->assignPackage(
\App\Package::where('title', 'domain-hosting')->first(),
$this->domainOwner
);
$wallet = $this->domainOwner->wallets()->first();
$wallet->addController($this->jane);
$this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first();
$this->publicDomainUser = $this->getTestUser(
'john@' . $this->publicDomain->namespace,
['password' => $this->userPassword]
);
$this->publicDomainUser->assignPackage($packageKolab);
}
public function tearDown(): void
{
foreach ($this->domainUsers as $user) {
if ($user == $this->domainOwner) {
continue;
}
$this->deleteTestUser($user->email);
}
if ($this->domainOwner) {
$this->deleteTestUser($this->domainOwner->email);
}
if ($this->domainHosted) {
$this->deleteTestDomain($this->domainHosted->namespace);
}
if ($this->publicDomainUser) {
$this->deleteTestUser($this->publicDomainUser->email);
}
parent::tearDown();
}
}
diff --git a/src/tests/Unit/Rules/ResourceNameTest.php b/src/tests/Unit/Rules/ResourceNameTest.php
new file mode 100644
index 00000000..478310f0
--- /dev/null
+++ b/src/tests/Unit/Rules/ResourceNameTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\ResourceName;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class ResourceNameTest extends TestCase
+{
+ /**
+ * Tests the resource name validator
+ */
+ public function testValidation(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $rules = ['name' => ['present', new ResourceName($user, 'kolab.org')]];
+
+ // Empty/invalid input
+ $v = Validator::make(['name' => null], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ $v = Validator::make(['name' => []], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ // Forbidden chars
+ $v = Validator::make(['name' => 'Test@'], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ // Length limit
+ $v = Validator::make(['name' => str_repeat('a', 192)], $rules);
+ $this->assertSame(['name' => ["The name may not be greater than 191 characters."]], $v->errors()->toArray());
+
+ // Existing resource
+ $v = Validator::make(['name' => 'Conference Room #1'], $rules);
+ $this->assertSame(['name' => ["The specified name is not available."]], $v->errors()->toArray());
+
+ // Valid name
+ $v = Validator::make(['name' => 'TestRule'], $rules);
+ $this->assertSame([], $v->errors()->toArray());
+
+ // Invalid domain
+ $rules = ['name' => ['present', new ResourceName($user, 'kolabnow.com')]];
+ $v = Validator::make(['name' => 'TestRule'], $rules);
+ $this->assertSame(['name' => ["The specified domain is invalid."]], $v->errors()->toArray());
+ }
+}
diff --git a/src/tests/Unit/Rules/SharedFolderNameTest.php b/src/tests/Unit/Rules/SharedFolderNameTest.php
new file mode 100644
index 00000000..fdaab65c
--- /dev/null
+++ b/src/tests/Unit/Rules/SharedFolderNameTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\SharedFolderName;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class SharedFolderNameTest extends TestCase
+{
+ /**
+ * Tests the shared folder name validator
+ */
+ public function testValidation(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $rules = ['name' => ['present', new SharedFolderName($user, 'kolab.org')]];
+
+ // Empty/invalid input
+ $v = Validator::make(['name' => null], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ $v = Validator::make(['name' => []], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ // Forbidden chars
+ $v = Validator::make(['name' => 'Test@'], $rules);
+ $this->assertSame(['name' => ["The specified name is invalid."]], $v->errors()->toArray());
+
+ // Length limit
+ $v = Validator::make(['name' => str_repeat('a', 192)], $rules);
+ $this->assertSame(['name' => ["The name may not be greater than 191 characters."]], $v->errors()->toArray());
+
+ // Existing resource
+ $v = Validator::make(['name' => 'Calendar'], $rules);
+ $this->assertSame(['name' => ["The specified name is not available."]], $v->errors()->toArray());
+
+ // Valid name
+ $v = Validator::make(['name' => 'TestRule'], $rules);
+ $this->assertSame([], $v->errors()->toArray());
+
+ // Invalid domain
+ $rules = ['name' => ['present', new SharedFolderName($user, 'kolabnow.com')]];
+ $v = Validator::make(['name' => 'TestRule'], $rules);
+ $this->assertSame(['name' => ["The specified domain is invalid."]], $v->errors()->toArray());
+ }
+}
diff --git a/src/tests/Unit/Rules/SharedFolderTypeTest.php b/src/tests/Unit/Rules/SharedFolderTypeTest.php
new file mode 100644
index 00000000..44eab34b
--- /dev/null
+++ b/src/tests/Unit/Rules/SharedFolderTypeTest.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Tests\Unit\Rules;
+
+use App\Rules\SharedFolderType;
+use Illuminate\Support\Facades\Validator;
+use Tests\TestCase;
+
+class SharedFolderTypeTest extends TestCase
+{
+ /**
+ * Tests the shared folder type validator
+ */
+ public function testValidation(): void
+ {
+ $rules = ['type' => ['present', new SharedFolderType()]];
+
+ // Empty/invalid input
+ $v = Validator::make(['type' => null], $rules);
+ $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray());
+
+ $v = Validator::make(['type' => []], $rules);
+ $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray());
+
+ $v = Validator::make(['type' => 'Test'], $rules);
+ $this->assertSame(['type' => ["The specified type is invalid."]], $v->errors()->toArray());
+
+ // Valid type
+ foreach (\App\SharedFolder::SUPPORTED_TYPES as $type) {
+ $v = Validator::make(['type' => $type], $rules);
+ $this->assertSame([], $v->errors()->toArray());
+ }
+ }
+}
diff --git a/src/tests/Unit/SharedFolderTest.php b/src/tests/Unit/SharedFolderTest.php
new file mode 100644
index 00000000..beb5e479
--- /dev/null
+++ b/src/tests/Unit/SharedFolderTest.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\SharedFolder;
+use Tests\TestCase;
+
+class SharedFolderTest extends TestCase
+{
+ /**
+ * Test SharedFolder status property and is*() methods
+ */
+ public function testSharedFolderStatus(): void
+ {
+ $statuses = [
+ SharedFolder::STATUS_NEW,
+ SharedFolder::STATUS_ACTIVE,
+ SharedFolder::STATUS_DELETED,
+ SharedFolder::STATUS_LDAP_READY,
+ SharedFolder::STATUS_IMAP_READY,
+ ];
+
+ $folders = \App\Utils::powerSet($statuses);
+
+ $folder = new SharedFolder(['name' => 'test']);
+
+ foreach ($folders as $folderStatuses) {
+ $folder->status = \array_sum($folderStatuses);
+
+ $folderStatuses = [];
+
+ foreach ($statuses as $status) {
+ if ($folder->status & $status) {
+ $folderStatuses[] = $status;
+ }
+ }
+
+ $this->assertSame($folder->status, \array_sum($folderStatuses));
+
+ // either one is true, but not both
+ $this->assertSame(
+ $folder->isNew() === in_array(SharedFolder::STATUS_NEW, $folderStatuses),
+ $folder->isActive() === in_array(SharedFolder::STATUS_ACTIVE, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isNew() === in_array(SharedFolder::STATUS_NEW, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isActive() === in_array(SharedFolder::STATUS_ACTIVE, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isDeleted() === in_array(SharedFolder::STATUS_DELETED, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isLdapReady() === in_array(SharedFolder::STATUS_LDAP_READY, $folderStatuses)
+ );
+
+ $this->assertTrue(
+ $folder->isImapReady() === in_array(SharedFolder::STATUS_IMAP_READY, $folderStatuses)
+ );
+ }
+
+ $this->expectException(\Exception::class);
+ $folder->status = 111;
+ }
+
+ /**
+ * Test basic SharedFolder funtionality
+ */
+ public function testSharedFolderType(): void
+ {
+ $folder = new SharedFolder(['name' => 'test']);
+
+ foreach (SharedFolder::SUPPORTED_TYPES as $type) {
+ $folder->type = $type;
+ }
+
+ $this->expectException(\Exception::class);
+ $folder->type = 'unknown';
+ }
+}

File Metadata

Mime Type
text/x-diff
Expires
Mon, Feb 2, 1:29 AM (1 d, 19 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
426776
Default Alt Text
(801 KB)

Event Timeline